aboutsummaryrefslogtreecommitdiff
path: root/src/store
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-01-30 11:31:24 +0100
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-01-30 13:05:48 +0100
commit0d4b61993cba37c180ac91e40a0fb362711de8de (patch)
treef41dd68420b51766c1ca6fc1ee2c20dc5df830d1 /src/store
Initial implementation
Diffstat (limited to 'src/store')
-rw-r--r--src/store/store.test.ts307
-rw-r--r--src/store/store.ts119
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 });
+ },
+}));