diff --git a/TODO.md b/TODO.md index b6d766123..b4098fdd1 100644 --- a/TODO.md +++ b/TODO.md @@ -54,7 +54,7 @@ A step-by-step checklist for porting MatterControl's design features to a Vue + - [x] Implement `addObject()` function - [x] Implement `removeObject()` function - [x] Implement `clearScene()` function -- [ ] Add scene object list to Pinia store +- [x] Add scene object list to Pinia store --- diff --git a/src/composables/.gitkeep b/src/composables/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/composables/useScene.spec.ts b/src/stores/scene.spec.ts similarity index 58% rename from src/composables/useScene.spec.ts rename to src/stores/scene.spec.ts index 64d8a6b09..7d88b1bfd 100644 --- a/src/composables/useScene.spec.ts +++ b/src/stores/scene.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' import * as THREE from 'three' -import { useScene, type SceneObject } from './useScene' +import { useSceneStore } from './scene' // Mock THREE.Scene function createMockScene() { @@ -17,29 +18,29 @@ function createTestMesh(): THREE.Mesh { return new THREE.Mesh(geometry, material) } -describe('useScene', () => { +describe('useSceneStore', () => { + let store: ReturnType let scene: THREE.Scene - let sceneApi: ReturnType beforeEach(() => { - // Get a fresh composable instance - sceneApi = useScene() + setActivePinia(createPinia()) + store = useSceneStore() scene = createMockScene() - sceneApi.setScene(scene) - sceneApi.clearScene() + store.setScene(scene) + store.clearScene() }) describe('addObject', () => { it('adds a mesh to the scene', () => { const mesh = createTestMesh() - sceneApi.addObject(mesh, 'Test Cube') + store.addObject(mesh, 'Test Cube') expect(scene.add).toHaveBeenCalledWith(mesh) }) it('returns a SceneObject with correct properties', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Test Cube') + const obj = store.addObject(mesh, 'Test Cube') expect(obj.name).toBe('Test Cube') expect(obj.mesh).toBe(mesh) @@ -51,15 +52,15 @@ describe('useScene', () => { it('generates unique IDs', () => { const mesh1 = createTestMesh() const mesh2 = createTestMesh() - const obj1 = sceneApi.addObject(mesh1, 'Cube 1') - const obj2 = sceneApi.addObject(mesh2, 'Cube 2') + const obj1 = store.addObject(mesh1, 'Cube 1') + const obj2 = store.addObject(mesh2, 'Cube 2') expect(obj1.id).not.toBe(obj2.id) }) it('auto-generates name when not provided', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh) + const obj = store.addObject(mesh) expect(obj.name).toBe('Object') }) @@ -69,70 +70,67 @@ describe('useScene', () => { const mesh2 = createTestMesh() const mesh3 = createTestMesh() - sceneApi.addObject(mesh1, 'Cube') - const obj2 = sceneApi.addObject(mesh2, 'Cube') - const obj3 = sceneApi.addObject(mesh3, 'Cube') + store.addObject(mesh1, 'Cube') + const obj2 = store.addObject(mesh2, 'Cube') + const obj3 = store.addObject(mesh3, 'Cube') expect(obj2.name).toBe('Cube 1') expect(obj3.name).toBe('Cube 2') }) it('throws if scene not initialized', () => { - const freshApi = useScene() - // Don't call setScene + setActivePinia(createPinia()) + const freshStore = useSceneStore() const mesh = createTestMesh() - // Clear the shared scene reference by setting it to a new scene then clearing - freshApi.setScene(createMockScene()) - freshApi.clearScene() - // This is tricky because useScene shares state - the test demonstrates the API + expect(() => freshStore.addObject(mesh)).toThrow('Scene not initialized') }) }) describe('removeObject', () => { it('removes object from scene', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Test') + const obj = store.addObject(mesh, 'Test') - const result = sceneApi.removeObject(obj.id) + const result = store.removeObject(obj.id) expect(result).toBe(true) expect(scene.remove).toHaveBeenCalledWith(mesh) }) it('returns false for non-existent id', () => { - const result = sceneApi.removeObject('nonexistent') + const result = store.removeObject('nonexistent') expect(result).toBe(false) }) it('removes object from objectList', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Test') + const obj = store.addObject(mesh, 'Test') - expect(sceneApi.objectCount.value).toBe(1) - sceneApi.removeObject(obj.id) - expect(sceneApi.objectCount.value).toBe(0) + expect(store.objectCount).toBe(1) + store.removeObject(obj.id) + expect(store.objectCount).toBe(0) }) }) describe('clearScene', () => { it('removes all objects', () => { - sceneApi.addObject(createTestMesh(), 'Cube 1') - sceneApi.addObject(createTestMesh(), 'Cube 2') - sceneApi.addObject(createTestMesh(), 'Cube 3') + store.addObject(createTestMesh(), 'Cube 1') + store.addObject(createTestMesh(), 'Cube 2') + store.addObject(createTestMesh(), 'Cube 3') - expect(sceneApi.objectCount.value).toBe(3) - sceneApi.clearScene() - expect(sceneApi.objectCount.value).toBe(0) + expect(store.objectCount).toBe(3) + store.clearScene() + expect(store.objectCount).toBe(0) }) }) describe('getObject', () => { it('returns object by id', () => { const mesh = createTestMesh() - const added = sceneApi.addObject(mesh, 'Test') + const added = store.addObject(mesh, 'Test') - const found = sceneApi.getObject(added.id) + const found = store.getObject(added.id) expect(found).toBeDefined() expect(found?.id).toBe(added.id) @@ -141,7 +139,7 @@ describe('useScene', () => { }) it('returns undefined for non-existent id', () => { - const found = sceneApi.getObject('nonexistent') + const found = store.getObject('nonexistent') expect(found).toBeUndefined() }) }) @@ -149,11 +147,11 @@ describe('useScene', () => { describe('setObjectVisible', () => { it('updates visibility on SceneObject and mesh', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Test') + const obj = store.addObject(mesh, 'Test') - sceneApi.setObjectVisible(obj.id, false) + store.setObjectVisible(obj.id, false) - const updated = sceneApi.getObject(obj.id) + const updated = store.getObject(obj.id) expect(updated?.visible).toBe(false) expect(mesh.visible).toBe(false) }) @@ -162,11 +160,11 @@ describe('useScene', () => { describe('setObjectLocked', () => { it('updates locked state', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Test') + const obj = store.addObject(mesh, 'Test') - sceneApi.setObjectLocked(obj.id, true) + store.setObjectLocked(obj.id, true) - const updated = sceneApi.getObject(obj.id) + const updated = store.getObject(obj.id) expect(updated?.locked).toBe(true) }) }) @@ -174,21 +172,21 @@ describe('useScene', () => { describe('renameObject', () => { it('updates object name', () => { const mesh = createTestMesh() - const obj = sceneApi.addObject(mesh, 'Old Name') + const obj = store.addObject(mesh, 'Old Name') - sceneApi.renameObject(obj.id, 'New Name') + store.renameObject(obj.id, 'New Name') - const updated = sceneApi.getObject(obj.id) + const updated = store.getObject(obj.id) expect(updated?.name).toBe('New Name') }) }) describe('objectList', () => { it('returns all objects as computed', () => { - sceneApi.addObject(createTestMesh(), 'A') - sceneApi.addObject(createTestMesh(), 'B') + store.addObject(createTestMesh(), 'A') + store.addObject(createTestMesh(), 'B') - const list = sceneApi.objectList.value + const list = store.objectList expect(list).toHaveLength(2) expect(list[0].name).toBe('A') diff --git a/src/composables/useScene.ts b/src/stores/scene.ts similarity index 71% rename from src/composables/useScene.ts rename to src/stores/scene.ts index 237624eab..347e8bcb5 100644 --- a/src/composables/useScene.ts +++ b/src/stores/scene.ts @@ -1,3 +1,4 @@ +import { defineStore } from 'pinia' import { shallowRef, computed } from 'vue' import * as THREE from 'three' @@ -9,35 +10,35 @@ export interface SceneObject { locked: boolean } -// Use shallowRef to avoid Vue making THREE.Mesh objects deeply reactive -const objects = shallowRef([]) -let scene: THREE.Scene | null = null -let nextId = 1 +export const useSceneStore = defineStore('scene', () => { + // Use shallowRef to avoid Vue making THREE.Mesh objects deeply reactive + const objects = shallowRef([]) + let threeScene: THREE.Scene | null = null + let nextId = 1 -function generateId(): string { - return `obj_${nextId++}` -} - -function generateName(baseName: string): string { - const existingNames = objects.value.map((o) => o.name) - if (!existingNames.includes(baseName)) { - return baseName + function generateId(): string { + return `obj_${nextId++}` } - let counter = 1 - while (existingNames.includes(`${baseName} ${counter}`)) { - counter++ - } - return `${baseName} ${counter}` -} + function generateName(baseName: string): string { + const existingNames = objects.value.map((o) => o.name) + if (!existingNames.includes(baseName)) { + return baseName + } -export function useScene() { - function setScene(threeScene: THREE.Scene) { - scene = threeScene + let counter = 1 + while (existingNames.includes(`${baseName} ${counter}`)) { + counter++ + } + return `${baseName} ${counter}` + } + + function setScene(scene: THREE.Scene) { + threeScene = scene } function addObject(mesh: THREE.Mesh, name?: string): SceneObject { - if (!scene) { + if (!threeScene) { throw new Error('Scene not initialized. Call setScene() first.') } @@ -51,7 +52,7 @@ export function useScene() { locked: false, } - scene.add(mesh) + threeScene.add(mesh) objects.value = [...objects.value, sceneObject] return sceneObject @@ -62,8 +63,8 @@ export function useScene() { if (index === -1) return false const obj = objects.value[index] - if (scene) { - scene.remove(obj.mesh) + if (threeScene) { + threeScene.remove(obj.mesh) } obj.mesh.geometry.dispose() @@ -112,6 +113,12 @@ export function useScene() { const objectCount = computed(() => objects.value.length) return { + // State + objects, + // Getters + objectList, + objectCount, + // Actions setScene, addObject, removeObject, @@ -120,7 +127,5 @@ export function useScene() { setObjectVisible, setObjectLocked, renameObject, - objectList, - objectCount, } -} +})