Migrate scene state to Pinia store

This commit is contained in:
Nettika 2026-01-29 00:24:41 -08:00
parent 862f31f6c4
commit f2dc318f09
No known key found for this signature in database
4 changed files with 81 additions and 78 deletions

View file

@ -54,7 +54,7 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
- [x] Implement `addObject()` function - [x] Implement `addObject()` function
- [x] Implement `removeObject()` function - [x] Implement `removeObject()` function
- [x] Implement `clearScene()` function - [x] Implement `clearScene()` function
- [ ] Add scene object list to Pinia store - [x] Add scene object list to Pinia store
--- ---

View file

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import * as THREE from 'three' import * as THREE from 'three'
import { useScene, type SceneObject } from './useScene' import { useSceneStore } from './scene'
// Mock THREE.Scene // Mock THREE.Scene
function createMockScene() { function createMockScene() {
@ -17,29 +18,29 @@ function createTestMesh(): THREE.Mesh {
return new THREE.Mesh(geometry, material) return new THREE.Mesh(geometry, material)
} }
describe('useScene', () => { describe('useSceneStore', () => {
let store: ReturnType<typeof useSceneStore>
let scene: THREE.Scene let scene: THREE.Scene
let sceneApi: ReturnType<typeof useScene>
beforeEach(() => { beforeEach(() => {
// Get a fresh composable instance setActivePinia(createPinia())
sceneApi = useScene() store = useSceneStore()
scene = createMockScene() scene = createMockScene()
sceneApi.setScene(scene) store.setScene(scene)
sceneApi.clearScene() store.clearScene()
}) })
describe('addObject', () => { describe('addObject', () => {
it('adds a mesh to the scene', () => { it('adds a mesh to the scene', () => {
const mesh = createTestMesh() const mesh = createTestMesh()
sceneApi.addObject(mesh, 'Test Cube') store.addObject(mesh, 'Test Cube')
expect(scene.add).toHaveBeenCalledWith(mesh) expect(scene.add).toHaveBeenCalledWith(mesh)
}) })
it('returns a SceneObject with correct properties', () => { it('returns a SceneObject with correct properties', () => {
const mesh = createTestMesh() 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.name).toBe('Test Cube')
expect(obj.mesh).toBe(mesh) expect(obj.mesh).toBe(mesh)
@ -51,15 +52,15 @@ describe('useScene', () => {
it('generates unique IDs', () => { it('generates unique IDs', () => {
const mesh1 = createTestMesh() const mesh1 = createTestMesh()
const mesh2 = createTestMesh() const mesh2 = createTestMesh()
const obj1 = sceneApi.addObject(mesh1, 'Cube 1') const obj1 = store.addObject(mesh1, 'Cube 1')
const obj2 = sceneApi.addObject(mesh2, 'Cube 2') const obj2 = store.addObject(mesh2, 'Cube 2')
expect(obj1.id).not.toBe(obj2.id) expect(obj1.id).not.toBe(obj2.id)
}) })
it('auto-generates name when not provided', () => { it('auto-generates name when not provided', () => {
const mesh = createTestMesh() const mesh = createTestMesh()
const obj = sceneApi.addObject(mesh) const obj = store.addObject(mesh)
expect(obj.name).toBe('Object') expect(obj.name).toBe('Object')
}) })
@ -69,70 +70,67 @@ describe('useScene', () => {
const mesh2 = createTestMesh() const mesh2 = createTestMesh()
const mesh3 = createTestMesh() const mesh3 = createTestMesh()
sceneApi.addObject(mesh1, 'Cube') store.addObject(mesh1, 'Cube')
const obj2 = sceneApi.addObject(mesh2, 'Cube') const obj2 = store.addObject(mesh2, 'Cube')
const obj3 = sceneApi.addObject(mesh3, 'Cube') const obj3 = store.addObject(mesh3, 'Cube')
expect(obj2.name).toBe('Cube 1') expect(obj2.name).toBe('Cube 1')
expect(obj3.name).toBe('Cube 2') expect(obj3.name).toBe('Cube 2')
}) })
it('throws if scene not initialized', () => { it('throws if scene not initialized', () => {
const freshApi = useScene() setActivePinia(createPinia())
// Don't call setScene const freshStore = useSceneStore()
const mesh = createTestMesh() const mesh = createTestMesh()
// Clear the shared scene reference by setting it to a new scene then clearing expect(() => freshStore.addObject(mesh)).toThrow('Scene not initialized')
freshApi.setScene(createMockScene())
freshApi.clearScene()
// This is tricky because useScene shares state - the test demonstrates the API
}) })
}) })
describe('removeObject', () => { describe('removeObject', () => {
it('removes object from scene', () => { it('removes object from scene', () => {
const mesh = createTestMesh() 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(result).toBe(true)
expect(scene.remove).toHaveBeenCalledWith(mesh) expect(scene.remove).toHaveBeenCalledWith(mesh)
}) })
it('returns false for non-existent id', () => { it('returns false for non-existent id', () => {
const result = sceneApi.removeObject('nonexistent') const result = store.removeObject('nonexistent')
expect(result).toBe(false) expect(result).toBe(false)
}) })
it('removes object from objectList', () => { it('removes object from objectList', () => {
const mesh = createTestMesh() const mesh = createTestMesh()
const obj = sceneApi.addObject(mesh, 'Test') const obj = store.addObject(mesh, 'Test')
expect(sceneApi.objectCount.value).toBe(1) expect(store.objectCount).toBe(1)
sceneApi.removeObject(obj.id) store.removeObject(obj.id)
expect(sceneApi.objectCount.value).toBe(0) expect(store.objectCount).toBe(0)
}) })
}) })
describe('clearScene', () => { describe('clearScene', () => {
it('removes all objects', () => { it('removes all objects', () => {
sceneApi.addObject(createTestMesh(), 'Cube 1') store.addObject(createTestMesh(), 'Cube 1')
sceneApi.addObject(createTestMesh(), 'Cube 2') store.addObject(createTestMesh(), 'Cube 2')
sceneApi.addObject(createTestMesh(), 'Cube 3') store.addObject(createTestMesh(), 'Cube 3')
expect(sceneApi.objectCount.value).toBe(3) expect(store.objectCount).toBe(3)
sceneApi.clearScene() store.clearScene()
expect(sceneApi.objectCount.value).toBe(0) expect(store.objectCount).toBe(0)
}) })
}) })
describe('getObject', () => { describe('getObject', () => {
it('returns object by id', () => { it('returns object by id', () => {
const mesh = createTestMesh() 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).toBeDefined()
expect(found?.id).toBe(added.id) expect(found?.id).toBe(added.id)
@ -141,7 +139,7 @@ describe('useScene', () => {
}) })
it('returns undefined for non-existent id', () => { it('returns undefined for non-existent id', () => {
const found = sceneApi.getObject('nonexistent') const found = store.getObject('nonexistent')
expect(found).toBeUndefined() expect(found).toBeUndefined()
}) })
}) })
@ -149,11 +147,11 @@ describe('useScene', () => {
describe('setObjectVisible', () => { describe('setObjectVisible', () => {
it('updates visibility on SceneObject and mesh', () => { it('updates visibility on SceneObject and mesh', () => {
const mesh = createTestMesh() 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(updated?.visible).toBe(false)
expect(mesh.visible).toBe(false) expect(mesh.visible).toBe(false)
}) })
@ -162,11 +160,11 @@ describe('useScene', () => {
describe('setObjectLocked', () => { describe('setObjectLocked', () => {
it('updates locked state', () => { it('updates locked state', () => {
const mesh = createTestMesh() 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) expect(updated?.locked).toBe(true)
}) })
}) })
@ -174,21 +172,21 @@ describe('useScene', () => {
describe('renameObject', () => { describe('renameObject', () => {
it('updates object name', () => { it('updates object name', () => {
const mesh = createTestMesh() 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') expect(updated?.name).toBe('New Name')
}) })
}) })
describe('objectList', () => { describe('objectList', () => {
it('returns all objects as computed', () => { it('returns all objects as computed', () => {
sceneApi.addObject(createTestMesh(), 'A') store.addObject(createTestMesh(), 'A')
sceneApi.addObject(createTestMesh(), 'B') store.addObject(createTestMesh(), 'B')
const list = sceneApi.objectList.value const list = store.objectList
expect(list).toHaveLength(2) expect(list).toHaveLength(2)
expect(list[0].name).toBe('A') expect(list[0].name).toBe('A')

View file

@ -1,3 +1,4 @@
import { defineStore } from 'pinia'
import { shallowRef, computed } from 'vue' import { shallowRef, computed } from 'vue'
import * as THREE from 'three' import * as THREE from 'three'
@ -9,35 +10,35 @@ export interface SceneObject {
locked: boolean locked: boolean
} }
// Use shallowRef to avoid Vue making THREE.Mesh objects deeply reactive export const useSceneStore = defineStore('scene', () => {
const objects = shallowRef<SceneObject[]>([]) // Use shallowRef to avoid Vue making THREE.Mesh objects deeply reactive
let scene: THREE.Scene | null = null const objects = shallowRef<SceneObject[]>([])
let nextId = 1 let threeScene: THREE.Scene | null = null
let nextId = 1
function generateId(): string { function generateId(): string {
return `obj_${nextId++}` return `obj_${nextId++}`
}
function generateName(baseName: string): string {
const existingNames = objects.value.map((o) => o.name)
if (!existingNames.includes(baseName)) {
return baseName
} }
let counter = 1 function generateName(baseName: string): string {
while (existingNames.includes(`${baseName} ${counter}`)) { const existingNames = objects.value.map((o) => o.name)
counter++ if (!existingNames.includes(baseName)) {
} return baseName
return `${baseName} ${counter}` }
}
export function useScene() { let counter = 1
function setScene(threeScene: THREE.Scene) { while (existingNames.includes(`${baseName} ${counter}`)) {
scene = threeScene counter++
}
return `${baseName} ${counter}`
}
function setScene(scene: THREE.Scene) {
threeScene = scene
} }
function addObject(mesh: THREE.Mesh, name?: string): SceneObject { function addObject(mesh: THREE.Mesh, name?: string): SceneObject {
if (!scene) { if (!threeScene) {
throw new Error('Scene not initialized. Call setScene() first.') throw new Error('Scene not initialized. Call setScene() first.')
} }
@ -51,7 +52,7 @@ export function useScene() {
locked: false, locked: false,
} }
scene.add(mesh) threeScene.add(mesh)
objects.value = [...objects.value, sceneObject] objects.value = [...objects.value, sceneObject]
return sceneObject return sceneObject
@ -62,8 +63,8 @@ export function useScene() {
if (index === -1) return false if (index === -1) return false
const obj = objects.value[index] const obj = objects.value[index]
if (scene) { if (threeScene) {
scene.remove(obj.mesh) threeScene.remove(obj.mesh)
} }
obj.mesh.geometry.dispose() obj.mesh.geometry.dispose()
@ -112,6 +113,12 @@ export function useScene() {
const objectCount = computed(() => objects.value.length) const objectCount = computed(() => objects.value.length)
return { return {
// State
objects,
// Getters
objectList,
objectCount,
// Actions
setScene, setScene,
addObject, addObject,
removeObject, removeObject,
@ -120,7 +127,5 @@ export function useScene() {
setObjectVisible, setObjectVisible,
setObjectLocked, setObjectLocked,
renameObject, renameObject,
objectList,
objectCount,
} }
} })