From 26e8b79ed63b69a4bf2409f265716ea8c757f3bd Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:04:55 +0200 Subject: [PATCH 01/10] refactor: make SelectionState guards structural --- src/plugins/toolbox/src/SelectionState.ts | 21 ++- .../src/__test__/SelectionState.test.ts | 127 ++++++++++++++++++ 2 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 src/plugins/toolbox/src/__test__/SelectionState.test.ts diff --git a/src/plugins/toolbox/src/SelectionState.ts b/src/plugins/toolbox/src/SelectionState.ts index 7393ff1d..059e5de8 100644 --- a/src/plugins/toolbox/src/SelectionState.ts +++ b/src/plugins/toolbox/src/SelectionState.ts @@ -31,12 +31,17 @@ export class SelectionState { public select(obj: Object3D & DIVESelectable): void { if (this._selected === obj) return; - // Deselect previous - if (this._selected) { - this._selected.onDeselect?.(); - } + // Both handlers below can call straight back in here, so the field is + // moved to its next value before each of them rather than after. The + // guards then break any loop on their own, without relying on a + // handler being asynchronous. + const previous = this._selected; + + // cleared first, so a deselect() from within onDeselect finds nothing + // to do instead of dropping the incoming selection + this._selected = null; + previous?.onDeselect?.(); - // Select new this._selected = obj; obj.onSelect?.(); @@ -48,10 +53,12 @@ export class SelectionState { * Calls onDeselect on the object. */ public deselect(): void { - if (!this._selected) return; + const previous = this._selected; + if (!previous) return; - this._selected.onDeselect?.(); + // cleared before the handler runs, for the same reason as in select() this._selected = null; + previous.onDeselect?.(); this.notifyListeners(); } diff --git a/src/plugins/toolbox/src/__test__/SelectionState.test.ts b/src/plugins/toolbox/src/__test__/SelectionState.test.ts new file mode 100644 index 00000000..7d609695 --- /dev/null +++ b/src/plugins/toolbox/src/__test__/SelectionState.test.ts @@ -0,0 +1,127 @@ +import { Object3D } from 'three/webgpu'; +import { type DIVESelectable } from '@shopware-ag/dive'; +import { SelectionState } from '../SelectionState.ts'; + +type Selectable = Object3D & DIVESelectable; + +const selectable = (): Selectable => + Object.assign(new Object3D(), { + isSelectable: true as const, + onSelect: vi.fn(), + onDeselect: vi.fn(), + }) as unknown as Selectable; + +describe('SelectionState', () => { + it('should select and deselect an object', () => { + const state = new SelectionState(); + const object = selectable(); + + state.select(object); + expect(state.selected).toBe(object); + expect(object.onSelect).toHaveBeenCalledTimes(1); + + state.deselect(); + expect(state.selected).toBeNull(); + expect(object.onDeselect).toHaveBeenCalledTimes(1); + }); + + it('should deselect the previous object when selecting another', () => { + const state = new SelectionState(); + const first = selectable(); + const second = selectable(); + + state.select(first); + state.select(second); + + expect(first.onDeselect).toHaveBeenCalledTimes(1); + expect(second.onSelect).toHaveBeenCalledTimes(1); + expect(state.selected).toBe(second); + }); + + it('should ignore selecting the object that is already selected', () => { + const state = new SelectionState(); + const object = selectable(); + + state.select(object); + state.select(object); + + expect(object.onSelect).toHaveBeenCalledTimes(1); + expect(object.onDeselect).not.toHaveBeenCalled(); + }); + + it('should notify listeners and stop after offChange', () => { + const state = new SelectionState(); + const object = selectable(); + const listener = vi.fn(); + + state.onChange(listener); + state.select(object); + expect(listener).toHaveBeenCalledWith(object); + + state.offChange(listener); + state.deselect(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should drop the selection and the listeners on dispose', () => { + const state = new SelectionState(); + const listener = vi.fn(); + + state.onChange(listener); + state.select(selectable()); + state.dispose(); + + expect(state.selected).toBeNull(); + + listener.mockClear(); + state.select(selectable()); + expect(listener).not.toHaveBeenCalled(); + }); + + describe('re-entrancy', () => { + // The state plugin reacts to onSelect/onDeselect by performing an + // action, which can call straight back in here. Nothing may rely on + // that call being deferred. + + it('should survive a synchronous select from within onSelect', () => { + const state = new SelectionState(); + const object = selectable(); + vi.mocked(object.onSelect!).mockImplementation(() => { + state.select(object); + }); + + expect(() => state.select(object)).not.toThrow(); + expect(object.onSelect).toHaveBeenCalledTimes(1); + expect(state.selected).toBe(object); + }); + + it('should survive a synchronous deselect from within onDeselect', () => { + const state = new SelectionState(); + const object = selectable(); + vi.mocked(object.onDeselect!).mockImplementation(() => { + state.deselect(); + }); + + state.select(object); + + expect(() => state.deselect()).not.toThrow(); + expect(object.onDeselect).toHaveBeenCalledTimes(1); + expect(state.selected).toBeNull(); + }); + + it('should survive a synchronous deselect while switching selection', () => { + const state = new SelectionState(); + const first = selectable(); + const second = selectable(); + vi.mocked(first.onDeselect!).mockImplementation(() => { + state.deselect(); + }); + + state.select(first); + + expect(() => state.select(second)).not.toThrow(); + expect(first.onDeselect).toHaveBeenCalledTimes(1); + expect(state.selected).toBe(second); + }); + }); +}); From 262c111bfe18f0603f7d06397c9436503ccff7eb Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:04:55 +0200 Subject: [PATCH 02/10] feat: report entity changes as events instead of calling the state --- .../boundingbox/__test__/BoundingBox.test.ts | 12 +++ src/components/group/Group.ts | 58 ---------- src/components/group/__test__/Group.test.ts | 59 ++++++---- src/components/light/AmbientLight.ts | 6 +- src/components/light/PointLight.ts | 32 +++--- src/components/light/SceneLight.ts | 6 +- .../light/__test__/PointLight.test.ts | 71 +++++++++--- .../light/__test__/SceneLight.test.ts | 17 --- src/components/model/Model.ts | 24 +---- src/components/model/__test__/Model.test.ts | 101 ++++++++---------- src/components/node/Node.ts | 39 +++---- src/components/node/__test__/Node.test.ts | 95 ++++++++++++---- .../primitive/__test__/Primitive.test.ts | 59 +++------- src/engine/Dive.ts | 15 +-- src/types/events/DIVEEntityEventMap.ts | 38 +++++++ src/types/events/index.ts | 1 + src/types/index.ts | 1 + 17 files changed, 324 insertions(+), 310 deletions(-) create mode 100644 src/types/events/DIVEEntityEventMap.ts create mode 100644 src/types/events/index.ts diff --git a/src/components/boundingbox/__test__/BoundingBox.test.ts b/src/components/boundingbox/__test__/BoundingBox.test.ts index 17163106..bd43185a 100644 --- a/src/components/boundingbox/__test__/BoundingBox.test.ts +++ b/src/components/boundingbox/__test__/BoundingBox.test.ts @@ -425,5 +425,17 @@ describe('BoundingBox', () => { expect(boundingBox.children).toBeDefined(); expect(Array.isArray(boundingBox.children)).toBe(true); }); + + it('should stay silent, because nothing subscribes to it', () => { + // A bounding box is scaffolding, not an entity. It inherits + // onMove() from DIVENode, but the state layer never wires it up, + // so the inherited call has to be inert rather than guarded away. + const boundingBox = new BoundingBox(mockObject); + + expect( + (boundingBox as unknown as { _listeners?: object })._listeners, + ).toBeUndefined(); + expect(() => boundingBox.onMove()).not.toThrow(); + }); }); }); diff --git a/src/components/group/Group.ts b/src/components/group/Group.ts index 7a05803e..a6d3fc9d 100644 --- a/src/components/group/Group.ts +++ b/src/components/group/Group.ts @@ -128,62 +128,4 @@ export class DIVEGroup extends DIVENode { line.geometry.setFromPoints(points); line.computeLineDistances(); } - - // public setBoundingBoxVisibility(visible: boolean): void { - // this._boxMesh.visible = visible; - // } - - // /** - // * Recalculates the position of the group based on it's bounding box. - // * Children's world positions are kept. - // */ - // private recalculatePosition(): void { - // // store all children's world positions - // const childrensWorldPositions: Vector3[] = this.children.map((child) => child.getWorldPosition(new Vector3())); - - // // calculate new center and set it as the group's position - // const bbcenter = this.updateBB(); - // this.position.copy(bbcenter); - - // // set childrens's positions so their world positions are kept - // this.children.forEach((child, i) => { - // if (child.uuid === this._boxMesh.uuid) return; - // child.position.copy(this.worldToLocal(childrensWorldPositions[i])); - // }); - - // DIVECommunication.get(this.userData.id)?.performAction('UPDATE_OBJECT', { id: this.userData.id, position: this.position }); - // } - - // /** - // * Updates the bounding box of the group. - // * @returns {Vector3} The new center of the bounding box. - // */ - // private updateBB(): Vector3 { - // this._boundingBox.makeEmpty(); - - // if (this.children.length === 1) { - // // because we always have the box mesh as 1 child - // return this.position.clone(); - // } - - // this.children.forEach((child) => { - // if (child.uuid === this._boxMesh.uuid) return; - // this._boundingBox.expandByObject(child); - // }); - - // return this._boundingBox.getCenter(new Vector3()); - // } - - // private updateBoxMesh(): void { - // if (this.children.length === 1) { - // // because we always have the box mesh as 1 child - // this._boxMesh.visible = false; - // return; - // } - - // this._boxMesh.quaternion.copy(this.quaternion.clone().invert()); - // this._boxMesh.scale.set(1 / this.scale.x, 1 / this.scale.y, 1 / this.scale.z); - // this._boxMesh.geometry = new BoxGeometry(this._boundingBox.max.x - this._boundingBox.min.x, this._boundingBox.max.y - this._boundingBox.min.y, this._boundingBox.max.z - this._boundingBox.min.z); - // this._boxMesh.visible = true; - // } } diff --git a/src/components/group/__test__/Group.test.ts b/src/components/group/__test__/Group.test.ts index b17867ba..c06b46cd 100644 --- a/src/components/group/__test__/Group.test.ts +++ b/src/components/group/__test__/Group.test.ts @@ -1,23 +1,7 @@ import { Object3D, type Vector3Like } from 'three/webgpu'; -import { State } from '@shopware-ag/dive/state'; -import { type DIVENode } from '../../node/Node.ts'; +import { DIVENode } from '../../node/Node.ts'; import { DIVEGroup } from '../Group.ts'; - -vi.mock('../../../modules/state/State', () => { - return { - State: { - get: vi.fn(() => { - return { - performAction: vi.fn(), - }; - }), - }, - }; -}); - -vi.spyOn(State, 'get').mockReturnValue({ - performAction: vi.fn(), -} as unknown as State); +import { type DIVESceneObject } from '../../../types/components/DIVESceneObject.ts'; let group: DIVEGroup; let obj: Object3D; @@ -108,7 +92,6 @@ describe('dive/group/DIVEGroup', () => { expect(() => group.onMove()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => group.onMove()).not.toThrow(); }); @@ -117,7 +100,6 @@ describe('dive/group/DIVEGroup', () => { expect(() => group.onSelect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => group.onSelect()).not.toThrow(); }); @@ -126,7 +108,6 @@ describe('dive/group/DIVEGroup', () => { expect(() => group.onDeselect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => group.onDeselect()).not.toThrow(); }); @@ -135,7 +116,6 @@ describe('dive/group/DIVEGroup', () => { expect(() => group.onMove()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => group.onMove()).not.toThrow(); }); @@ -247,4 +227,39 @@ describe('dive/group/DIVEGroup', () => { expect(() => group.remove(objWithoutId as any)).not.toThrow(); expect(group.members).not.toContain(objWithoutId); }); + + describe('cascading moves to its members', () => { + // Moving a group moves everything in it, so every member has to report + // its own new transform — the group's event alone says nothing about + // where the members ended up. + + it('should make each node member report a transform', () => { + const memberA = new DIVENode(); + const memberB = new DIVENode(); + memberA.userData.id = 'a'; + memberB.userData.id = 'b'; + // DIVENode is the base of every real member; attach() is typed + // to the concrete union, so the test stands in for it + group.attach(memberA as unknown as DIVESceneObject); + group.attach(memberB as unknown as DIVESceneObject); + + const onA = vi.fn(); + const onB = vi.fn(); + memberA.addEventListener('object-transform', onA); + memberB.addEventListener('object-transform', onB); + + group.setPosition({ x: 5, y: 0, z: 0 }); + + expect(onA).toHaveBeenCalledTimes(1); + expect(onB).toHaveBeenCalledTimes(1); + }); + + it('should skip members that are not nodes', () => { + const plain = new Object3D(); + plain.userData.id = 'plain'; + group.attach(plain as unknown as DIVESceneObject); + + expect(() => group.setPosition({ x: 5, y: 0, z: 0 })).not.toThrow(); + }); + }); }); diff --git a/src/components/light/AmbientLight.ts b/src/components/light/AmbientLight.ts index 55cef9d4..27ae2556 100644 --- a/src/components/light/AmbientLight.ts +++ b/src/components/light/AmbientLight.ts @@ -1,6 +1,7 @@ import { AmbientLight, Color, Object3D } from 'three/webgpu'; import { PRODUCT_LAYER_MASK } from '../../constants/VisibilityLayerMask.ts'; import { DIVESelectable } from '@shopware-ag/dive'; +import { type DIVEEntityEventMap } from '../../types/events/index.ts'; /** * A basic ambient light. @@ -10,7 +11,10 @@ import { DIVESelectable } from '@shopware-ag/dive'; * @module */ -export class DIVEAmbientLight extends Object3D implements DIVESelectable { +export class DIVEAmbientLight + extends Object3D + implements DIVESelectable +{ readonly isDIVELight: true = true; readonly isDIVEAmbientLight: true = true; readonly isSelectable: true = true; diff --git a/src/components/light/PointLight.ts b/src/components/light/PointLight.ts index 1190b9a3..cc82b56b 100644 --- a/src/components/light/PointLight.ts +++ b/src/components/light/PointLight.ts @@ -6,6 +6,7 @@ import { Mesh, FrontSide, Object3D, + Vector3, } from 'three/webgpu'; import { PRODUCT_LAYER_MASK, @@ -14,6 +15,7 @@ import { import { DIVEMovable } from '../../interfaces/Movable.ts'; import { DIVESelectable } from '../../interfaces/Selectable.ts'; import type { TransformControls } from 'three/examples/jsm/controls/TransformControls.ts'; +import { type DIVEEntityEventMap } from '../../types/events/index.ts'; /** * A basic point light. @@ -26,7 +28,7 @@ import type { TransformControls } from 'three/examples/jsm/controls/TransformCon */ export class DIVEPointLight - extends Object3D + extends Object3D implements DIVESelectable, DIVEMovable { readonly isDIVELight: true = true; @@ -39,6 +41,9 @@ export class DIVEPointLight private light: PointLight; private mesh: Mesh; + /** Reused so reporting a move does not allocate every frame. */ + private _positionWorldBuffer = new Vector3(); + constructor() { super(); @@ -92,27 +97,22 @@ export class DIVEPointLight } public onMove(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('UPDATE_OBJECT', { - id: this.userData.id, - position: this.position, - }); + // reports the world position, same as every other entity. The local + // one this used to send is only correct while the light hangs + // directly off the root. + this.dispatchEvent({ + type: 'object-transform', + position: this.getWorldPosition(this._positionWorldBuffer), + rotation: this.rotation, + scale: this.scale, }); } public onSelect(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('SELECT_OBJECT', { - id: this.userData.id, - }); - }); + this.dispatchEvent({ type: 'object-select' }); } public onDeselect(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('DESELECT_OBJECT', { - id: this.userData.id, - }); - }); + this.dispatchEvent({ type: 'object-deselect' }); } } diff --git a/src/components/light/SceneLight.ts b/src/components/light/SceneLight.ts index 9449cd85..9fddb471 100644 --- a/src/components/light/SceneLight.ts +++ b/src/components/light/SceneLight.ts @@ -1,4 +1,5 @@ import { DIVESelectable } from '@shopware-ag/dive'; +import { type DIVEEntityEventMap } from '../../types/events/index.ts'; import { PRODUCT_LAYER_MASK } from '../../constants/VisibilityLayerMask.ts'; import { Color, @@ -15,7 +16,10 @@ import { * @module */ -export class DIVESceneLight extends Object3D implements DIVESelectable { +export class DIVESceneLight + extends Object3D + implements DIVESelectable +{ readonly isDIVELight: true = true; readonly isDIVESceneLight: true = true; readonly isSelectable: true = true; diff --git a/src/components/light/__test__/PointLight.test.ts b/src/components/light/__test__/PointLight.test.ts index a26d2ab5..a2fb2e24 100644 --- a/src/components/light/__test__/PointLight.test.ts +++ b/src/components/light/__test__/PointLight.test.ts @@ -1,18 +1,5 @@ import { DIVEPointLight } from '../PointLight.ts'; -import { State } from '@shopware-ag/dive/state'; -import { type Color, type PointLight } from 'three/webgpu'; - -vi.mock('../../../modules/state/State', () => { - return { - State: { - get: vi.fn(() => { - return { - performAction: vi.fn(), - }; - }), - }, - }; -}); +import { Object3D, type Color, type PointLight } from 'three/webgpu'; describe('dive/light/DIVEPointLight', () => { it('should instantiate', () => { @@ -50,7 +37,6 @@ describe('dive/light/DIVEPointLight', () => { testLight.userData.id = 'something'; expect(() => testLight.onMove()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => testLight.onMove()).not.toThrow(); }); @@ -59,7 +45,6 @@ describe('dive/light/DIVEPointLight', () => { testLight.userData.id = 'something'; expect(() => testLight.onSelect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => testLight.onSelect()).not.toThrow(); }); @@ -68,7 +53,59 @@ describe('dive/light/DIVEPointLight', () => { testLight.userData.id = 'something'; expect(() => testLight.onDeselect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => testLight.onDeselect()).not.toThrow(); }); + + describe('reporting about itself', () => { + it('should report a transform on move', () => { + const testLight = new DIVEPointLight(); + const onTransform = vi.fn(); + testLight.addEventListener('object-transform', onTransform); + testLight.position.set(1, 2, 3); + + testLight.onMove(); + + expect(onTransform).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledWith( + expect.objectContaining({ + position: expect.objectContaining({ x: 1, y: 2, z: 3 }), + }), + ); + }); + + it('should report the world position when nested in a group', () => { + // used to report the local position, which is only the same thing + // while the light hangs directly off the root + const parent = new Object3D(); + parent.position.set(10, 0, 0); + const testLight = new DIVEPointLight(); + parent.add(testLight); + testLight.position.set(1, 0, 0); + + const onTransform = vi.fn(); + testLight.addEventListener('object-transform', onTransform); + + testLight.onMove(); + + expect(onTransform).toHaveBeenCalledWith( + expect.objectContaining({ + position: expect.objectContaining({ x: 11 }), + }), + ); + }); + + it('should report selection and deselection', () => { + const testLight = new DIVEPointLight(); + const onSelect = vi.fn(); + const onDeselect = vi.fn(); + testLight.addEventListener('object-select', onSelect); + testLight.addEventListener('object-deselect', onDeselect); + + testLight.onSelect(); + testLight.onDeselect(); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onDeselect).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/components/light/__test__/SceneLight.test.ts b/src/components/light/__test__/SceneLight.test.ts index ac1b812d..f7f8baf4 100644 --- a/src/components/light/__test__/SceneLight.test.ts +++ b/src/components/light/__test__/SceneLight.test.ts @@ -1,23 +1,6 @@ -import { State } from '@shopware-ag/dive/state'; import { type Color } from 'three/webgpu'; import { DIVESceneLight } from '../SceneLight.ts'; -vi.mock('../../../modules/state/State', () => { - return { - State: { - get: vi.fn(() => { - return { - performAction: vi.fn(), - }; - }), - }, - }; -}); - -vi.spyOn(State, 'get').mockReturnValue({ - performAction: vi.fn(), -} as unknown as State); - describe('dive/light/DIVESceneLight', () => { it('should instantiate', () => { const testLight = new DIVESceneLight(); diff --git a/src/components/model/Model.ts b/src/components/model/Model.ts index 8084d10e..babfb91b 100644 --- a/src/components/model/Model.ts +++ b/src/components/model/Model.ts @@ -56,11 +56,7 @@ export class DIVEModel extends DIVENode { const assetLoader = await this._getAssetLoader(); const gltf = await assetLoader.load(url); this.setFromGLTF(gltf); - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id!)?.performAction('MODEL_LOADED', { - id: this.userData.id!, - }); - }); + this.dispatchEvent({ type: 'object-load' }); return this; } @@ -197,15 +193,6 @@ export class DIVEModel extends DIVENode { this.setPosition(worldPos); - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('UPDATE_OBJECT', { - id: this.userData.id, - position: worldPos, - rotation: this.rotation, - scale: this.scale, - }); - }); - this.onMove(); } @@ -259,15 +246,6 @@ export class DIVEModel extends DIVENode { this.setPosition(worldPos); - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('UPDATE_OBJECT', { - id: this.userData.id, - position: worldPos, - rotation: this.rotation, - scale: this.scale, - }); - }); - this.onMove(); } else { this.placeOnFloor(); diff --git a/src/components/model/__test__/Model.test.ts b/src/components/model/__test__/Model.test.ts index ee2287f4..62f695a6 100644 --- a/src/components/model/__test__/Model.test.ts +++ b/src/components/model/__test__/Model.test.ts @@ -115,6 +115,23 @@ vi.mock('three/webgpu', async (importOriginal) => { target.z = this.position.z; return target; }); + + // the EventDispatcher half of Object3D, which the entities now use to + // report about themselves + this._listeners = {}; + this.addEventListener = vi.fn((type: string, listener: any) => { + (this._listeners[type] ??= []).push(listener); + }); + this.removeEventListener = vi.fn((type: string, listener: any) => { + this._listeners[type] = (this._listeners[type] ?? []).filter( + (entry: any) => entry !== listener, + ); + }); + this.dispatchEvent = vi.fn((event: any) => { + (this._listeners[event.type] ?? []).forEach((listener: any) => + listener({ ...event, target: this }), + ); + }); return this; }); @@ -136,14 +153,6 @@ vi.mock('three/webgpu', async (importOriginal) => { }; }); -vi.mock('@shopware-ag/dive/state', () => ({ - State: { - get: vi.fn().mockReturnValue({ - performAction: vi.fn(), - }), - }, -})); - // Mock for AssetLoader const mockLoad = vi.fn(); vi.mock('@shopware-ag/dive/assetloader', () => ({ @@ -181,14 +190,10 @@ describe('dive/model/DIVEModel', () => { }); it('should place on floor', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - model.setFromGLTF(object); - const com = State.get('id')!; - const spyperformAction = vi.spyOn(com, 'performAction'); + const onTransform = vi.fn(); + model.addEventListener('object-transform', onTransform); model.userData.id = 'something'; model.position.set(0, 4, 0); @@ -209,35 +214,28 @@ describe('dive/model/DIVEModel', () => { }, } as unknown as DIVEScene; scene.root.parent = scene; + (scene.root as any).worldToLocal = (v: Vector3) => v; model.parent = scene.root; - vi.spyOn(DIVENode.prototype, 'setPosition').mockImplementationOnce( - () => {}, - ); - const onMoveSpy = vi - .spyOn(model, 'onMove') - .mockImplementation(() => {}); + // setPosition runs for real, so the reported world position is the one + // the model actually ended up at + // spied but not stubbed, so the real dispatch still happens + const onMoveSpy = vi.spyOn(model, 'onMove'); model.placeOnFloor(); - await new Promise(setImmediate); - expect(spyperformAction).toHaveBeenCalledWith( - 'UPDATE_OBJECT', + + // exactly one report: the explicit dispatch beside onMove is gone + expect(onMoveSpy).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledWith( expect.objectContaining({ - position: expect.objectContaining({ - y: 6, - }), + position: expect.objectContaining({ y: 6 }), }), ); - expect(onMoveSpy).toHaveBeenCalledTimes(1); }); it('should drop it', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - - const spyOnMove = vi - .spyOn(model, 'onMove') - .mockImplementation(() => {}); + // spied but not stubbed, so the real dispatch still happens + const spyOnMove = vi.spyOn(model, 'onMove'); const size = { x: 1, @@ -301,12 +299,12 @@ describe('dive/model/DIVEModel', () => { model.parent = scene.root; // first drop with movement - const com = State.get('id')!; - const spyPerform = vi.spyOn(com, 'performAction'); + const onTransform = vi.fn(); + model.addEventListener('object-transform', onTransform); expect(() => model.dropIt()).not.toThrow(); - await new Promise(setImmediate); - expect(spyPerform).toHaveBeenCalledWith( - 'UPDATE_OBJECT', + // exactly one report, the explicit dispatch next to onMove is gone + expect(onTransform).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledWith( expect.objectContaining({ position: expect.objectContaining({ y: 2.5 }), }), @@ -374,7 +372,6 @@ describe('dive/model/DIVEModel', () => { this.setFromObject = vi.fn(() => this); return this; }); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined as any); expect(() => model.dropIt()).not.toThrow(); expect(spyOnMove).toHaveBeenCalledTimes(2); }); @@ -450,10 +447,6 @@ describe('dive/model/DIVEModel', () => { }); it('should handle placeOnFloor when position does not change', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - model.setFromGLTF(object); model.userData.id = 'something'; @@ -466,14 +459,14 @@ describe('dive/model/DIVEModel', () => { return this; }); - const com = State.get('id')!; - const spyperformAction = vi.spyOn(com, 'performAction'); + const onTransform = vi.fn(); + model.addEventListener('object-transform', onTransform); const onMoveSpy = vi .spyOn(model, 'onMove') .mockImplementation(() => {}); model.placeOnFloor(); - expect(spyperformAction).not.toHaveBeenCalled(); + expect(onTransform).not.toHaveBeenCalled(); expect(onMoveSpy).not.toHaveBeenCalled(); }); @@ -493,28 +486,20 @@ describe('dive/model/DIVEModel', () => { }); it('should load model from URL', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - const mockGltf = new Object3D(); mockGltf.children.push(new Mesh()); mockLoad.mockResolvedValue(mockGltf); model.userData.id = 'test-id'; + const onLoad = vi.fn(); + model.addEventListener('object-load', onLoad); const result = await model.setFromURL('https://example.com/model.glb'); expect(mockLoad).toHaveBeenCalledWith('https://example.com/model.glb'); expect(result).toBe(model); - // Wait for the dynamic import and action - await new Promise(setImmediate); - - const com = State.get('test-id')!; - expect(com.performAction).toHaveBeenCalledWith('MODEL_LOADED', { - id: 'test-id', - }); + expect(onLoad).toHaveBeenCalledTimes(1); }); it('should reuse existing asset loader on subsequent calls', async () => { diff --git a/src/components/node/Node.ts b/src/components/node/Node.ts index 5b299502..304baedb 100644 --- a/src/components/node/Node.ts +++ b/src/components/node/Node.ts @@ -5,8 +5,12 @@ import { DIVEMovable } from '../../interfaces/Movable.ts'; import { DIVESelectable } from '../../interfaces/Selectable.ts'; import { type TransformControls } from 'three/examples/jsm/controls/TransformControls.ts'; import { type DIVEGroup } from '../group/Group.ts'; +import { type DIVEEntityEventMap } from '../../types/events/index.ts'; -export class DIVENode extends Object3D implements DIVESelectable, DIVEMovable { +export class DIVENode + extends Object3D + implements DIVESelectable, DIVEMovable +{ readonly isSelectable: true = true; readonly isMovable: true = true; readonly isDIVENode: true = true; @@ -55,43 +59,26 @@ export class DIVENode extends Object3D implements DIVESelectable, DIVEMovable { public setToWorldOrigin(): void { this.position.set(0, 0, 0); - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('UPDATE_OBJECT', { - id: this.userData.id, - position: this.getWorldPosition(this._positionWorldBuffer), - rotation: this.rotation, - scale: this.scale, - }); - }); + this.onMove(); } /** * Can be called when the object is moved from a foreign object (gizmo, parent, etc.) to update the object's position. */ public onMove(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('UPDATE_OBJECT', { - id: this.userData.id, - position: this.getWorldPosition(this._positionWorldBuffer), - rotation: this.rotation, - scale: this.scale, - }); + this.dispatchEvent({ + type: 'object-transform', + position: this.getWorldPosition(this._positionWorldBuffer), + rotation: this.rotation, + scale: this.scale, }); } public onSelect(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('SELECT_OBJECT', { - id: this.userData.id, - }); - }); + this.dispatchEvent({ type: 'object-select' }); } public onDeselect(): void { - import('@shopware-ag/dive/state').then(({ State }) => { - State.get(this.userData.id)?.performAction('DESELECT_OBJECT', { - id: this.userData.id, - }); - }); + this.dispatchEvent({ type: 'object-deselect' }); } } diff --git a/src/components/node/__test__/Node.test.ts b/src/components/node/__test__/Node.test.ts index 49a5fa10..d76696e1 100644 --- a/src/components/node/__test__/Node.test.ts +++ b/src/components/node/__test__/Node.test.ts @@ -1,23 +1,6 @@ import { DIVENode } from '../Node.ts'; -import { State } from '@shopware-ag/dive/state'; import { Vector3 } from 'three/webgpu'; -vi.mock('../../../modules/state/State', () => { - return { - State: { - get: vi.fn(() => { - return { - performAction: vi.fn(), - }; - }), - }, - }; -}); - -vi.spyOn(State, 'get').mockReturnValue({ - performAction: vi.fn(), -} as unknown as State); - let node: DIVENode; describe('dive/node/DIVENode', () => { @@ -90,7 +73,6 @@ describe('dive/node/DIVENode', () => { expect(node.position.y).toBe(0); expect(node.position.z).toBe(0); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => node.setToWorldOrigin()).not.toThrow(); }); @@ -103,7 +85,6 @@ describe('dive/node/DIVENode', () => { expect(() => node.onMove()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => node.onMove()).not.toThrow(); }); @@ -112,7 +93,6 @@ describe('dive/node/DIVENode', () => { expect(() => node.onSelect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => node.onSelect()).not.toThrow(); }); @@ -121,7 +101,80 @@ describe('dive/node/DIVENode', () => { expect(() => node.onDeselect()).not.toThrow(); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined); expect(() => node.onDeselect()).not.toThrow(); }); + + describe('reporting about itself', () => { + // The engine only states facts; turning them into actions is the state + // plugin's job, and it subscribes per object. + + it('should report a transform on move', () => { + const onTransform = vi.fn(); + node.addEventListener('object-transform', onTransform); + node.position.set(1, 2, 3); + + node.onMove(); + + expect(onTransform).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledWith( + expect.objectContaining({ + position: expect.objectContaining({ x: 1, y: 2, z: 3 }), + rotation: node.rotation, + scale: node.scale, + }), + ); + }); + + it('should report the world position, not the local one', () => { + const parent = new DIVENode(); + parent.position.set(10, 0, 0); + parent.add(node); + node.position.set(1, 0, 0); + vi.mocked(node.getWorldPosition).mockRestore(); + + const onTransform = vi.fn(); + node.addEventListener('object-transform', onTransform); + + node.onMove(); + + expect(onTransform).toHaveBeenCalledWith( + expect.objectContaining({ + position: expect.objectContaining({ x: 11 }), + }), + ); + }); + + it('should report exactly once when moved to the world origin', () => { + const onTransform = vi.fn(); + node.addEventListener('object-transform', onTransform); + + node.setToWorldOrigin(); + + expect(node.position.x).toBe(0); + expect(onTransform).toHaveBeenCalledTimes(1); + }); + + it('should report selection and deselection', () => { + const onSelect = vi.fn(); + const onDeselect = vi.fn(); + node.addEventListener('object-select', onSelect); + node.addEventListener('object-deselect', onDeselect); + + node.onSelect(); + node.onDeselect(); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onDeselect).toHaveBeenCalledTimes(1); + }); + + it('should stay silent after the listener is removed', () => { + const onTransform = vi.fn(); + node.addEventListener('object-transform', onTransform); + node.removeEventListener('object-transform', onTransform); + + node.onMove(); + + expect(onTransform).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/primitive/__test__/Primitive.test.ts b/src/components/primitive/__test__/Primitive.test.ts index 28a16a1f..e9510a2c 100644 --- a/src/components/primitive/__test__/Primitive.test.ts +++ b/src/components/primitive/__test__/Primitive.test.ts @@ -6,7 +6,6 @@ import { type Texture, type MeshStandardMaterial, } from 'three/webgpu'; -import { type State } from '@shopware-ag/dive/state'; import { DIVEScene } from 'src/engine/scene/Scene.ts'; import { GeometrySchema } from 'src/types/schema/GeometrySchema.ts'; import { MaterialSchema } from 'src/types/schema/MaterialSchema.ts'; @@ -29,14 +28,6 @@ vi.mock('three', async () => { }; }); -vi.mock('@shopware-ag/dive/state', () => ({ - State: { - get: vi.fn().mockReturnValue({ - performAction: vi.fn(), - }), - }, -})); - let primitive: DIVEPrimitive; describe('dive/primitive/DIVEPrimitive', () => { @@ -83,14 +74,8 @@ describe('dive/primitive/DIVEPrimitive', () => { }); it('should place on floor', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - - const performAction = vi.fn(); - vi.spyOn(State, 'get').mockReturnValue({ - performAction, - } as unknown as State); + const onTransform = vi.fn(); + primitive.addEventListener('object-transform', onTransform); // ensure placeOnFloor uses a gltf reference (primitive as any)['_gltf'] = primitive; @@ -118,23 +103,19 @@ describe('dive/primitive/DIVEPrimitive', () => { (scene.root as any).updateWorldMatrix = vi.fn(); primitive.placeOnFloor(); - await new Promise(setImmediate); - expect(performAction).toHaveBeenCalledWith( - 'UPDATE_OBJECT', + + // exactly one report: the explicit dispatch beside onMove is gone + expect(onTransform).toHaveBeenCalledTimes(1); + expect(onTransform).toHaveBeenCalledWith( expect.objectContaining({ - position: expect.objectContaining({ - y: 6, - }), + position: expect.objectContaining({ y: 6 }), }), ); }); it('should drop it', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - - const spy = vi.spyOn(primitive, 'onMove').mockImplementation(() => {}); + // spied but not stubbed, so the real dispatch still happens + const spy = vi.spyOn(primitive, 'onMove'); const size = { x: 1, @@ -188,13 +169,10 @@ describe('dive/primitive/DIVEPrimitive', () => { primitive.parent = scene.root; - const performAction = vi.fn(); - vi.spyOn(State, 'get').mockReturnValue({ - performAction, - } as unknown as State); + const onTransform = vi.fn(); + primitive.addEventListener('object-transform', onTransform); expect(() => primitive.dropIt()).not.toThrow(); - await new Promise(setImmediate); - expect(performAction).toHaveBeenCalled(); + expect(onTransform).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1); // second drop with zero delta -> no move @@ -231,7 +209,6 @@ describe('dive/primitive/DIVEPrimitive', () => { this.max.set(0, 2, 0); return this; }); - vi.spyOn(State, 'get').mockReturnValueOnce(undefined as any); expect(() => primitive.dropIt()).not.toThrow(); expect(spy).toHaveBeenCalledTimes(2); }); @@ -360,10 +337,6 @@ describe('dive/primitive/DIVEPrimitive', () => { }); it('should handle placeOnFloor when position does not change', async () => { - const State = await import('@shopware-ag/dive/state').then( - ({ State }) => State, - ); - primitive.userData.id = 'something'; (primitive as any)['_gltf'] = primitive; @@ -375,13 +348,11 @@ describe('dive/primitive/DIVEPrimitive', () => { }, ); - const comMock = { - performAction: vi.fn(), - } as unknown as State; - vi.spyOn(State, 'get').mockReturnValue(comMock); + const onTransform = vi.fn(); + primitive.addEventListener('object-transform', onTransform); primitive.placeOnFloor(); - expect(comMock.performAction).not.toHaveBeenCalled(); + expect(onTransform).not.toHaveBeenCalled(); }); it('should set material with all properties', () => { diff --git a/src/engine/Dive.ts b/src/engine/Dive.ts index 497c7724..9a8975c2 100644 --- a/src/engine/Dive.ts +++ b/src/engine/Dive.ts @@ -81,7 +81,7 @@ export const DIVEDefaultSettings: Required = { * #### DIVE * is the main class of the DIVE framework. * - * An instance of this class delivers a complete 3D environment with a perspective camera, orbit controls, a toolbox, and a communication system. + * An instance of this class delivers a complete 3D environment with a perspective camera and orbit controls. * ```ts * import { DIVE } from "@shopware-ag/dive"; * @@ -89,13 +89,16 @@ export const DIVEDefaultSettings: Required = { * * const dive = new DIVE(); * - * myWrapper.appendChild(dive.Canvas); + * myWrapper.appendChild(dive.canvas); + * ``` * - * dive.Communication.subscribe('GET_ALL_SCENE_DATA', () => { - * // do something - * })); + * Driving a scene from data is the job of the state plugin, which wraps a + * DIVE instance rather than being part of it: + * ```ts + * import { State } from "@shopware-ag/dive/state"; * - * dive.Communication.performAction('GET_ALL_SCENE_DATA', {}); + * const state = new State(dive, orbitController); + * await state.performAction('SET_STATE', sceneData); * ``` * @module */ diff --git a/src/types/events/DIVEEntityEventMap.ts b/src/types/events/DIVEEntityEventMap.ts new file mode 100644 index 00000000..d6e07c91 --- /dev/null +++ b/src/types/events/DIVEEntityEventMap.ts @@ -0,0 +1,38 @@ +import { type Object3DEventMap, type Vector3Like } from 'three/webgpu'; + +/** + * The transform an entity reports about itself. + * + * `position` is in world space, `rotation` and `scale` are local — that is + * what the objects already computed before they reported anything. + * + * The vectors are live references into the emitting object, including a + * scratch buffer that the next frame overwrites. A listener that keeps them + * must copy first. This is deliberate: the event fires once per gizmo frame + * and must not allocate. + */ +export type DIVEEntityTransformEvent = { + position: Vector3Like; + rotation: Vector3Like; + scale: Vector3Like; +}; + +/** + * What a scene object announces about itself. + * + * The engine only states facts here — that something moved, was selected, or + * finished loading. Whether any of it means something is for a listener to + * decide, which is what keeps the engine free of state knowledge. + * + * No event carries an id: whoever attached the listener knows which object it + * belongs to, and `event.target` is available for anyone who does not. + */ +/** Carries nothing beyond the fact that it happened. */ +type DIVEEntityBareEvent = object; + +export type DIVEEntityEventMap = Object3DEventMap & { + 'object-transform': DIVEEntityTransformEvent; + 'object-select': DIVEEntityBareEvent; + 'object-deselect': DIVEEntityBareEvent; + 'object-load': DIVEEntityBareEvent; +}; diff --git a/src/types/events/index.ts b/src/types/events/index.ts new file mode 100644 index 00000000..892fb188 --- /dev/null +++ b/src/types/events/index.ts @@ -0,0 +1 @@ +export * from './DIVEEntityEventMap.ts'; diff --git a/src/types/index.ts b/src/types/index.ts index d19e71fa..304cb219 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from './components/index.ts'; +export * from './events/index.ts'; export * from './file/index.ts'; export * from './schema/index.ts'; From 2922cf0f0243b7c156cfa02f346f15f2973a2f84 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:05:07 +0200 Subject: [PATCH 03/10] fix: create the scene light in XRLightRoot instead of updating a root that has none --- .../scene/xrroot/xrlightroot/XRLightRoot.ts | 21 ++++++------ .../xrlightroot/__test__/XRLightRoot.test.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 src/engine/scene/xrroot/xrlightroot/__test__/XRLightRoot.test.ts diff --git a/src/engine/scene/xrroot/xrlightroot/XRLightRoot.ts b/src/engine/scene/xrroot/xrlightroot/XRLightRoot.ts index d17b1aae..ccc126d2 100644 --- a/src/engine/scene/xrroot/xrlightroot/XRLightRoot.ts +++ b/src/engine/scene/xrroot/xrlightroot/XRLightRoot.ts @@ -2,6 +2,7 @@ import { XREstimatedLight } from 'three/examples/jsm/webxr/XREstimatedLight.ts'; import { Object3D } from 'three/webgpu'; import { type DIVEScene } from '../../Scene.ts'; import { DIVERoot } from '../../../../components/root/Root.ts'; +import { DIVESceneLight } from '../../../../components/light/SceneLight.ts'; export class DIVEXRLightRoot extends Object3D { private _scene: DIVEScene; @@ -21,16 +22,16 @@ export class DIVEXRLightRoot extends Object3D { // add scene this._lightRoot = new DIVERoot(); - this._lightRoot.updateSceneObject({ - id: 'XRSceneLight', - entityType: 'light', - name: 'XRSceneLight', - type: 'scene', - color: 0xffffff, - intensity: 1, - enabled: true, - visible: true, - }); + + // This used to go through updateSceneObject on a freshly built root, + // which found nothing to update and warned — the XR light root has + // been shipping without a light. The defaults of DIVESceneLight are + // white, intensity 1 and enabled, which is what the old call asked for. + const light = new DIVESceneLight(); + light.name = 'XRSceneLight'; + light.userData.id = 'XRSceneLight'; + this._lightRoot.add(light); + this.add(this._lightRoot); } diff --git a/src/engine/scene/xrroot/xrlightroot/__test__/XRLightRoot.test.ts b/src/engine/scene/xrroot/xrlightroot/__test__/XRLightRoot.test.ts new file mode 100644 index 00000000..64a969dc --- /dev/null +++ b/src/engine/scene/xrroot/xrlightroot/__test__/XRLightRoot.test.ts @@ -0,0 +1,33 @@ +import { DIVEXRLightRoot } from '../XRLightRoot.ts'; +import { DIVESceneLight } from '../../../../../components/light/SceneLight.ts'; +import { type DIVEScene } from '../../../Scene.ts'; + +const mockScene = { environment: null } as unknown as DIVEScene; + +describe('engine/scene/xrroot/DIVEXRLightRoot', () => { + it('should hold a scene light', () => { + // it used to call updateSceneObject on a freshly built root, which + // found nothing to update — the light root shipped with only a floor + const xrLightRoot = new DIVEXRLightRoot(mockScene); + + const lights: DIVESceneLight[] = []; + xrLightRoot.traverse((child) => { + if (child instanceof DIVESceneLight) lights.push(child); + }); + + expect(lights).toHaveLength(1); + expect(lights[0].name).toBe('XRSceneLight'); + expect(lights[0].userData.id).toBe('XRSceneLight'); + }); + + it('should hide and show the light root with the estimation', () => { + const xrLightRoot = new DIVEXRLightRoot(mockScene); + const lightRoot = xrLightRoot['_lightRoot']; + + xrLightRoot['onEstimationStart'](); + expect(lightRoot.visible).toBe(false); + + xrLightRoot['onEstimationEnd'](); + expect(lightRoot.visible).toBe(true); + }); +}); From 8a4fc93a69361e72e5a1615c3ee4aeee8dd43466 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:05:07 +0200 Subject: [PATCH 04/10] refactor: extract detachTransformControls into a helper --- .../__test__/detachTransformControls.test.ts | 47 +++++++++++++++++++ .../detachTransformControls.ts | 40 ++++++++++++++++ src/helpers/index.ts | 1 + 3 files changed, 88 insertions(+) create mode 100644 src/helpers/detachTransformControls/__test__/detachTransformControls.test.ts create mode 100644 src/helpers/detachTransformControls/detachTransformControls.ts diff --git a/src/helpers/detachTransformControls/__test__/detachTransformControls.test.ts b/src/helpers/detachTransformControls/__test__/detachTransformControls.test.ts new file mode 100644 index 00000000..5c89800e --- /dev/null +++ b/src/helpers/detachTransformControls/__test__/detachTransformControls.test.ts @@ -0,0 +1,47 @@ +import { Object3D } from 'three/webgpu'; +import { detachTransformControls } from '../detachTransformControls.ts'; + +describe('helpers/detachTransformControls', () => { + it('should detach transform controls from object', () => { + const mockObject = new Object3D(); + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + mockObject.parent = mockScene; + + detachTransformControls(mockObject); + expect(mockTransformControls.detach).toHaveBeenCalled(); + }); + + it('should detach controls from transform control helper roots', () => { + const mockObject = new Object3D(); + const detach = vi.fn(); + const mockHelperRoot = Object.assign(new Object3D(), { + isTransformControlsRoot: true, + controls: { + detach, + }, + }); + + const mockScene = new Object3D(); + mockScene.children = [mockHelperRoot]; + mockObject.parent = mockScene; + + detachTransformControls(mockObject); + expect(detach).toHaveBeenCalled(); + }); + + it('should handle object without transform controls', () => { + const mockObject = new Object3D(); + const mockScene = new Object3D(); + mockScene.children = []; + mockObject.parent = mockScene; + + detachTransformControls(mockObject); + // No error should be thrown + }); +}); diff --git a/src/helpers/detachTransformControls/detachTransformControls.ts b/src/helpers/detachTransformControls/detachTransformControls.ts new file mode 100644 index 00000000..c10f4b95 --- /dev/null +++ b/src/helpers/detachTransformControls/detachTransformControls.ts @@ -0,0 +1,40 @@ +import { type Object3D } from 'three/webgpu'; +import { type TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import { findSceneRecursive } from '../findSceneRecursive/findSceneRecursive.ts'; + +/** + * Release the gizmo if it is currently holding this object. + * + * Only necessary because the old `TransformControls` are still in use instead + * of `DIVEGizmo`: they keep a reference to their target, so removing the + * object from the scene without detaching first leaves the gizmo pointing at + * something that is no longer there. + * + * Both shapes are checked because the controls sit under a helper root in + * newer three versions and directly in the scene in older ones. + * + * @param object - The object about to leave the scene. + */ +export const detachTransformControls = (object: Object3D): void => { + findSceneRecursive(object).children.find((sceneChild) => { + const helperRoot = sceneChild as Object3D & { + isTransformControlsRoot?: boolean; + controls?: TransformControls; + }; + if (helperRoot.isTransformControlsRoot && helperRoot.controls) { + helperRoot.controls.detach(); + return true; + } + + const controls = sceneChild as Object3D & { + isTransformControls?: boolean; + detach?: () => void; + }; + if (controls.isTransformControls && controls.detach) { + controls.detach(); + return true; + } + + return false; + }); +}; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 93af6f8f..c4fae2a0 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,5 +1,6 @@ export * from './applyMixins/applyMixins.ts'; export * from './deepClone/deepClone.ts'; +export * from './detachTransformControls/detachTransformControls.ts'; export * from './findInterface/findInterface.ts'; export * from './findSceneRecursive/findSceneRecursive.ts'; export * from './getFileTypeFromUri/getFileTypeFromUri.ts'; From 48988e494bb0172949af6bab2215be131326c239 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:06:19 +0200 Subject: [PATCH 05/10] refactor!: rename MaterialSchema, GeometrySchema and GeometryTypeSchema to DIVEMaterial, DIVEGeometry and DIVEGeometryType --- src/components/model/Model.ts | 4 +-- src/components/model/__test__/Model.test.ts | 14 ++++---- src/components/primitive/Primitive.ts | 20 +++++------ .../primitive/__test__/Primitive.test.ts | 36 +++++++++---------- src/components/root/__test__/Root.test.ts | 4 +-- .../DIVEGeometry.ts} | 8 ++--- .../DIVEGeometryType.ts} | 2 +- src/types/geometry/index.ts | 2 ++ src/types/index.ts | 2 ++ .../DIVEMaterial.ts} | 2 +- src/types/material/index.ts | 1 + src/types/schema/ModelSchema.ts | 4 +-- src/types/schema/PrimitiveSchema.ts | 10 +++--- src/types/schema/index.ts | 3 -- 14 files changed, 57 insertions(+), 55 deletions(-) rename src/types/{schema/GeometrySchema.ts => geometry/DIVEGeometry.ts} (65%) rename src/types/{schema/GeometryTypeSchema.ts => geometry/DIVEGeometryType.ts} (90%) create mode 100644 src/types/geometry/index.ts rename src/types/{schema/MaterialSchema.ts => material/DIVEMaterial.ts} (95%) create mode 100644 src/types/material/index.ts diff --git a/src/components/model/Model.ts b/src/components/model/Model.ts index babfb91b..00fbdd48 100644 --- a/src/components/model/Model.ts +++ b/src/components/model/Model.ts @@ -9,7 +9,7 @@ import { import { PRODUCT_LAYER_MASK } from '../../constants/VisibilityLayerMask.ts'; import { findSceneRecursive } from '../../helpers/findSceneRecursive/findSceneRecursive.ts'; import { DIVENode } from '../node/Node.ts'; -import { MaterialSchema } from 'src/types/index.ts'; +import { DIVEMaterial } from 'src/types/index.ts'; import { BoundingBox } from '../boundingbox/BoundingBox.ts'; /** @@ -108,7 +108,7 @@ export class DIVEModel extends DIVENode { return this; } - public setMaterial(material: Partial): void { + public setMaterial(material: Partial): void { // if there is no material, create a new one if (!this._material) { this._material = new MeshStandardMaterial(); diff --git a/src/components/model/__test__/Model.test.ts b/src/components/model/__test__/Model.test.ts index 62f695a6..7b5434ef 100644 --- a/src/components/model/__test__/Model.test.ts +++ b/src/components/model/__test__/Model.test.ts @@ -9,7 +9,7 @@ import { Object3D, } from 'three/webgpu'; import { DIVENode } from '../../node/Node.ts'; -import { type MaterialSchema } from '../../../types/schema/MaterialSchema.ts'; +import { type DIVEMaterial } from '../../../types/material/DIVEMaterial.ts'; import { BoundingBox } from '../../boundingbox/BoundingBox.ts'; // ============================================================================ @@ -378,7 +378,7 @@ describe('dive/model/DIVEModel', () => { it('should set material', () => { // apply invalid material should not crash - expect(() => model.setMaterial({} as MaterialSchema)).not.toThrow(); + expect(() => model.setMaterial({} as DIVEMaterial)).not.toThrow(); expect(model['_material']).not.toBeNull(); expect(() => @@ -386,7 +386,7 @@ describe('dive/model/DIVEModel', () => { color: 0xffffff, roughness: 0, metalness: 1, - } as MaterialSchema), + } as DIVEMaterial), ).not.toThrow(); expect((model['_material'] as MeshStandardMaterial).roughness).toBe(0); expect( @@ -407,7 +407,7 @@ describe('dive/model/DIVEModel', () => { roughnessMap: 'This_Is_A_Texture' as unknown as Texture, metalness: 1, metalnessMap: 'This_Is_A_Texture' as unknown as Texture, - } as MaterialSchema), + } as DIVEMaterial), ).not.toThrow(); expect((model['_material'] as MeshStandardMaterial).roughness).toBe(1); expect( @@ -420,7 +420,7 @@ describe('dive/model/DIVEModel', () => { }); it('should set model material when material already set before', () => { - model.setMaterial({ roughness: 0.5 } as MaterialSchema); + model.setMaterial({ roughness: 0.5 } as DIVEMaterial); expect(() => model.setFromGLTF(object)).not.toThrow(); expect( (model['_mesh']?.material as MeshStandardMaterial).roughness, @@ -430,7 +430,7 @@ describe('dive/model/DIVEModel', () => { it('should set material to model when model already set before', () => { model.setFromGLTF(object); expect(() => - model.setMaterial({ roughness: 0.5 } as MaterialSchema), + model.setMaterial({ roughness: 0.5 } as DIVEMaterial), ).not.toThrow(); expect( (model['_mesh']?.material as MeshStandardMaterial).roughness, @@ -475,7 +475,7 @@ describe('dive/model/DIVEModel', () => { (model['_material'] as unknown) = null; (model['_mesh'] as unknown) = null; expect(() => - model.setMaterial({ roughness: 0.5 } as MaterialSchema), + model.setMaterial({ roughness: 0.5 } as DIVEMaterial), ).not.toThrow(); // Verify new material was created diff --git a/src/components/primitive/Primitive.ts b/src/components/primitive/Primitive.ts index 1620523b..3bd948ac 100644 --- a/src/components/primitive/Primitive.ts +++ b/src/components/primitive/Primitive.ts @@ -10,7 +10,7 @@ import { } from 'three/webgpu'; import { PRODUCT_LAYER_MASK } from '../../constants/VisibilityLayerMask.ts'; import { DIVEModel } from '../model/Model.ts'; -import { type GeometrySchema } from '../../types/index.ts'; +import { type DIVEGeometry } from '../../types/index.ts'; /** * A basic model class. @@ -41,7 +41,7 @@ export class DIVEPrimitive extends DIVEModel { this._mesh.material = this._material; } - public setGeometry(geometry: GeometrySchema): void { + public setGeometry(geometry: DIVEGeometry): void { const geo = this.assembleGeometry(geometry); if (!geo) return; @@ -52,7 +52,7 @@ export class DIVEPrimitive extends DIVEModel { this._boundingBox.setFromObject(this._mesh); } - private assembleGeometry(geometry: GeometrySchema): BufferGeometry | null { + private assembleGeometry(geometry: DIVEGeometry): BufferGeometry | null { // reset material to smooth shading this._material.flatShading = false; @@ -84,7 +84,7 @@ export class DIVEPrimitive extends DIVEModel { } } - private createCylinderGeometry(geometry: GeometrySchema): BufferGeometry { + private createCylinderGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new CylinderGeometry( geometry.width / 2, geometry.width / 2, @@ -95,12 +95,12 @@ export class DIVEPrimitive extends DIVEModel { return geo; } - private createSphereGeometry(geometry: GeometrySchema): BufferGeometry { + private createSphereGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new SphereGeometry(geometry.width / 2, 256, 256); return geo; } - private createPyramidGeometry(geometry: GeometrySchema): BufferGeometry { + private createPyramidGeometry(geometry: DIVEGeometry): BufferGeometry { const vertices = new Float32Array([ -geometry.width / 2, 0, @@ -132,7 +132,7 @@ export class DIVEPrimitive extends DIVEModel { return geometryBuffer; } - private createBoxGeometry(geometry: GeometrySchema): BufferGeometry { + private createBoxGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new BoxGeometry( geometry.width, geometry.height, @@ -142,13 +142,13 @@ export class DIVEPrimitive extends DIVEModel { return geo; } - private createConeGeometry(geometry: GeometrySchema): BufferGeometry { + private createConeGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new ConeGeometry(geometry.width / 2, geometry.height, 256); geo.translate(0, geometry.height / 2, 0); return geo; } - private createWallGeometry(geometry: GeometrySchema): BufferGeometry { + private createWallGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new BoxGeometry( geometry.width, geometry.height, @@ -159,7 +159,7 @@ export class DIVEPrimitive extends DIVEModel { return geo; } - private createPlaneGeometry(geometry: GeometrySchema): BufferGeometry { + private createPlaneGeometry(geometry: DIVEGeometry): BufferGeometry { const geo = new BoxGeometry( geometry.width, geometry.height, diff --git a/src/components/primitive/__test__/Primitive.test.ts b/src/components/primitive/__test__/Primitive.test.ts index e9510a2c..aed19068 100644 --- a/src/components/primitive/__test__/Primitive.test.ts +++ b/src/components/primitive/__test__/Primitive.test.ts @@ -7,9 +7,9 @@ import { type MeshStandardMaterial, } from 'three/webgpu'; import { DIVEScene } from 'src/engine/scene/Scene.ts'; -import { GeometrySchema } from 'src/types/schema/GeometrySchema.ts'; -import { MaterialSchema } from 'src/types/schema/MaterialSchema.ts'; -import { GeometryTypeSchema } from 'src/types/schema/GeometryTypeSchema.ts'; +import { DIVEGeometry } from '../../../types/geometry/DIVEGeometry.ts'; +import { DIVEMaterial } from '../../../types/material/DIVEMaterial.ts'; +import { DIVEGeometryType } from '../../../types/geometry/DIVEGeometryType.ts'; const RaycasterIntersectObjectMock = vi.fn().mockReturnValue([]); @@ -55,11 +55,11 @@ describe('dive/primitive/DIVEPrimitive', () => { it('should set geometry', () => { vi.spyOn(console, 'warn'); const geometry = { - name: 'cube' as GeometryTypeSchema, + name: 'cube' as DIVEGeometryType, width: 1, height: 1, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(geometry)).not.toThrow(); expect(console.warn).not.toHaveBeenCalled(); }); @@ -67,8 +67,8 @@ describe('dive/primitive/DIVEPrimitive', () => { it('should warn when geometry is invalid', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const geometry = { - name: 'INVALID' as GeometryTypeSchema, - } as GeometrySchema; + name: 'INVALID' as DIVEGeometryType, + } as DIVEGeometry; expect(() => primitive.setGeometry(geometry)).not.toThrow(); expect(console.warn).toHaveBeenCalled(); }); @@ -222,7 +222,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1.5, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(cylinder)).not.toThrow(); // sphere @@ -231,7 +231,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(sphere)).not.toThrow(); // pyramid @@ -240,7 +240,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1.5, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(pyramid)).not.toThrow(); // box @@ -249,7 +249,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(box)).not.toThrow(); // cone @@ -258,7 +258,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1.5, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(cone)).not.toThrow(); // wall @@ -267,14 +267,14 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 1.5, depth: 0.1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(wall)).not.toThrow(); const wallWithoutDepth = { name: 'wall', width: 1, height: 1.5, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(wallWithoutDepth)).not.toThrow(); // plane @@ -283,7 +283,7 @@ describe('dive/primitive/DIVEPrimitive', () => { width: 1, height: 0.1, depth: 1, - } as GeometrySchema; + } as DIVEGeometry; expect(() => primitive.setGeometry(plane)).not.toThrow(); }); @@ -291,7 +291,7 @@ describe('dive/primitive/DIVEPrimitive', () => { const material = primitive['_mesh'].material as MeshStandardMaterial; // apply invalid material should not crash - expect(() => primitive.setMaterial({} as MaterialSchema)).not.toThrow(); + expect(() => primitive.setMaterial({} as DIVEMaterial)).not.toThrow(); expect(material).toBeDefined(); expect(() => @@ -299,7 +299,7 @@ describe('dive/primitive/DIVEPrimitive', () => { color: 0xffffff, roughness: 0, metalness: 1, - } as MaterialSchema), + } as DIVEMaterial), ).not.toThrow(); expect((material as MeshStandardMaterial).roughness).toBe(0); expect((material as MeshStandardMaterial).roughnessMap).toBeNull(); @@ -316,7 +316,7 @@ describe('dive/primitive/DIVEPrimitive', () => { roughnessMap: 'This_Is_A_Texture' as unknown as Texture, metalness: 1, metalnessMap: 'This_Is_A_Texture' as unknown as Texture, - } as MaterialSchema), + } as DIVEMaterial), ).not.toThrow(); expect((material as MeshStandardMaterial).roughness).toBe(1.0); expect((material as MeshStandardMaterial).roughnessMap).toBeDefined(); diff --git a/src/components/root/__test__/Root.test.ts b/src/components/root/__test__/Root.test.ts index dd0d4233..68431bf2 100644 --- a/src/components/root/__test__/Root.test.ts +++ b/src/components/root/__test__/Root.test.ts @@ -7,7 +7,7 @@ import { GroupSchema, CameraSchema, EntityTypeSchema, - GeometryTypeSchema, + DIVEGeometryType, } from '@shopware-ag/dive'; import { Object3D, Vector3, Box3 } from 'three/webgpu'; @@ -854,7 +854,7 @@ describe('components/root/DIVERoot', () => { const updatedData = { ...primitiveData, geometry: { - name: 'box' as GeometryTypeSchema, + name: 'box' as DIVEGeometryType, width: 2, height: 2, depth: 2, diff --git a/src/types/schema/GeometrySchema.ts b/src/types/geometry/DIVEGeometry.ts similarity index 65% rename from src/types/schema/GeometrySchema.ts rename to src/types/geometry/DIVEGeometry.ts index fa710462..2bd6e154 100644 --- a/src/types/schema/GeometrySchema.ts +++ b/src/types/geometry/DIVEGeometry.ts @@ -1,4 +1,4 @@ -import { GeometryTypeSchema } from './GeometryTypeSchema.ts'; +import { DIVEGeometryType } from './DIVEGeometryType.ts'; /** * Describes the shape of a primitive. @@ -8,9 +8,9 @@ import { GeometryTypeSchema } from './GeometryTypeSchema.ts'; * radius from `width` alone and ignores the other two, while a box uses all * three. */ -export type GeometrySchema = { - /** Picks the shape to build, see {@link GeometryTypeSchema}. */ - name: GeometryTypeSchema; +export type DIVEGeometry = { + /** Picks the shape to build, see {@link DIVEGeometryType}. */ + name: DIVEGeometryType; width: number; height: number; depth: number; diff --git a/src/types/schema/GeometryTypeSchema.ts b/src/types/geometry/DIVEGeometryType.ts similarity index 90% rename from src/types/schema/GeometryTypeSchema.ts rename to src/types/geometry/DIVEGeometryType.ts index 24af402f..9ea76ff9 100644 --- a/src/types/schema/GeometryTypeSchema.ts +++ b/src/types/geometry/DIVEGeometryType.ts @@ -4,7 +4,7 @@ * `cube` and `box` build the same geometry. An unknown value is not an error, * it only warns and leaves the primitive without a mesh. */ -export type GeometryTypeSchema = +export type DIVEGeometryType = | 'cylinder' | 'sphere' | 'pyramid' diff --git a/src/types/geometry/index.ts b/src/types/geometry/index.ts new file mode 100644 index 00000000..5044611d --- /dev/null +++ b/src/types/geometry/index.ts @@ -0,0 +1,2 @@ +export * from './DIVEGeometry.ts'; +export * from './DIVEGeometryType.ts'; diff --git a/src/types/index.ts b/src/types/index.ts index 304cb219..ca92b665 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,6 @@ export * from './components/index.ts'; export * from './events/index.ts'; export * from './file/index.ts'; +export * from './geometry/index.ts'; +export * from './material/index.ts'; export * from './schema/index.ts'; diff --git a/src/types/schema/MaterialSchema.ts b/src/types/material/DIVEMaterial.ts similarity index 95% rename from src/types/schema/MaterialSchema.ts rename to src/types/material/DIVEMaterial.ts index fb401ca2..962dc96e 100644 --- a/src/types/schema/MaterialSchema.ts +++ b/src/types/material/DIVEMaterial.ts @@ -9,7 +9,7 @@ import { type Texture } from 'three/webgpu'; * * The `null` on the texture slots means "no texture", not "unchanged". */ -export type MaterialSchema = { +export type DIVEMaterial = { vertexColors: boolean; color: string | number; /** The base colour texture. */ diff --git a/src/types/material/index.ts b/src/types/material/index.ts new file mode 100644 index 00000000..d9218e39 --- /dev/null +++ b/src/types/material/index.ts @@ -0,0 +1 @@ +export * from './DIVEMaterial.ts'; diff --git a/src/types/schema/ModelSchema.ts b/src/types/schema/ModelSchema.ts index 33b966f7..c434f38b 100644 --- a/src/types/schema/ModelSchema.ts +++ b/src/types/schema/ModelSchema.ts @@ -1,5 +1,5 @@ import { type Vector3Like } from 'three/webgpu'; -import { type MaterialSchema } from './MaterialSchema.ts'; +import { type DIVEMaterial } from '../material/DIVEMaterial.ts'; import { type BaseEntitySchema } from './BaseEntitySchema.ts'; import { type EntitySchema } from './EntitySchema.ts'; @@ -30,5 +30,5 @@ export type ModelSchema = BaseEntitySchema & { */ loaded: boolean; /** Overrides on top of what the asset itself brings along. */ - material?: Partial; + material?: Partial; }; diff --git a/src/types/schema/PrimitiveSchema.ts b/src/types/schema/PrimitiveSchema.ts index 97a265f3..2c3cfb23 100644 --- a/src/types/schema/PrimitiveSchema.ts +++ b/src/types/schema/PrimitiveSchema.ts @@ -1,7 +1,7 @@ import { type Vector3Like } from 'three/webgpu'; import { type BaseEntitySchema } from './BaseEntitySchema.ts'; -import { type GeometrySchema } from './GeometrySchema.ts'; -import { type MaterialSchema } from './MaterialSchema.ts'; +import { type DIVEGeometry } from '../geometry/DIVEGeometry.ts'; +import { type DIVEMaterial } from '../material/DIVEMaterial.ts'; import { type EntitySchema } from './EntitySchema.ts'; export function isPrimitiveSchema( @@ -19,7 +19,7 @@ export type PrimitiveSchema = BaseEntitySchema & { position: Vector3Like; rotation: Vector3Like; scale: Vector3Like; - /** Rebuilding this replaces the mesh, see {@link GeometrySchema}. */ - geometry: GeometrySchema; - material?: Partial; + /** Rebuilding this replaces the mesh, see {@link DIVEGeometry}. */ + geometry: DIVEGeometry; + material?: Partial; }; diff --git a/src/types/schema/index.ts b/src/types/schema/index.ts index 10710c76..928be5ac 100644 --- a/src/types/schema/index.ts +++ b/src/types/schema/index.ts @@ -1,11 +1,8 @@ export * from './BaseEntitySchema.ts'; export * from './EntitySchema.ts'; export * from './EntityTypeSchema.ts'; -export * from './GeometrySchema.ts'; -export * from './GeometryTypeSchema.ts'; export * from './GroupSchema.ts'; export * from './LightSchema.ts'; -export * from './MaterialSchema.ts'; export * from './ModelSchema.ts'; export * from './CameraSchema.ts'; export * from './PrimitiveSchema.ts'; From a819b18a1ee5d0a3e9c89178cbe142065148ea33 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:08:47 +0200 Subject: [PATCH 06/10] feat!: add EngineGateway to the state plugin and strip the entity mapping out of DIVERoot --- src/components/root/Root.ts | 361 +-- src/components/root/__test__/Root.test.ts | 1678 -------------- src/plugins/state/src/EngineGateway.ts | 481 ++++ src/plugins/state/src/State.ts | 22 +- .../state/src/__test__/EngineGateway.test.ts | 2014 +++++++++++++++++ src/plugins/state/src/__test__/State.test.ts | 19 +- .../__test__/computeencompassingview.test.ts | 12 +- .../camera/__test__/movecamera.test.ts | 26 +- .../actions/camera/computeencompassingview.ts | 6 +- .../state/src/actions/camera/movecamera.ts | 8 +- .../actions/object/__test__/addobject.test.ts | 37 +- .../object/__test__/deleteobject.test.ts | 38 +- .../object/__test__/deselectobject.test.ts | 33 +- .../actions/object/__test__/dropit.test.ts | 34 +- .../object/__test__/getobjects.test.ts | 6 +- .../object/__test__/placeonfloor.test.ts | 34 +- .../object/__test__/selectobject.test.ts | 33 +- .../actions/object/__test__/setparent.test.ts | 63 +- .../object/__test__/updateobject.test.ts | 22 +- .../state/src/actions/object/addobject.ts | 11 +- .../state/src/actions/object/deleteobject.ts | 10 +- .../src/actions/object/deselectobject.ts | 6 +- .../state/src/actions/object/dropit.ts | 8 +- .../state/src/actions/object/getobjects.ts | 2 +- .../state/src/actions/object/placeonfloor.ts | 8 +- .../state/src/actions/object/selectobject.ts | 9 +- .../state/src/actions/object/setparent.ts | 14 +- .../state/src/actions/object/updateobject.ts | 6 +- .../renderer/__test__/startrender.test.ts | 11 +- .../state/src/actions/renderer/startrender.ts | 6 +- .../scene/__test__/exportscene.test.ts | 22 +- .../scene/__test__/getallscenedata.test.ts | 32 +- .../scene/__test__/setbackground.test.ts | 17 +- .../scene/__test__/updatescene.test.ts | 138 +- .../state/src/actions/scene/exportscene.ts | 6 +- .../src/actions/scene/getallscenedata.ts | 20 +- .../state/src/actions/scene/setbackground.ts | 6 +- .../state/src/actions/scene/updatescene.ts | 32 +- .../actions/state/__test__/getstate.test.ts | 32 +- .../actions/state/__test__/setstate.test.ts | 77 +- .../state/src/actions/state/getstate.ts | 20 +- .../state/src/actions/state/setstate.ts | 31 +- src/plugins/state/types/ActionTypes.ts | 4 +- src/plugins/state/types/StateData.ts | 12 +- src/types/components/DIVESceneObject.ts | 20 +- 45 files changed, 2907 insertions(+), 2580 deletions(-) create mode 100644 src/plugins/state/src/EngineGateway.ts create mode 100644 src/plugins/state/src/__test__/EngineGateway.test.ts diff --git a/src/components/root/Root.ts b/src/components/root/Root.ts index 769af1e4..0656587e 100644 --- a/src/components/root/Root.ts +++ b/src/components/root/Root.ts @@ -1,29 +1,12 @@ -import { Box3, Color, Object3D } from 'three/webgpu'; -import { DIVEAmbientLight } from '../light/AmbientLight.ts'; -import { DIVEPointLight } from '../light/PointLight.ts'; -import { DIVESceneLight } from '../light/SceneLight.ts'; -import { DIVEModel } from '../model/Model.ts'; -import { DIVEPrimitive } from '../primitive/Primitive.ts'; - -import { type DIVEScene } from '../../engine/scene/Scene.ts'; -import { type TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; -import { - LightSchema, - ModelSchema, - EntitySchema, - PrimitiveSchema, - GroupSchema, - EntityTypeSchema, - MinimalSchema, - PartialSchema, -} from '@shopware-ag/dive'; -import { DIVELight, type DIVESceneObject } from '../../types/index.ts'; -import { DIVEGroup } from '../group/Group.ts'; +import { Box3, Object3D } from 'three/webgpu'; import { DIVEFloor } from '../floor/Floor.ts'; /** * A basic scene node to hold grid, floor and all lower level roots. * + * It holds objects, it does not interpret them: turning entity data into + * scene objects is the state plugin's job and lives in its `EngineGateway`. + * * @module */ @@ -56,340 +39,4 @@ export class DIVERoot extends Object3D { }); return bb; } - - public getSceneObject( - object: Partial & { id: string; entityType: E }, - ): DIVESceneObject | undefined { - let foundObject: DIVESceneObject | undefined; - this.traverse((object3D) => { - if (foundObject) return; - if (object3D.userData.id === object.id) { - foundObject = object3D as DIVESceneObject; - } - }); - return foundObject; - } - - public async addSceneObject( - object: EntitySchema, - ): Promise { - let sceneObject = this.getSceneObject(object); - if (sceneObject) { - console.warn( - `DIVERoot.addSceneObject: Scene object with id ${object.id} already exists`, - ); - return sceneObject; - } - - switch (object.entityType) { - case 'camera': { - break; - } - case 'light': { - switch (object.type) { - case 'scene': { - sceneObject = new DIVESceneLight(); - break; - } - case 'ambient': { - sceneObject = new DIVEAmbientLight(); - break; - } - case 'point': { - sceneObject = new DIVEPointLight(); - break; - } - default: { - throw new Error( - `DIVERoot.addSceneObject: Unknown light type: ${(object as unknown as LightSchema).type}`, - ); - } - } - - sceneObject.name = object.name; - sceneObject.userData.id = object.id; - this.add(sceneObject); - this._updateLight(sceneObject as DIVELight, object); - break; - } - case 'model': { - sceneObject = new DIVEModel(); - sceneObject.name = object.name; - sceneObject.userData.id = object.id; - this.add(sceneObject); - await this._updateModel(sceneObject as DIVEModel, object); - break; - } - case 'primitive': { - sceneObject = new DIVEPrimitive(); - sceneObject.name = object.name; - sceneObject.userData.id = object.id; - this.add(sceneObject); - this._updatePrimitive(sceneObject as DIVEPrimitive, object); - break; - } - case 'group': { - sceneObject = new DIVEGroup(); - sceneObject.name = object.name; - sceneObject.userData.id = object.id; - this.add(sceneObject); - this._updateGroup(sceneObject as DIVEGroup, object); - break; - } - default: { - throw new Error( - `DIVERoot.addSceneObject: Unknown entity type: ${(object as unknown as EntitySchema).entityType}`, - ); - } - } - - return sceneObject; - } - - public async updateSceneObject(object: PartialSchema): Promise { - const sceneObject = this.getSceneObject(object); - if (!sceneObject) { - console.warn( - `DIVERoot.updateSceneObject: Scene object with id ${object.id} does not exist`, - ); - return; - } - - switch (object.entityType) { - case 'camera': { - break; - } - case 'light': { - this._updateLight(sceneObject as DIVELight, object); - break; - } - case 'model': { - await this._updateModel(sceneObject as DIVEModel, object); - break; - } - case 'primitive': { - this._updatePrimitive(sceneObject as DIVEPrimitive, object); - break; - } - case 'group': { - this._updateGroup(sceneObject as DIVEGroup, object); - break; - } - default: { - throw new Error( - `DIVERoot.updateSceneObject: Unknown entity type: ${(object as unknown as EntitySchema).entityType}`, - ); - } - } - } - - public deleteSceneObject(object: MinimalSchema): void { - const sceneObject = this.getSceneObject(object); - if (!sceneObject) { - console.warn( - `DIVERoot.deleteSceneObject: Object with id ${object.id} not found`, - ); - return; - } - - switch (object.entityType) { - case 'camera': { - break; - } - case 'light': { - this._deleteLight(sceneObject as DIVELight); - break; - } - case 'model': { - this._deleteModel(sceneObject as DIVEModel); - break; - } - case 'primitive': { - this._deletePrimitive(sceneObject as DIVEPrimitive); - break; - } - case 'group': { - this._deleteGroup(sceneObject as DIVEGroup); - break; - } - default: { - throw new Error( - `DIVERoot.deleteSceneObject: Unknown entity type: ${(object as unknown as EntitySchema).entityType}`, - ); - } - } - } - - private _updateLight( - sceneObject: DIVELight, - props: PartialSchema, - ): void { - if (props.name !== undefined) sceneObject.name = props.name; - if (props.position !== undefined) - sceneObject.position.set( - props.position.x, - props.position.y, - props.position.z, - ); - if (props.intensity !== undefined) - sceneObject.setIntensity(props.intensity); - if (props.enabled !== undefined) sceneObject.setEnabled(props.enabled); - if (props.color !== undefined) - sceneObject.setColor(new Color(props.color)); - if (props.visible !== undefined) sceneObject.visible = props.visible; - if (props.parentId !== undefined) - this._setParent({ ...props, parentId: props.parentId }); - } - - private async _updateModel( - sceneObject: DIVEModel, - model: PartialSchema, - ): Promise { - // awaited, so callers can tell when the model is actually in the scene. - // userData.uri holds what is currently loaded, so an update that only - // moves the model does not fetch the asset again. - if (model.uri !== undefined && model.uri !== sceneObject.userData.uri) { - await sceneObject.setFromURL(model.uri); - sceneObject.userData.uri = model.uri; - } - if (model.name !== undefined) sceneObject.name = model.name; - if (model.position !== undefined) - sceneObject.setPosition(model.position); - if (model.rotation !== undefined) - sceneObject.setRotation(model.rotation); - if (model.scale !== undefined) sceneObject.setScale(model.scale); - if (model.visible !== undefined) - sceneObject.setVisibility(model.visible); - if (model.material !== undefined) - sceneObject.setMaterial(model.material); - if (model.parentId !== undefined) - this._setParent({ ...model, parentId: model.parentId }); - } - - private _updatePrimitive( - sceneObject: DIVEPrimitive, - primitive: PartialSchema, - ): void { - if (primitive.name !== undefined) sceneObject.name = primitive.name; - if (primitive.geometry !== undefined) - (sceneObject as DIVEPrimitive).setGeometry(primitive.geometry); - if (primitive.position !== undefined) - (sceneObject as DIVEPrimitive).setPosition(primitive.position); - if (primitive.rotation !== undefined) - (sceneObject as DIVEPrimitive).setRotation(primitive.rotation); - if (primitive.scale !== undefined) - (sceneObject as DIVEPrimitive).setScale(primitive.scale); - if (primitive.visible !== undefined) - (sceneObject as DIVEPrimitive).setVisibility(primitive.visible); - if (primitive.material !== undefined) - (sceneObject as DIVEPrimitive).setMaterial(primitive.material); - if (primitive.parentId !== undefined) - this._setParent({ ...primitive, parentId: primitive.parentId }); - } - - private _updateGroup( - sceneObject: DIVEGroup, - props: PartialSchema, - ): void { - if (props.name !== undefined) sceneObject.name = props.name; - if (props.position !== undefined) - (sceneObject as DIVEGroup).setPosition(props.position); - if (props.rotation !== undefined) - (sceneObject as DIVEGroup).setRotation(props.rotation); - if (props.scale !== undefined) - (sceneObject as DIVEGroup).setScale(props.scale); - if (props.visible !== undefined) - (sceneObject as DIVEGroup).setVisibility(props.visible); - if (props.bbVisible !== undefined) - (sceneObject as DIVEGroup).setLinesVisibility(props.bbVisible); - if (props.parentId !== undefined) - this._setParent({ ...props, parentId: props.parentId }); - } - - private _deleteLight(sceneObject: DIVELight): void { - this._detachTransformControls(sceneObject); - - sceneObject.parent!.remove(sceneObject); - } - - private _deleteModel(sceneObject: DIVEModel): void { - this._detachTransformControls(sceneObject); - - sceneObject.parent!.remove(sceneObject); - } - - private _deletePrimitive(sceneObject: DIVEPrimitive): void { - this._detachTransformControls(sceneObject); - - sceneObject.parent!.remove(sceneObject); - } - - private _deleteGroup(sceneObject: DIVEGroup): void { - this._detachTransformControls(sceneObject); - - for (let i = sceneObject.members.length - 1; i >= 0; i--) { - this.attach(sceneObject.members[i]); - } - - sceneObject.parent!.remove(sceneObject); - } - - private _setParent( - object: MinimalSchema & { - parentId: string | null; - }, - ): void { - const sceneObject = this.getSceneObject(object)!; - - if (object.parentId !== null) { - const parent = this.getSceneObject({ - id: object.parentId, - entityType: object.entityType, - }); - if (!parent) { - console.warn( - `DIVERoot._setParent: Parent with id ${object.parentId} is not in the scene, ${object.id} stays at the root`, - ); - return; - } - - // attach to new parent (if exists in scene) - parent.attach(sceneObject); - } else { - // attach to root if no parent is found - this.attach(sceneObject); - } - } - - private _detachTransformControls(object: Object3D): void { - // this is only neccessary due to using the old TransformControls instead of the new DIVEGizmo - this._findScene(object).children.find((sceneChild) => { - const helperRoot = sceneChild as Object3D & { - isTransformControlsRoot?: boolean; - controls?: TransformControls; - }; - if (helperRoot.isTransformControlsRoot && helperRoot.controls) { - helperRoot.controls.detach(); - return true; - } - - const controls = sceneChild as Object3D & { - isTransformControls?: boolean; - detach?: () => void; - }; - if (controls.isTransformControls && controls.detach) { - controls.detach(); - return true; - } - - return false; - }); - } - - private _findScene(object: Object3D): DIVEScene { - if (object.parent !== null) { - return this._findScene(object.parent); - } - return object as DIVEScene; - } } diff --git a/src/components/root/__test__/Root.test.ts b/src/components/root/__test__/Root.test.ts index 68431bf2..6d189e29 100644 --- a/src/components/root/__test__/Root.test.ts +++ b/src/components/root/__test__/Root.test.ts @@ -1,14 +1,4 @@ import { DIVERoot } from '../Root.ts'; -import { - LightSchema, - ModelSchema, - EntitySchema, - PrimitiveSchema, - GroupSchema, - CameraSchema, - EntityTypeSchema, - DIVEGeometryType, -} from '@shopware-ag/dive'; import { Object3D, Vector3, Box3 } from 'three/webgpu'; vi.mock('three/webgpu', async () => { @@ -78,35 +68,6 @@ vi.mock('three/webgpu', async () => { }; }); -vi.mock('../../../modules/ModuleRegistry', () => ({ - getModule: vi.fn((moduleName: string) => { - if (moduleName === 'State') { - return Promise.resolve({ - get: vi.fn().mockReturnValue({ - performAction: vi.fn(), - }), - }); - } - return Promise.resolve( - class { - load = vi.fn().mockResolvedValue({}); - }, - ); - }), -})); - -vi.mock('../../../modules/state/State', () => { - return { - State: { - get: vi.fn(() => { - return { - performAction: vi.fn(), - }; - }), - }, - }; -}); - vi.mock('../../floor/Floor', () => { return { DIVEFloor: vi.fn(function (this: any) { @@ -123,187 +84,11 @@ vi.mock('../../floor/Floor', () => { }; }); -vi.mock('../../grid/Grid', () => { - return { - DIVEGrid: vi.fn(function (this: any) { - this.isObject3D = true; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.removeFromParent = vi.fn(); - this.updateMatrixWorld = vi.fn(); - return this; - }), - }; -}); - -vi.mock('../../light/AmbientLight', () => { - return { - DIVEAmbientLight: vi.fn(function (this: any) { - this.isObject3D = true; - this.name = ''; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.position = new Vector3(); - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setIntensity = vi.fn(); - this.setEnabled = vi.fn(); - this.setColor = vi.fn(); - this.userData = { - id: undefined, - }; - this.removeFromParent = vi.fn(); - return this; - }), - }; -}); - -vi.mock('../../light/PointLight', () => { - return { - DIVEPointLight: vi.fn(function (this: any) { - this.isObject3D = true; - this.name = ''; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.position = new Vector3(); - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setIntensity = vi.fn(); - this.setEnabled = vi.fn(); - this.setColor = vi.fn(); - this.userData = { - id: undefined, - }; - this.removeFromParent = vi.fn(); - return this; - }), - }; -}); - -vi.mock('../../light/SceneLight', () => { - return { - DIVESceneLight: vi.fn(function (this: any) { - this.isObject3D = true; - this.name = ''; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.position = new Vector3(); - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setIntensity = vi.fn(); - this.setEnabled = vi.fn(); - this.setColor = vi.fn(); - this.userData = { - id: undefined, - }; - this.removeFromParent = vi.fn(); - return this; - }), - }; -}); - -vi.mock('../../model/Model', () => { - return { - DIVEModel: vi.fn(function (this: any) { - this.isObject3D = true; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.userData = { - id: undefined, - }; - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setFromGLTF = vi.fn(); - this.setPosition = vi.fn(); - this.setRotation = vi.fn(); - this.setScale = vi.fn(); - this.setVisibility = vi.fn(); - this.setMaterial = vi.fn(); - this.placeOnFloor = vi.fn(); - this.removeFromParent = vi.fn(); - this.position = new Vector3(); - this.setFromURL = vi.fn().mockResolvedValue(void 0); - return this; - }), - }; -}); - -vi.mock('../../primitive/Primitive', () => { - return { - DIVEPrimitive: vi.fn(function (this: any) { - this.isObject3D = true; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.userData = { - id: undefined, - }; - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setGeometry = vi.fn(); - this.setMaterial = vi.fn(); - this.setPosition = vi.fn(); - this.setRotation = vi.fn(); - this.setScale = vi.fn(); - this.setVisibility = vi.fn(); - this.placeOnFloor = vi.fn(); - this.removeFromParent = vi.fn(); - this.position = new Vector3(); - return this; - }), - }; -}); - -vi.mock('../../group/Group', () => { - return { - DIVEGroup: vi.fn(function (this: any) { - this.isDIVEGroup = true; - this.isObject3D = true; - this.parent = null; - this.dispatchEvent = vi.fn(); - this.userData = { - id: undefined, - }; - this.attach = vi.fn(); - this.applyMatrix4 = vi.fn(); - this.updateWorldMatrix = vi.fn(); - this.children = []; - this.setGeometry = vi.fn(); - this.setMaterial = vi.fn(); - this.setPosition = vi.fn(); - this.setRotation = vi.fn(); - this.setScale = vi.fn(); - this.setVisibility = vi.fn(); - this.setLinesVisibility = vi.fn(); - this.placeOnFloor = vi.fn(); - this.removeFromParent = vi.fn(); - this.position = new Vector3(); - this.members = []; - return this; - }), - }; -}); - -const spyConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - describe('components/root/DIVERoot', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterAll(() => { - spyConsoleWarn.mockRestore(); - }); - describe('constructor', () => { it('should initialize with correct properties', () => { const root = new DIVERoot(); @@ -356,1467 +141,4 @@ describe('components/root/DIVERoot', () => { expect(mockObject2.traverse).toHaveBeenCalled(); }); }); - - describe('getSceneObject', () => { - it('should find object by id', () => { - const mockObject = new Object3D(); - mockObject.userData = { id: 'test-id' }; - - const root = new DIVERoot(); - root.add(mockObject); - - const found = root.getSceneObject({ - id: 'test-id', - entityType: 'model', - }); - expect(found).toBeDefined(); - }); - - it('should return undefined for non-existent id', () => { - const root = new DIVERoot(); - const found = root.getSceneObject({ - id: 'non-existent', - entityType: 'model', - }); - expect(found).toBeUndefined(); - }); - - it('should get scene object by id', () => { - const root = new DIVERoot(); - const mockObject = { - isObject3D: true, - userData: { - id: 'test-id', - }, - }; - root.add(mockObject as any); - const result = root.getSceneObject({ - id: 'test-id', - entityType: 'model', - }); - expect(result).toBe(mockObject); - }); - - it('should return undefined when object is not found', () => { - const root = new DIVERoot(); - const result = root.getSceneObject({ - id: 'non-existent-id', - entityType: 'model', - }); - expect(result).toBeUndefined(); - }); - - it('should stop traversing when object is found', () => { - const root = new DIVERoot(); - const mockObject1 = { - isObject3D: true, - userData: { - id: 'test-id', - }, - id: 'obj1', - uuid: 'uuid1', - name: 'obj1', - type: 'Object3D', - parent: null, - children: [], - up: { x: 0, y: 1, z: 0 }, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - matrix: { elements: new Float32Array(16) }, - matrixWorld: { elements: new Float32Array(16) }, - matrixAutoUpdate: true, - matrixWorldNeedsUpdate: false, - layers: { mask: 1 }, - visible: true, - castShadow: false, - receiveShadow: false, - frustumCulled: true, - renderOrder: 0, - animations: [], - updateMatrix: vi.fn(), - updateMatrixWorld: vi.fn(), - updateWorldMatrix: vi.fn(), - traverse: vi.fn(), - traverseVisible: vi.fn(), - traverseAncestors: vi.fn(), - addEventListener: vi.fn(), - hasEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }; - const mockObject2 = { ...mockObject1, id: 'obj2', uuid: 'uuid2' }; - - let traverseCount = 0; - root.traverse = vi.fn((callback) => { - traverseCount++; - callback(mockObject1 as any); - callback(mockObject2 as any); - }); - - const result = root.getSceneObject({ - id: 'test-id', - entityType: 'model', - }); - expect(result).toBe(mockObject1); - expect(traverseCount).toBe(1); - }); - }); - - describe('addSceneObject', () => { - it('should add different types of lights', async () => { - const sceneLightData: LightSchema = { - id: 'scene-light-1', - entityType: 'light', - type: 'scene', - name: 'Test Scene Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const ambientLightData: LightSchema = { - id: 'ambient-light-1', - entityType: 'light', - type: 'ambient', - name: 'Test Ambient Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const pointLightData: LightSchema = { - id: 'point-light-1', - entityType: 'light', - type: 'point', - name: 'Test Point Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const unknownLightData: LightSchema = { - id: 'unknown-light-1', - entityType: 'light', - type: 'unknown', - name: 'Test Unknown Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - } as any; - - const root = new DIVERoot(); - await root.addSceneObject(sceneLightData); - await root.addSceneObject(ambientLightData); - await root.addSceneObject(pointLightData); - await expect(root.addSceneObject(unknownLightData)).rejects.toThrow( - 'DIVERoot.addSceneObject: Unknown light type: unknown', - ); - - const sceneLight = root.getSceneObject(sceneLightData); - const ambientLight = root.getSceneObject(ambientLightData); - const pointLight = root.getSceneObject(pointLightData); - const unknownLight = root.getSceneObject(unknownLightData); - - expect(sceneLight).toBeDefined(); - expect(ambientLight).toBeDefined(); - expect(pointLight).toBeDefined(); - expect(unknownLight).toBeUndefined(); - }); - - it('should update all light properties', async () => { - const lightData: LightSchema = { - id: 'light-1', - entityType: 'light', - type: 'point', - name: 'Test Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(lightData); - expect(spyConsoleWarn).not.toHaveBeenCalled(); - - const light = root.getSceneObject(lightData); - expect(light).toBeDefined(); - expect(light?.name).toBe('Test Light'); - expect(light?.position.x).toBe(1); - expect(light?.position.y).toBe(2); - expect(light?.position.z).toBe(3); - expect((light as any).setIntensity).toHaveBeenCalledWith(1.0); - expect((light as any).setEnabled).toHaveBeenCalledWith(true); - expect((light as any).setColor).toHaveBeenCalled(); - expect(light?.visible).toBe(true); - }); - - it('should update all model properties', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - material: { color: '#ffffff' }, - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - expect(model?.name).toBe('Test Model'); - expect(model?.setPosition).toHaveBeenCalledWith(modelData.position); - expect(model?.setRotation).toHaveBeenCalledWith(modelData.rotation); - expect(model?.setScale).toHaveBeenCalledWith(modelData.scale); - expect(model?.setVisibility).toHaveBeenCalledWith( - modelData.visible, - ); - expect(model?.setMaterial).toHaveBeenCalledWith(modelData.material); - }); - - it('should update all primitive properties', async () => { - const primitiveData: PrimitiveSchema = { - id: 'primitive-1', - entityType: 'primitive', - name: 'Test Primitive', - visible: true, - geometry: { name: 'box', width: 1, height: 1, depth: 1 }, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - material: { color: '#ffffff' }, - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - expect(primitive?.name).toBe('Test Primitive'); - expect(primitive?.setGeometry).toHaveBeenCalledWith( - primitiveData.geometry, - ); - expect(primitive?.setPosition).toHaveBeenCalledWith( - primitiveData.position, - ); - expect(primitive?.setRotation).toHaveBeenCalledWith( - primitiveData.rotation, - ); - expect(primitive?.setScale).toHaveBeenCalledWith( - primitiveData.scale, - ); - expect(primitive?.setVisibility).toHaveBeenCalledWith( - primitiveData.visible, - ); - expect(primitive?.setMaterial).toHaveBeenCalledWith( - primitiveData.material, - ); - }); - - it('should update all group properties', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - bbVisible: true, - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - const group = root.getSceneObject(groupData); - expect(group).toBeDefined(); - expect(group?.name).toBe('Test Group'); - expect(group?.setPosition).toHaveBeenCalledWith(groupData.position); - expect(group?.setRotation).toHaveBeenCalledWith(groupData.rotation); - expect(group?.setScale).toHaveBeenCalledWith(groupData.scale); - expect(group?.setVisibility).toHaveBeenCalledWith( - groupData.visible, - ); - expect(group?.setLinesVisibility).toHaveBeenCalledWith( - groupData.bbVisible, - ); - }); - - it('should add a model object', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - expect(model?.userData.uri).toBe('test.glb'); - expect(model?.userData.id).toBe('model-1'); - }); - - it('should add a primitive object', async () => { - const primitiveData: PrimitiveSchema = { - id: 'primitive-1', - entityType: 'primitive', - name: 'Test Primitive', - visible: true, - geometry: { name: 'box', width: 1, height: 1, depth: 1 }, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - expect(primitive?.userData.id).toBe('primitive-1'); - }); - - it('should add a group object', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - const group = root.getSceneObject(groupData); - expect(group).toBeDefined(); - expect(group?.userData.id).toBe('group-1'); - }); - - it('should handle CAMERA objects', async () => { - const cameraData: CameraSchema = { - id: 'camera-1', - entityType: 'camera', - name: 'Test Camera', - visible: true, - position: { x: 1, y: 2, z: 3 }, - target: { x: 0, y: 0, z: 0 }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(cameraData); - // CAMERA objects are not added to the scene - const camera = root.getSceneObject(cameraData); - expect(camera).toBeUndefined(); - }); - - it('should warn for unknown entity type', async () => { - const unknownData = { - id: 'unknown', - entityType: 'unknown' as EntityTypeSchema, - name: 'Unknown', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - } as unknown as EntitySchema; - - const root = new DIVERoot(); - await expect(root.addSceneObject(unknownData)).rejects.toThrow( - 'DIVERoot.addSceneObject: Unknown entity type: unknown', - ); - }); - - it('should warn and return the existing object when adding a duplicate id', async () => { - const modelData: ModelSchema = { - id: 'model-duplicate', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - }; - - const root = new DIVERoot(); - const firstObject = await root.addSceneObject(modelData); - const duplicateObject = await root.addSceneObject(modelData); - - expect(duplicateObject).toBe(firstObject); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.addSceneObject: Scene object with id model-duplicate already exists', - ); - }); - }); - - describe('updateSceneObject', () => { - it('should update existing object properties', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - - const updatedData = { - ...modelData, - position: { x: 2, y: 3, z: 4 }, - }; - - await root.updateSceneObject(updatedData); - expect(model?.setPosition).toHaveBeenCalledWith( - updatedData.position, - ); - }); - - it('should update existing light properties', async () => { - const lightData: LightSchema = { - id: 'light-1', - entityType: 'light', - type: 'point', - name: 'Test Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const root = new DIVERoot(); - await root.addSceneObject(lightData); - const light = root.getSceneObject(lightData); - expect(light).toBeDefined(); - - const updatedData = { - ...lightData, - intensity: 2.0, - color: '#ff0000', - }; - - await root.updateSceneObject(updatedData); - expect((light as any).setIntensity).toHaveBeenCalledWith(2.0); - expect((light as any).setColor).toHaveBeenCalled(); - }); - - it('should update existing primitive properties', async () => { - const primitiveData: PrimitiveSchema = { - id: 'primitive-1', - entityType: 'primitive', - name: 'Test Primitive', - visible: true, - geometry: { name: 'box', width: 1, height: 1, depth: 1 }, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - - const updatedData = { - ...primitiveData, - geometry: { - name: 'box' as DIVEGeometryType, - width: 2, - height: 2, - depth: 2, - }, - }; - - await root.updateSceneObject(updatedData); - expect((primitive as any).setGeometry).toHaveBeenCalledWith( - updatedData.geometry, - ); - }); - - it('should update existing group properties', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - const group = root.getSceneObject(groupData); - expect(group).toBeDefined(); - - const updatedData = { - ...groupData, - visible: false, - bbVisible: true, - }; - - await root.updateSceneObject(updatedData); - expect((group as any).setVisibility).toHaveBeenCalledWith(false); - expect((group as any).setLinesVisibility).toHaveBeenCalledWith( - true, - ); - }); - - it('should handle update of non-existent object', async () => { - const nonExistentData = { - id: 'non-existent', - entityType: 'model' as EntityTypeSchema, - name: 'Non Existent', - visible: true, - }; - - const root = new DIVERoot(); - await root.updateSceneObject(nonExistentData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.updateSceneObject: Scene object with id non-existent does not exist', - ); - }); - - it('should handle CAMERA update', async () => { - const cameraData = { - id: 'camera-1', - entityType: 'camera' as EntityTypeSchema, - name: 'Test CAMERA', - visible: true, - }; - - const root = new DIVERoot(); - await root.updateSceneObject(cameraData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.updateSceneObject: Scene object with id camera-1 does not exist', - ); - }); - - it('should no-op when updating a found CAMERA object', async () => { - const cameraData = { - id: 'camera-1', - entityType: 'camera' as EntityTypeSchema, - name: 'Test CAMERA', - visible: true, - }; - - const root = new DIVERoot(); - const cameraObject = new Object3D(); - cameraObject.userData.id = cameraData.id; - root.add(cameraObject); - - await root.updateSceneObject(cameraData); - - expect(spyConsoleWarn).not.toHaveBeenCalled(); - expect(root.getSceneObject(cameraData)).toBe(cameraObject); - }); - - it('should warn for unknown entity type in update', async () => { - const unknownData = { - id: 'unknown', - entityType: 'unknown' as EntityTypeSchema, - name: 'Unknown', - }; - - const root = new DIVERoot(); - await root.updateSceneObject(unknownData); - expect(spyConsoleWarn).toHaveBeenCalled(); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.updateSceneObject: Scene object with id unknown does not exist', - ); - }); - - it('should throw for unknown entity type when the object exists', async () => { - const unknownData = { - id: 'unknown', - entityType: 'unknown' as EntityTypeSchema, - name: 'Unknown', - }; - - const root = new DIVERoot(); - const existingObject = new Object3D(); - existingObject.userData.id = unknownData.id; - root.add(existingObject); - - await expect(root.updateSceneObject(unknownData)).rejects.toThrow( - 'DIVERoot.updateSceneObject: Unknown entity type: unknown', - ); - }); - }); - - describe('deleteSceneObject', () => { - it('should remove object from scene', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - - if (model) { - model.parent = root; - root.children = [model]; - } - - root.deleteSceneObject(modelData); - const deletedModel = root.getSceneObject(modelData); - expect(deletedModel).toBeUndefined(); - }); - - it('should warn when trying to delete non-existent object', () => { - const nonExistentData = { - id: 'non-existent', - entityType: 'model' as EntityTypeSchema, - name: 'Non Existent', - visible: true, - }; - - const root = new DIVERoot(); - root.deleteSceneObject(nonExistentData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.deleteSceneObject: Object with id non-existent not found', - ); - }); - - it('should handle CAMERA deletion', () => { - const cameraData: CameraSchema = { - id: 'camera-1', - entityType: 'camera', - name: 'Test CAMERA', - visible: true, - position: { x: 1, y: 2, z: 3 }, - target: { x: 0, y: 0, z: 0 }, - }; - - const root = new DIVERoot(); - root.deleteSceneObject(cameraData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.deleteSceneObject: Object with id camera-1 not found', - ); - }); - - it('should no-op when deleting a found CAMERA object', () => { - const cameraData: CameraSchema = { - id: 'camera-1', - entityType: 'camera', - name: 'Test CAMERA', - visible: true, - position: { x: 1, y: 2, z: 3 }, - target: { x: 0, y: 0, z: 0 }, - }; - - const root = new DIVERoot(); - const cameraObject = new Object3D(); - cameraObject.userData.id = cameraData.id; - root.add(cameraObject); - - root.deleteSceneObject(cameraData); - - expect(spyConsoleWarn).not.toHaveBeenCalled(); - expect(root.getSceneObject(cameraData)).toBe(cameraObject); - }); - - it('should warn for unknown entity type in deletion', () => { - const unknownData = { - id: 'unknown', - entityType: 'unknown' as EntityTypeSchema, - name: 'Unknown', - }; - - const root = new DIVERoot(); - root.children = [ - { - userData: { - id: 'unknown', - }, - } as any, - ]; - expect(() => root.deleteSceneObject(unknownData as any)).toThrow( - 'DIVERoot.deleteSceneObject: Unknown entity type: unknown', - ); - }); - - it('should handle group member detachment', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const memberData: ModelSchema = { - id: 'member-1', - entityType: 'model', - name: 'Test Member', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: 'group-1', - }; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - await root.addSceneObject(memberData); - - const group = root.getSceneObject(groupData); - const member = root.getSceneObject(memberData); - - expect(group).toBeDefined(); - expect(member).toBeDefined(); - - if (group && member) { - (group as any).members = [member]; - group.parent = root; - } - - root.deleteSceneObject(groupData); - expect(root.attach).toHaveBeenCalledWith(member); - }); - - it('should handle transform controls detachment', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - }; - - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - - if (model) { - model.parent = root; - root.parent = mockScene; - } - - root.deleteSceneObject(modelData); - expect(mockTransformControls.detach).toHaveBeenCalled(); - }); - - it('should handle primitive deletion', async () => { - const primitiveData: PrimitiveSchema = { - id: 'primitive-1', - entityType: 'primitive', - name: 'Test Primitive', - visible: true, - geometry: { name: 'box', width: 1, height: 1, depth: 1 }, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - - if (primitive) { - primitive.parent = root; - root.parent = mockScene; - } - - root.deleteSceneObject(primitiveData); - expect(mockTransformControls.detach).toHaveBeenCalled(); - }); - - it('should handle group deletion with transform controls', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - const group = root.getSceneObject(groupData); - expect(group).toBeDefined(); - - if (group) { - group.parent = root; - root.parent = mockScene; - (group as any).members = [new Object3D()]; - } - - root.deleteSceneObject(groupData); - expect(mockTransformControls.detach).toHaveBeenCalled(); - expect(root.attach).toHaveBeenCalled(); - }); - }); - - describe('_setParent', () => { - it('should set parent-child relationship', async () => { - const parentData: GroupSchema = { - id: 'parent-1', - entityType: 'group', - name: 'Parent Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const childData: ModelSchema = { - id: 'child-1', - entityType: 'model', - name: 'Child Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: 'parent-1', - }; - - const root = new DIVERoot(); - await root.addSceneObject(parentData); - await root.addSceneObject(childData); - - const parent = root.getSceneObject(parentData); - const child = root.getSceneObject(childData); - - expect(parent).toBeDefined(); - expect(child).toBeDefined(); - expect(parent?.attach).toHaveBeenCalled(); - }); - - it('should attach to root when parent is null', async () => { - const childData: ModelSchema = { - id: 'child-1', - entityType: 'model', - name: 'Child Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(childData); - const child = root.getSceneObject(childData); - expect(child).toBeDefined(); - expect(root.attach).toHaveBeenCalled(); - }); - - it('should handle non-existent parent', async () => { - const childData: ModelSchema = { - id: 'child-1', - entityType: 'model', - name: 'Child Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: 'non-existent', - }; - - const root = new DIVERoot(); - await root.addSceneObject(childData); - const child = root.getSceneObject(childData); - expect(child).toBeDefined(); - // When parent doesn't exist, the object should remain where it is - expect(root.attach).not.toHaveBeenCalled(); - }); - - it('should handle non-existent object', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: 'parent-1', - }; - - const root = new DIVERoot(); - // Don't add the object to the scene - await root.updateSceneObject(modelData); - expect(root.attach).not.toHaveBeenCalled(); - }); - }); - - describe('_updateLight', () => { - it('should handle light with undefined properties', async () => { - const lightData: Partial & { - id: string; - entityType: string; - type: string; - } = { - id: 'light-1', - entityType: 'light', - type: 'point', - name: undefined, - visible: undefined, - position: undefined, - intensity: undefined, - enabled: undefined, - color: undefined, - }; - - const root = new DIVERoot(); - await root.addSceneObject(lightData as LightSchema); - const light = root.getSceneObject(lightData); - expect(light).toBeDefined(); - }); - - it('should only touch the fields a patch carries', async () => { - const root = new DIVERoot(); - await root.addSceneObject({ - id: 'light-1', - entityType: 'light', - type: 'point', - name: 'Lamp', - intensity: 2, - } as LightSchema); - - const light = root.getSceneObject({ - id: 'light-1', - entityType: 'light', - }) as any; - light.setIntensity.mockClear(); - light.setColor.mockClear(); - light.setEnabled.mockClear(); - - await root.updateSceneObject({ - id: 'light-1', - entityType: 'light', - name: 'Renamed', - }); - - expect(light.name).toBe('Renamed'); - // absent means unchanged, so no other setter runs - expect(light.setIntensity).not.toHaveBeenCalled(); - expect(light.setColor).not.toHaveBeenCalled(); - expect(light.setEnabled).not.toHaveBeenCalled(); - }); - }); - - describe('model asset loading', () => { - const addModel = async (root: DIVERoot, uri: string): Promise => { - await root.addSceneObject({ - id: 'model-1', - entityType: 'model', - name: 'M', - uri, - } as ModelSchema); - - return root.getSceneObject({ - id: 'model-1', - entityType: 'model', - }) as any; - }; - - it('should load the asset when the model is added', async () => { - const root = new DIVERoot(); - const model = await addModel(root, 'a.glb'); - - expect(model.setFromURL).toHaveBeenCalledWith('a.glb'); - expect(model.userData.uri).toBe('a.glb'); - }); - - it('should not fetch the asset again when the uri is unchanged', async () => { - const root = new DIVERoot(); - const model = await addModel(root, 'a.glb'); - model.setFromURL.mockClear(); - - await root.updateSceneObject({ - id: 'model-1', - entityType: 'model', - uri: 'a.glb', - position: { x: 1, y: 2, z: 3 }, - }); - - expect(model.setFromURL).not.toHaveBeenCalled(); - // the rest of the patch still applies - expect(model.setPosition).toHaveBeenCalledWith({ - x: 1, - y: 2, - z: 3, - }); - }); - - it('should fetch the asset when the uri changed', async () => { - const root = new DIVERoot(); - const model = await addModel(root, 'a.glb'); - model.setFromURL.mockClear(); - - await root.updateSceneObject({ - id: 'model-1', - entityType: 'model', - uri: 'b.glb', - }); - - expect(model.setFromURL).toHaveBeenCalledWith('b.glb'); - expect(model.userData.uri).toBe('b.glb'); - }); - }); - - describe('_deleteLight', () => { - it('should handle light with transform controls', async () => { - const lightData: LightSchema = { - id: 'light-1', - entityType: 'light', - type: 'point', - name: 'Test Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - - const root = new DIVERoot(); - await root.addSceneObject(lightData); - const light = root.getSceneObject(lightData); - expect(light).toBeDefined(); - - if (light) { - light.parent = root; - root.parent = mockScene; - } - - root.deleteSceneObject(lightData); - expect(mockTransformControls.detach).toHaveBeenCalled(); - }); - - it('should handle non-existent light', () => { - const lightData: LightSchema = { - id: 'non-existent-light', - entityType: 'light', - type: 'point', - name: 'Test Light', - visible: true, - position: { x: 1, y: 2, z: 3 }, - intensity: 1.0, - enabled: true, - color: '#ffffff', - }; - - const root = new DIVERoot(); - root.deleteSceneObject(lightData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.deleteSceneObject: Object with id non-existent-light not found', - ); - }); - }); - - describe('_deleteGroup', () => { - it('should handle group with transform controls and members', async () => { - const groupData: GroupSchema = { - id: 'group-1', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - - const root = new DIVERoot(); - await root.addSceneObject(groupData); - const group = root.getSceneObject(groupData); - expect(group).toBeDefined(); - - if (group) { - group.parent = root; - root.parent = mockScene; - (group as any).members = [new Object3D()]; - } - - root.deleteSceneObject(groupData); - expect(mockTransformControls.detach).toHaveBeenCalled(); - expect(root.attach).toHaveBeenCalled(); - }); - - it('should handle non-existent group', () => { - const groupData: GroupSchema = { - id: 'non-existent-group', - entityType: 'group', - name: 'Test Group', - visible: true, - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - root.deleteSceneObject(groupData); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.deleteSceneObject: Object with id non-existent-group not found', - ); - }); - }); - - describe('_setParent', () => { - it('should handle object with null parentId', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: null, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - expect(root.attach).toHaveBeenCalled(); - }); - - it('should handle object with non-existent parent', async () => { - const modelData: ModelSchema = { - id: 'model-1', - entityType: 'model', - name: 'Test Model', - visible: true, - uri: 'test.glb', - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - loaded: false, - parentId: 'non-existent', - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - expect(root.attach).not.toHaveBeenCalled(); - }); - }); - - describe('_updateModel', () => { - it('should handle model with undefined properties', async () => { - const modelData: Partial & { - id: string; - entityType: string; - } = { - id: 'model-1', - entityType: 'model', - name: null as unknown as string, - visible: null as unknown as boolean, - position: null as unknown as { - x: number; - y: number; - z: number; - }, - rotation: null as unknown as { - x: number; - y: number; - z: number; - }, - scale: null as unknown as { x: number; y: number; z: number }, - material: null as unknown as { color: string }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData as ModelSchema); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - }); - - it('should handle model with null properties', async () => { - const modelData: Partial & { - id: string; - entityType: string; - } = { - id: 'model-1', - entityType: 'model', - name: null as unknown as string, - visible: null as unknown as boolean, - position: null as unknown as { - x: number; - y: number; - z: number; - }, - rotation: null as unknown as { - x: number; - y: number; - z: number; - }, - scale: null as unknown as { x: number; y: number; z: number }, - uri: null as unknown as string, - loaded: null as unknown as boolean, - material: null as unknown as { color: string }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(modelData as ModelSchema); - const model = root.getSceneObject(modelData); - expect(model).toBeDefined(); - }); - }); - - describe('_updatePrimitive', () => { - it('should handle primitive with undefined properties', async () => { - const primitiveData: Partial & { - id: string; - entityType: string; - } = { - id: 'primitive-1', - entityType: 'primitive', - name: undefined, - visible: undefined, - position: undefined, - rotation: undefined, - scale: undefined, - geometry: undefined, - material: undefined, - }; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData as PrimitiveSchema); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - }); - - it('should handle primitive with null properties', async () => { - const primitiveData: Partial & { - id: string; - entityType: string; - } = { - id: 'primitive-1', - entityType: 'primitive', - name: null as unknown as string, - visible: null as unknown as boolean, - position: null as unknown as { - x: number; - y: number; - z: number; - }, - rotation: null as unknown as { - x: number; - y: number; - z: number; - }, - scale: null as unknown as { x: number; y: number; z: number }, - geometry: null as unknown as { - name: 'box'; - width: number; - height: number; - depth: number; - }, - material: null as unknown as { color: string }, - }; - - const root = new DIVERoot(); - await root.addSceneObject(primitiveData as PrimitiveSchema); - const primitive = root.getSceneObject(primitiveData); - expect(primitive).toBeDefined(); - }); - }); - - describe('_deletePrimitive', () => { - it('should handle non-existent primitive', () => { - const primitiveData: PrimitiveSchema = { - id: 'non-existent-primitive', - entityType: 'primitive', - name: 'Test Primitive', - visible: true, - geometry: { name: 'box', width: 1, height: 1, depth: 1 }, - position: { x: 1, y: 2, z: 3 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - }; - - const root = new DIVERoot(); - root.deleteSceneObject(primitiveData); - expect(spyConsoleWarn).toHaveBeenCalled(); - expect(spyConsoleWarn).toHaveBeenCalledWith( - 'DIVERoot.deleteSceneObject: Object with id non-existent-primitive not found', - ); - }); - }); - - describe('_findScene', () => { - it('should find scene from object hierarchy', () => { - const mockScene = new Object3D(); - mockScene.name = 'Scene'; - const mockParent = new Object3D(); - mockParent.name = 'Parent'; - const mockChild = new Object3D(); - mockChild.name = 'Child'; - - mockChild.parent = mockParent; - mockParent.parent = mockScene; - - const root = new DIVERoot(); - const result = root['_findScene'](mockChild); - expect(result).toBe(mockScene); - }); - - it('should return object itself if it has no parent', () => { - const mockObject = new Object3D(); - mockObject.name = 'Object'; - mockObject.parent = null; - - const root = new DIVERoot(); - const result = root['_findScene'](mockObject); - expect(result).toBe(mockObject); - }); - }); - - describe('_detachTransformControls', () => { - it('should detach transform controls from object', () => { - const mockObject = new Object3D(); - const mockTransformControls = Object.assign(new Object3D(), { - isTransformControls: true, - detach: vi.fn(), - }); - - const mockScene = new Object3D(); - mockScene.children = [mockTransformControls]; - mockObject.parent = mockScene; - - const root = new DIVERoot(); - root['_detachTransformControls'](mockObject); - expect(mockTransformControls.detach).toHaveBeenCalled(); - }); - - it('should detach controls from transform control helper roots', () => { - const mockObject = new Object3D(); - const detach = vi.fn(); - const mockHelperRoot = Object.assign(new Object3D(), { - isTransformControlsRoot: true, - controls: { - detach, - }, - }); - - const mockScene = new Object3D(); - mockScene.children = [mockHelperRoot]; - mockObject.parent = mockScene; - - const root = new DIVERoot(); - root['_detachTransformControls'](mockObject); - expect(detach).toHaveBeenCalled(); - }); - - it('should handle object without transform controls', () => { - const mockObject = new Object3D(); - const mockScene = new Object3D(); - mockScene.children = []; - mockObject.parent = mockScene; - - const root = new DIVERoot(); - root['_detachTransformControls'](mockObject); - // No error should be thrown - }); - }); }); diff --git a/src/plugins/state/src/EngineGateway.ts b/src/plugins/state/src/EngineGateway.ts new file mode 100644 index 00000000..60b107ff --- /dev/null +++ b/src/plugins/state/src/EngineGateway.ts @@ -0,0 +1,481 @@ +import { Color, MeshStandardMaterial, Object3D } from 'three/webgpu'; +import { + detachTransformControls, + DIVEAmbientLight, + DIVEGroup, + DIVEModel, + DIVEPointLight, + DIVEPrimitive, + DIVESceneLight, + type DIVE, + type DIVELight, + type DIVEEntityTransformEvent, + type DIVERoot, + type DIVESceneObject, +} from '@shopware-ag/dive'; +import { + isCameraSchema, + isGroupSchema, + isLightSchema, + isModelSchema, + isPrimitiveSchema, + type EntitySchema, + type GroupSchema, + type LightSchema, + type MinimalSchema, + type ModelSchema, + type PartialSchema, + type PrimitiveSchema, +} from '@shopware-ag/dive'; +import { type State } from './State.ts'; + +/** + * The scene properties that are not entities. + * + * They live on three different engine objects — scene, grid and floor — and + * used to be read and written in three places that had already drifted apart. + */ +export type SceneSettings = { + name: string; + backgroundColor: string; + gridEnabled: boolean; + floorEnabled: boolean; + floorColor: string; +}; + +/** + * What may be written back. + * + * Colours come out as hex strings but go in either way, because three accepts + * both and callers pass whatever they happen to hold. + */ +export type SceneSettingsPatch = Partial< + Omit & { + backgroundColor: string | number; + floorColor: string | number; + } +>; + +/** + * Vectors arrive as live references into the emitting object, including a + * scratch buffer the next frame overwrites. `UpdateObjectAction` merges the + * payload into the registered schema, and lodash assigns by reference when the + * target key is absent — so without this copy a moving object would keep + * rewriting its own stored transform. + */ +const copyVec = (v: { + x: number; + y: number; + z: number; +}): { x: number; y: number; z: number } => ({ + x: v.x, + y: v.y, + z: v.z, +}); + +/** + * #### EngineGateway + * is the single seam between the state plugin and the engine. + * + * The engine holds objects; it does not know what an entity, an action or a + * state is. Everything that turns entity data into scene objects — and every + * report travelling back the other way — passes through here. + * + * It is not a facade over the engine API. It offers what the state layer + * needs, in the state layer's vocabulary: entities, scene settings, rendering. + * + * @module + */ +export class EngineGateway { + private readonly _engine: DIVE; + private readonly _state: State; + + /** + * One teardown per entity id, so an object that leaves the scene stops + * reporting. Keyed by id rather than held on the object because + * `_deleteGroup` re-parents members to the root — those stay registered + * and have to stay wired. + */ + private readonly _unsubscribes: Map void> = new Map(); + + /** + * The id the toolbox currently holds selected. + * + * `SELECT_OBJECT` runs `selectionState.select()`, which calls back into + * `onSelect()` — this breaks that one loop without silencing the events in + * general, which would also kill the wanted group cascade. + */ + private _selectedId: string | null = null; + + constructor(engine: DIVE, state: State) { + this._engine = engine; + this._state = state; + } + + private get _root(): DIVERoot { + return this._engine.scene.root; + } + + /** + * The scene root as a plain object, for the few consumers that need the + * whole subtree rather than a single entity — computing an encompassing + * view and exporting. + */ + public get sceneRoot(): Object3D { + return this._root; + } + + // ---------------------------------------------------------------- entities + + public findEntity( + entity: MinimalSchema, + ): DIVESceneObject | undefined { + let found: DIVESceneObject | undefined; + this._root.traverse((object3D) => { + if (found) return; + if (object3D.userData.id === entity.id) { + found = object3D as DIVESceneObject; + } + }); + return found; + } + + public async addEntity( + entity: EntitySchema, + ): Promise { + const existing = this.findEntity(entity); + if (existing) { + console.warn( + `EngineGateway.addEntity: Scene object with id ${entity.id} already exists`, + ); + return existing; + } + + // A camera is state-only, there is nothing to put in the scene. + if (isCameraSchema(entity)) return undefined; + + const sceneObject = this._instantiate(entity); + + sceneObject.name = entity.name; + sceneObject.userData.id = entity.id; + this._root.add(sceneObject); + + // Wired before the schema is applied, not after: applying a model + // schema awaits `setFromURL`, and that is exactly where `object-load` + // fires. Listening afterwards would miss it. + this._wire(entity, sceneObject); + + await this._apply(sceneObject, entity); + + return sceneObject; + } + + public async updateEntity(patch: PartialSchema): Promise { + const sceneObject = this.findEntity(patch); + if (!sceneObject) { + console.warn( + `EngineGateway.updateEntity: Scene object with id ${patch.id} does not exist`, + ); + return; + } + + await this._apply(sceneObject, patch); + } + + public removeEntity(entity: MinimalSchema): void { + const sceneObject = this.findEntity(entity); + if (!sceneObject) { + console.warn( + `EngineGateway.removeEntity: Object with id ${entity.id} not found`, + ); + return; + } + + this._unsubscribes.get(entity.id)?.(); + this._unsubscribes.delete(entity.id); + + detachTransformControls(sceneObject); + + // A group only ever held its members, so they outlive it at the root. + // Their own wiring is keyed by their own id and stays untouched. + if (sceneObject instanceof DIVEGroup) { + for (let i = sceneObject.members.length - 1; i >= 0; i--) { + this._root.attach(sceneObject.members[i]); + } + } + + sceneObject.parent!.remove(sceneObject); + } + + /** Drops every listener this gateway ever attached. */ + public dispose(): void { + this._unsubscribes.forEach((unsubscribe) => unsubscribe()); + this._unsubscribes.clear(); + } + + // ----------------------------------------------------------------- scene + + public readSceneSettings(): SceneSettings { + const scene = this._engine.scene; + return { + name: scene.name, + backgroundColor: '#' + (scene.background as Color).getHexString(), + gridEnabled: scene.grid.visible, + floorEnabled: scene.root.floor.visible, + floorColor: + '#' + + ( + scene.root.floor.material as MeshStandardMaterial + ).color.getHexString(), + }; + } + + public applySceneSettings(patch: SceneSettingsPatch): void { + const scene = this._engine.scene; + if (patch.name !== undefined) scene.name = patch.name; + if (patch.backgroundColor !== undefined) + scene.setBackground(patch.backgroundColor); + if (patch.gridEnabled !== undefined) + scene.grid.setVisibility(patch.gridEnabled); + if (patch.floorEnabled !== undefined) + scene.root.floor.setVisibility(patch.floorEnabled); + if (patch.floorColor !== undefined) + scene.root.floor.setColor(patch.floorColor); + } + + public setBackground(color: string | number): void { + this._engine.scene.setBackground(color); + } + + // ---------------------------------------------------------------- engine + + public startRendering(): Promise { + return this._engine.startAsync(); + } + + public registerTicker( + ticker: Parameters[0], + ): void { + if (!this._engine.clock.hasTicker(ticker)) { + this._engine.clock.addTicker(ticker); + } + } + + // --------------------------------------------------------------- private + + private _instantiate(entity: EntitySchema): DIVESceneObject { + if (isModelSchema(entity)) return new DIVEModel(); + if (isPrimitiveSchema(entity)) return new DIVEPrimitive(); + if (isGroupSchema(entity)) return new DIVEGroup(); + if (isLightSchema(entity)) { + switch (entity.type) { + case 'scene': + return new DIVESceneLight(); + case 'ambient': + return new DIVEAmbientLight(); + case 'point': + return new DIVEPointLight(); + default: + throw new Error( + `EngineGateway.addEntity: Unknown light type: ${(entity as LightSchema).type}`, + ); + } + } + + throw new Error( + `EngineGateway.addEntity: Unknown entity type: ${(entity as EntitySchema).entityType}`, + ); + } + + private async _apply( + sceneObject: DIVESceneObject, + patch: PartialSchema, + ): Promise { + switch (patch.entityType) { + case 'camera': + return; + case 'light': + this._applyLight(sceneObject as DIVELight, patch); + return; + case 'model': + await this._applyModel(sceneObject as DIVEModel, patch); + return; + case 'primitive': + this._applyPrimitive(sceneObject as DIVEPrimitive, patch); + return; + case 'group': + this._applyGroup(sceneObject as DIVEGroup, patch); + return; + default: + throw new Error( + `EngineGateway.updateEntity: Unknown entity type: ${(patch as EntitySchema).entityType}`, + ); + } + } + + private _applyLight( + sceneObject: DIVELight, + props: PartialSchema, + ): void { + if (props.name !== undefined) sceneObject.name = props.name; + if (props.position !== undefined) + sceneObject.position.set( + props.position.x, + props.position.y, + props.position.z, + ); + if (props.intensity !== undefined) + sceneObject.setIntensity(props.intensity); + if (props.enabled !== undefined) sceneObject.setEnabled(props.enabled); + if (props.color !== undefined) + sceneObject.setColor(new Color(props.color)); + if (props.visible !== undefined) sceneObject.visible = props.visible; + if (props.parentId !== undefined) + this._setParent({ ...props, parentId: props.parentId }); + } + + private async _applyModel( + sceneObject: DIVEModel, + model: PartialSchema, + ): Promise { + // awaited, so callers can tell when the model is actually in the scene. + // userData.uri holds what is currently loaded, so an update that only + // moves the model does not fetch the asset again. + if (model.uri !== undefined && model.uri !== sceneObject.userData.uri) { + await sceneObject.setFromURL(model.uri); + sceneObject.userData.uri = model.uri; + } + if (model.name !== undefined) sceneObject.name = model.name; + if (model.position !== undefined) + sceneObject.setPosition(model.position); + if (model.rotation !== undefined) + sceneObject.setRotation(model.rotation); + if (model.scale !== undefined) sceneObject.setScale(model.scale); + if (model.visible !== undefined) + sceneObject.setVisibility(model.visible); + if (model.material !== undefined) + sceneObject.setMaterial(model.material); + if (model.parentId !== undefined) + this._setParent({ ...model, parentId: model.parentId }); + } + + private _applyPrimitive( + sceneObject: DIVEPrimitive, + primitive: PartialSchema, + ): void { + if (primitive.name !== undefined) sceneObject.name = primitive.name; + if (primitive.geometry !== undefined) + sceneObject.setGeometry(primitive.geometry); + if (primitive.position !== undefined) + sceneObject.setPosition(primitive.position); + if (primitive.rotation !== undefined) + sceneObject.setRotation(primitive.rotation); + if (primitive.scale !== undefined) + sceneObject.setScale(primitive.scale); + if (primitive.visible !== undefined) + sceneObject.setVisibility(primitive.visible); + if (primitive.material !== undefined) + sceneObject.setMaterial(primitive.material); + if (primitive.parentId !== undefined) + this._setParent({ ...primitive, parentId: primitive.parentId }); + } + + private _applyGroup( + sceneObject: DIVEGroup, + props: PartialSchema, + ): void { + if (props.name !== undefined) sceneObject.name = props.name; + if (props.position !== undefined) + sceneObject.setPosition(props.position); + if (props.rotation !== undefined) + sceneObject.setRotation(props.rotation); + if (props.scale !== undefined) sceneObject.setScale(props.scale); + if (props.visible !== undefined) + sceneObject.setVisibility(props.visible); + if (props.bbVisible !== undefined) + sceneObject.setLinesVisibility(props.bbVisible); + if (props.parentId !== undefined) + this._setParent({ ...props, parentId: props.parentId }); + } + + private _setParent( + entity: MinimalSchema & { parentId: string | null }, + ): void { + const sceneObject = this.findEntity(entity); + if (!sceneObject) { + console.warn( + `EngineGateway._setParent: ${entity.id} is not in the scene`, + ); + return; + } + + if (entity.parentId === null) { + this._root.attach(sceneObject); + return; + } + + const parent = this.findEntity({ + id: entity.parentId, + entityType: entity.entityType, + }); + if (!parent) { + console.warn( + `EngineGateway._setParent: Parent with id ${entity.parentId} is not in the scene, ${entity.id} stays at the root`, + ); + return; + } + + parent.attach(sceneObject); + } + + /** + * Subscribe to what the object reports about itself. + * + * This closure is the routing: the id comes from the entity that was just + * created, so nothing has to search for it later. The engine never learns + * that any of this happens. + */ + private _wire(entity: EntitySchema, sceneObject: DIVESceneObject): void { + const { id, entityType } = entity; + const state = this._state; + + const onTransform = (event: DIVEEntityTransformEvent): void => { + void state.performAction('UPDATE_OBJECT', { + id, + entityType, + position: copyVec(event.position), + rotation: copyVec(event.rotation), + scale: copyVec(event.scale), + }); + }; + + const onSelect = (): void => { + if (this._selectedId === id) return; + this._selectedId = id; + void state.performAction('SELECT_OBJECT', { id, entityType }); + }; + + const onDeselect = (): void => { + if (this._selectedId !== id) return; + this._selectedId = null; + void state.performAction('DESELECT_OBJECT', { id, entityType }); + }; + + const onLoad = (): void => { + state.performAction('MODEL_LOADED', { id }); + }; + + sceneObject.addEventListener('object-transform', onTransform); + sceneObject.addEventListener('object-select', onSelect); + sceneObject.addEventListener('object-deselect', onDeselect); + sceneObject.addEventListener('object-load', onLoad); + + this._unsubscribes.set(id, () => { + sceneObject.removeEventListener('object-transform', onTransform); + sceneObject.removeEventListener('object-select', onSelect); + sceneObject.removeEventListener('object-deselect', onDeselect); + sceneObject.removeEventListener('object-load', onLoad); + if (this._selectedId === id) this._selectedId = null; + }); + } +} diff --git a/src/plugins/state/src/State.ts b/src/plugins/state/src/State.ts index 34fff317..0d9ccfaf 100644 --- a/src/plugins/state/src/State.ts +++ b/src/plugins/state/src/State.ts @@ -1,7 +1,8 @@ import { MathUtils } from 'three/webgpu'; // type imports -import { type EntitySchema, type DIVE } from '@shopware-ag/dive'; +import { type DIVE } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { type OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { ActionDependencies, @@ -9,6 +10,7 @@ import { ActionReturn, } from '../types/index.ts'; import { getActionClass } from './ActionRegistry.ts'; +import { EngineGateway } from './EngineGateway.ts'; export type ActionSubscriber = ( payload: ActionPayload, @@ -19,6 +21,16 @@ export type ActionUnsubscribe = () => void; export class State { private static __instances: State[] = []; + /** + * Find the instance that owns an id, either the state's own or one of the + * entities it holds. + * + * @deprecated Nothing inside DIVE uses this any more. It existed so scene + * objects could look up their state at runtime; they now report through + * events and the {@link EngineGateway} routes them without a search. Hold + * on to the instance you created instead. Will be removed in a future + * major release. + */ public static get(id: string): State | undefined { const fromComID = this.__instances.find( (instance) => instance.id === id, @@ -39,6 +51,9 @@ export class State { private engine: DIVE; private controller: OrbitController; + /** The only way from here into the engine, see {@link EngineGateway}. */ + private gateway: EngineGateway; + // modules private _mediaCreator: import('@shopware-ag/dive/mediacreator').MediaCreator | null = null; @@ -124,6 +139,7 @@ export class State { this._id = MathUtils.generateUUID(); this.engine = dive; this.controller = controller; + this.gateway = new EngineGateway(dive, this); State.__instances.push(this); } @@ -134,6 +150,7 @@ export class State { ); if (existingIndex === -1) return false; State.__instances.splice(existingIndex, 1); + this.gateway.dispose(); return true; } @@ -221,7 +238,7 @@ export class State { private getDependencies(): ActionDependencies { return { registered: this.registered, - engine: this.engine, + gateway: this.gateway, controller: this.controller, getARSystem: () => this.getARSystem(), getAssetExporter: () => this.getAssetExporter(), @@ -233,6 +250,7 @@ export class State { } export * from './ActionRegistry.ts'; +export * from './EngineGateway.ts'; export * from './actions/index.ts'; export * from '../types/index.ts'; export type { ActionTypes }; diff --git a/src/plugins/state/src/__test__/EngineGateway.test.ts b/src/plugins/state/src/__test__/EngineGateway.test.ts new file mode 100644 index 00000000..724a20c4 --- /dev/null +++ b/src/plugins/state/src/__test__/EngineGateway.test.ts @@ -0,0 +1,2014 @@ +import { EngineGateway } from '../EngineGateway.ts'; +import { + detachTransformControls, + DIVERoot, + DIVEGeometryType, + type DIVE, +} from '@shopware-ag/dive'; +import { type State } from '../State.ts'; +import { + LightSchema, + ModelSchema, + EntitySchema, + PrimitiveSchema, + GroupSchema, + CameraSchema, + EntityTypeSchema, +} from '@shopware-ag/dive'; +import { Color, Object3D, Vector3 } from 'three/webgpu'; + +vi.mock('three/webgpu', async () => { + const actual = + await vi.importActual('three/webgpu'); + + const Object3D = vi.fn(function (this: any) { + this.isObject3D = true; + this.children = []; + this.parent = null; + this.name = ''; + this.userData = {}; + this.visible = true; + this.layers = { mask: 0 }; + this.position = new actual.Vector3(); + this.rotation = new actual.Euler(); + this.quaternion = new actual.Quaternion(); + this.scale = new actual.Vector3(1, 1, 1); + this.add = vi.fn((...objects: any[]) => { + objects.forEach((object) => { + this.children.push(object); + if (object && typeof object === 'object') { + object.parent = this; + } + }); + return this; + }); + this.attach = vi.fn((object: any) => { + this.children.push(object); + if (object && typeof object === 'object') { + object.parent = this; + } + return this; + }); + this.remove = vi.fn((object: any) => { + this.children = this.children.filter( + (child: any) => child !== object, + ); + if (object && typeof object === 'object') { + object.parent = null; + } + return this; + }); + this.removeFromParent = vi.fn(() => { + this.parent?.remove?.(this); + }); + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.worldToLocal = vi.fn((vector: any) => vector); + this.traverse = vi.fn((callback: (object: any) => void) => { + callback(this); + this.children.forEach((child: any) => { + if (child?.traverse && child !== this) { + child.traverse(callback); + } else { + callback(child); + } + }); + }); + return this; + }); + + return { + ...actual, + Object3D, + }; +}); + +vi.mock('../../../../components/floor/Floor', () => { + return { + DIVEFloor: vi.fn(function (this: any) { + this.isDIVEFloor = true; + this.isObject3D = true; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.removeFromParent = vi.fn(); + this.userData = { + id: undefined, + }; + return this; + }), + }; +}); + +vi.mock('../../../../components/grid/Grid', () => { + return { + DIVEGrid: vi.fn(function (this: any) { + this.isObject3D = true; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.removeFromParent = vi.fn(); + this.updateMatrixWorld = vi.fn(); + return this; + }), + }; +}); + +vi.mock('../../../../components/light/AmbientLight', () => { + return { + DIVEAmbientLight: vi.fn(function (this: any) { + this.isObject3D = true; + this.name = ''; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.position = new Vector3(); + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setIntensity = vi.fn(); + this.setEnabled = vi.fn(); + this.setColor = vi.fn(); + this.userData = { + id: undefined, + }; + this.removeFromParent = vi.fn(); + return this; + }), + }; +}); + +vi.mock('../../../../components/light/PointLight', () => { + return { + DIVEPointLight: vi.fn(function (this: any) { + this.isObject3D = true; + this.name = ''; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.position = new Vector3(); + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setIntensity = vi.fn(); + this.setEnabled = vi.fn(); + this.setColor = vi.fn(); + this.userData = { + id: undefined, + }; + this.removeFromParent = vi.fn(); + return this; + }), + }; +}); + +vi.mock('../../../../components/light/SceneLight', () => { + return { + DIVESceneLight: vi.fn(function (this: any) { + this.isObject3D = true; + this.name = ''; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.position = new Vector3(); + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setIntensity = vi.fn(); + this.setEnabled = vi.fn(); + this.setColor = vi.fn(); + this.userData = { + id: undefined, + }; + this.removeFromParent = vi.fn(); + return this; + }), + }; +}); + +vi.mock('../../../../components/model/Model', () => { + return { + DIVEModel: vi.fn(function (this: any) { + this.isObject3D = true; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.userData = { + id: undefined, + }; + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setFromGLTF = vi.fn(); + this.setPosition = vi.fn(); + this.setRotation = vi.fn(); + this.setScale = vi.fn(); + this.setVisibility = vi.fn(); + this.setMaterial = vi.fn(); + this.placeOnFloor = vi.fn(); + this.removeFromParent = vi.fn(); + this.position = new Vector3(); + this.setFromURL = vi.fn().mockResolvedValue(void 0); + return this; + }), + }; +}); + +vi.mock('../../../../components/primitive/Primitive', () => { + return { + DIVEPrimitive: vi.fn(function (this: any) { + this.isObject3D = true; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.userData = { + id: undefined, + }; + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setGeometry = vi.fn(); + this.setMaterial = vi.fn(); + this.setPosition = vi.fn(); + this.setRotation = vi.fn(); + this.setScale = vi.fn(); + this.setVisibility = vi.fn(); + this.placeOnFloor = vi.fn(); + this.removeFromParent = vi.fn(); + this.position = new Vector3(); + return this; + }), + }; +}); + +vi.mock('../../../../components/group/Group', () => { + return { + DIVEGroup: vi.fn(function (this: any) { + this.isDIVEGroup = true; + this.isObject3D = true; + this.parent = null; + this.dispatchEvent = vi.fn(); + this.addEventListener = vi.fn(); + this.removeEventListener = vi.fn(); + this.userData = { + id: undefined, + }; + this.attach = vi.fn(); + this.applyMatrix4 = vi.fn(); + this.updateWorldMatrix = vi.fn(); + this.children = []; + this.setGeometry = vi.fn(); + this.setMaterial = vi.fn(); + this.setPosition = vi.fn(); + this.setRotation = vi.fn(); + this.setScale = vi.fn(); + this.setVisibility = vi.fn(); + this.setLinesVisibility = vi.fn(); + this.placeOnFloor = vi.fn(); + this.removeFromParent = vi.fn(); + this.position = new Vector3(); + this.members = []; + return this; + }), + }; +}); + +const spyConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +/** + * A gateway over a bare root, with the State side stubbed out. + * + * The mapping is what these tests are about; whether an action fires on top of + * it is covered where the wiring lives. + */ +/** + * Every scene class is replaced by a mock above, so what comes back out is a + * bag of spies rather than a real DIVEModel. Typed loosely on purpose — the + * narrowing that `getSceneObject` used to do lives in the schema guards now. + */ +type MockedSceneObject = Record; + +const findEntity = ( + gateway: EngineGateway, + entity: { id: string; entityType: EntityTypeSchema }, +): MockedSceneObject | undefined => + gateway.findEntity(entity) as MockedSceneObject | undefined; + +const makeGateway = (): EngineGateway => { + const scene = Object.assign(new Object3D(), { + root: new DIVERoot(), + grid: { visible: true, setVisibility: vi.fn() }, + background: new Color(0x000000), + setBackground: vi.fn(), + }); + const engine = { scene } as unknown as DIVE; + const state = { performAction: vi.fn() } as unknown as State; + return new EngineGateway(engine, state); +}; + +describe('plugins/state/EngineGateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(() => { + spyConsoleWarn.mockRestore(); + }); + + describe('findEntity', () => { + it('should find object by id', () => { + const mockObject = new Object3D(); + mockObject.userData = { id: 'test-id' }; + + const gateway = makeGateway(); + gateway.sceneRoot.add(mockObject); + + const found = findEntity(gateway, { + id: 'test-id', + entityType: 'model', + }); + expect(found).toBeDefined(); + }); + + it('should return undefined for non-existent id', () => { + const gateway = makeGateway(); + const found = findEntity(gateway, { + id: 'non-existent', + entityType: 'model', + }); + expect(found).toBeUndefined(); + }); + + it('should get scene object by id', () => { + const gateway = makeGateway(); + const mockObject = { + isObject3D: true, + userData: { + id: 'test-id', + }, + }; + gateway.sceneRoot.add(mockObject as any); + const result = findEntity(gateway, { + id: 'test-id', + entityType: 'model', + }); + expect(result).toBe(mockObject); + }); + + it('should return undefined when object is not found', () => { + const gateway = makeGateway(); + const result = findEntity(gateway, { + id: 'non-existent-id', + entityType: 'model', + }); + expect(result).toBeUndefined(); + }); + + it('should stop traversing when object is found', () => { + const gateway = makeGateway(); + const mockObject1 = { + isObject3D: true, + userData: { + id: 'test-id', + }, + id: 'obj1', + uuid: 'uuid1', + name: 'obj1', + type: 'Object3D', + parent: null, + children: [], + up: { x: 0, y: 1, z: 0 }, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + matrix: { elements: new Float32Array(16) }, + matrixWorld: { elements: new Float32Array(16) }, + matrixAutoUpdate: true, + matrixWorldNeedsUpdate: false, + layers: { mask: 1 }, + visible: true, + castShadow: false, + receiveShadow: false, + frustumCulled: true, + renderOrder: 0, + animations: [], + updateMatrix: vi.fn(), + updateMatrixWorld: vi.fn(), + updateWorldMatrix: vi.fn(), + traverse: vi.fn(), + traverseVisible: vi.fn(), + traverseAncestors: vi.fn(), + addEventListener: vi.fn(), + hasEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + const mockObject2 = { ...mockObject1, id: 'obj2', uuid: 'uuid2' }; + + let traverseCount = 0; + gateway.sceneRoot.traverse = vi.fn((callback) => { + traverseCount++; + callback(mockObject1 as any); + callback(mockObject2 as any); + }); + + const result = findEntity(gateway, { + id: 'test-id', + entityType: 'model', + }); + expect(result).toBe(mockObject1); + expect(traverseCount).toBe(1); + }); + }); + + describe('addEntity', () => { + it('should add different types of lights', async () => { + const sceneLightData: LightSchema = { + id: 'scene-light-1', + entityType: 'light', + type: 'scene', + name: 'Test Scene Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const ambientLightData: LightSchema = { + id: 'ambient-light-1', + entityType: 'light', + type: 'ambient', + name: 'Test Ambient Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const pointLightData: LightSchema = { + id: 'point-light-1', + entityType: 'light', + type: 'point', + name: 'Test Point Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const unknownLightData: LightSchema = { + id: 'unknown-light-1', + entityType: 'light', + type: 'unknown', + name: 'Test Unknown Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + } as any; + + const gateway = makeGateway(); + await gateway.addEntity(sceneLightData); + await gateway.addEntity(ambientLightData); + await gateway.addEntity(pointLightData); + await expect(gateway.addEntity(unknownLightData)).rejects.toThrow( + 'EngineGateway.addEntity: Unknown light type: unknown', + ); + + const sceneLight = findEntity(gateway, sceneLightData); + const ambientLight = findEntity(gateway, ambientLightData); + const pointLight = findEntity(gateway, pointLightData); + const unknownLight = findEntity(gateway, unknownLightData); + + expect(sceneLight).toBeDefined(); + expect(ambientLight).toBeDefined(); + expect(pointLight).toBeDefined(); + expect(unknownLight).toBeUndefined(); + }); + + it('should update all light properties', async () => { + const lightData: LightSchema = { + id: 'light-1', + entityType: 'light', + type: 'point', + name: 'Test Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(lightData); + expect(spyConsoleWarn).not.toHaveBeenCalled(); + + const light = findEntity(gateway, lightData); + expect(light).toBeDefined(); + expect(light?.name).toBe('Test Light'); + expect(light?.position.x).toBe(1); + expect(light?.position.y).toBe(2); + expect(light?.position.z).toBe(3); + expect((light as any).setIntensity).toHaveBeenCalledWith(1.0); + expect((light as any).setEnabled).toHaveBeenCalledWith(true); + expect((light as any).setColor).toHaveBeenCalled(); + expect(light?.visible).toBe(true); + }); + + it('should update all model properties', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + material: { color: '#ffffff' }, + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + expect(model?.name).toBe('Test Model'); + expect(model?.setPosition).toHaveBeenCalledWith(modelData.position); + expect(model?.setRotation).toHaveBeenCalledWith(modelData.rotation); + expect(model?.setScale).toHaveBeenCalledWith(modelData.scale); + expect(model?.setVisibility).toHaveBeenCalledWith( + modelData.visible, + ); + expect(model?.setMaterial).toHaveBeenCalledWith(modelData.material); + }); + + it('should update all primitive properties', async () => { + const primitiveData: PrimitiveSchema = { + id: 'primitive-1', + entityType: 'primitive', + name: 'Test Primitive', + visible: true, + geometry: { name: 'box', width: 1, height: 1, depth: 1 }, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + material: { color: '#ffffff' }, + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + expect(primitive?.name).toBe('Test Primitive'); + expect(primitive?.setGeometry).toHaveBeenCalledWith( + primitiveData.geometry, + ); + expect(primitive?.setPosition).toHaveBeenCalledWith( + primitiveData.position, + ); + expect(primitive?.setRotation).toHaveBeenCalledWith( + primitiveData.rotation, + ); + expect(primitive?.setScale).toHaveBeenCalledWith( + primitiveData.scale, + ); + expect(primitive?.setVisibility).toHaveBeenCalledWith( + primitiveData.visible, + ); + expect(primitive?.setMaterial).toHaveBeenCalledWith( + primitiveData.material, + ); + }); + + it('should update all group properties', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + bbVisible: true, + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + const group = findEntity(gateway, groupData); + expect(group).toBeDefined(); + expect(group?.name).toBe('Test Group'); + expect(group?.setPosition).toHaveBeenCalledWith(groupData.position); + expect(group?.setRotation).toHaveBeenCalledWith(groupData.rotation); + expect(group?.setScale).toHaveBeenCalledWith(groupData.scale); + expect(group?.setVisibility).toHaveBeenCalledWith( + groupData.visible, + ); + expect(group?.setLinesVisibility).toHaveBeenCalledWith( + groupData.bbVisible, + ); + }); + + it('should add a model object', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + expect(model?.userData.uri).toBe('test.glb'); + expect(model?.userData.id).toBe('model-1'); + }); + + it('should add a primitive object', async () => { + const primitiveData: PrimitiveSchema = { + id: 'primitive-1', + entityType: 'primitive', + name: 'Test Primitive', + visible: true, + geometry: { name: 'box', width: 1, height: 1, depth: 1 }, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + expect(primitive?.userData.id).toBe('primitive-1'); + }); + + it('should add a group object', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + const group = findEntity(gateway, groupData); + expect(group).toBeDefined(); + expect(group?.userData.id).toBe('group-1'); + }); + + it('should handle CAMERA objects', async () => { + const cameraData: CameraSchema = { + id: 'camera-1', + entityType: 'camera', + name: 'Test Camera', + visible: true, + position: { x: 1, y: 2, z: 3 }, + target: { x: 0, y: 0, z: 0 }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(cameraData); + // CAMERA objects are not added to the scene + const camera = findEntity(gateway, cameraData); + expect(camera).toBeUndefined(); + }); + + it('should warn for unknown entity type', async () => { + const unknownData = { + id: 'unknown', + entityType: 'unknown' as EntityTypeSchema, + name: 'Unknown', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + } as unknown as EntitySchema; + + const gateway = makeGateway(); + await expect(gateway.addEntity(unknownData)).rejects.toThrow( + 'EngineGateway.addEntity: Unknown entity type: unknown', + ); + }); + + it('should warn and return the existing object when adding a duplicate id', async () => { + const modelData: ModelSchema = { + id: 'model-duplicate', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + }; + + const gateway = makeGateway(); + const firstObject = await gateway.addEntity(modelData); + const duplicateObject = await gateway.addEntity(modelData); + + expect(duplicateObject).toBe(firstObject); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.addEntity: Scene object with id model-duplicate already exists', + ); + }); + }); + + describe('updateEntity', () => { + it('should update existing object properties', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + + const updatedData = { + ...modelData, + position: { x: 2, y: 3, z: 4 }, + }; + + await gateway.updateEntity(updatedData); + expect(model?.setPosition).toHaveBeenCalledWith( + updatedData.position, + ); + }); + + it('should update existing light properties', async () => { + const lightData: LightSchema = { + id: 'light-1', + entityType: 'light', + type: 'point', + name: 'Test Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const gateway = makeGateway(); + await gateway.addEntity(lightData); + const light = findEntity(gateway, lightData); + expect(light).toBeDefined(); + + const updatedData = { + ...lightData, + intensity: 2.0, + color: '#ff0000', + }; + + await gateway.updateEntity(updatedData); + expect((light as any).setIntensity).toHaveBeenCalledWith(2.0); + expect((light as any).setColor).toHaveBeenCalled(); + }); + + it('should update existing primitive properties', async () => { + const primitiveData: PrimitiveSchema = { + id: 'primitive-1', + entityType: 'primitive', + name: 'Test Primitive', + visible: true, + geometry: { name: 'box', width: 1, height: 1, depth: 1 }, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + + const updatedData = { + ...primitiveData, + geometry: { + name: 'box' as DIVEGeometryType, + width: 2, + height: 2, + depth: 2, + }, + }; + + await gateway.updateEntity(updatedData); + expect((primitive as any).setGeometry).toHaveBeenCalledWith( + updatedData.geometry, + ); + }); + + it('should update existing group properties', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + const group = findEntity(gateway, groupData); + expect(group).toBeDefined(); + + const updatedData = { + ...groupData, + visible: false, + bbVisible: true, + }; + + await gateway.updateEntity(updatedData); + expect((group as any).setVisibility).toHaveBeenCalledWith(false); + expect((group as any).setLinesVisibility).toHaveBeenCalledWith( + true, + ); + }); + + it('should handle update of non-existent object', async () => { + const nonExistentData = { + id: 'non-existent', + entityType: 'model' as EntityTypeSchema, + name: 'Non Existent', + visible: true, + }; + + const gateway = makeGateway(); + await gateway.updateEntity(nonExistentData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.updateEntity: Scene object with id non-existent does not exist', + ); + }); + + it('should handle CAMERA update', async () => { + const cameraData = { + id: 'camera-1', + entityType: 'camera' as EntityTypeSchema, + name: 'Test CAMERA', + visible: true, + }; + + const gateway = makeGateway(); + await gateway.updateEntity(cameraData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.updateEntity: Scene object with id camera-1 does not exist', + ); + }); + + it('should no-op when updating a found CAMERA object', async () => { + const cameraData = { + id: 'camera-1', + entityType: 'camera' as EntityTypeSchema, + name: 'Test CAMERA', + visible: true, + }; + + const gateway = makeGateway(); + const cameraObject = new Object3D(); + cameraObject.userData.id = cameraData.id; + gateway.sceneRoot.add(cameraObject); + + await gateway.updateEntity(cameraData); + + expect(spyConsoleWarn).not.toHaveBeenCalled(); + expect(findEntity(gateway, cameraData)).toBe(cameraObject); + }); + + it('should warn for unknown entity type in update', async () => { + const unknownData = { + id: 'unknown', + entityType: 'unknown' as EntityTypeSchema, + name: 'Unknown', + }; + + const gateway = makeGateway(); + await gateway.updateEntity(unknownData); + expect(spyConsoleWarn).toHaveBeenCalled(); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.updateEntity: Scene object with id unknown does not exist', + ); + }); + + it('should throw for unknown entity type when the object exists', async () => { + const unknownData = { + id: 'unknown', + entityType: 'unknown' as EntityTypeSchema, + name: 'Unknown', + }; + + const gateway = makeGateway(); + const existingObject = new Object3D(); + existingObject.userData.id = unknownData.id; + gateway.sceneRoot.add(existingObject); + + await expect(gateway.updateEntity(unknownData)).rejects.toThrow( + 'EngineGateway.updateEntity: Unknown entity type: unknown', + ); + }); + }); + + describe('removeEntity', () => { + it('should remove object from scene', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + + if (model) { + model.parent = gateway.sceneRoot; + gateway.sceneRoot.children = [model as unknown as Object3D]; + } + + gateway.removeEntity(modelData); + const deletedModel = findEntity(gateway, modelData); + expect(deletedModel).toBeUndefined(); + }); + + it('should warn when trying to delete non-existent object', () => { + const nonExistentData = { + id: 'non-existent', + entityType: 'model' as EntityTypeSchema, + name: 'Non Existent', + visible: true, + }; + + const gateway = makeGateway(); + gateway.removeEntity(nonExistentData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.removeEntity: Object with id non-existent not found', + ); + }); + + it('should handle CAMERA deletion', () => { + const cameraData: CameraSchema = { + id: 'camera-1', + entityType: 'camera', + name: 'Test CAMERA', + visible: true, + position: { x: 1, y: 2, z: 3 }, + target: { x: 0, y: 0, z: 0 }, + }; + + const gateway = makeGateway(); + gateway.removeEntity(cameraData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.removeEntity: Object with id camera-1 not found', + ); + }); + + it('should remove whatever it finds, whatever the entity type says', () => { + // Removal does not need to know what kind of thing it is holding: + // it detaches the gizmo and takes the object out. addEntity is the + // only side that has to map an entity type onto a class, so the + // second switch that used to sit here was noise — and it made a + // camera object, once in the scene, impossible to get rid of. + const cameraData: CameraSchema = { + id: 'camera-1', + entityType: 'camera', + name: 'Test CAMERA', + visible: true, + position: { x: 1, y: 2, z: 3 }, + target: { x: 0, y: 0, z: 0 }, + }; + + const gateway = makeGateway(); + const cameraObject = new Object3D(); + cameraObject.userData.id = cameraData.id; + gateway.sceneRoot.add(cameraObject); + + gateway.removeEntity(cameraData); + + expect(spyConsoleWarn).not.toHaveBeenCalled(); + expect(findEntity(gateway, cameraData)).toBeUndefined(); + }); + + it('should remove an object whose entity type it does not recognise', () => { + const unknownData = { + id: 'unknown', + entityType: 'unknown' as EntityTypeSchema, + name: 'Unknown', + }; + + const gateway = makeGateway(); + const stranger = new Object3D(); + stranger.userData.id = 'unknown'; + gateway.sceneRoot.add(stranger); + + gateway.removeEntity(unknownData); + + expect(findEntity(gateway, unknownData)).toBeUndefined(); + }); + + it('should handle group member detachment', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const memberData: ModelSchema = { + id: 'member-1', + entityType: 'model', + name: 'Test Member', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: 'group-1', + }; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + await gateway.addEntity(memberData); + + const group = findEntity(gateway, groupData); + const member = findEntity(gateway, memberData); + + expect(group).toBeDefined(); + expect(member).toBeDefined(); + + if (group && member) { + (group as any).members = [member]; + group.parent = gateway.sceneRoot; + } + + gateway.removeEntity(groupData); + expect(gateway.sceneRoot.attach).toHaveBeenCalledWith(member); + }); + + it('should handle transform controls detachment', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + }; + + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + + if (model) { + model.parent = gateway.sceneRoot; + gateway.sceneRoot.parent = mockScene; + } + + gateway.removeEntity(modelData); + expect(mockTransformControls.detach).toHaveBeenCalled(); + }); + + it('should handle primitive deletion', async () => { + const primitiveData: PrimitiveSchema = { + id: 'primitive-1', + entityType: 'primitive', + name: 'Test Primitive', + visible: true, + geometry: { name: 'box', width: 1, height: 1, depth: 1 }, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + + if (primitive) { + primitive.parent = gateway.sceneRoot; + gateway.sceneRoot.parent = mockScene; + } + + gateway.removeEntity(primitiveData); + expect(mockTransformControls.detach).toHaveBeenCalled(); + }); + + it('should handle group deletion with transform controls', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + const group = findEntity(gateway, groupData); + expect(group).toBeDefined(); + + if (group) { + group.parent = gateway.sceneRoot; + gateway.sceneRoot.parent = mockScene; + (group as any).members = [new Object3D()]; + } + + gateway.removeEntity(groupData); + expect(mockTransformControls.detach).toHaveBeenCalled(); + expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + }); + }); + + describe('_setParent', () => { + it('should set parent-child relationship', async () => { + const parentData: GroupSchema = { + id: 'parent-1', + entityType: 'group', + name: 'Parent Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const childData: ModelSchema = { + id: 'child-1', + entityType: 'model', + name: 'Child Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: 'parent-1', + }; + + const gateway = makeGateway(); + await gateway.addEntity(parentData); + await gateway.addEntity(childData); + + const parent = findEntity(gateway, parentData); + const child = findEntity(gateway, childData); + + expect(parent).toBeDefined(); + expect(child).toBeDefined(); + expect(parent?.attach).toHaveBeenCalled(); + }); + + it('should attach to gateway when parent is null', async () => { + const childData: ModelSchema = { + id: 'child-1', + entityType: 'model', + name: 'Child Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(childData); + const child = findEntity(gateway, childData); + expect(child).toBeDefined(); + expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + }); + + it('should handle non-existent parent', async () => { + const childData: ModelSchema = { + id: 'child-1', + entityType: 'model', + name: 'Child Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: 'non-existent', + }; + + const gateway = makeGateway(); + await gateway.addEntity(childData); + const child = findEntity(gateway, childData); + expect(child).toBeDefined(); + // When parent doesn't exist, the object should remain where it is + expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + }); + + it('should handle non-existent object', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: 'parent-1', + }; + + const gateway = makeGateway(); + // Don't add the object to the scene + await gateway.updateEntity(modelData); + expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + }); + }); + + describe('_applyLight', () => { + it('should handle light with undefined properties', async () => { + const lightData: Partial & { + id: string; + entityType: string; + type: string; + } = { + id: 'light-1', + entityType: 'light', + type: 'point', + name: undefined, + visible: undefined, + position: undefined, + intensity: undefined, + enabled: undefined, + color: undefined, + }; + + const gateway = makeGateway(); + await gateway.addEntity(lightData as LightSchema); + const light = findEntity(gateway, lightData); + expect(light).toBeDefined(); + }); + + it('should only touch the fields a patch carries', async () => { + const gateway = makeGateway(); + await gateway.addEntity({ + id: 'light-1', + entityType: 'light', + type: 'point', + name: 'Lamp', + intensity: 2, + } as LightSchema); + + const light = findEntity(gateway, { + id: 'light-1', + entityType: 'light', + }) as any; + light.setIntensity.mockClear(); + light.setColor.mockClear(); + light.setEnabled.mockClear(); + + await gateway.updateEntity({ + id: 'light-1', + entityType: 'light', + name: 'Renamed', + }); + + expect(light.name).toBe('Renamed'); + // absent means unchanged, so no other setter runs + expect(light.setIntensity).not.toHaveBeenCalled(); + expect(light.setColor).not.toHaveBeenCalled(); + expect(light.setEnabled).not.toHaveBeenCalled(); + }); + }); + + describe('model asset loading', () => { + const addModel = async ( + gateway: EngineGateway, + uri: string, + ): Promise => { + await gateway.addEntity({ + id: 'model-1', + entityType: 'model', + name: 'M', + uri, + } as ModelSchema); + + return findEntity(gateway, { + id: 'model-1', + entityType: 'model', + }) as any; + }; + + it('should load the asset when the model is added', async () => { + const gateway = makeGateway(); + const model = await addModel(gateway, 'a.glb'); + + expect(model.setFromURL).toHaveBeenCalledWith('a.glb'); + expect(model.userData.uri).toBe('a.glb'); + }); + + it('should not fetch the asset again when the uri is unchanged', async () => { + const gateway = makeGateway(); + const model = await addModel(gateway, 'a.glb'); + model.setFromURL.mockClear(); + + await gateway.updateEntity({ + id: 'model-1', + entityType: 'model', + uri: 'a.glb', + position: { x: 1, y: 2, z: 3 }, + }); + + expect(model.setFromURL).not.toHaveBeenCalled(); + // the rest of the patch still applies + expect(model.setPosition).toHaveBeenCalledWith({ + x: 1, + y: 2, + z: 3, + }); + }); + + it('should fetch the asset when the uri changed', async () => { + const gateway = makeGateway(); + const model = await addModel(gateway, 'a.glb'); + model.setFromURL.mockClear(); + + await gateway.updateEntity({ + id: 'model-1', + entityType: 'model', + uri: 'b.glb', + }); + + expect(model.setFromURL).toHaveBeenCalledWith('b.glb'); + expect(model.userData.uri).toBe('b.glb'); + }); + }); + + describe('removing a light', () => { + it('should handle light with transform controls', async () => { + const lightData: LightSchema = { + id: 'light-1', + entityType: 'light', + type: 'point', + name: 'Test Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + + const gateway = makeGateway(); + await gateway.addEntity(lightData); + const light = findEntity(gateway, lightData); + expect(light).toBeDefined(); + + if (light) { + light.parent = gateway.sceneRoot; + gateway.sceneRoot.parent = mockScene; + } + + gateway.removeEntity(lightData); + expect(mockTransformControls.detach).toHaveBeenCalled(); + }); + + it('should handle non-existent light', () => { + const lightData: LightSchema = { + id: 'non-existent-light', + entityType: 'light', + type: 'point', + name: 'Test Light', + visible: true, + position: { x: 1, y: 2, z: 3 }, + intensity: 1.0, + enabled: true, + color: '#ffffff', + }; + + const gateway = makeGateway(); + gateway.removeEntity(lightData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.removeEntity: Object with id non-existent-light not found', + ); + }); + }); + + describe('removing a group', () => { + it('should handle group with transform controls and members', async () => { + const groupData: GroupSchema = { + id: 'group-1', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const mockTransformControls = Object.assign(new Object3D(), { + isTransformControls: true, + detach: vi.fn(), + }); + + const mockScene = new Object3D(); + mockScene.children = [mockTransformControls]; + + const gateway = makeGateway(); + await gateway.addEntity(groupData); + const group = findEntity(gateway, groupData); + expect(group).toBeDefined(); + + if (group) { + group.parent = gateway.sceneRoot; + gateway.sceneRoot.parent = mockScene; + (group as any).members = [new Object3D()]; + } + + gateway.removeEntity(groupData); + expect(mockTransformControls.detach).toHaveBeenCalled(); + expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + }); + + it('should handle non-existent group', () => { + const groupData: GroupSchema = { + id: 'non-existent-group', + entityType: 'group', + name: 'Test Group', + visible: true, + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + gateway.removeEntity(groupData); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.removeEntity: Object with id non-existent-group not found', + ); + }); + }); + + describe('_setParent', () => { + it('should handle object with null parentId', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: null, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + }); + + it('should handle object with non-existent parent', async () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'Test Model', + visible: true, + uri: 'test.glb', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: 'non-existent', + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + }); + }); + + describe('_applyModel', () => { + it('should handle model with undefined properties', async () => { + const modelData: Partial & { + id: string; + entityType: string; + } = { + id: 'model-1', + entityType: 'model', + name: null as unknown as string, + visible: null as unknown as boolean, + position: null as unknown as { + x: number; + y: number; + z: number; + }, + rotation: null as unknown as { + x: number; + y: number; + z: number; + }, + scale: null as unknown as { x: number; y: number; z: number }, + material: null as unknown as { color: string }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData as ModelSchema); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + }); + + it('should handle model with null properties', async () => { + const modelData: Partial & { + id: string; + entityType: string; + } = { + id: 'model-1', + entityType: 'model', + name: null as unknown as string, + visible: null as unknown as boolean, + position: null as unknown as { + x: number; + y: number; + z: number; + }, + rotation: null as unknown as { + x: number; + y: number; + z: number; + }, + scale: null as unknown as { x: number; y: number; z: number }, + uri: null as unknown as string, + loaded: null as unknown as boolean, + material: null as unknown as { color: string }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(modelData as ModelSchema); + const model = findEntity(gateway, modelData); + expect(model).toBeDefined(); + }); + }); + + describe('_applyPrimitive', () => { + it('should handle primitive with undefined properties', async () => { + const primitiveData: Partial & { + id: string; + entityType: string; + } = { + id: 'primitive-1', + entityType: 'primitive', + name: undefined, + visible: undefined, + position: undefined, + rotation: undefined, + scale: undefined, + geometry: undefined, + material: undefined, + }; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData as PrimitiveSchema); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + }); + + it('should handle primitive with null properties', async () => { + const primitiveData: Partial & { + id: string; + entityType: string; + } = { + id: 'primitive-1', + entityType: 'primitive', + name: null as unknown as string, + visible: null as unknown as boolean, + position: null as unknown as { + x: number; + y: number; + z: number; + }, + rotation: null as unknown as { + x: number; + y: number; + z: number; + }, + scale: null as unknown as { x: number; y: number; z: number }, + geometry: null as unknown as { + name: 'box'; + width: number; + height: number; + depth: number; + }, + material: null as unknown as { color: string }, + }; + + const gateway = makeGateway(); + await gateway.addEntity(primitiveData as PrimitiveSchema); + const primitive = findEntity(gateway, primitiveData); + expect(primitive).toBeDefined(); + }); + }); + + describe('removing a primitive', () => { + it('should handle non-existent primitive', () => { + const primitiveData: PrimitiveSchema = { + id: 'non-existent-primitive', + entityType: 'primitive', + name: 'Test Primitive', + visible: true, + geometry: { name: 'box', width: 1, height: 1, depth: 1 }, + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + + const gateway = makeGateway(); + gateway.removeEntity(primitiveData); + expect(spyConsoleWarn).toHaveBeenCalled(); + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway.removeEntity: Object with id non-existent-primitive not found', + ); + }); + }); + + describe('scene settings', () => { + // These used to be spelled out three times — in updatescene, setstate + // and getstate — and had already drifted: setstate never applied + // gridEnabled. + + const makeSceneGateway = () => { + const floor = { + visible: false, + material: { color: { getHexString: () => 'abcdef' } }, + setVisibility: vi.fn(), + setColor: vi.fn(), + }; + const scene = { + name: 'Scene', + background: { getHexString: () => '112233' }, + grid: { visible: true, setVisibility: vi.fn() }, + setBackground: vi.fn(), + root: { floor }, + }; + const gateway = new EngineGateway( + { scene } as unknown as DIVE, + { performAction: vi.fn() } as unknown as State, + ); + return { gateway, scene, floor }; + }; + + it('should read every property off the scene', () => { + const { gateway } = makeSceneGateway(); + + expect(gateway.readSceneSettings()).toEqual({ + name: 'Scene', + backgroundColor: '#112233', + gridEnabled: true, + floorEnabled: false, + floorColor: '#abcdef', + }); + }); + + it('should write every property, grid included', () => { + const { gateway, scene, floor } = makeSceneGateway(); + + gateway.applySceneSettings({ + name: 'New', + backgroundColor: '#ff0000', + gridEnabled: false, + floorEnabled: true, + floorColor: '#00ff00', + }); + + expect(scene.name).toBe('New'); + expect(scene.setBackground).toHaveBeenCalledWith('#ff0000'); + expect(scene.grid.setVisibility).toHaveBeenCalledWith(false); + expect(floor.setVisibility).toHaveBeenCalledWith(true); + expect(floor.setColor).toHaveBeenCalledWith('#00ff00'); + }); + + it('should leave out what the patch does not carry', () => { + const { gateway, scene, floor } = makeSceneGateway(); + + gateway.applySceneSettings({ name: 'Only the name' }); + + expect(scene.name).toBe('Only the name'); + expect(scene.setBackground).not.toHaveBeenCalled(); + expect(scene.grid.setVisibility).not.toHaveBeenCalled(); + expect(floor.setVisibility).not.toHaveBeenCalled(); + expect(floor.setColor).not.toHaveBeenCalled(); + }); + + it('should accept a numeric colour as well as a string', () => { + const { gateway, scene, floor } = makeSceneGateway(); + + gateway.applySceneSettings({ + backgroundColor: 0xff0000, + floorColor: 0x00ff00, + }); + + expect(scene.setBackground).toHaveBeenCalledWith(0xff0000); + expect(floor.setColor).toHaveBeenCalledWith(0x00ff00); + }); + }); + + describe('wiring objects to the state', () => { + const modelData: ModelSchema = { + id: 'model-1', + entityType: 'model', + name: 'M', + visible: true, + uri: 'a.glb', + position: { x: 0, y: 0, z: 0 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + loaded: false, + parentId: null, + }; + + /** A gateway whose listeners are the real ones, over a spying State. */ + const makeWired = () => { + const performAction = vi.fn(); + const gateway = new EngineGateway( + { + scene: { root: new DIVERoot() }, + } as unknown as DIVE, + { performAction } as unknown as State, + ); + return { gateway, performAction }; + }; + + /** Replays what the object would have dispatched. */ + const fire = ( + object: MockedSceneObject, + type: string, + payload: object = {}, + ): void => { + object.addEventListener.mock.calls + .filter((call: unknown[]) => call[0] === type) + .forEach((call: unknown[]) => + (call[1] as (e: object) => void)({ type, ...payload }), + ); + }; + + it('should subscribe before the schema is applied', async () => { + // applying a model schema awaits setFromURL, and object-load fires + // in there — listening afterwards would miss it + const { gateway } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + const order = (name: string): number => + model[name].mock.invocationCallOrder[0]; + + expect(order('addEventListener')).toBeLessThan(order('setFromURL')); + }); + + it('should turn a reported transform into one UPDATE_OBJECT', async () => { + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + fire(model, 'object-transform', { + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }); + + const updates = performAction.mock.calls.filter( + (call) => call[0] === 'UPDATE_OBJECT', + ); + expect(updates).toHaveLength(1); + expect(updates[0][1]).toEqual({ + id: 'model-1', + entityType: 'model', + position: { x: 1, y: 2, z: 3 }, + rotation: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }); + }); + + it('should copy the reported vectors', async () => { + // the object hands out a scratch buffer it overwrites next frame, + // and UPDATE_OBJECT merges the payload straight into the registry + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + const live = { x: 1, y: 2, z: 3 }; + fire(model, 'object-transform', { + position: live, + rotation: live, + scale: live, + }); + + const sent = performAction.mock.calls.find( + (call) => call[0] === 'UPDATE_OBJECT', + )![1]; + expect(sent.position).not.toBe(live); + + live.x = 999; + expect(sent.position.x).toBe(1); + }); + + it('should report a model load', async () => { + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + fire(model, 'object-load'); + + expect(performAction).toHaveBeenCalledWith('MODEL_LOADED', { + id: 'model-1', + }); + }); + + it('should not select the same object twice', async () => { + // SELECT_OBJECT runs selectionState.select(), which calls back into + // onSelect() — without the guard that loops + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + fire(model, 'object-select'); + fire(model, 'object-select'); + + expect( + performAction.mock.calls.filter( + (call) => call[0] === 'SELECT_OBJECT', + ), + ).toHaveLength(1); + }); + + it('should deselect only what is actually selected', async () => { + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + fire(model, 'object-deselect'); + expect(performAction).not.toHaveBeenCalledWith( + 'DESELECT_OBJECT', + expect.anything(), + ); + + fire(model, 'object-select'); + fire(model, 'object-deselect'); + expect(performAction).toHaveBeenCalledWith('DESELECT_OBJECT', { + id: 'model-1', + entityType: 'model', + }); + }); + + it('should allow selecting again after a deselect', async () => { + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + fire(model, 'object-select'); + fire(model, 'object-deselect'); + fire(model, 'object-select'); + + expect( + performAction.mock.calls.filter( + (call) => call[0] === 'SELECT_OBJECT', + ), + ).toHaveLength(2); + }); + + it('should stop listening once the object is removed', async () => { + const { gateway } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + model.parent = gateway.sceneRoot; + + gateway.removeEntity(modelData); + + const removed = model.removeEventListener.mock.calls.map( + (call: unknown[]) => call[0], + ); + expect(removed).toEqual( + expect.arrayContaining([ + 'object-transform', + 'object-select', + 'object-deselect', + 'object-load', + ]), + ); + }); + + it('should drop every subscription on dispose', async () => { + const { gateway } = makeWired(); + await gateway.addEntity(modelData); + const model = findEntity(gateway, modelData)!; + + gateway.dispose(); + + expect(model.removeEventListener).toHaveBeenCalledTimes(4); + }); + + it('should not wire a camera, because it never enters the scene', async () => { + const { gateway } = makeWired(); + + const result = await gateway.addEntity({ + id: 'camera-1', + entityType: 'camera', + name: 'C', + visible: true, + position: { x: 0, y: 0, z: 0 }, + target: { x: 0, y: 0, z: 0 }, + } as CameraSchema); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/plugins/state/src/__test__/State.test.ts b/src/plugins/state/src/__test__/State.test.ts index 79e05a92..b6f032ad 100644 --- a/src/plugins/state/src/__test__/State.test.ts +++ b/src/plugins/state/src/__test__/State.test.ts @@ -1,3 +1,4 @@ +import { EngineGateway } from '../EngineGateway.ts'; vi.mock('three/webgpu', async (importOriginal) => { const actual = await importOriginal(); return { ...actual }; @@ -713,8 +714,17 @@ describe('modules/state/State', () => { return capturedDependencies!; }; - it('should pass the DIVE instance as the engine dependency', () => { - expect(performCapture().engine).toBe(mockDive); + it('should pass a gateway instead of the engine itself', () => { + // the DIVE instance is deliberately not handed out — an action + // reaching past the gateway is what this replaces + const deps = performCapture(); + + expect(deps.gateway).toBeInstanceOf(EngineGateway); + expect(deps).not.toHaveProperty('engine'); + }); + + it('should reuse one gateway for the whole instance', () => { + expect(performCapture().gateway).toBe(performCapture().gateway); }); it('should pass the orbit controller it was constructed with', () => { @@ -750,7 +760,7 @@ describe('modules/state/State', () => { const second = performCapture(); expect(second).not.toBe(first); - expect(second.engine).toBe(first.engine); + expect(second.gateway).toBe(first.gateway); expect(second.registered).toBe(first.registered); }); @@ -773,8 +783,7 @@ describe('modules/state/State', () => { otherState.performAction('TEST_DEPENDENCIES'); const other = capturedDependencies!; - expect(own.engine).toBe(mockDive); - expect(other.engine).toBe(otherDive); + expect(other.gateway).not.toBe(own.gateway); expect(other.controller).toBe(otherController); expect(other.registered).not.toBe(own.registered); }); diff --git a/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts b/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts index b547df0b..243f7595 100644 --- a/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts +++ b/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts @@ -1,4 +1,4 @@ -import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { type EngineGateway } from '../../../EngineGateway.ts'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { ComputeEncompassingViewAction } from '../computeencompassingview.ts'; import { Vector3 } from 'three/webgpu'; @@ -10,13 +10,13 @@ vi.mock('../../../../../../components/boundingbox/BoundingBox.ts', () => ({ describe('modules/state/actions/camera/computeEncompassingView', () => { it('should compute encompassing view for a scene', async () => { // Mock dependencies - const mockScene = { + const sceneRoot = { computeSceneBB: vi.fn().mockReturnValue({ min: new Vector3(0, 0, 0), max: new Vector3(10, 10, 10), }), add: vi.fn(), - } as unknown as DIVEScene; + }; const mockController = { computeEncompassingView: vi.fn().mockReturnValue({ @@ -25,12 +25,10 @@ describe('modules/state/actions/camera/computeEncompassingView', () => { }), } as unknown as OrbitController; - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const mockGateway = { sceneRoot } as unknown as EngineGateway; const action = new ComputeEncompassingViewAction(undefined, { - engine: mockEngine, + gateway: mockGateway, controller: mockController, }); diff --git a/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts b/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts index 9ef05658..dbcc92f2 100644 --- a/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts +++ b/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts @@ -1,3 +1,4 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { MoveCameraAction } from '../movecamera.ts'; import { EntitySchema } from '@shopware-ag/dive'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; @@ -21,12 +22,9 @@ const mockGetAnimationSystem = vi.fn().mockResolvedValue({ }, }); -const mockEngine = { - clock: { - addTicker: vi.fn(), - hasTicker: vi.fn(), - }, -} as unknown as DIVE; +const mockGateway = { + registerTicker: vi.fn(), +} as unknown as EngineGateway; const mockController = { object: { @@ -58,14 +56,14 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); const result = await action.execute(); expect(mockGetAnimationSystem).toHaveBeenCalled(); - expect(mockEngine.clock.addTicker).toHaveBeenCalled(); + expect(mockGateway.registerTicker).toHaveBeenCalled(); expect(mockFromTargets).toHaveBeenCalledWith( expect.arrayContaining([ @@ -105,7 +103,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); @@ -146,7 +144,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); @@ -185,7 +183,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); @@ -218,7 +216,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); @@ -243,7 +241,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); @@ -271,7 +269,7 @@ describe('MoveCameraAction', () => { controller: mockController, registered: mockRegistered, getAnimationSystem: mockGetAnimationSystem, - engine: mockEngine, + gateway: mockGateway, }, ); diff --git a/src/plugins/state/src/actions/camera/computeencompassingview.ts b/src/plugins/state/src/actions/camera/computeencompassingview.ts index 36c97c07..92d33921 100644 --- a/src/plugins/state/src/actions/camera/computeencompassingview.ts +++ b/src/plugins/state/src/actions/camera/computeencompassingview.ts @@ -6,7 +6,7 @@ import { BoundingBox } from '../../../../../components/boundingbox/BoundingBox.t export const ComputeEncompassingViewAction = Action.define< void, - Pick, + Pick, { position: Vector3Like; target: Vector3Like; @@ -14,8 +14,8 @@ export const ComputeEncompassingViewAction = Action.define< >({ description: 'Calculates the camera position and target to view the whole scene. (experimental).', - execute: (_payload, { engine, controller }) => { - const sceneBB = new BoundingBox(engine.scene.root, false, 0x00ff00); + execute: (_payload, { gateway, controller }) => { + const sceneBB = new BoundingBox(gateway.sceneRoot, false, 0x00ff00); return controller.computeEncompassingView(sceneBB); }, }); diff --git a/src/plugins/state/src/actions/camera/movecamera.ts b/src/plugins/state/src/actions/camera/movecamera.ts index 451494da..c191f467 100644 --- a/src/plugins/state/src/actions/camera/movecamera.ts +++ b/src/plugins/state/src/actions/camera/movecamera.ts @@ -18,14 +18,14 @@ export const MoveCameraAction = Action.define< }, Pick< ActionDependencies, - 'registered' | 'controller' | 'getAnimationSystem' | 'engine' + 'registered' | 'controller' | 'getAnimationSystem' | 'gateway' >, Promise<{ stop: () => void }> >({ description: 'Moves the camera to a new position and target.', execute: async ( payload, - { controller, registered, getAnimationSystem, engine }, + { controller, registered, getAnimationSystem, gateway }, ) => { const animationSystem = await getAnimationSystem(); let position = { x: 0, y: 0, z: 0 }; @@ -51,9 +51,7 @@ export const MoveCameraAction = Action.define< target = payload.target; } - if (!engine.clock.hasTicker(animationSystem)) { - engine.clock.addTicker(animationSystem); - } + gateway.registerTicker(animationSystem); controller.enabled = true; diff --git a/src/plugins/state/src/actions/object/__test__/addobject.test.ts b/src/plugins/state/src/actions/object/__test__/addobject.test.ts index 393aac3e..a808c02f 100644 --- a/src/plugins/state/src/actions/object/__test__/addobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/addobject.test.ts @@ -1,14 +1,15 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; +import { type DIVESceneObject } from '@shopware-ag/dive'; import { AddObjectAction } from '../addobject.ts'; -import { DIVE, DIVEScene, type EntitySchema } from '@shopware-ag/dive'; +import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; -const mockEngine = { - scene: { - root: { - addSceneObject: vi.fn(), - getSceneObject: vi.fn(), - }, - } as unknown as DIVEScene, -} as unknown as DIVE; +const existingSceneObject = { name: 'already there' } as DIVESceneObject; + +const mockGateway = { + addEntity: vi.fn(), + findEntity: vi.fn().mockReturnValue(existingSceneObject), +} as unknown as EngineGateway; const mockRegistered = new Map(); @@ -30,17 +31,15 @@ describe('AddObjectAction', () => { } as unknown as EntitySchema; const action = new AddObjectAction(testObject, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }); // Execute action - action.execute(); + await action.execute(); // Verify results - expect(mockEngine.scene.root.addSceneObject).toHaveBeenCalledWith( - testObject, - ); + expect(mockGateway.addEntity).toHaveBeenCalledWith(testObject); expect(mockRegistered.get(testObject.id)).toEqual(testObject); }); @@ -59,14 +58,16 @@ describe('AddObjectAction', () => { mockRegistered.set(testObject.id, testObject); const action = new AddObjectAction(testObject, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }); - // Execute action - action.execute(); + // awaited, so a rejection surfaces here instead of going unhandled + const result = await action.execute(); // Verify results - expect(mockEngine.scene.root.addSceneObject).not.toHaveBeenCalled(); + expect(mockGateway.addEntity).not.toHaveBeenCalled(); + expect(mockGateway.findEntity).toHaveBeenCalledWith(testObject); + expect(result).toBe(existingSceneObject); }); }); diff --git a/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts b/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts index 0c51a66b..8ad681e1 100644 --- a/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts @@ -1,5 +1,7 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { DeleteObjectAction } from '../deleteobject.ts'; -import { DIVE, DIVEScene, type EntitySchema } from '@shopware-ag/dive'; +import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { SetParentAction } from '../setparent.ts'; import { UpdateObjectAction } from '../updateobject.ts'; @@ -14,9 +16,9 @@ describe('DeleteObjectAction', () => { }, } as unknown as DIVEScene; - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const mockGateway = { + removeEntity: vi.fn(), + } as unknown as EngineGateway; const mockRegistered = new Map(); @@ -50,14 +52,12 @@ describe('DeleteObjectAction', () => { // Act const action = new DeleteObjectAction( { id: object.id }, - { engine: mockEngine, registered: mockRegistered }, + { gateway: mockGateway, registered: mockRegistered }, ); action.execute(); // Assert - expect(mockEngine.scene.root.deleteSceneObject).toHaveBeenCalledWith( - object, - ); + expect(mockGateway.removeEntity).toHaveBeenCalledWith(object); expect(mockRegistered.has(object.id)).toBe(false); }); @@ -84,7 +84,7 @@ describe('DeleteObjectAction', () => { // Act const action = new DeleteObjectAction( { id: object.id }, - { engine: mockEngine, registered: mockRegistered }, + { gateway: mockGateway, registered: mockRegistered }, ); action.execute(); @@ -95,13 +95,11 @@ describe('DeleteObjectAction', () => { parent: null, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); - expect(mockEngine.scene.root.deleteSceneObject).toHaveBeenCalledWith( - object, - ); + expect(mockGateway.removeEntity).toHaveBeenCalledWith(object); expect(mockRegistered.has(object.id)).toBe(false); }); @@ -157,7 +155,7 @@ describe('DeleteObjectAction', () => { // Act const action = new DeleteObjectAction( { id: group.id }, - { engine: mockEngine, registered: mockRegistered }, + { gateway: mockGateway, registered: mockRegistered }, ); action.execute(); @@ -169,7 +167,7 @@ describe('DeleteObjectAction', () => { parentId: null, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -179,13 +177,11 @@ describe('DeleteObjectAction', () => { parentId: null, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); - expect(mockEngine.scene.root.deleteSceneObject).toHaveBeenCalledWith( - group, - ); + expect(mockGateway.removeEntity).toHaveBeenCalledWith(group); expect(mockRegistered.has(group.id)).toBe(false); }); @@ -193,12 +189,12 @@ describe('DeleteObjectAction', () => { // Act const action = new DeleteObjectAction( { id: 'non-existent' }, - { engine: mockEngine, registered: mockRegistered }, + { gateway: mockGateway, registered: mockRegistered }, ); const result = action.execute(); // Assert expect(result).toBe(false); - expect(mockEngine.scene.root.deleteSceneObject).not.toHaveBeenCalled(); + expect(mockGateway.removeEntity).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts b/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts index f8f9c1b1..5ed9e754 100644 --- a/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts @@ -1,11 +1,8 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { DeselectObjectAction } from '../deselectobject.ts'; import { Object3D } from 'three/webgpu'; -import { - DIVE, - type DIVESelectable, - DIVESceneObject, - type EntitySchema, -} from '@shopware-ag/dive'; +import { DIVE, type DIVESelectable, DIVESceneObject } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { type Toolbox, type SelectionState } from '@shopware-ag/dive/toolbox'; const mockSceneObject = { @@ -13,13 +10,9 @@ const mockSceneObject = { isSelectable: true, } as unknown as Object3D & DIVESelectable; -const mockEngine = { - scene: { - root: { - getSceneObject: vi.fn().mockReturnValue(mockSceneObject), - }, - }, -} as unknown as DIVE; +const mockGateway = { + findEntity: vi.fn().mockReturnValue(mockSceneObject), +} as unknown as EngineGateway; const mockSelectionState = { select: vi.fn(), @@ -63,7 +56,7 @@ describe('DeselectObjectAction', () => { const action = new DeselectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -79,7 +72,7 @@ describe('DeselectObjectAction', () => { const action = new DeselectObjectAction( { id: 'non-existent-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -109,15 +102,13 @@ describe('DeselectObjectAction', () => { } as unknown as EntitySchema; mockRegistered.set(testObject.id, testObject); - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValueOnce( - undefined, - ); + vi.mocked(mockGateway.findEntity).mockReturnValueOnce(undefined); // Act const action = new DeselectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -149,7 +140,7 @@ describe('DeselectObjectAction', () => { } as unknown as EntitySchema; mockRegistered.set(testObject.id, testObject); - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValueOnce( + vi.mocked(mockGateway.findEntity).mockReturnValueOnce( {} as DIVESceneObject, ); @@ -157,7 +148,7 @@ describe('DeselectObjectAction', () => { const action = new DeselectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, diff --git a/src/plugins/state/src/actions/object/__test__/dropit.test.ts b/src/plugins/state/src/actions/object/__test__/dropit.test.ts index 567106ac..d9f229ca 100644 --- a/src/plugins/state/src/actions/object/__test__/dropit.test.ts +++ b/src/plugins/state/src/actions/object/__test__/dropit.test.ts @@ -1,18 +1,16 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { DropItAction } from '../dropit.ts'; -import { DIVEModel, DIVE, type EntitySchema } from '@shopware-ag/dive'; +import { DIVEModel, DIVE } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; const mockModel = { isDIVEModel: true, dropIt: vi.fn(), } as unknown as DIVEModel; -const mockEngine = { - scene: { - root: { - getSceneObject: vi.fn().mockReturnValue(mockModel), - }, - }, -} as unknown as DIVE; +const mockGateway = { + findEntity: vi.fn().mockReturnValue(mockModel), +} as unknown as EngineGateway; const mockRegistered = new Map(); @@ -41,7 +39,7 @@ describe('DropItAction', () => { const action = new DropItAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -50,9 +48,7 @@ describe('DropItAction', () => { await action.execute(); // Verify results - expect(mockEngine.scene.root.getSceneObject).toHaveBeenCalledWith( - testObject, - ); + expect(mockGateway.findEntity).toHaveBeenCalledWith(testObject); expect(mockModel.dropIt).toHaveBeenCalled(); }); @@ -60,7 +56,7 @@ describe('DropItAction', () => { const action = new DropItAction( { id: 'non-existent-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -72,9 +68,7 @@ describe('DropItAction', () => { }); it('should throw error if object is not found in scene', async () => { - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValue( - undefined, - ); + vi.mocked(mockGateway.findEntity).mockReturnValue(undefined); const testObject: EntitySchema = { id: 'test-object', @@ -92,7 +86,7 @@ describe('DropItAction', () => { const action = new DropItAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -123,14 +117,12 @@ describe('DropItAction', () => { // isDIVEModel: true <= specifically not set dropIt: vi.fn(), } as unknown as DIVEModel; - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValue( - mockDIVEModel, - ); + vi.mocked(mockGateway.findEntity).mockReturnValue(mockDIVEModel); const action = new DropItAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); diff --git a/src/plugins/state/src/actions/object/__test__/getobjects.test.ts b/src/plugins/state/src/actions/object/__test__/getobjects.test.ts index acaa07cf..e44e1151 100644 --- a/src/plugins/state/src/actions/object/__test__/getobjects.test.ts +++ b/src/plugins/state/src/actions/object/__test__/getobjects.test.ts @@ -64,7 +64,7 @@ describe('GetObjectsAction', () => { { ids: ['object1', 'object3'], }, - { engine: {} as any, registered: mockRegistered }, + { gateway: {} as never, registered: mockRegistered }, ); const result = action.execute(); @@ -79,7 +79,7 @@ describe('GetObjectsAction', () => { // Act const action = new GetObjectsAction( { ids: [] }, - { engine: {} as any, registered: mockRegistered }, + { gateway: {} as never, registered: mockRegistered }, ); const result = action.execute(); @@ -111,7 +111,7 @@ describe('GetObjectsAction', () => { // Act const action = new GetObjectsAction( { ids: ['non-existent'] }, - { engine: {} as any, registered: mockRegistered }, + { gateway: {} as never, registered: mockRegistered }, ); const result = action.execute(); diff --git a/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts b/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts index f889e5b9..9261edd4 100644 --- a/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts +++ b/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts @@ -1,18 +1,16 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { PlaceOnFloorAction } from '../placeonfloor.ts'; -import { DIVE, DIVEModel, type EntitySchema } from '@shopware-ag/dive'; +import { DIVE, DIVEModel } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; const mockModel = { isDIVEModel: true, placeOnFloor: vi.fn(), } as unknown as DIVEModel; -const mockEngine = { - scene: { - root: { - getSceneObject: vi.fn().mockReturnValue(mockModel), - }, - }, -} as unknown as DIVE; +const mockGateway = { + findEntity: vi.fn().mockReturnValue(mockModel), +} as unknown as EngineGateway; const mockRegistered = new Map(); @@ -39,7 +37,7 @@ describe('PlaceOnFloorAction', () => { const action = new PlaceOnFloorAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -48,9 +46,7 @@ describe('PlaceOnFloorAction', () => { action.execute(); // Verify results - expect(mockEngine.scene.root.getSceneObject).toHaveBeenCalledWith( - testObject, - ); + expect(mockGateway.findEntity).toHaveBeenCalledWith(testObject); expect(mockModel.placeOnFloor).toHaveBeenCalled(); }); @@ -58,7 +54,7 @@ describe('PlaceOnFloorAction', () => { const action = new PlaceOnFloorAction( { id: 'non-existent-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -70,9 +66,7 @@ describe('PlaceOnFloorAction', () => { }); it('should throw error if object is not found in scene', async () => { - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValue( - undefined, - ); + vi.mocked(mockGateway.findEntity).mockReturnValue(undefined); const testObject: EntitySchema = { id: 'test-object', @@ -90,7 +84,7 @@ describe('PlaceOnFloorAction', () => { const action = new PlaceOnFloorAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -121,14 +115,12 @@ describe('PlaceOnFloorAction', () => { // isDIVEModel: true <= specifically not set dropIt: vi.fn(), } as unknown as DIVEModel; - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValue( - mockDIVEModel, - ); + vi.mocked(mockGateway.findEntity).mockReturnValue(mockDIVEModel); const action = new PlaceOnFloorAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); diff --git a/src/plugins/state/src/actions/object/__test__/selectobject.test.ts b/src/plugins/state/src/actions/object/__test__/selectobject.test.ts index 7eca49d0..f8465432 100644 --- a/src/plugins/state/src/actions/object/__test__/selectobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/selectobject.test.ts @@ -1,9 +1,6 @@ -import { - DIVE, - DIVESceneObject, - DIVESelectable, - type EntitySchema, -} from '@shopware-ag/dive'; +import { type EngineGateway } from '../../../EngineGateway.ts'; +import { DIVE, DIVESceneObject, DIVESelectable } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { SelectObjectAction } from '../selectobject.ts'; import { Object3D } from 'three/webgpu'; import { Toolbox, SelectionState } from '@shopware-ag/dive/toolbox'; @@ -13,13 +10,9 @@ const mockSceneObject = { isSelectable: true, } as unknown as Object3D & DIVESelectable; -const mockEngine = { - scene: { - root: { - getSceneObject: vi.fn().mockReturnValue(mockSceneObject), - }, - }, -} as unknown as DIVE; +const mockGateway = { + findEntity: vi.fn().mockReturnValue(mockSceneObject), +} as unknown as EngineGateway; const mockSelectionState = { select: vi.fn(), @@ -65,7 +58,7 @@ describe('SelectObjectAction', () => { const action = new SelectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -81,7 +74,7 @@ describe('SelectObjectAction', () => { const action = new SelectObjectAction( { id: 'non-existent-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -109,15 +102,13 @@ describe('SelectObjectAction', () => { }; mockRegistered.set(testObject.id, testObject); - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValueOnce( - undefined, - ); + vi.mocked(mockGateway.findEntity).mockReturnValueOnce(undefined); // Act const action = new SelectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, @@ -147,7 +138,7 @@ describe('SelectObjectAction', () => { }; mockRegistered.set(testObject.id, testObject); - vi.mocked(mockEngine.scene.root.getSceneObject).mockReturnValueOnce( + vi.mocked(mockGateway.findEntity).mockReturnValueOnce( {} as DIVESceneObject, ); @@ -155,7 +146,7 @@ describe('SelectObjectAction', () => { const action = new SelectObjectAction( { id: 'test-object' }, { - engine: mockEngine, + gateway: mockGateway, getToolbox: mockGetToolbox, registered: mockRegistered, }, diff --git a/src/plugins/state/src/actions/object/__test__/setparent.test.ts b/src/plugins/state/src/actions/object/__test__/setparent.test.ts index d550741e..9965ba2c 100644 --- a/src/plugins/state/src/actions/object/__test__/setparent.test.ts +++ b/src/plugins/state/src/actions/object/__test__/setparent.test.ts @@ -1,10 +1,7 @@ import { SetParentAction } from '../setparent.ts'; -import { - DIVE, - DIVEScene, - DIVESceneObject, - type EntitySchema, -} from '@shopware-ag/dive'; +import { DIVESceneObject } from '@shopware-ag/dive'; +import { type EngineGateway } from '../../../EngineGateway.ts'; +import { type EntitySchema } from '@shopware-ag/dive'; import { Object3D } from 'three/webgpu'; describe('SetParentAction', () => { @@ -17,25 +14,19 @@ describe('SetParentAction', () => { attach: vi.fn(), } as unknown as Object3D; - const mockScene = { - root: { - getSceneObject: vi - .fn() - .mockImplementation( - (obj: Partial & { id: string }) => { - if (obj.id === 'test-object') return mockSceneObject; - if (obj.id === 'parent-object') return mockParentObject; - return null; - }, - ), - attach: vi.fn(), - updateSceneObject: vi.fn(), - }, - } as unknown as DIVEScene; - - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const mockGateway = { + findEntity: vi + .fn() + .mockImplementation( + (obj: Partial & { id: string }) => { + if (obj.id === 'test-object') return mockSceneObject; + if (obj.id === 'parent-object') return mockParentObject; + return null; + }, + ), + sceneRoot: { attach: vi.fn() }, + updateEntity: vi.fn(), + } as unknown as EngineGateway; const mockRegistered = new Map(); @@ -84,7 +75,7 @@ describe('SetParentAction', () => { parent: { id: 'parent-object' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -122,7 +113,7 @@ describe('SetParentAction', () => { parent: null, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -131,7 +122,9 @@ describe('SetParentAction', () => { action.execute(); // Assert - expect(mockScene.root.attach).toHaveBeenCalledWith(mockSceneObject); + expect(mockGateway.sceneRoot.attach).toHaveBeenCalledWith( + mockSceneObject, + ); }); it('should throw error if object does not exist', () => { @@ -142,7 +135,7 @@ describe('SetParentAction', () => { parent: { id: 'parent-object' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -170,7 +163,7 @@ describe('SetParentAction', () => { }; mockRegistered.set(testObject.id, testObject); - vi.mocked(mockScene.root.getSceneObject).mockReturnValueOnce(undefined); + vi.mocked(mockGateway.findEntity).mockReturnValueOnce(undefined); // Act & Assert const action = new SetParentAction( @@ -179,7 +172,7 @@ describe('SetParentAction', () => { parent: { id: 'parent-object' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -217,7 +210,7 @@ describe('SetParentAction', () => { parent: { id: 'non-existent-parent' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -260,7 +253,7 @@ describe('SetParentAction', () => { mockRegistered.set(testObject.id, testObject); mockRegistered.set(parentObject.id, parentObject); - vi.mocked(mockScene.root.getSceneObject).mockImplementation( + vi.mocked(mockGateway.findEntity).mockImplementation( (obj: Partial & { id: string }) => { if (obj.id === 'test-object') return mockSceneObject; if (obj.id === 'parent-object') return undefined; @@ -275,7 +268,7 @@ describe('SetParentAction', () => { parent: { id: 'parent-object' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); @@ -316,7 +309,7 @@ describe('SetParentAction', () => { parent: { id: 'test-object' }, }, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }, ); diff --git a/src/plugins/state/src/actions/object/__test__/updateobject.test.ts b/src/plugins/state/src/actions/object/__test__/updateobject.test.ts index e9d33969..06db89ed 100644 --- a/src/plugins/state/src/actions/object/__test__/updateobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/updateobject.test.ts @@ -1,14 +1,12 @@ -import { DIVE, DIVEScene, type EntitySchema } from '@shopware-ag/dive'; +import { type EngineGateway } from '../../../EngineGateway.ts'; +import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { UpdateObjectAction } from '../updateobject.ts'; // Mock dependencies -const mockEngine = { - scene: { - root: { - updateSceneObject: vi.fn(), - }, - } as unknown as DIVEScene, -} as unknown as DIVE; +const mockGateway = { + updateEntity: vi.fn(), +} as unknown as EngineGateway; const mockRegistered = new Map(); @@ -38,7 +36,7 @@ describe('UpdateObjectAction', () => { mockRegistered.set(originalObject.id, originalObject); const action = new UpdateObjectAction(updatePayload, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }); @@ -46,7 +44,7 @@ describe('UpdateObjectAction', () => { action.execute(); // Verify results - expect(mockEngine.scene.root.updateSceneObject).toHaveBeenCalledWith({ + expect(mockGateway.updateEntity).toHaveBeenCalledWith({ ...updatePayload, entityType: 'model', }); @@ -63,7 +61,7 @@ describe('UpdateObjectAction', () => { }; const action = new UpdateObjectAction(updatePayload, { - engine: mockEngine, + gateway: mockGateway, registered: mockRegistered, }); @@ -71,6 +69,6 @@ describe('UpdateObjectAction', () => { await expect(action.execute()).rejects.toThrow('Object not found.'); // Verify results - expect(mockEngine.scene.root.updateSceneObject).not.toHaveBeenCalled(); + expect(mockGateway.updateEntity).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/state/src/actions/object/addobject.ts b/src/plugins/state/src/actions/object/addobject.ts index 012a30a0..7ca26ad0 100644 --- a/src/plugins/state/src/actions/object/addobject.ts +++ b/src/plugins/state/src/actions/object/addobject.ts @@ -1,23 +1,24 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema, type DIVESceneObject } from '@shopware-ag/dive'; +import { type DIVESceneObject } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; export const AddObjectAction = Action.define< EntitySchema, - Pick, + Pick, Promise >({ description: 'Adds an object to the scene.', - execute: async (payload, { engine, registered }) => { + execute: async (payload, { gateway, registered }) => { const existing = registered.get(payload.id); - if (existing) return engine.scene.root.getSceneObject(existing); + if (existing) return gateway.findEntity(existing); if (payload.parentId === undefined) payload.parentId = null; registered.set(payload.id, payload); - return engine.scene.root.addSceneObject(payload); + return gateway.addEntity(payload); }, }); diff --git a/src/plugins/state/src/actions/object/deleteobject.ts b/src/plugins/state/src/actions/object/deleteobject.ts index 66ca4ee6..0e7404a3 100644 --- a/src/plugins/state/src/actions/object/deleteobject.ts +++ b/src/plugins/state/src/actions/object/deleteobject.ts @@ -7,11 +7,11 @@ import { type EntitySchema } from '@shopware-ag/dive'; export const DeleteObjectAction = Action.define< Partial & { id: string }, - Pick, + Pick, void >({ description: 'Deletes an object from the scene.', - execute: (payload, { engine, registered }) => { + execute: (payload, { gateway, registered }) => { const deletedObject = registered.get(payload.id); if (!deletedObject) return false; @@ -24,7 +24,7 @@ export const DeleteObjectAction = Action.define< parent: null, }, { - engine, + gateway, registered, }, ).execute(); @@ -40,7 +40,7 @@ export const DeleteObjectAction = Action.define< parentId: null, }, { - engine, + gateway, registered, }, ).execute(); @@ -53,7 +53,7 @@ export const DeleteObjectAction = Action.define< registered.delete(payload.id); - engine.scene.root.deleteSceneObject(deletedObject); + gateway.removeEntity(deletedObject); }, }); diff --git a/src/plugins/state/src/actions/object/deselectobject.ts b/src/plugins/state/src/actions/object/deselectobject.ts index 7460f573..54f86e4a 100644 --- a/src/plugins/state/src/actions/object/deselectobject.ts +++ b/src/plugins/state/src/actions/object/deselectobject.ts @@ -5,15 +5,15 @@ import { type EntitySchema } from '@shopware-ag/dive'; export const DeselectObjectAction = Action.define< Partial & { id: string }, - Pick, + Pick, Promise >({ description: 'Deselects an existing object.', - execute: async (payload, { engine, getToolbox, registered }) => { + execute: async (payload, { gateway, getToolbox, registered }) => { const object = registered.get(payload.id); if (!object) throw new Error('Object not found.'); - const sceneObject = engine.scene.root.getSceneObject(object); + const sceneObject = gateway.findEntity(object); if (!sceneObject) throw new Error('Object not found in scene.'); if (!('isSelectable' in sceneObject)) diff --git a/src/plugins/state/src/actions/object/dropit.ts b/src/plugins/state/src/actions/object/dropit.ts index 9fa1ce3c..c6814fb2 100644 --- a/src/plugins/state/src/actions/object/dropit.ts +++ b/src/plugins/state/src/actions/object/dropit.ts @@ -4,12 +4,12 @@ import { type ActionDependencies } from '../../../types/index.ts'; export const DropItAction = Action.define< { id: string }, - Pick, + Pick, void >({ description: 'Places an object on top of an underlying object or the floor.', - execute: (payload, { engine, registered }) => { + execute: (payload, { gateway, registered }) => { const object = registered.get(payload.id); if (!object) { throw new Error( @@ -17,10 +17,10 @@ export const DropItAction = Action.define< ); } - const model = engine.scene.root.getSceneObject(object); + const model = gateway.findEntity(object); if (!model) { throw new Error( - `Object with id ${payload.id} is not found in the scene. Scene: ${engine.scene}`, + `Object with id ${payload.id} is not found in the scene.`, ); } diff --git a/src/plugins/state/src/actions/object/getobjects.ts b/src/plugins/state/src/actions/object/getobjects.ts index 74ce1b6c..0e5298a4 100644 --- a/src/plugins/state/src/actions/object/getobjects.ts +++ b/src/plugins/state/src/actions/object/getobjects.ts @@ -5,7 +5,7 @@ import { type EntitySchema } from '@shopware-ag/dive'; export const GetObjectsAction = Action.define< { ids: string[] }, - Pick, + Pick, EntitySchema[] >({ description: 'Returns a list of objects of given IDs.', diff --git a/src/plugins/state/src/actions/object/placeonfloor.ts b/src/plugins/state/src/actions/object/placeonfloor.ts index 0d05ae33..47dfbc63 100644 --- a/src/plugins/state/src/actions/object/placeonfloor.ts +++ b/src/plugins/state/src/actions/object/placeonfloor.ts @@ -4,11 +4,11 @@ import { type ActionDependencies } from '../../../types/index.ts'; export const PlaceOnFloorAction = Action.define< { id: string }, - Pick, + Pick, void >({ description: 'Places an object on the floor.', - execute: (payload, { engine, registered }) => { + execute: (payload, { gateway, registered }) => { const object = registered.get(payload.id); if (!object) { throw new Error( @@ -16,10 +16,10 @@ export const PlaceOnFloorAction = Action.define< ); } - const model = engine.scene.root.getSceneObject(object); + const model = gateway.findEntity(object); if (!model) { throw new Error( - `Object with id ${payload.id} is not found in the scene. Scene: ${engine.scene}`, + `Object with id ${payload.id} is not found in the scene.`, ); } diff --git a/src/plugins/state/src/actions/object/selectobject.ts b/src/plugins/state/src/actions/object/selectobject.ts index d85d63af..b2b3dc87 100644 --- a/src/plugins/state/src/actions/object/selectobject.ts +++ b/src/plugins/state/src/actions/object/selectobject.ts @@ -2,19 +2,20 @@ import { type Object3D } from 'three/webgpu'; import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema, type DIVESelectable } from '@shopware-ag/dive'; +import { type DIVESelectable } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; export const SelectObjectAction = Action.define< Partial & { id: string }, - Pick, + Pick, Promise >({ description: 'Selects an existing object.', - execute: async (payload, { engine, getToolbox, registered }) => { + execute: async (payload, { gateway, getToolbox, registered }) => { const object = registered.get(payload.id); if (!object) throw new Error('Object not found.'); - const sceneObject = engine.scene.root.getSceneObject(object); + const sceneObject = gateway.findEntity(object); if (!sceneObject) throw new Error('Object not found in scene.'); if (!('isSelectable' in sceneObject)) diff --git a/src/plugins/state/src/actions/object/setparent.ts b/src/plugins/state/src/actions/object/setparent.ts index 054e1d0d..9d8afcaf 100644 --- a/src/plugins/state/src/actions/object/setparent.ts +++ b/src/plugins/state/src/actions/object/setparent.ts @@ -9,27 +9,27 @@ export const SetParentAction = Action.define< object: Partial & { id: string }; parent: (Partial & { id: string }) | null; }, - Pick, + Pick, void >({ description: 'Attach an object to another object.', - execute: (payload, { engine, registered }) => { + execute: (payload, { gateway, registered }) => { const object = registered.get(payload.object.id); if (!object) throw new Error('Object not found.'); - const sceneObject = engine.scene.root.getSceneObject(object); + const sceneObject = gateway.findEntity(object); if (!sceneObject) throw new Error('Object not found in scene.'); if (payload.parent === null) { // detach from current parent - engine.scene.root.attach(sceneObject); + gateway.sceneRoot.attach(sceneObject); // Update registration to reflect no parent new UpdateObjectAction( { id: object.id, parentId: null, }, - { engine, registered }, + { gateway, registered }, ).execute(); return; } @@ -47,7 +47,7 @@ export const SetParentAction = Action.define< } // attach to new parent - const parentObject = engine.scene.root.getSceneObject(parent); + const parentObject = gateway.findEntity(parent); if (!parentObject) { console.warn('Parent object not found in scene.'); return; @@ -61,7 +61,7 @@ export const SetParentAction = Action.define< id: object.id, parentId: parent.id, }, - { engine, registered }, + { gateway, registered }, ).execute(); }, }); diff --git a/src/plugins/state/src/actions/object/updateobject.ts b/src/plugins/state/src/actions/object/updateobject.ts index 832863fd..305320a6 100644 --- a/src/plugins/state/src/actions/object/updateobject.ts +++ b/src/plugins/state/src/actions/object/updateobject.ts @@ -6,18 +6,18 @@ import { merge } from 'lodash'; export const UpdateObjectAction = Action.define< Partial & { id: string }, - Pick, + Pick, Promise >({ description: 'Updates an existing object.', - execute: async (payload, { engine, registered }) => { + execute: async (payload, { gateway, registered }) => { const objectToUpdate = registered.get(payload.id); if (!objectToUpdate) throw new Error('Object not found.'); const updatedObject = merge(objectToUpdate, payload); registered.set(payload.id, updatedObject); - await engine.scene.root.updateSceneObject({ + await gateway.updateEntity({ ...payload, id: updatedObject.id, entityType: updatedObject.entityType, diff --git a/src/plugins/state/src/actions/renderer/__test__/startrender.test.ts b/src/plugins/state/src/actions/renderer/__test__/startrender.test.ts index 9f41b8fe..7fac18af 100644 --- a/src/plugins/state/src/actions/renderer/__test__/startrender.test.ts +++ b/src/plugins/state/src/actions/renderer/__test__/startrender.test.ts @@ -1,21 +1,22 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { StartRenderAction } from '../startrender.ts'; import { DIVE } from '@shopware-ag/dive'; describe('StartRenderAction', () => { it('should start the renderer', async () => { // Mock dependencies - const mockEngine = { - startAsync: vi.fn(), - } as unknown as DIVE; + const mockGateway = { + startRendering: vi.fn(), + } as unknown as EngineGateway; const action = new StartRenderAction(undefined, { - engine: mockEngine, + gateway: mockGateway, }); // Execute action action.execute(); // Verify results - expect(mockEngine.startAsync).toHaveBeenCalled(); + expect(mockGateway.startRendering).toHaveBeenCalled(); }); }); diff --git a/src/plugins/state/src/actions/renderer/startrender.ts b/src/plugins/state/src/actions/renderer/startrender.ts index fa14d173..5de271c0 100644 --- a/src/plugins/state/src/actions/renderer/startrender.ts +++ b/src/plugins/state/src/actions/renderer/startrender.ts @@ -4,12 +4,12 @@ import { ActionDependencies } from '../../../types/index.ts'; export const StartRenderAction = Action.define< void, - Pick, + Pick, Promise >({ description: 'Starts the render process.', - execute: async (_, { engine }) => { - return engine.startAsync(); + execute: async (_, { gateway }) => { + return gateway.startRendering(); }, }); diff --git a/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts b/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts index dd689565..a31b4d54 100644 --- a/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts @@ -1,39 +1,29 @@ import { ExportSceneAction } from '../exportscene.ts'; -import { DIVE } from '@shopware-ag/dive'; +import { Object3D } from 'three/webgpu'; +import { type EngineGateway } from '../../../EngineGateway.ts'; const mockExport = vi.fn().mockResolvedValue('exported-scene-data'); const mockGetAssetExporter = vi.fn().mockResolvedValue({ export: mockExport, }); -const mockEngine = { - scene: { - root: {}, - }, -} as unknown as DIVE; - describe('ExportSceneAction', () => { it('should export scene', async () => { - const mockEngine = { - scene: { - root: {}, - }, - } as unknown as DIVE; + const sceneRoot = new Object3D(); + const mockGateway = { sceneRoot } as unknown as EngineGateway; const action = new ExportSceneAction( { type: 'glb' }, { - engine: mockEngine, + gateway: mockGateway, getAssetExporter: mockGetAssetExporter, }, ); - // Execute action const result = await action.execute(); - // Verify results expect(mockGetAssetExporter).toHaveBeenCalled(); - expect(mockExport).toHaveBeenCalledWith(mockEngine.scene.root, 'glb'); + expect(mockExport).toHaveBeenCalledWith(sceneRoot, 'glb'); expect(result).toBe('exported-scene-data'); }); }); diff --git a/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts b/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts index ec937e0a..b2439d6d 100644 --- a/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts @@ -1,7 +1,6 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { GetAllSceneDataAction } from '../getallscenedata.ts'; import { - DIVE, - DIVEScene, type GroupSchema, type LightSchema, type ModelSchema, @@ -9,27 +8,20 @@ import { type PrimitiveSchema, } from '@shopware-ag/dive'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; -import { Color, MeshStandardMaterial, Vector3 } from 'three/webgpu'; +import { Vector3 } from 'three/webgpu'; describe('GetAllSceneDataAction', () => { it('should get all scene data', async () => { // Mock dependencies - const mockScene = { - name: 'Test Scene', - background: new Color(0x000000), - root: { - floor: { - visible: true, - material: new MeshStandardMaterial({ color: 0xffffff }), - }, - }, - objects: [], - settings: {}, - } as unknown as DIVEScene; - - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const mockGateway = { + readSceneSettings: vi.fn(() => ({ + name: 'Test Scene', + backgroundColor: '#000000', + gridEnabled: true, + floorEnabled: true, + floorColor: '#ffffff', + })), + } as unknown as EngineGateway; const mockController = { object: { @@ -73,7 +65,7 @@ describe('GetAllSceneDataAction', () => { const action = new GetAllSceneDataAction( {}, { - engine: mockEngine, + gateway: mockGateway, controller: mockController, registered: mockRegistered, }, diff --git a/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts b/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts index dea50ddb..3e72c2a6 100644 --- a/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts @@ -1,28 +1,21 @@ import { SetBackgroundAction } from '../setbackground.ts'; -import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { type EngineGateway } from '../../../EngineGateway.ts'; describe('SetBackgroundAction', () => { it('should set scene background', async () => { - // Mock dependencies - const mockScene = { + const mockGateway = { setBackground: vi.fn(), - } as unknown as DIVEScene; - - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + } as unknown as EngineGateway; const action = new SetBackgroundAction( { color: '#ff0000' }, { - engine: mockEngine, + gateway: mockGateway, }, ); - // Execute action await action.execute(); - // Verify results - expect(mockScene.setBackground).toHaveBeenCalledWith('#ff0000'); + expect(mockGateway.setBackground).toHaveBeenCalledWith('#ff0000'); }); }); diff --git a/src/plugins/state/src/actions/scene/__test__/updatescene.test.ts b/src/plugins/state/src/actions/scene/__test__/updatescene.test.ts index b080b530..b64d16be 100644 --- a/src/plugins/state/src/actions/scene/__test__/updatescene.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/updatescene.test.ts @@ -1,105 +1,63 @@ import { UpdateSceneAction } from '../updatescene.ts'; -import { DIVE, DIVEScene } from '@shopware-ag/dive'; -import { Color, MeshStandardMaterial } from 'three/webgpu'; +import { + type EngineGateway, + type SceneSettings, +} from '../../../EngineGateway.ts'; + +/** + * What the scene actually holds is the gateway's business and is covered in + * its own tests. What matters here is that the action writes the patch and + * then reports back what the scene ended up with, rather than echoing the + * patch it was handed. + */ +const settled: SceneSettings = { + name: 'Updated Scene', + backgroundColor: '#ff0000', + gridEnabled: false, + floorEnabled: false, + floorColor: '#00ff00', +}; describe('UpdateSceneAction', () => { - it('should update scene properties', async () => { - // Mock dependencies - const mockGrid = { - setVisibility: vi.fn(), - visible: true, + it('should apply the patch and fill the payload with the result', async () => { + const mockGateway = { + applySceneSettings: vi.fn(), + readSceneSettings: vi.fn(() => settled), + } as unknown as EngineGateway; + + const payload = { + name: 'Updated Scene', + backgroundColor: '#ff0000', + gridEnabled: false, + floorEnabled: false, + floorColor: '#00ff00', }; - const mockFloor = { - setVisibility: vi.fn(), - setColor: vi.fn(), - visible: true, - material: new MeshStandardMaterial({ color: new Color('#ffffff') }), - }; - - const mockScene = { - name: 'Test Scene', - background: new Color('#000000'), - setBackground: vi.fn(), - grid: mockGrid, - root: { - floor: mockFloor, - }, - } as unknown as DIVEScene; - - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const action = new UpdateSceneAction(payload, { + gateway: mockGateway, + }); - const action = new UpdateSceneAction( - { - name: 'Updated Scene', - backgroundColor: '#ff0000', - gridEnabled: false, - floorEnabled: false, - floorColor: '#00ff00', - }, - { - engine: mockEngine, - }, - ); - - // Execute action await action.execute(); - // Verify results - expect(mockScene.name).toBe('Updated Scene'); - expect(mockScene.setBackground).toHaveBeenCalledWith('#ff0000'); - expect(mockGrid.setVisibility).toHaveBeenCalledWith(false); - expect(mockFloor.setVisibility).toHaveBeenCalledWith(false); - expect(mockFloor.setColor).toHaveBeenCalledWith('#00ff00'); + expect(mockGateway.applySceneSettings).toHaveBeenCalledWith(payload); + expect(payload).toEqual(settled); }); - it('should update only specified properties', async () => { - // Mock dependencies - const mockGrid = { - setVisibility: vi.fn(), - visible: true, - }; - - const mockFloor = { - setVisibility: vi.fn(), - setColor: vi.fn(), - visible: true, - material: new MeshStandardMaterial({ color: new Color('#ffffff') }), - }; - - const mockScene = { - name: 'Test Scene', - background: new Color('#000000'), - setBackground: vi.fn(), - grid: mockGrid, - root: { - floor: mockFloor, - }, - } as unknown as DIVEScene; + it('should report the scene state even for properties it did not touch', async () => { + const mockGateway = { + applySceneSettings: vi.fn(), + readSceneSettings: vi.fn(() => settled), + } as unknown as EngineGateway; - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const payload: Partial = { name: 'Updated Scene' }; - const action = new UpdateSceneAction( - { - name: 'Updated Scene', - }, - { - engine: mockEngine, - }, - ); - - // Execute action - await action.execute(); + await new UpdateSceneAction(payload, { + gateway: mockGateway, + }).execute(); - // Verify results - expect(mockScene.name).toBe('Updated Scene'); - expect(mockScene.setBackground).not.toHaveBeenCalled(); - expect(mockGrid.setVisibility).not.toHaveBeenCalled(); - expect(mockFloor.setVisibility).not.toHaveBeenCalled(); - expect(mockFloor.setColor).not.toHaveBeenCalled(); + // gridEnabled was never in the patch and still comes back — this is + // the property setstate used to drop + expect(payload.gridEnabled).toBe(false); + expect(payload.floorColor).toBe('#00ff00'); }); }); diff --git a/src/plugins/state/src/actions/scene/exportscene.ts b/src/plugins/state/src/actions/scene/exportscene.ts index 9939dbe8..47cfd590 100644 --- a/src/plugins/state/src/actions/scene/exportscene.ts +++ b/src/plugins/state/src/actions/scene/exportscene.ts @@ -5,13 +5,13 @@ import { registerAction } from '../../ActionRegistry.ts'; export const ExportSceneAction = Action.define< { type: keyof StateExportFileType }, - Pick, + Pick, Promise >({ description: 'Exports the current scene to a blob and returns the URL.', - execute: async (payload, { engine, getAssetExporter }) => { + execute: async (payload, { gateway, getAssetExporter }) => { return getAssetExporter().then((assetExporter) => { - return assetExporter.export(engine.scene.root, payload.type); + return assetExporter.export(gateway.sceneRoot, payload.type); }); }, }); diff --git a/src/plugins/state/src/actions/scene/getallscenedata.ts b/src/plugins/state/src/actions/scene/getallscenedata.ts index 61a2cb01..2ebc30e4 100644 --- a/src/plugins/state/src/actions/scene/getallscenedata.ts +++ b/src/plugins/state/src/actions/scene/getallscenedata.ts @@ -2,7 +2,6 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type StateData } from '../../../types/StateData.ts'; -import { Color, MeshStandardMaterial } from 'three/webgpu'; import { GroupSchema, LightSchema, @@ -16,22 +15,19 @@ import { */ export const GetAllSceneDataAction = Action.define< object, - Pick, + Pick, StateData >({ description: 'Retrieves all current scene data.', - execute: (_payload, { engine, controller, registered }) => { + execute: (_payload, { gateway, controller, registered }) => { + const settings = gateway.readSceneSettings(); + return { - name: engine.scene.name, + name: settings.name, mediaItem: null, - backgroundColor: - '#' + (engine.scene.background as Color).getHexString(), - floorEnabled: engine.scene.root.floor.visible, - floorColor: - '#' + - ( - engine.scene.root.floor.material as MeshStandardMaterial - ).color.getHexString(), + backgroundColor: settings.backgroundColor, + floorEnabled: settings.floorEnabled, + floorColor: settings.floorColor, userCamera: { position: controller.object.position.clone(), target: controller.target.clone(), diff --git a/src/plugins/state/src/actions/scene/setbackground.ts b/src/plugins/state/src/actions/scene/setbackground.ts index 2d0e31e7..2513bd5b 100644 --- a/src/plugins/state/src/actions/scene/setbackground.ts +++ b/src/plugins/state/src/actions/scene/setbackground.ts @@ -4,12 +4,12 @@ import { type ActionDependencies } from '../../../types/index.ts'; export const SetBackgroundAction = Action.define< { color: string | number }, - Pick, + Pick, void >({ description: 'Set the background color of the scene.', - execute: (payload, { engine }) => { - engine.scene.setBackground(payload.color); + execute: (payload, { gateway }) => { + gateway.setBackground(payload.color); }, }); diff --git a/src/plugins/state/src/actions/scene/updatescene.ts b/src/plugins/state/src/actions/scene/updatescene.ts index ed4994e2..dc77a04e 100644 --- a/src/plugins/state/src/actions/scene/updatescene.ts +++ b/src/plugins/state/src/actions/scene/updatescene.ts @@ -1,7 +1,6 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { ActionDependencies } from '../../../types/index.ts'; -import { Color, MeshStandardMaterial } from 'three/webgpu'; export const UpdateSceneAction = Action.define< Partial<{ @@ -11,35 +10,16 @@ export const UpdateSceneAction = Action.define< floorEnabled: boolean; floorColor: string | number; }>, - Pick, + Pick, void >({ description: 'Updates scene properties.', - execute: (payload, { engine }) => { - if (payload.name !== undefined) engine.scene.name = payload.name; - if (payload.backgroundColor !== undefined) - engine.scene.setBackground(payload.backgroundColor); + execute: (payload, { gateway }) => { + gateway.applySceneSettings(payload); - if (payload.gridEnabled !== undefined) - engine.scene.grid.setVisibility(payload.gridEnabled); - - if (payload.floorEnabled !== undefined) - engine.scene.root.floor.setVisibility(payload.floorEnabled); - if (payload.floorColor !== undefined) - engine.scene.root.floor.setColor(payload.floorColor); - - // fill payload with current values - // TODO optmize this - payload.name = engine.scene.name; - payload.backgroundColor = - '#' + (engine.scene.background as Color).getHexString(); - payload.gridEnabled = engine.scene.grid.visible; - payload.floorEnabled = engine.scene.root.floor.visible; - payload.floorColor = - '#' + - ( - engine.scene.root.floor.material as MeshStandardMaterial - ).color.getHexString(); + // the payload doubles as the action's result, so it is filled with + // what the scene actually holds afterwards + Object.assign(payload, gateway.readSceneSettings()); }, }); diff --git a/src/plugins/state/src/actions/state/__test__/getstate.test.ts b/src/plugins/state/src/actions/state/__test__/getstate.test.ts index 10fed246..34c2e55d 100644 --- a/src/plugins/state/src/actions/state/__test__/getstate.test.ts +++ b/src/plugins/state/src/actions/state/__test__/getstate.test.ts @@ -1,7 +1,6 @@ +import { type EngineGateway } from '../../../EngineGateway.ts'; import { GetStateAction } from '../getstate.ts'; import { - DIVE, - DIVEScene, type GroupSchema, type LightSchema, type ModelSchema, @@ -9,27 +8,20 @@ import { type PrimitiveSchema, } from '@shopware-ag/dive'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; -import { Color, MeshStandardMaterial, Vector3 } from 'three/webgpu'; +import { Vector3 } from 'three/webgpu'; describe('GetStateAction', () => { it('should get complete state data', async () => { // Mock dependencies - const mockScene = { - name: 'Test Scene', - background: new Color(0x000000), - root: { - floor: { - visible: true, - material: new MeshStandardMaterial({ color: 0xffffff }), - }, - }, - objects: [], - settings: {}, - } as unknown as DIVEScene; - - const mockEngine = { - scene: mockScene, - } as unknown as DIVE; + const mockGateway = { + readSceneSettings: vi.fn(() => ({ + name: 'Test Scene', + backgroundColor: '#000000', + gridEnabled: true, + floorEnabled: true, + floorColor: '#ffffff', + })), + } as unknown as EngineGateway; const mockController = { object: { @@ -71,7 +63,7 @@ describe('GetStateAction', () => { } as unknown as GroupSchema); const action = new GetStateAction(undefined, { - engine: mockEngine, + gateway: mockGateway, controller: mockController, registered: mockRegistered, }); diff --git a/src/plugins/state/src/actions/state/__test__/setstate.test.ts b/src/plugins/state/src/actions/state/__test__/setstate.test.ts index bf1c3a59..8eb2b7e1 100644 --- a/src/plugins/state/src/actions/state/__test__/setstate.test.ts +++ b/src/plugins/state/src/actions/state/__test__/setstate.test.ts @@ -6,9 +6,9 @@ import { SetParentAction, } from '@shopware-ag/dive/state'; import { GetStateAction } from '../getstate.ts'; +import { DIVE, DIVEScene } from '@shopware-ag/dive'; +import { EngineGateway } from '../../../EngineGateway.ts'; import { - DIVE, - DIVEScene, type EntitySchema, type CameraSchema, type GroupSchema, @@ -71,34 +71,26 @@ const controllerState = { const createDependencies = ( alreadyRegistered: EntitySchema[] = [], ): { - engine: DIVE; + gateway: EngineGateway; controller: OrbitController; registered: Map; - floor: { setVisibility: ReturnType; setColor: typeof vi.fn }; } => { - const floor = { - setVisibility: vi.fn(), - setColor: vi.fn(), - }; - const registered = new Map( alreadyRegistered.map((entity) => [entity.id, entity]), ); - const engine = { - scene: { - name: 'untouched', - setBackground: vi.fn(), - root: { floor }, - } as unknown as DIVEScene, - } as unknown as DIVE; + // what the scene ends up holding is the gateway's own business, tested + // there; here it only matters that the state is handed over in one piece + const gateway = { + applySceneSettings: vi.fn(), + } as unknown as EngineGateway; const controller = { setState: vi.fn(), getState: vi.fn(() => controllerState), } as unknown as OrbitController; - return { engine, controller, registered, floor } as never; + return { gateway, controller, registered }; }; /** Scene data with everything left out unless explicitly given. */ @@ -133,12 +125,14 @@ describe('SetStateAction', () => { deps, ).execute(); - expect(deps.engine.scene.name).toBe('Applied Scene'); - expect(deps.engine.scene.setBackground).toHaveBeenCalledWith( - '#ff0000', + expect(deps.gateway.applySceneSettings).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Applied Scene', + backgroundColor: '#ff0000', + floorEnabled: true, + floorColor: '#00ff00', + }), ); - expect(deps.floor.setVisibility).toHaveBeenCalledWith(true); - expect(deps.floor.setColor).toHaveBeenCalledWith('#00ff00'); }); it('should leave out properties the state does not carry', async () => { @@ -146,10 +140,11 @@ describe('SetStateAction', () => { await new SetStateAction(stateData(), deps).execute(); - expect(deps.engine.scene.name).toBe('untouched'); - expect(deps.engine.scene.setBackground).not.toHaveBeenCalled(); - expect(deps.floor.setVisibility).not.toHaveBeenCalled(); - expect(deps.floor.setColor).not.toHaveBeenCalled(); + // the gateway is handed the state as it stands and is the one + // that skips what is not in it + expect(deps.gateway.applySceneSettings).toHaveBeenCalledWith( + expect.not.objectContaining({ name: expect.anything() }), + ); expect(deps.controller.setState).not.toHaveBeenCalled(); }); @@ -474,10 +469,12 @@ describe('SetStateAction', () => { const createInstance = ( entities: EntitySchema[] = [], ): { - engine: DIVE; + gateway: EngineGateway; controller: OrbitController; state: State; registered: Map; + scene: { name: string; background: Color }; + floor: { visible: boolean; material: MeshStandardMaterial }; } => { const floor = { visible: false, @@ -493,11 +490,12 @@ describe('SetStateAction', () => { const scene = { name: '', background: new Color('#000000'), + grid: { visible: false, setVisibility: vi.fn() }, setBackground: vi.fn((color: string) => { scene.background = new Color(color); }), root: { floor }, - } as unknown as DIVEScene; + } as unknown as DIVEScene & { name: string; background: Color }; const controller = { object: { position: new Vector3(0, 0, 0) }, @@ -532,12 +530,11 @@ describe('SetStateAction', () => { ), } as unknown as State; - return { - engine: { scene } as unknown as DIVE, - controller, - state, - registered, - }; + // the real gateway, so the round trip goes through the same read and + // write path the application uses + const gateway = new EngineGateway({ scene } as unknown as DIVE, state); + + return { gateway, controller, state, registered, scene, floor }; }; const entity = (id: string, entityType: string): T => @@ -563,12 +560,10 @@ describe('SetStateAction', () => { entity('primitive-1', 'primitive'), entity('group-1', 'group'), ]); - source.engine.scene.name = 'Source Scene'; - source.engine.scene.background = new Color('#123456'); - source.engine.scene.root.floor.visible = true; - ( - source.engine.scene.root.floor.material as MeshStandardMaterial - ).color = new Color('#abcdef'); + source.scene.name = 'Source Scene'; + source.scene.background = new Color('#123456'); + source.floor.visible = true; + source.floor.material.color = new Color('#abcdef'); source.controller.object.position.set(1, 2, 3); source.controller.target.set(4, 5, 6); @@ -635,7 +630,7 @@ describe('SetStateAction', () => { const source = createInstance([ entity('model-1', 'model'), ]); - source.engine.scene.name = 'Twice'; + source.scene.name = 'Twice'; const exported = await new GetStateAction( undefined, source, diff --git a/src/plugins/state/src/actions/state/getstate.ts b/src/plugins/state/src/actions/state/getstate.ts index c1172cb1..0969825c 100644 --- a/src/plugins/state/src/actions/state/getstate.ts +++ b/src/plugins/state/src/actions/state/getstate.ts @@ -2,7 +2,6 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type StateData } from '../../../types/index.ts'; -import { Color, MeshStandardMaterial } from 'three/webgpu'; import { GroupSchema, LightSchema, @@ -13,22 +12,19 @@ import { export const GetStateAction = Action.define< void, - Pick, + Pick, StateData >({ description: 'Retrieves complete state data.', - execute: (_payload, { engine, controller, registered }) => { + execute: (_payload, { gateway, controller, registered }) => { + const settings = gateway.readSceneSettings(); + return { - name: engine.scene.name, + name: settings.name, mediaItem: null, - backgroundColor: - '#' + (engine.scene.background as Color).getHexString(), - floorEnabled: engine.scene.root.floor.visible, - floorColor: - '#' + - ( - engine.scene.root.floor.material as MeshStandardMaterial - ).color.getHexString(), + backgroundColor: settings.backgroundColor, + floorEnabled: settings.floorEnabled, + floorColor: settings.floorColor, userCamera: { position: controller.object.position.clone(), target: controller.target.clone(), diff --git a/src/plugins/state/src/actions/state/setstate.ts b/src/plugins/state/src/actions/state/setstate.ts index c3c8683c..b4be29f7 100644 --- a/src/plugins/state/src/actions/state/setstate.ts +++ b/src/plugins/state/src/actions/state/setstate.ts @@ -2,7 +2,8 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type StateData } from '../../../types/index.ts'; -import { type DIVESceneObject, type EntitySchema } from '@shopware-ag/dive'; +import { type DIVESceneObject } from '@shopware-ag/dive'; +import { type EntitySchema } from '@shopware-ag/dive'; import { AddObjectAction, DeleteObjectAction, @@ -11,11 +12,11 @@ import { export const SetStateAction = Action.define< StateData, - Pick, + Pick, Promise >({ description: 'Applies complete state data to current dive instance.', - execute: async (_payload, { engine, controller, registered }) => { + execute: async (_payload, { gateway, controller, registered }) => { // the state is meant to replace what is there, and ADD_OBJECT skips ids that are already registered, so clear the scene up front Array.from(registered.values()).forEach((entity: EntitySchema) => { new DeleteObjectAction( @@ -23,17 +24,13 @@ export const SetStateAction = Action.define< id: entity.id, entityType: entity.entityType, }, - { engine, registered }, + { gateway, registered }, ).execute(); }); - _payload.name !== undefined && (engine.scene.name = _payload.name); - _payload.backgroundColor !== undefined && - engine.scene.setBackground(_payload.backgroundColor); - _payload.floorEnabled !== undefined && - engine.scene.root.floor.setVisibility(_payload.floorEnabled); - _payload.floorColor !== undefined && - engine.scene.root.floor.setColor(_payload.floorColor); + // one call instead of a hand-written copy per property, which is how + // gridEnabled went missing here while updatescene had it + gateway.applySceneSettings(_payload); _payload.userCamera !== undefined && controller.setState({ position: _payload.userCamera.position, @@ -68,7 +65,7 @@ export const SetStateAction = Action.define< ...entity, parentId: null, }, - { engine, registered }, + { gateway, registered }, ) .execute() .then((object) => { @@ -95,7 +92,7 @@ export const SetStateAction = Action.define< ...entity, parentId: null, }, - { engine, registered }, + { gateway, registered }, ) .execute() .then((object) => { @@ -121,7 +118,7 @@ export const SetStateAction = Action.define< ...entity, parentId: null, }, - { engine, registered }, + { gateway, registered }, ) .execute() .then((object) => { @@ -147,7 +144,7 @@ export const SetStateAction = Action.define< ...entity, parentId: null, }, - { engine, registered }, + { gateway, registered }, ) .execute() .then((object) => { @@ -173,7 +170,7 @@ export const SetStateAction = Action.define< ...entity, parentId: null, }, - { engine, registered }, + { gateway, registered }, ) .execute() .then((sceneObject) => { @@ -206,7 +203,7 @@ export const SetStateAction = Action.define< object: { id: entity.id }, parent: { id: entity.parentId }, }, - { engine, registered }, + { gateway, registered }, ).execute(); } catch (reason) { failed.push({ entity, reason }); diff --git a/src/plugins/state/types/ActionTypes.ts b/src/plugins/state/types/ActionTypes.ts index 74ed30ab..fb510935 100644 --- a/src/plugins/state/types/ActionTypes.ts +++ b/src/plugins/state/types/ActionTypes.ts @@ -1,6 +1,6 @@ -import { DIVE } from '@shopware-ag/dive'; import { type OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { type EntitySchema } from '@shopware-ag/dive'; +import { type EngineGateway } from '../src/EngineGateway.ts'; // Extracted types for performAction_new export type ActionPayload = T extends new ( @@ -30,7 +30,7 @@ export type ActionDeps = T extends new ( export interface ActionDependencies { registered: Map; - engine: DIVE; + gateway: EngineGateway; controller: OrbitController; getAnimationSystem: () => Promise< import('@shopware-ag/dive/animation').AnimationSystem diff --git a/src/plugins/state/types/StateData.ts b/src/plugins/state/types/StateData.ts index 7032b02e..c4f2ea69 100644 --- a/src/plugins/state/types/StateData.ts +++ b/src/plugins/state/types/StateData.ts @@ -1,10 +1,10 @@ import type { Vector3Like } from 'three/webgpu'; -import type { - GroupSchema, - LightSchema, - ModelSchema, - CameraSchema, - PrimitiveSchema, +import { + type GroupSchema, + type LightSchema, + type ModelSchema, + type CameraSchema, + type PrimitiveSchema, } from '@shopware-ag/dive'; export type StateData = { diff --git a/src/types/components/DIVESceneObject.ts b/src/types/components/DIVESceneObject.ts index 80f931d6..f58c9b6d 100644 --- a/src/types/components/DIVESceneObject.ts +++ b/src/types/components/DIVESceneObject.ts @@ -3,15 +3,11 @@ import { DIVEModel } from '../../components/model/Model.ts'; import { DIVEPrimitive } from '../../components/primitive/Primitive.ts'; import { DIVELight } from './DIVELight.ts'; -import { EntityTypeSchema } from '../index.ts'; - -export type DIVESceneObject = - T extends 'model' - ? DIVEModel - : T extends 'group' - ? DIVEGroup - : T extends 'primitive' - ? DIVEPrimitive - : T extends 'light' - ? DIVELight - : DIVEModel | DIVEGroup | DIVEPrimitive | DIVELight; +/** + * Everything that can sit in the scene as a thing of its own. + * + * Deliberately a plain union: the engine knows these classes, not what any of + * them mean to a state. Picking one of them for a given entity type is the + * state plugin's job and lives in its gateway. + */ +export type DIVESceneObject = DIVEModel | DIVEGroup | DIVEPrimitive | DIVELight; From deca88bc664c1209b786712fb75ced19b2f2ff56 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 09:09:14 +0200 Subject: [PATCH 07/10] feat!: move the entity schemas from the engine into the state plugin --- src/plugins/state/src/EngineGateway.ts | 2 +- src/plugins/state/src/State.ts | 2 +- src/plugins/state/src/__test__/EngineGateway.test.ts | 2 +- .../src/actions/camera/__test__/movecamera.test.ts | 2 +- src/plugins/state/src/actions/camera/movecamera.ts | 2 +- .../src/actions/media/__test__/generatemedia.test.ts | 2 +- src/plugins/state/src/actions/media/generatemedia.ts | 2 +- .../src/actions/object/__test__/addobject.test.ts | 2 +- .../src/actions/object/__test__/deleteobject.test.ts | 2 +- .../actions/object/__test__/deselectobject.test.ts | 2 +- .../state/src/actions/object/__test__/dropit.test.ts | 2 +- .../actions/object/__test__/getallobjects.test.ts | 2 +- .../src/actions/object/__test__/getobjects.test.ts | 2 +- .../src/actions/object/__test__/modelloaded.test.ts | 5 ++++- .../src/actions/object/__test__/placeonfloor.test.ts | 2 +- .../src/actions/object/__test__/selectobject.test.ts | 2 +- .../src/actions/object/__test__/setparent.test.ts | 2 +- .../src/actions/object/__test__/updateobject.test.ts | 2 +- src/plugins/state/src/actions/object/addobject.ts | 2 +- src/plugins/state/src/actions/object/deleteobject.ts | 2 +- .../state/src/actions/object/deselectobject.ts | 2 +- .../state/src/actions/object/getallobjects.ts | 2 +- src/plugins/state/src/actions/object/getobjects.ts | 2 +- src/plugins/state/src/actions/object/modelloaded.ts | 2 +- src/plugins/state/src/actions/object/selectobject.ts | 2 +- src/plugins/state/src/actions/object/setparent.ts | 2 +- src/plugins/state/src/actions/object/updateobject.ts | 2 +- .../actions/scene/__test__/getallscenedata.test.ts | 2 +- .../state/src/actions/scene/getallscenedata.ts | 2 +- .../src/actions/state/__test__/getstate.test.ts | 2 +- .../src/actions/state/__test__/setstate.test.ts | 2 +- src/plugins/state/src/actions/state/getstate.ts | 2 +- src/plugins/state/src/actions/state/setstate.ts | 2 +- src/plugins/state/types/ActionTypes.ts | 2 +- src/plugins/state/types/StateData.ts | 12 ++++++------ src/plugins/state/types/index.ts | 1 + .../state}/types/schema/BaseEntitySchema.ts | 0 src/{ => plugins/state}/types/schema/CameraSchema.ts | 0 src/{ => plugins/state}/types/schema/EntitySchema.ts | 0 .../state}/types/schema/EntityTypeSchema.ts | 0 src/{ => plugins/state}/types/schema/GroupSchema.ts | 0 src/{ => plugins/state}/types/schema/LightSchema.ts | 0 src/{ => plugins/state}/types/schema/ModelSchema.ts | 9 ++++++--- .../state}/types/schema/PrimitiveSchema.ts | 3 +-- .../types/schema/__test__/CameraSchema.test.ts | 0 .../state}/types/schema/__test__/GroupSchema.test.ts | 0 .../state}/types/schema/__test__/LightSchema.test.ts | 0 .../state}/types/schema/__test__/ModelSchema.test.ts | 0 .../types/schema/__test__/PrimitiveSchema.test.ts | 0 src/{ => plugins/state}/types/schema/index.ts | 0 src/types/index.ts | 1 - 51 files changed, 51 insertions(+), 46 deletions(-) rename src/{ => plugins/state}/types/schema/BaseEntitySchema.ts (100%) rename src/{ => plugins/state}/types/schema/CameraSchema.ts (100%) rename src/{ => plugins/state}/types/schema/EntitySchema.ts (100%) rename src/{ => plugins/state}/types/schema/EntityTypeSchema.ts (100%) rename src/{ => plugins/state}/types/schema/GroupSchema.ts (100%) rename src/{ => plugins/state}/types/schema/LightSchema.ts (100%) rename src/{ => plugins/state}/types/schema/ModelSchema.ts (78%) rename src/{ => plugins/state}/types/schema/PrimitiveSchema.ts (84%) rename src/{ => plugins/state}/types/schema/__test__/CameraSchema.test.ts (100%) rename src/{ => plugins/state}/types/schema/__test__/GroupSchema.test.ts (100%) rename src/{ => plugins/state}/types/schema/__test__/LightSchema.test.ts (100%) rename src/{ => plugins/state}/types/schema/__test__/ModelSchema.test.ts (100%) rename src/{ => plugins/state}/types/schema/__test__/PrimitiveSchema.test.ts (100%) rename src/{ => plugins/state}/types/schema/index.ts (100%) diff --git a/src/plugins/state/src/EngineGateway.ts b/src/plugins/state/src/EngineGateway.ts index 60b107ff..c5f586da 100644 --- a/src/plugins/state/src/EngineGateway.ts +++ b/src/plugins/state/src/EngineGateway.ts @@ -26,7 +26,7 @@ import { type ModelSchema, type PartialSchema, type PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../types/index.ts'; import { type State } from './State.ts'; /** diff --git a/src/plugins/state/src/State.ts b/src/plugins/state/src/State.ts index 0d9ccfaf..69597c1c 100644 --- a/src/plugins/state/src/State.ts +++ b/src/plugins/state/src/State.ts @@ -2,7 +2,7 @@ import { MathUtils } from 'three/webgpu'; // type imports import { type DIVE } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../types/index.ts'; import { type OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { ActionDependencies, diff --git a/src/plugins/state/src/__test__/EngineGateway.test.ts b/src/plugins/state/src/__test__/EngineGateway.test.ts index 724a20c4..76db7d1a 100644 --- a/src/plugins/state/src/__test__/EngineGateway.test.ts +++ b/src/plugins/state/src/__test__/EngineGateway.test.ts @@ -14,7 +14,7 @@ import { GroupSchema, CameraSchema, EntityTypeSchema, -} from '@shopware-ag/dive'; +} from '../../types/index.ts'; import { Color, Object3D, Vector3 } from 'three/webgpu'; vi.mock('three/webgpu', async () => { diff --git a/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts b/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts index dbcc92f2..8f9515a3 100644 --- a/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts +++ b/src/plugins/state/src/actions/camera/__test__/movecamera.test.ts @@ -1,6 +1,6 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { MoveCameraAction } from '../movecamera.ts'; -import { EntitySchema } from '@shopware-ag/dive'; +import { EntitySchema } from '../../../../types/index.ts'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { Vector3 } from 'three/webgpu'; import { DIVE } from '@shopware-ag/dive'; diff --git a/src/plugins/state/src/actions/camera/movecamera.ts b/src/plugins/state/src/actions/camera/movecamera.ts index c191f467..4516c5ef 100644 --- a/src/plugins/state/src/actions/camera/movecamera.ts +++ b/src/plugins/state/src/actions/camera/movecamera.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { isCameraSchema } from '@shopware-ag/dive'; +import { isCameraSchema } from '../../../types/index.ts'; import { type Vector3Like } from 'three/webgpu'; export const MoveCameraAction = Action.define< diff --git a/src/plugins/state/src/actions/media/__test__/generatemedia.test.ts b/src/plugins/state/src/actions/media/__test__/generatemedia.test.ts index 811d0e54..0aefc341 100644 --- a/src/plugins/state/src/actions/media/__test__/generatemedia.test.ts +++ b/src/plugins/state/src/actions/media/__test__/generatemedia.test.ts @@ -1,5 +1,5 @@ import { GenerateMediaAction } from '../generatemedia.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { Vector3 } from 'three/webgpu'; import { type MediaGenerationById, diff --git a/src/plugins/state/src/actions/media/generatemedia.ts b/src/plugins/state/src/actions/media/generatemedia.ts index 501854c0..2fa5d472 100644 --- a/src/plugins/state/src/actions/media/generatemedia.ts +++ b/src/plugins/state/src/actions/media/generatemedia.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { isCameraSchema } from '@shopware-ag/dive'; +import { isCameraSchema } from '../../../types/index.ts'; import { type MediaGenerationByPosition, type MediaGenerationById, diff --git a/src/plugins/state/src/actions/object/__test__/addobject.test.ts b/src/plugins/state/src/actions/object/__test__/addobject.test.ts index a808c02f..198ddb9a 100644 --- a/src/plugins/state/src/actions/object/__test__/addobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/addobject.test.ts @@ -2,7 +2,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { type DIVESceneObject } from '@shopware-ag/dive'; import { AddObjectAction } from '../addobject.ts'; import { DIVE, DIVEScene } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; const existingSceneObject = { name: 'already there' } as DIVESceneObject; diff --git a/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts b/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts index 8ad681e1..483c9b01 100644 --- a/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/deleteobject.test.ts @@ -1,7 +1,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { DeleteObjectAction } from '../deleteobject.ts'; import { DIVE, DIVEScene } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { SetParentAction } from '../setparent.ts'; import { UpdateObjectAction } from '../updateobject.ts'; diff --git a/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts b/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts index 5ed9e754..74292349 100644 --- a/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/deselectobject.test.ts @@ -2,7 +2,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { DeselectObjectAction } from '../deselectobject.ts'; import { Object3D } from 'three/webgpu'; import { DIVE, type DIVESelectable, DIVESceneObject } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { type Toolbox, type SelectionState } from '@shopware-ag/dive/toolbox'; const mockSceneObject = { diff --git a/src/plugins/state/src/actions/object/__test__/dropit.test.ts b/src/plugins/state/src/actions/object/__test__/dropit.test.ts index d9f229ca..8ca097fe 100644 --- a/src/plugins/state/src/actions/object/__test__/dropit.test.ts +++ b/src/plugins/state/src/actions/object/__test__/dropit.test.ts @@ -1,7 +1,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { DropItAction } from '../dropit.ts'; import { DIVEModel, DIVE } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; const mockModel = { isDIVEModel: true, diff --git a/src/plugins/state/src/actions/object/__test__/getallobjects.test.ts b/src/plugins/state/src/actions/object/__test__/getallobjects.test.ts index 2704fdd3..8d941d8d 100644 --- a/src/plugins/state/src/actions/object/__test__/getallobjects.test.ts +++ b/src/plugins/state/src/actions/object/__test__/getallobjects.test.ts @@ -1,5 +1,5 @@ import { GetAllObjectsAction } from '../getallobjects.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; describe('GetAllObjectsAction', () => { const mockRegistered = new Map(); diff --git a/src/plugins/state/src/actions/object/__test__/getobjects.test.ts b/src/plugins/state/src/actions/object/__test__/getobjects.test.ts index e44e1151..d2efb10e 100644 --- a/src/plugins/state/src/actions/object/__test__/getobjects.test.ts +++ b/src/plugins/state/src/actions/object/__test__/getobjects.test.ts @@ -1,5 +1,5 @@ import { GetObjectsAction } from '../getobjects.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; describe('GetObjectsAction', () => { const mockRegistered = new Map(); diff --git a/src/plugins/state/src/actions/object/__test__/modelloaded.test.ts b/src/plugins/state/src/actions/object/__test__/modelloaded.test.ts index df2a402b..67c7e69a 100644 --- a/src/plugins/state/src/actions/object/__test__/modelloaded.test.ts +++ b/src/plugins/state/src/actions/object/__test__/modelloaded.test.ts @@ -1,5 +1,8 @@ import { ModelLoadedAction } from '../modelloaded.ts'; -import { type EntitySchema, type ModelSchema } from '@shopware-ag/dive'; +import { + type EntitySchema, + type ModelSchema, +} from '../../../../types/index.ts'; describe('ModelLoadedAction', () => { it('should mark a model as loaded', async () => { diff --git a/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts b/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts index 9261edd4..22a1107b 100644 --- a/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts +++ b/src/plugins/state/src/actions/object/__test__/placeonfloor.test.ts @@ -1,7 +1,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { PlaceOnFloorAction } from '../placeonfloor.ts'; import { DIVE, DIVEModel } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; const mockModel = { isDIVEModel: true, diff --git a/src/plugins/state/src/actions/object/__test__/selectobject.test.ts b/src/plugins/state/src/actions/object/__test__/selectobject.test.ts index f8465432..81934a12 100644 --- a/src/plugins/state/src/actions/object/__test__/selectobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/selectobject.test.ts @@ -1,6 +1,6 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { DIVE, DIVESceneObject, DIVESelectable } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { SelectObjectAction } from '../selectobject.ts'; import { Object3D } from 'three/webgpu'; import { Toolbox, SelectionState } from '@shopware-ag/dive/toolbox'; diff --git a/src/plugins/state/src/actions/object/__test__/setparent.test.ts b/src/plugins/state/src/actions/object/__test__/setparent.test.ts index 9965ba2c..6ad4020f 100644 --- a/src/plugins/state/src/actions/object/__test__/setparent.test.ts +++ b/src/plugins/state/src/actions/object/__test__/setparent.test.ts @@ -1,7 +1,7 @@ import { SetParentAction } from '../setparent.ts'; import { DIVESceneObject } from '@shopware-ag/dive'; import { type EngineGateway } from '../../../EngineGateway.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { Object3D } from 'three/webgpu'; describe('SetParentAction', () => { diff --git a/src/plugins/state/src/actions/object/__test__/updateobject.test.ts b/src/plugins/state/src/actions/object/__test__/updateobject.test.ts index 06db89ed..124da0a1 100644 --- a/src/plugins/state/src/actions/object/__test__/updateobject.test.ts +++ b/src/plugins/state/src/actions/object/__test__/updateobject.test.ts @@ -1,6 +1,6 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { DIVE, DIVEScene } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../../types/index.ts'; import { UpdateObjectAction } from '../updateobject.ts'; // Mock dependencies diff --git a/src/plugins/state/src/actions/object/addobject.ts b/src/plugins/state/src/actions/object/addobject.ts index 7ca26ad0..03d6d461 100644 --- a/src/plugins/state/src/actions/object/addobject.ts +++ b/src/plugins/state/src/actions/object/addobject.ts @@ -2,7 +2,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type DIVESceneObject } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const AddObjectAction = Action.define< EntitySchema, diff --git a/src/plugins/state/src/actions/object/deleteobject.ts b/src/plugins/state/src/actions/object/deleteobject.ts index 0e7404a3..d72a2d78 100644 --- a/src/plugins/state/src/actions/object/deleteobject.ts +++ b/src/plugins/state/src/actions/object/deleteobject.ts @@ -3,7 +3,7 @@ import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { SetParentAction } from './setparent.ts'; import { UpdateObjectAction } from './updateobject.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const DeleteObjectAction = Action.define< Partial & { id: string }, diff --git a/src/plugins/state/src/actions/object/deselectobject.ts b/src/plugins/state/src/actions/object/deselectobject.ts index 54f86e4a..2cff7c29 100644 --- a/src/plugins/state/src/actions/object/deselectobject.ts +++ b/src/plugins/state/src/actions/object/deselectobject.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const DeselectObjectAction = Action.define< Partial & { id: string }, diff --git a/src/plugins/state/src/actions/object/getallobjects.ts b/src/plugins/state/src/actions/object/getallobjects.ts index 89f79dec..16f2cfd4 100644 --- a/src/plugins/state/src/actions/object/getallobjects.ts +++ b/src/plugins/state/src/actions/object/getallobjects.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const GetAllObjectsAction = Action.define< void, diff --git a/src/plugins/state/src/actions/object/getobjects.ts b/src/plugins/state/src/actions/object/getobjects.ts index 0e5298a4..e0b152ca 100644 --- a/src/plugins/state/src/actions/object/getobjects.ts +++ b/src/plugins/state/src/actions/object/getobjects.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const GetObjectsAction = Action.define< { ids: string[] }, diff --git a/src/plugins/state/src/actions/object/modelloaded.ts b/src/plugins/state/src/actions/object/modelloaded.ts index fea62af7..bccbc071 100644 --- a/src/plugins/state/src/actions/object/modelloaded.ts +++ b/src/plugins/state/src/actions/object/modelloaded.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { isModelSchema } from '@shopware-ag/dive'; +import { isModelSchema } from '../../../types/index.ts'; export const ModelLoadedAction = Action.define< { id: string }, diff --git a/src/plugins/state/src/actions/object/selectobject.ts b/src/plugins/state/src/actions/object/selectobject.ts index b2b3dc87..15308396 100644 --- a/src/plugins/state/src/actions/object/selectobject.ts +++ b/src/plugins/state/src/actions/object/selectobject.ts @@ -3,7 +3,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type DIVESelectable } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const SelectObjectAction = Action.define< Partial & { id: string }, diff --git a/src/plugins/state/src/actions/object/setparent.ts b/src/plugins/state/src/actions/object/setparent.ts index 9d8afcaf..5e86db34 100644 --- a/src/plugins/state/src/actions/object/setparent.ts +++ b/src/plugins/state/src/actions/object/setparent.ts @@ -2,7 +2,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { UpdateObjectAction } from './updateobject.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; export const SetParentAction = Action.define< { diff --git a/src/plugins/state/src/actions/object/updateobject.ts b/src/plugins/state/src/actions/object/updateobject.ts index 305320a6..9a619b5c 100644 --- a/src/plugins/state/src/actions/object/updateobject.ts +++ b/src/plugins/state/src/actions/object/updateobject.ts @@ -1,7 +1,7 @@ import { Action } from '../action.ts'; import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; import { merge } from 'lodash'; export const UpdateObjectAction = Action.define< diff --git a/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts b/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts index b2439d6d..29b8547e 100644 --- a/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/getallscenedata.test.ts @@ -6,7 +6,7 @@ import { type ModelSchema, type CameraSchema, type PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../../../../types/index.ts'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { Vector3 } from 'three/webgpu'; diff --git a/src/plugins/state/src/actions/scene/getallscenedata.ts b/src/plugins/state/src/actions/scene/getallscenedata.ts index 2ebc30e4..2fe90f88 100644 --- a/src/plugins/state/src/actions/scene/getallscenedata.ts +++ b/src/plugins/state/src/actions/scene/getallscenedata.ts @@ -8,7 +8,7 @@ import { ModelSchema, CameraSchema, PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../../../types/index.ts'; /** * @deprecated use [`GetStateAction`](../state/getstate.ts) instead. This action will be removed in next major release. diff --git a/src/plugins/state/src/actions/state/__test__/getstate.test.ts b/src/plugins/state/src/actions/state/__test__/getstate.test.ts index 34c2e55d..a4a524b7 100644 --- a/src/plugins/state/src/actions/state/__test__/getstate.test.ts +++ b/src/plugins/state/src/actions/state/__test__/getstate.test.ts @@ -6,7 +6,7 @@ import { type ModelSchema, type CameraSchema, type PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../../../../types/index.ts'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { Vector3 } from 'three/webgpu'; diff --git a/src/plugins/state/src/actions/state/__test__/setstate.test.ts b/src/plugins/state/src/actions/state/__test__/setstate.test.ts index 8eb2b7e1..4f05379a 100644 --- a/src/plugins/state/src/actions/state/__test__/setstate.test.ts +++ b/src/plugins/state/src/actions/state/__test__/setstate.test.ts @@ -15,7 +15,7 @@ import { type LightSchema, type ModelSchema, type PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../../../../types/index.ts'; import { Color, MeshStandardMaterial, Vector3 } from 'three/webgpu'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { type StateData } from '../../../../types/index.ts'; diff --git a/src/plugins/state/src/actions/state/getstate.ts b/src/plugins/state/src/actions/state/getstate.ts index 0969825c..39a031c2 100644 --- a/src/plugins/state/src/actions/state/getstate.ts +++ b/src/plugins/state/src/actions/state/getstate.ts @@ -8,7 +8,7 @@ import { ModelSchema, CameraSchema, PrimitiveSchema, -} from '@shopware-ag/dive'; +} from '../../../types/index.ts'; export const GetStateAction = Action.define< void, diff --git a/src/plugins/state/src/actions/state/setstate.ts b/src/plugins/state/src/actions/state/setstate.ts index b4be29f7..4e8d25cc 100644 --- a/src/plugins/state/src/actions/state/setstate.ts +++ b/src/plugins/state/src/actions/state/setstate.ts @@ -3,7 +3,7 @@ import { registerAction } from '../../ActionRegistry.ts'; import { type ActionDependencies } from '../../../types/index.ts'; import { type StateData } from '../../../types/index.ts'; import { type DIVESceneObject } from '@shopware-ag/dive'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from '../../../types/index.ts'; import { AddObjectAction, DeleteObjectAction, diff --git a/src/plugins/state/types/ActionTypes.ts b/src/plugins/state/types/ActionTypes.ts index fb510935..235e0fbb 100644 --- a/src/plugins/state/types/ActionTypes.ts +++ b/src/plugins/state/types/ActionTypes.ts @@ -1,5 +1,5 @@ import { type OrbitController } from '@shopware-ag/dive/orbitcontroller'; -import { type EntitySchema } from '@shopware-ag/dive'; +import { type EntitySchema } from './schema/index.ts'; import { type EngineGateway } from '../src/EngineGateway.ts'; // Extracted types for performAction_new diff --git a/src/plugins/state/types/StateData.ts b/src/plugins/state/types/StateData.ts index c4f2ea69..bd2c5f8c 100644 --- a/src/plugins/state/types/StateData.ts +++ b/src/plugins/state/types/StateData.ts @@ -1,11 +1,11 @@ import type { Vector3Like } from 'three/webgpu'; import { - type GroupSchema, - type LightSchema, - type ModelSchema, - type CameraSchema, - type PrimitiveSchema, -} from '@shopware-ag/dive'; + GroupSchema, + LightSchema, + ModelSchema, + CameraSchema, + PrimitiveSchema, +} from './index.ts'; export type StateData = { // scene data diff --git a/src/plugins/state/types/index.ts b/src/plugins/state/types/index.ts index c19091c4..85ad334b 100644 --- a/src/plugins/state/types/index.ts +++ b/src/plugins/state/types/index.ts @@ -1,3 +1,4 @@ +export * from './schema/index.ts'; export * from './ActionTypes.ts'; export * from './StateSceneData.ts'; export * from './StateData.ts'; diff --git a/src/types/schema/BaseEntitySchema.ts b/src/plugins/state/types/schema/BaseEntitySchema.ts similarity index 100% rename from src/types/schema/BaseEntitySchema.ts rename to src/plugins/state/types/schema/BaseEntitySchema.ts diff --git a/src/types/schema/CameraSchema.ts b/src/plugins/state/types/schema/CameraSchema.ts similarity index 100% rename from src/types/schema/CameraSchema.ts rename to src/plugins/state/types/schema/CameraSchema.ts diff --git a/src/types/schema/EntitySchema.ts b/src/plugins/state/types/schema/EntitySchema.ts similarity index 100% rename from src/types/schema/EntitySchema.ts rename to src/plugins/state/types/schema/EntitySchema.ts diff --git a/src/types/schema/EntityTypeSchema.ts b/src/plugins/state/types/schema/EntityTypeSchema.ts similarity index 100% rename from src/types/schema/EntityTypeSchema.ts rename to src/plugins/state/types/schema/EntityTypeSchema.ts diff --git a/src/types/schema/GroupSchema.ts b/src/plugins/state/types/schema/GroupSchema.ts similarity index 100% rename from src/types/schema/GroupSchema.ts rename to src/plugins/state/types/schema/GroupSchema.ts diff --git a/src/types/schema/LightSchema.ts b/src/plugins/state/types/schema/LightSchema.ts similarity index 100% rename from src/types/schema/LightSchema.ts rename to src/plugins/state/types/schema/LightSchema.ts diff --git a/src/types/schema/ModelSchema.ts b/src/plugins/state/types/schema/ModelSchema.ts similarity index 78% rename from src/types/schema/ModelSchema.ts rename to src/plugins/state/types/schema/ModelSchema.ts index c434f38b..e3c77ea1 100644 --- a/src/types/schema/ModelSchema.ts +++ b/src/plugins/state/types/schema/ModelSchema.ts @@ -1,5 +1,5 @@ import { type Vector3Like } from 'three/webgpu'; -import { type DIVEMaterial } from '../material/DIVEMaterial.ts'; +import { type DIVEMaterial } from '@shopware-ag/dive'; import { type BaseEntitySchema } from './BaseEntitySchema.ts'; import { type EntitySchema } from './EntitySchema.ts'; @@ -25,8 +25,11 @@ export type ModelSchema = BaseEntitySchema & { rotation: Vector3Like; scale: Vector3Like; /** - * @deprecated Never written or read. Whether an asset has arrived is - * signalled by the `MODEL_LOADED` action instead. + * Whether the asset has arrived. + * + * Set by the `MODEL_LOADED` action once the load finishes, never by the + * caller. It travels back out with the state, so a stored scene records + * which models were ready. */ loaded: boolean; /** Overrides on top of what the asset itself brings along. */ diff --git a/src/types/schema/PrimitiveSchema.ts b/src/plugins/state/types/schema/PrimitiveSchema.ts similarity index 84% rename from src/types/schema/PrimitiveSchema.ts rename to src/plugins/state/types/schema/PrimitiveSchema.ts index 2c3cfb23..75c359cd 100644 --- a/src/types/schema/PrimitiveSchema.ts +++ b/src/plugins/state/types/schema/PrimitiveSchema.ts @@ -1,7 +1,6 @@ import { type Vector3Like } from 'three/webgpu'; import { type BaseEntitySchema } from './BaseEntitySchema.ts'; -import { type DIVEGeometry } from '../geometry/DIVEGeometry.ts'; -import { type DIVEMaterial } from '../material/DIVEMaterial.ts'; +import { type DIVEGeometry, type DIVEMaterial } from '@shopware-ag/dive'; import { type EntitySchema } from './EntitySchema.ts'; export function isPrimitiveSchema( diff --git a/src/types/schema/__test__/CameraSchema.test.ts b/src/plugins/state/types/schema/__test__/CameraSchema.test.ts similarity index 100% rename from src/types/schema/__test__/CameraSchema.test.ts rename to src/plugins/state/types/schema/__test__/CameraSchema.test.ts diff --git a/src/types/schema/__test__/GroupSchema.test.ts b/src/plugins/state/types/schema/__test__/GroupSchema.test.ts similarity index 100% rename from src/types/schema/__test__/GroupSchema.test.ts rename to src/plugins/state/types/schema/__test__/GroupSchema.test.ts diff --git a/src/types/schema/__test__/LightSchema.test.ts b/src/plugins/state/types/schema/__test__/LightSchema.test.ts similarity index 100% rename from src/types/schema/__test__/LightSchema.test.ts rename to src/plugins/state/types/schema/__test__/LightSchema.test.ts diff --git a/src/types/schema/__test__/ModelSchema.test.ts b/src/plugins/state/types/schema/__test__/ModelSchema.test.ts similarity index 100% rename from src/types/schema/__test__/ModelSchema.test.ts rename to src/plugins/state/types/schema/__test__/ModelSchema.test.ts diff --git a/src/types/schema/__test__/PrimitiveSchema.test.ts b/src/plugins/state/types/schema/__test__/PrimitiveSchema.test.ts similarity index 100% rename from src/types/schema/__test__/PrimitiveSchema.test.ts rename to src/plugins/state/types/schema/__test__/PrimitiveSchema.test.ts diff --git a/src/types/schema/index.ts b/src/plugins/state/types/schema/index.ts similarity index 100% rename from src/types/schema/index.ts rename to src/plugins/state/types/schema/index.ts diff --git a/src/types/index.ts b/src/types/index.ts index ca92b665..44c83744 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,4 +3,3 @@ export * from './events/index.ts'; export * from './file/index.ts'; export * from './geometry/index.ts'; export * from './material/index.ts'; -export * from './schema/index.ts'; From ee12ca0a23c5c9b7421659ec36556cffe9e5d8bc Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 10:10:00 +0200 Subject: [PATCH 08/10] refactor: drop the duplicate setBackground from EngineGateway and expose its root publicly --- src/plugins/state/src/EngineGateway.ts | 26 +++----- .../state/src/__test__/EngineGateway.test.ts | 59 ++++++++++--------- .../__test__/computeencompassingview.test.ts | 6 +- .../actions/camera/computeencompassingview.ts | 2 +- .../actions/object/__test__/setparent.test.ts | 6 +- .../state/src/actions/object/setparent.ts | 2 +- .../scene/__test__/exportscene.test.ts | 6 +- .../scene/__test__/setbackground.test.ts | 8 ++- .../state/src/actions/scene/exportscene.ts | 2 +- .../state/src/actions/scene/setbackground.ts | 2 +- 10 files changed, 55 insertions(+), 64 deletions(-) diff --git a/src/plugins/state/src/EngineGateway.ts b/src/plugins/state/src/EngineGateway.ts index c5f586da..79f2c329 100644 --- a/src/plugins/state/src/EngineGateway.ts +++ b/src/plugins/state/src/EngineGateway.ts @@ -1,4 +1,4 @@ -import { Color, MeshStandardMaterial, Object3D } from 'three/webgpu'; +import { Color, MeshStandardMaterial } from 'three/webgpu'; import { detachTransformControls, DIVEAmbientLight, @@ -112,26 +112,18 @@ export class EngineGateway { this._state = state; } - private get _root(): DIVERoot { + /** The scene root, for everything that needs the subtree as a whole. */ + public get root(): DIVERoot { return this._engine.scene.root; } - /** - * The scene root as a plain object, for the few consumers that need the - * whole subtree rather than a single entity — computing an encompassing - * view and exporting. - */ - public get sceneRoot(): Object3D { - return this._root; - } - // ---------------------------------------------------------------- entities public findEntity( entity: MinimalSchema, ): DIVESceneObject | undefined { let found: DIVESceneObject | undefined; - this._root.traverse((object3D) => { + this.root.traverse((object3D) => { if (found) return; if (object3D.userData.id === entity.id) { found = object3D as DIVESceneObject; @@ -158,7 +150,7 @@ export class EngineGateway { sceneObject.name = entity.name; sceneObject.userData.id = entity.id; - this._root.add(sceneObject); + this.root.add(sceneObject); // Wired before the schema is applied, not after: applying a model // schema awaits `setFromURL`, and that is exactly where `object-load` @@ -200,7 +192,7 @@ export class EngineGateway { // Their own wiring is keyed by their own id and stays untouched. if (sceneObject instanceof DIVEGroup) { for (let i = sceneObject.members.length - 1; i >= 0; i--) { - this._root.attach(sceneObject.members[i]); + this.root.attach(sceneObject.members[i]); } } @@ -243,10 +235,6 @@ export class EngineGateway { scene.root.floor.setColor(patch.floorColor); } - public setBackground(color: string | number): void { - this._engine.scene.setBackground(color); - } - // ---------------------------------------------------------------- engine public startRendering(): Promise { @@ -410,7 +398,7 @@ export class EngineGateway { } if (entity.parentId === null) { - this._root.attach(sceneObject); + this.root.attach(sceneObject); return; } diff --git a/src/plugins/state/src/__test__/EngineGateway.test.ts b/src/plugins/state/src/__test__/EngineGateway.test.ts index 76db7d1a..9ee3999d 100644 --- a/src/plugins/state/src/__test__/EngineGateway.test.ts +++ b/src/plugins/state/src/__test__/EngineGateway.test.ts @@ -6,6 +6,7 @@ import { type DIVE, } from '@shopware-ag/dive'; import { type State } from '../State.ts'; +import { type DIVESceneObject } from '@shopware-ag/dive'; import { LightSchema, ModelSchema, @@ -335,7 +336,7 @@ describe('plugins/state/EngineGateway', () => { mockObject.userData = { id: 'test-id' }; const gateway = makeGateway(); - gateway.sceneRoot.add(mockObject); + gateway.root.add(mockObject); const found = findEntity(gateway, { id: 'test-id', @@ -361,7 +362,7 @@ describe('plugins/state/EngineGateway', () => { id: 'test-id', }, }; - gateway.sceneRoot.add(mockObject as any); + gateway.root.add(mockObject as any); const result = findEntity(gateway, { id: 'test-id', entityType: 'model', @@ -420,7 +421,7 @@ describe('plugins/state/EngineGateway', () => { const mockObject2 = { ...mockObject1, id: 'obj2', uuid: 'uuid2' }; let traverseCount = 0; - gateway.sceneRoot.traverse = vi.fn((callback) => { + gateway.root.traverse = vi.fn((callback) => { traverseCount++; callback(mockObject1 as any); callback(mockObject2 as any); @@ -909,7 +910,7 @@ describe('plugins/state/EngineGateway', () => { const gateway = makeGateway(); const cameraObject = new Object3D(); cameraObject.userData.id = cameraData.id; - gateway.sceneRoot.add(cameraObject); + gateway.root.add(cameraObject); await gateway.updateEntity(cameraData); @@ -942,7 +943,7 @@ describe('plugins/state/EngineGateway', () => { const gateway = makeGateway(); const existingObject = new Object3D(); existingObject.userData.id = unknownData.id; - gateway.sceneRoot.add(existingObject); + gateway.root.add(existingObject); await expect(gateway.updateEntity(unknownData)).rejects.toThrow( 'EngineGateway.updateEntity: Unknown entity type: unknown', @@ -970,8 +971,8 @@ describe('plugins/state/EngineGateway', () => { expect(model).toBeDefined(); if (model) { - model.parent = gateway.sceneRoot; - gateway.sceneRoot.children = [model as unknown as Object3D]; + model.parent = gateway.root; + gateway.root.children = [model as unknown as Object3D]; } gateway.removeEntity(modelData); @@ -1029,7 +1030,7 @@ describe('plugins/state/EngineGateway', () => { const gateway = makeGateway(); const cameraObject = new Object3D(); cameraObject.userData.id = cameraData.id; - gateway.sceneRoot.add(cameraObject); + gateway.root.add(cameraObject); gateway.removeEntity(cameraData); @@ -1047,7 +1048,7 @@ describe('plugins/state/EngineGateway', () => { const gateway = makeGateway(); const stranger = new Object3D(); stranger.userData.id = 'unknown'; - gateway.sceneRoot.add(stranger); + gateway.root.add(stranger); gateway.removeEntity(unknownData); @@ -1090,11 +1091,11 @@ describe('plugins/state/EngineGateway', () => { if (group && member) { (group as any).members = [member]; - group.parent = gateway.sceneRoot; + group.parent = gateway.root; } gateway.removeEntity(groupData); - expect(gateway.sceneRoot.attach).toHaveBeenCalledWith(member); + expect(gateway.root.attach).toHaveBeenCalledWith(member); }); it('should handle transform controls detachment', async () => { @@ -1124,8 +1125,8 @@ describe('plugins/state/EngineGateway', () => { expect(model).toBeDefined(); if (model) { - model.parent = gateway.sceneRoot; - gateway.sceneRoot.parent = mockScene; + model.parent = gateway.root; + gateway.root.parent = mockScene; } gateway.removeEntity(modelData); @@ -1158,8 +1159,8 @@ describe('plugins/state/EngineGateway', () => { expect(primitive).toBeDefined(); if (primitive) { - primitive.parent = gateway.sceneRoot; - gateway.sceneRoot.parent = mockScene; + primitive.parent = gateway.root; + gateway.root.parent = mockScene; } gateway.removeEntity(primitiveData); @@ -1191,14 +1192,14 @@ describe('plugins/state/EngineGateway', () => { expect(group).toBeDefined(); if (group) { - group.parent = gateway.sceneRoot; - gateway.sceneRoot.parent = mockScene; + group.parent = gateway.root; + gateway.root.parent = mockScene; (group as any).members = [new Object3D()]; } gateway.removeEntity(groupData); expect(mockTransformControls.detach).toHaveBeenCalled(); - expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + expect(gateway.root.attach).toHaveBeenCalled(); }); }); @@ -1257,7 +1258,7 @@ describe('plugins/state/EngineGateway', () => { await gateway.addEntity(childData); const child = findEntity(gateway, childData); expect(child).toBeDefined(); - expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + expect(gateway.root.attach).toHaveBeenCalled(); }); it('should handle non-existent parent', async () => { @@ -1279,7 +1280,7 @@ describe('plugins/state/EngineGateway', () => { const child = findEntity(gateway, childData); expect(child).toBeDefined(); // When parent doesn't exist, the object should remain where it is - expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + expect(gateway.root.attach).not.toHaveBeenCalled(); }); it('should handle non-existent object', async () => { @@ -1299,7 +1300,7 @@ describe('plugins/state/EngineGateway', () => { const gateway = makeGateway(); // Don't add the object to the scene await gateway.updateEntity(modelData); - expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + expect(gateway.root.attach).not.toHaveBeenCalled(); }); }); @@ -1450,8 +1451,8 @@ describe('plugins/state/EngineGateway', () => { expect(light).toBeDefined(); if (light) { - light.parent = gateway.sceneRoot; - gateway.sceneRoot.parent = mockScene; + light.parent = gateway.root; + gateway.root.parent = mockScene; } gateway.removeEntity(lightData); @@ -1505,14 +1506,14 @@ describe('plugins/state/EngineGateway', () => { expect(group).toBeDefined(); if (group) { - group.parent = gateway.sceneRoot; - gateway.sceneRoot.parent = mockScene; + group.parent = gateway.root; + gateway.root.parent = mockScene; (group as any).members = [new Object3D()]; } gateway.removeEntity(groupData); expect(mockTransformControls.detach).toHaveBeenCalled(); - expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + expect(gateway.root.attach).toHaveBeenCalled(); }); it('should handle non-existent group', () => { @@ -1553,7 +1554,7 @@ describe('plugins/state/EngineGateway', () => { await gateway.addEntity(modelData); const model = findEntity(gateway, modelData); expect(model).toBeDefined(); - expect(gateway.sceneRoot.attach).toHaveBeenCalled(); + expect(gateway.root.attach).toHaveBeenCalled(); }); it('should handle object with non-existent parent', async () => { @@ -1574,7 +1575,7 @@ describe('plugins/state/EngineGateway', () => { await gateway.addEntity(modelData); const model = findEntity(gateway, modelData); expect(model).toBeDefined(); - expect(gateway.sceneRoot.attach).not.toHaveBeenCalled(); + expect(gateway.root.attach).not.toHaveBeenCalled(); }); }); @@ -1969,7 +1970,7 @@ describe('plugins/state/EngineGateway', () => { const { gateway } = makeWired(); await gateway.addEntity(modelData); const model = findEntity(gateway, modelData)!; - model.parent = gateway.sceneRoot; + model.parent = gateway.root; gateway.removeEntity(modelData); diff --git a/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts b/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts index 243f7595..2f6c5de6 100644 --- a/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts +++ b/src/plugins/state/src/actions/camera/__test__/computeencompassingview.test.ts @@ -1,16 +1,16 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; import { OrbitController } from '@shopware-ag/dive/orbitcontroller'; import { ComputeEncompassingViewAction } from '../computeencompassingview.ts'; -import { Vector3 } from 'three/webgpu'; vi.mock('../../../../../../components/boundingbox/BoundingBox.ts', () => ({ BoundingBox: vi.fn(), })); +import { Vector3 } from 'three/webgpu'; describe('modules/state/actions/camera/computeEncompassingView', () => { it('should compute encompassing view for a scene', async () => { // Mock dependencies - const sceneRoot = { + const root = { computeSceneBB: vi.fn().mockReturnValue({ min: new Vector3(0, 0, 0), max: new Vector3(10, 10, 10), @@ -25,7 +25,7 @@ describe('modules/state/actions/camera/computeEncompassingView', () => { }), } as unknown as OrbitController; - const mockGateway = { sceneRoot } as unknown as EngineGateway; + const mockGateway = { root } as unknown as EngineGateway; const action = new ComputeEncompassingViewAction(undefined, { gateway: mockGateway, diff --git a/src/plugins/state/src/actions/camera/computeencompassingview.ts b/src/plugins/state/src/actions/camera/computeencompassingview.ts index 92d33921..316ea49e 100644 --- a/src/plugins/state/src/actions/camera/computeencompassingview.ts +++ b/src/plugins/state/src/actions/camera/computeencompassingview.ts @@ -15,7 +15,7 @@ export const ComputeEncompassingViewAction = Action.define< description: 'Calculates the camera position and target to view the whole scene. (experimental).', execute: (_payload, { gateway, controller }) => { - const sceneBB = new BoundingBox(gateway.sceneRoot, false, 0x00ff00); + const sceneBB = new BoundingBox(gateway.root, false, 0x00ff00); return controller.computeEncompassingView(sceneBB); }, }); diff --git a/src/plugins/state/src/actions/object/__test__/setparent.test.ts b/src/plugins/state/src/actions/object/__test__/setparent.test.ts index 6ad4020f..ef5ca816 100644 --- a/src/plugins/state/src/actions/object/__test__/setparent.test.ts +++ b/src/plugins/state/src/actions/object/__test__/setparent.test.ts @@ -24,7 +24,7 @@ describe('SetParentAction', () => { return null; }, ), - sceneRoot: { attach: vi.fn() }, + root: { attach: vi.fn() }, updateEntity: vi.fn(), } as unknown as EngineGateway; @@ -122,9 +122,7 @@ describe('SetParentAction', () => { action.execute(); // Assert - expect(mockGateway.sceneRoot.attach).toHaveBeenCalledWith( - mockSceneObject, - ); + expect(mockGateway.root.attach).toHaveBeenCalledWith(mockSceneObject); }); it('should throw error if object does not exist', () => { diff --git a/src/plugins/state/src/actions/object/setparent.ts b/src/plugins/state/src/actions/object/setparent.ts index 5e86db34..154d7ae0 100644 --- a/src/plugins/state/src/actions/object/setparent.ts +++ b/src/plugins/state/src/actions/object/setparent.ts @@ -22,7 +22,7 @@ export const SetParentAction = Action.define< if (payload.parent === null) { // detach from current parent - gateway.sceneRoot.attach(sceneObject); + gateway.root.attach(sceneObject); // Update registration to reflect no parent new UpdateObjectAction( { diff --git a/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts b/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts index a31b4d54..aa4d03c2 100644 --- a/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/exportscene.test.ts @@ -9,8 +9,8 @@ const mockGetAssetExporter = vi.fn().mockResolvedValue({ describe('ExportSceneAction', () => { it('should export scene', async () => { - const sceneRoot = new Object3D(); - const mockGateway = { sceneRoot } as unknown as EngineGateway; + const root = new Object3D(); + const mockGateway = { root } as unknown as EngineGateway; const action = new ExportSceneAction( { type: 'glb' }, @@ -23,7 +23,7 @@ describe('ExportSceneAction', () => { const result = await action.execute(); expect(mockGetAssetExporter).toHaveBeenCalled(); - expect(mockExport).toHaveBeenCalledWith(sceneRoot, 'glb'); + expect(mockExport).toHaveBeenCalledWith(root, 'glb'); expect(result).toBe('exported-scene-data'); }); }); diff --git a/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts b/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts index 3e72c2a6..9e9725bb 100644 --- a/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts +++ b/src/plugins/state/src/actions/scene/__test__/setbackground.test.ts @@ -4,7 +4,7 @@ import { type EngineGateway } from '../../../EngineGateway.ts'; describe('SetBackgroundAction', () => { it('should set scene background', async () => { const mockGateway = { - setBackground: vi.fn(), + applySceneSettings: vi.fn(), } as unknown as EngineGateway; const action = new SetBackgroundAction( @@ -16,6 +16,10 @@ describe('SetBackgroundAction', () => { await action.execute(); - expect(mockGateway.setBackground).toHaveBeenCalledWith('#ff0000'); + // there is one way into the scene properties, not a second one just + // for the background + expect(mockGateway.applySceneSettings).toHaveBeenCalledWith({ + backgroundColor: '#ff0000', + }); }); }); diff --git a/src/plugins/state/src/actions/scene/exportscene.ts b/src/plugins/state/src/actions/scene/exportscene.ts index 47cfd590..262e8a78 100644 --- a/src/plugins/state/src/actions/scene/exportscene.ts +++ b/src/plugins/state/src/actions/scene/exportscene.ts @@ -11,7 +11,7 @@ export const ExportSceneAction = Action.define< description: 'Exports the current scene to a blob and returns the URL.', execute: async (payload, { gateway, getAssetExporter }) => { return getAssetExporter().then((assetExporter) => { - return assetExporter.export(gateway.sceneRoot, payload.type); + return assetExporter.export(gateway.root, payload.type); }); }, }); diff --git a/src/plugins/state/src/actions/scene/setbackground.ts b/src/plugins/state/src/actions/scene/setbackground.ts index 2513bd5b..ceae0347 100644 --- a/src/plugins/state/src/actions/scene/setbackground.ts +++ b/src/plugins/state/src/actions/scene/setbackground.ts @@ -9,7 +9,7 @@ export const SetBackgroundAction = Action.define< >({ description: 'Set the background color of the scene.', execute: (payload, { gateway }) => { - gateway.setBackground(payload.color); + gateway.applySceneSettings({ backgroundColor: payload.color }); }, }); From 1f799b1b2d898944841e0f2160f4769c84de54c1 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 10:10:00 +0200 Subject: [PATCH 09/10] fix: type DIVEGroup members as DIVESceneObject, which is all attach lets in --- src/components/group/Group.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/group/Group.ts b/src/components/group/Group.ts index a6d3fc9d..1d3bd619 100644 --- a/src/components/group/Group.ts +++ b/src/components/group/Group.ts @@ -12,9 +12,9 @@ import { type DIVESceneObject } from '../../types/index.ts'; export class DIVEGroup extends DIVENode { readonly isDIVEGroup: true = true; - private _members: Object3D[]; // children objects + private _members: DIVESceneObject[]; - public get members(): Object3D[] { + public get members(): DIVESceneObject[] { return this._members; } @@ -47,7 +47,7 @@ export class DIVEGroup extends DIVENode { return; } - const index = this._members.indexOf(object); + const index = this._members.findIndex((member) => member === object); if (index === -1) return; this._lines[index].visible = visible; @@ -99,7 +99,7 @@ export class DIVEGroup extends DIVENode { } public updateLineTo(object: Object3D): void { - const index = this._members.indexOf(object); + const index = this._members.findIndex((member) => member === object); if (index === -1) return; this._updateLineTo(this._lines[index], object); From 7e84c1b58dd669ff944952dd71e94bd07c6644e6 Mon Sep 17 00:00:00 2001 From: Felix Frank Date: Fri, 7 Aug 2026 10:26:25 +0200 Subject: [PATCH 10/10] test: cover the engine control, _setParent and teardown branches of EngineGateway --- .../state/src/__test__/EngineGateway.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/plugins/state/src/__test__/EngineGateway.test.ts b/src/plugins/state/src/__test__/EngineGateway.test.ts index 9ee3999d..cdf157cf 100644 --- a/src/plugins/state/src/__test__/EngineGateway.test.ts +++ b/src/plugins/state/src/__test__/EngineGateway.test.ts @@ -1204,6 +1204,22 @@ describe('plugins/state/EngineGateway', () => { }); describe('_setParent', () => { + it('should warn when the entity itself is not in the scene', () => { + // reachable through a patch that carries a parentId for an id that + // was never added + const gateway = makeGateway(); + + gateway['_setParent']({ + id: 'ghost', + entityType: 'model', + parentId: null, + }); + + expect(spyConsoleWarn).toHaveBeenCalledWith( + 'EngineGateway._setParent: ghost is not in the scene', + ); + }); + it('should set parent-child relationship', async () => { const parentData: GroupSchema = { id: 'parent-1', @@ -1987,6 +2003,26 @@ describe('plugins/state/EngineGateway', () => { ); }); + it('should forget the selection when the selected object is removed', async () => { + // otherwise the id stays in _selectedId and an object added again + // under the same id could never be selected + const { gateway, performAction } = makeWired(); + await gateway.addEntity(modelData); + fire(findEntity(gateway, modelData)!, 'object-select'); + findEntity(gateway, modelData)!.parent = gateway.root; + + gateway.removeEntity(modelData); + + await gateway.addEntity(modelData); + fire(findEntity(gateway, modelData)!, 'object-select'); + + expect( + performAction.mock.calls.filter( + (call) => call[0] === 'SELECT_OBJECT', + ), + ).toHaveLength(2); + }); + it('should drop every subscription on dispose', async () => { const { gateway } = makeWired(); await gateway.addEntity(modelData); @@ -2012,4 +2048,52 @@ describe('plugins/state/EngineGateway', () => { expect(result).toBeUndefined(); }); }); + + describe('engine control', () => { + const makeControllable = () => { + const clock = { + hasTicker: vi.fn(() => false), + addTicker: vi.fn(), + }; + const startAsync = vi.fn().mockResolvedValue(undefined); + const gateway = new EngineGateway( + { + scene: { root: new DIVERoot() }, + clock, + startAsync, + } as unknown as DIVE, + { performAction: vi.fn() } as unknown as State, + ); + return { gateway, clock, startAsync }; + }; + + it('should start the engine', async () => { + const { gateway, startAsync } = makeControllable(); + + await gateway.startRendering(); + + expect(startAsync).toHaveBeenCalledTimes(1); + }); + + it('should add a ticker the clock does not have yet', () => { + const { gateway, clock } = makeControllable(); + const ticker = { tick: vi.fn() }; + + gateway.registerTicker(ticker as never); + + expect(clock.addTicker).toHaveBeenCalledWith(ticker); + }); + + it('should not add a ticker twice', () => { + // MOVE_CAMERA registers the animation system on every call, so the + // guard is what keeps one ticker from running several times + const { gateway, clock } = makeControllable(); + clock.hasTicker.mockReturnValue(true); + const ticker = { tick: vi.fn() }; + + gateway.registerTicker(ticker as never); + + expect(clock.addTicker).not.toHaveBeenCalled(); + }); + }); });