diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-01-30 11:31:24 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-01-30 13:05:48 +0100 |
| commit | 0d4b61993cba37c180ac91e40a0fb362711de8de (patch) | |
| tree | f41dd68420b51766c1ca6fc1ee2c20dc5df830d1 /src/store | |
Initial implementation
Diffstat (limited to 'src/store')
| -rw-r--r-- | src/store/store.test.ts | 307 | ||||
| -rw-r--r-- | src/store/store.ts | 119 |
2 files changed, 426 insertions, 0 deletions
diff --git a/src/store/store.test.ts b/src/store/store.test.ts new file mode 100644 index 0000000..24cac06 --- /dev/null +++ b/src/store/store.test.ts @@ -0,0 +1,307 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import "../test-setup.ts"; +import { useStore, isValidCameraView } from "./store.ts"; + +describe("Store", () => { + beforeEach(() => { + useStore.setState({ + models: [], + selectedModelId: null, + cameraView: "perspective", + backdropUrl: null, + availableModels: [ + { id: "servbot", name: "Servbot", url: "/models/servbot.gltf" }, + { id: "megaman", name: "Megaman", url: "/models/megaman_volnutt.gltf" }, + { id: "tron", name: "Tron Bonne", url: "/models/tron_bonne.gltf" }, + ], + }); + }); + + describe("addModel", () => { + it("should add a model to the scene", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + + const models = useStore.getState().models; + assert.strictEqual(models.length, 1); + assert.strictEqual(models[0].sourceId, "servbot"); + assert.deepStrictEqual(models[0].position, { x: 0, y: 0, z: 0 }); + }); + + it("should generate unique IDs for each model instance", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + state.addModel("servbot", { x: 1, y: 0, z: 0 }); + + const models = useStore.getState().models; + assert.strictEqual(models.length, 2); + assert.notStrictEqual(models[0].id, models[1].id); + }); + + it("should initialize with default hue shifts", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.hueShift, { red: 0, green: 0, blue: 0 }); + }); + + it("should initialize with zero rotation", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.rotation, { x: 0, y: 0, z: 0 }); + }); + + it("should not add model with invalid sourceId", () => { + const state = useStore.getState(); + state.addModel("nonexistent", { x: 0, y: 0, z: 0 }); + + const models = useStore.getState().models; + assert.strictEqual(models.length, 0); + }); + + it("should auto-select newly added model", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + + const { models, selectedModelId } = useStore.getState(); + assert.strictEqual(selectedModelId, models[0].id); + }); + }); + + describe("removeModel", () => { + it("should remove a model from the scene", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().removeModel(modelId); + + assert.strictEqual(useStore.getState().models.length, 0); + }); + + it("should clear selection if removed model was selected", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + useStore.getState().selectModel(modelId); + + useStore.getState().removeModel(modelId); + + assert.strictEqual(useStore.getState().selectedModelId, null); + }); + + it("should not affect state when removing non-existent model", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelsBefore = useStore.getState().models; + + useStore.getState().removeModel("non-existent-id"); + + const modelsAfter = useStore.getState().models; + assert.strictEqual(modelsAfter.length, modelsBefore.length); + }); + + it("should preserve selection when removing a different model", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + state.addModel("megaman", { x: 1, y: 0, z: 0 }); + + const models = useStore.getState().models; + useStore.getState().selectModel(models[0].id); + useStore.getState().removeModel(models[1].id); + + assert.strictEqual(useStore.getState().selectedModelId, models[0].id); + }); + }); + + describe("selectModel", () => { + it("should select a model", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().selectModel(modelId); + + assert.strictEqual(useStore.getState().selectedModelId, modelId); + }); + + it("should deselect when selecting null", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + useStore.getState().selectModel(modelId); + + useStore.getState().selectModel(null); + + assert.strictEqual(useStore.getState().selectedModelId, null); + }); + }); + + describe("updateModel", () => { + it("should update model position", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore + .getState() + .updateModel(modelId, { position: { x: 5, y: 3, z: 2 } }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.position, { x: 5, y: 3, z: 2 }); + }); + + it("should update model rotation", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().updateModel(modelId, { + rotation: { x: Math.PI, y: 0, z: Math.PI / 2 }, + }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.rotation, { + x: Math.PI, + y: 0, + z: Math.PI / 2, + }); + }); + + it("should update red hue shift", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().updateModel(modelId, { + hueShift: { red: 0.5, green: 0, blue: 0 }, + }); + + const model = useStore.getState().models[0]; + assert.strictEqual(model.hueShift.red, 0.5); + }); + + it("should update all hue shifts", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().updateModel(modelId, { + hueShift: { red: 0.2, green: 0.4, blue: 0.6 }, + }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.hueShift, { + red: 0.2, + green: 0.4, + blue: 0.6, + }); + }); + + it("should update multiple properties at once", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + const modelId = useStore.getState().models[0].id; + + useStore.getState().updateModel(modelId, { + position: { x: 10, y: 20, z: 30 }, + rotation: { x: 1, y: 2, z: 3 }, + }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.position, { x: 10, y: 20, z: 30 }); + assert.deepStrictEqual(model.rotation, { x: 1, y: 2, z: 3 }); + }); + + it("should not affect other models when updating one", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + state.addModel("megaman", { x: 1, y: 0, z: 0 }); + + const models = useStore.getState().models; + useStore.getState().updateModel(models[0].id, { + position: { x: 99, y: 99, z: 99 }, + }); + + const updatedModels = useStore.getState().models; + assert.deepStrictEqual(updatedModels[1].position, { x: 1, y: 0, z: 0 }); + }); + + it("should handle update for non-existent model gracefully", () => { + const state = useStore.getState(); + state.addModel("servbot", { x: 0, y: 0, z: 0 }); + + useStore.getState().updateModel("non-existent-id", { + position: { x: 99, y: 99, z: 99 }, + }); + + const model = useStore.getState().models[0]; + assert.deepStrictEqual(model.position, { x: 0, y: 0, z: 0 }); + }); + }); + + describe("setCameraView", () => { + it("should set camera view to perspective", () => { + useStore.getState().setCameraView("perspective"); + assert.strictEqual(useStore.getState().cameraView, "perspective"); + }); + + it("should set camera view to orthographic top", () => { + useStore.getState().setCameraView("ortho-top"); + assert.strictEqual(useStore.getState().cameraView, "ortho-top"); + }); + + it("should set camera view to orthographic front", () => { + useStore.getState().setCameraView("ortho-front"); + assert.strictEqual(useStore.getState().cameraView, "ortho-front"); + }); + + it("should set camera view to orthographic side", () => { + useStore.getState().setCameraView("ortho-side"); + assert.strictEqual(useStore.getState().cameraView, "ortho-side"); + }); + }); + + describe("setBackdrop", () => { + it("should set backdrop URL", () => { + useStore.getState().setBackdrop("blob:http://localhost/12345"); + assert.strictEqual( + useStore.getState().backdropUrl, + "blob:http://localhost/12345", + ); + }); + + it("should clear backdrop when set to null", () => { + useStore.getState().setBackdrop("blob:http://localhost/12345"); + useStore.getState().setBackdrop(null); + assert.strictEqual(useStore.getState().backdropUrl, null); + }); + }); + + describe("availableModels", () => { + it("should have default available models", () => { + const availableModels = useStore.getState().availableModels; + assert.strictEqual(availableModels.length, 3); + assert.strictEqual(availableModels[0].name, "Servbot"); + }); + }); + + describe("isValidCameraView", () => { + it("should return true for valid camera views", () => { + assert.strictEqual(isValidCameraView("perspective"), true); + assert.strictEqual(isValidCameraView("ortho-top"), true); + assert.strictEqual(isValidCameraView("ortho-front"), true); + assert.strictEqual(isValidCameraView("ortho-side"), true); + }); + + it("should return false for invalid camera views", () => { + assert.strictEqual(isValidCameraView("invalid"), false); + assert.strictEqual(isValidCameraView(""), false); + assert.strictEqual(isValidCameraView("PERSPECTIVE"), false); + }); + }); +}); diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..94519e4 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,119 @@ +import { create } from "zustand"; +import { availableModels } from "../data/availableModels.ts"; + +export interface Vector3D { + x: number; + y: number; + z: number; +} + +export interface HueShift { + red: number; + green: number; + blue: number; +} + +export interface Model3D { + id: string; + sourceId: string; + name: string; + url: string; + position: Vector3D; + rotation: Vector3D; + hueShift: HueShift; +} + +export interface AvailableModel { + id: string; + name: string; + url: string; +} + +const CAMERA_VIEWS = [ + "perspective", + "ortho-top", + "ortho-front", + "ortho-side", +] as const; +export type CameraView = (typeof CAMERA_VIEWS)[number]; + +export function isValidCameraView(value: string): value is CameraView { + return CAMERA_VIEWS.includes(value as CameraView); +} + +export interface StoreState { + models: Model3D[]; + selectedModelId: string | null; + cameraView: CameraView; + backdropUrl: string | null; + availableModels: AvailableModel[]; + addModel: (sourceId: string, position: Vector3D) => void; + removeModel: (id: string) => void; + selectModel: (id: string | null) => void; + updateModel: ( + id: string, + updates: Partial<Pick<Model3D, "position" | "rotation" | "hueShift">>, + ) => void; + setCameraView: (view: CameraView) => void; + setBackdrop: (url: string | null) => void; +} + +export const useStore = create<StoreState>((set, get) => ({ + models: [], + selectedModelId: null, + cameraView: "perspective", + backdropUrl: null, + availableModels, + + addModel: (sourceId: string, position: Vector3D) => { + const { availableModels } = get(); + const source = availableModels.find((model) => model.id === sourceId); + if (!source) return; + + const newModel: Model3D = { + id: crypto.randomUUID(), + sourceId, + name: source.name, + url: source.url, + position, + rotation: { x: 0, y: 0, z: 0 }, + hueShift: { red: 0, green: 0, blue: 0 }, + }; + + set((state) => ({ + models: [...state.models, newModel], + selectedModelId: newModel.id, + })); + }, + + removeModel: (id: string) => { + set((state) => ({ + models: state.models.filter((model) => model.id !== id), + selectedModelId: + state.selectedModelId === id ? null : state.selectedModelId, + })); + }, + + selectModel: (id: string | null) => { + set({ selectedModelId: id }); + }, + + updateModel: ( + id: string, + updates: Partial<Pick<Model3D, "position" | "rotation" | "hueShift">>, + ) => { + set((state) => ({ + models: state.models.map((model) => + model.id === id ? { ...model, ...updates } : model, + ), + })); + }, + + setCameraView: (view: CameraView) => { + set({ cameraView: view }); + }, + + setBackdrop: (url: string | null) => { + set({ backdropUrl: url }); + }, +})); |