aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/BackdropUpload.test.tsx113
-rw-r--r--src/components/BackdropUpload.tsx53
-rw-r--r--src/components/CameraController.tsx74
-rw-r--r--src/components/CameraSelector.test.tsx107
-rw-r--r--src/components/CameraSelector.tsx29
-rw-r--r--src/components/ControlSlider.tsx36
-rw-r--r--src/components/Model3D.tsx165
-rw-r--r--src/components/ModelControls.test.tsx204
-rw-r--r--src/components/ModelControls.tsx158
-rw-r--r--src/components/Scene.tsx78
-rw-r--r--src/components/SceneBackground.tsx33
-rw-r--r--src/components/Sidebar.test.tsx104
-rw-r--r--src/components/Sidebar.tsx30
13 files changed, 1184 insertions, 0 deletions
diff --git a/src/components/BackdropUpload.test.tsx b/src/components/BackdropUpload.test.tsx
new file mode 100644
index 0000000..72db03d
--- /dev/null
+++ b/src/components/BackdropUpload.test.tsx
@@ -0,0 +1,113 @@
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert";
+import "../test-setup.ts";
+import { createRoot, type Root } from "react-dom/client";
+import { act } from "@testing-library/react";
+import { BackdropUpload } from "./BackdropUpload.tsx";
+import { useStore } from "../store/store.ts";
+
+describe("BackdropUpload", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ useStore.setState({
+ models: [],
+ selectedModelId: null,
+ cameraView: "perspective",
+ backdropUrl: null,
+ availableModels: [],
+ });
+ });
+
+ afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("should render an upload button", async () => {
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ const uploadWrapper = container.querySelector(".backdrop-upload");
+ assert.ok(uploadWrapper, "Should render backdrop upload component");
+
+ const input = container.querySelector('input[type="file"]');
+ assert.ok(input, "Should have a file input");
+ });
+
+ it("should accept image files", async () => {
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ const input = container.querySelector(
+ 'input[type="file"]',
+ ) as HTMLInputElement;
+ assert.ok(input.accept.includes("image/"), "Should accept image files");
+ });
+
+ it("should have a clear button when backdrop is set", async () => {
+ useStore.getState().setBackdrop("blob:http://localhost/12345");
+
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ const clearButton = container.querySelector("button.clear-backdrop");
+ assert.ok(clearButton, "Should have clear backdrop button");
+ });
+
+ it("should not show clear button when no backdrop", async () => {
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ const clearButton = container.querySelector("button.clear-backdrop");
+ assert.ok(!clearButton, "Should not show clear button when no backdrop");
+ });
+
+ it("should clear backdrop when clear button clicked", async () => {
+ useStore.getState().setBackdrop("blob:http://localhost/12345");
+
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ const clearButton = container.querySelector(
+ "button.clear-backdrop",
+ ) as HTMLButtonElement;
+
+ await act(async () => {
+ clearButton.click();
+ });
+
+ assert.strictEqual(useStore.getState().backdropUrl, null);
+ });
+
+ it("should update UI when backdrop state changes", async () => {
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ let clearButton = container.querySelector("button.clear-backdrop");
+ assert.ok(!clearButton, "Clear button should not exist initially");
+
+ await act(async () => {
+ useStore.getState().setBackdrop("blob:http://localhost/12345");
+ });
+
+ await act(async () => {
+ root.render(<BackdropUpload />);
+ });
+
+ clearButton = container.querySelector("button.clear-backdrop");
+ assert.ok(clearButton, "Clear button should appear after backdrop is set");
+ });
+});
diff --git a/src/components/BackdropUpload.tsx b/src/components/BackdropUpload.tsx
new file mode 100644
index 0000000..87fafb1
--- /dev/null
+++ b/src/components/BackdropUpload.tsx
@@ -0,0 +1,53 @@
+import React, { useRef } from "react";
+import { useStore } from "../store/store.ts";
+
+export const BackdropUpload: React.FC = () => {
+ const backdropUrl = useStore((state) => state.backdropUrl);
+ const setBackdrop = useStore((state) => state.setBackdrop);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ if (backdropUrl) {
+ URL.revokeObjectURL(backdropUrl);
+ }
+ const url = URL.createObjectURL(file);
+ setBackdrop(url);
+ }
+ };
+
+ const handleClear = () => {
+ if (backdropUrl) {
+ URL.revokeObjectURL(backdropUrl);
+ }
+ setBackdrop(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const handleButtonClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ return (
+ <div className="backdrop-upload">
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/png,image/jpeg,image/jpg,image/webp,image/gif"
+ onChange={handleFileChange}
+ style={{ display: "none" }}
+ />
+ <button className="upload-backdrop" onClick={handleButtonClick}>
+ {backdropUrl ? "Change Backdrop" : "Upload Backdrop"}
+ </button>
+ {backdropUrl && (
+ <button className="clear-backdrop" onClick={handleClear}>
+ Clear
+ </button>
+ )}
+ </div>
+ );
+};
diff --git a/src/components/CameraController.tsx b/src/components/CameraController.tsx
new file mode 100644
index 0000000..99ca522
--- /dev/null
+++ b/src/components/CameraController.tsx
@@ -0,0 +1,74 @@
+import React, { useRef, useEffect } from "react";
+import { useThree } from "@react-three/fiber";
+import { PerspectiveCamera, OrthographicCamera } from "@react-three/drei";
+import * as THREE from "three";
+import { useStore } from "../store/store.ts";
+
+const CameraControllerInner: React.FC = () => {
+ const cameraView = useStore((state) => state.cameraView);
+ const { set, size } = useThree();
+ const perspectiveRef = useRef<THREE.PerspectiveCamera>(null);
+ const orthoRef = useRef<THREE.OrthographicCamera>(null);
+
+ useEffect(() => {
+ if (cameraView === "perspective") {
+ if (perspectiveRef.current) {
+ set({ camera: perspectiveRef.current });
+ }
+ return;
+ }
+
+ if (orthoRef.current) {
+ const camera = orthoRef.current;
+ const aspect = size.width / size.height;
+ const frustum = 150;
+
+ camera.left = -frustum * aspect;
+ camera.right = frustum * aspect;
+ camera.top = frustum;
+ camera.bottom = -frustum;
+ camera.near = 0.1;
+ camera.far = 2000;
+
+ switch (cameraView) {
+ case "ortho-top":
+ camera.position.set(0, 500, 0);
+ camera.lookAt(0, 0, 0);
+ break;
+ case "ortho-front":
+ camera.position.set(0, 50, -500);
+ camera.lookAt(0, 50, 0);
+ break;
+ case "ortho-side":
+ camera.position.set(-500, 50, 0);
+ camera.lookAt(0, 50, 0);
+ break;
+ }
+
+ camera.updateProjectionMatrix();
+ set({ camera: camera });
+ }
+ }, [cameraView, set, size]);
+
+ return (
+ <>
+ <PerspectiveCamera
+ ref={perspectiveRef}
+ makeDefault={cameraView === "perspective"}
+ position={[100, 80, 100]}
+ fov={60}
+ far={2000}
+ />
+ <OrthographicCamera
+ ref={orthoRef}
+ makeDefault={cameraView !== "perspective"}
+ position={[0, 500, 0]}
+ zoom={5}
+ far={2000}
+ />
+ </>
+ );
+};
+
+export const CameraController = React.memo(CameraControllerInner);
+CameraController.displayName = "CameraController";
diff --git a/src/components/CameraSelector.test.tsx b/src/components/CameraSelector.test.tsx
new file mode 100644
index 0000000..90968b7
--- /dev/null
+++ b/src/components/CameraSelector.test.tsx
@@ -0,0 +1,107 @@
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert";
+import "../test-setup.ts";
+import { createRoot, type Root } from "react-dom/client";
+import { act } from "@testing-library/react";
+import { CameraSelector } from "./CameraSelector.tsx";
+import { useStore } from "../store/store.ts";
+
+describe("CameraSelector", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ useStore.setState({
+ models: [],
+ selectedModelId: null,
+ cameraView: "perspective",
+ backdropUrl: null,
+ availableModels: [],
+ });
+ });
+
+ afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("should render a dropdown", async () => {
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const select = container.querySelector("select.camera-selector");
+ assert.ok(select, "Should render a select dropdown");
+ });
+
+ it("should have perspective as default option", async () => {
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const select = container.querySelector(
+ "select.camera-selector",
+ ) as HTMLSelectElement;
+ assert.strictEqual(select?.value, "perspective");
+ });
+
+ it("should have all camera view options", async () => {
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const options = container.querySelectorAll("select.camera-selector option");
+ const values = Array.from(options).map(
+ (opt) => (opt as HTMLOptionElement).value,
+ );
+
+ assert.ok(values.includes("perspective"), "Should have perspective option");
+ assert.ok(values.includes("ortho-top"), "Should have ortho-top option");
+ assert.ok(values.includes("ortho-front"), "Should have ortho-front option");
+ assert.ok(values.includes("ortho-side"), "Should have ortho-side option");
+ });
+
+ it("should update store when selection changes", async () => {
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const select = container.querySelector(
+ "select.camera-selector",
+ ) as HTMLSelectElement;
+
+ await act(async () => {
+ select.value = "ortho-top";
+ select.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ assert.strictEqual(useStore.getState().cameraView, "ortho-top");
+ });
+
+ it("should be positioned as overlay", async () => {
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const wrapper = container.querySelector(".camera-selector-wrapper");
+ assert.ok(wrapper, "Should have wrapper for positioning");
+ });
+
+ it("should reflect store camera view state", async () => {
+ useStore.setState({ cameraView: "ortho-front" });
+
+ await act(async () => {
+ root.render(<CameraSelector />);
+ });
+
+ const select = container.querySelector(
+ "select.camera-selector",
+ ) as HTMLSelectElement;
+ assert.strictEqual(select?.value, "ortho-front");
+ });
+});
diff --git a/src/components/CameraSelector.tsx b/src/components/CameraSelector.tsx
new file mode 100644
index 0000000..b3d787f
--- /dev/null
+++ b/src/components/CameraSelector.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { useStore, isValidCameraView } from "../store/store.ts";
+
+export const CameraSelector: React.FC = () => {
+ const cameraView = useStore((state) => state.cameraView);
+ const setCameraView = useStore((state) => state.setCameraView);
+
+ const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
+ const value = event.target.value;
+ if (isValidCameraView(value)) {
+ setCameraView(value);
+ }
+ };
+
+ return (
+ <div className="camera-selector-wrapper">
+ <select
+ className="camera-selector"
+ value={cameraView}
+ onChange={handleChange}
+ >
+ <option value="perspective">Perspective</option>
+ <option value="ortho-top">Top (Ortho)</option>
+ <option value="ortho-front">Front (Ortho)</option>
+ <option value="ortho-side">Side (Ortho)</option>
+ </select>
+ </div>
+ );
+};
diff --git a/src/components/ControlSlider.tsx b/src/components/ControlSlider.tsx
new file mode 100644
index 0000000..34d3c82
--- /dev/null
+++ b/src/components/ControlSlider.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+
+interface ControlSliderProps {
+ label: string;
+ name: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ onChange: (value: string) => void;
+}
+
+export const ControlSlider: React.FC<ControlSliderProps> = ({
+ label,
+ name,
+ value,
+ min,
+ max,
+ step,
+ onChange,
+}) => {
+ return (
+ <div className="control-row">
+ <label>{label}</label>
+ <input
+ type="range"
+ name={name}
+ min={min}
+ max={max}
+ value={value}
+ onChange={(event) => onChange(event.target.value)}
+ step={step}
+ />
+ </div>
+ );
+};
diff --git a/src/components/Model3D.tsx b/src/components/Model3D.tsx
new file mode 100644
index 0000000..dd44f3a
--- /dev/null
+++ b/src/components/Model3D.tsx
@@ -0,0 +1,165 @@
+import React, { Suspense, useRef, useMemo, useEffect } from "react";
+import { useGLTF } from "@react-three/drei";
+import * as THREE from "three";
+import { useStore, type Model3D as Model3DType } from "../store/store.ts";
+import {
+ hueShiftVertexShader,
+ hueShiftFragmentShader,
+} from "../shaders/hueShift.ts";
+
+interface Model3DProps {
+ model: Model3DType;
+}
+
+interface ShaderUniforms {
+ map: { value: THREE.Texture | null };
+ hasMap: { value: boolean };
+ baseColor: { value: THREE.Vector3 };
+ hueShiftRed: { value: number };
+ hueShiftGreen: { value: number };
+ hueShiftBlue: { value: number };
+}
+
+interface MeshWithUniforms extends THREE.Mesh {
+ userData: {
+ uniforms?: ShaderUniforms;
+ };
+}
+
+const Model3DInner: React.FC<Model3DProps> = ({ model }) => {
+ const groupRef = useRef<THREE.Group>(null);
+ const meshRefsRef = useRef<MeshWithUniforms[]>([]);
+ const { scene } = useGLTF(model.url);
+ const selectModel = useStore((state) => state.selectModel);
+ const selectedModelId = useStore((state) => state.selectedModelId);
+
+ const isSelected = selectedModelId === model.id;
+
+ const { clonedScene, boundingBox, meshes } = useMemo(() => {
+ const clone = scene.clone(true);
+ const collectedMeshes: MeshWithUniforms[] = [];
+
+ clone.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ const originalMaterial = child.material;
+
+ let textureMap: THREE.Texture | null = null;
+ let baseColor = new THREE.Vector3(0.8, 0.8, 0.8);
+
+ if (originalMaterial instanceof THREE.MeshStandardMaterial) {
+ textureMap = originalMaterial.map;
+ if (originalMaterial.color) {
+ baseColor = new THREE.Vector3(
+ originalMaterial.color.r,
+ originalMaterial.color.g,
+ originalMaterial.color.b,
+ );
+ }
+ } else if (originalMaterial instanceof THREE.MeshBasicMaterial) {
+ textureMap = originalMaterial.map;
+ if (originalMaterial.color) {
+ baseColor = new THREE.Vector3(
+ originalMaterial.color.r,
+ originalMaterial.color.g,
+ originalMaterial.color.b,
+ );
+ }
+ }
+
+ const uniforms: ShaderUniforms = {
+ map: { value: textureMap },
+ hasMap: { value: !!textureMap },
+ baseColor: { value: baseColor },
+ hueShiftRed: { value: 0 },
+ hueShiftGreen: { value: 0 },
+ hueShiftBlue: { value: 0 },
+ };
+
+ const shaderMaterial = new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: hueShiftVertexShader,
+ fragmentShader: hueShiftFragmentShader,
+ });
+
+ child.material = shaderMaterial;
+ (child as MeshWithUniforms).userData.uniforms = uniforms;
+ collectedMeshes.push(child as MeshWithUniforms);
+ }
+ });
+
+ const box = new THREE.Box3().setFromObject(clone);
+ const size = new THREE.Vector3();
+ const center = new THREE.Vector3();
+ box.getSize(size);
+ box.getCenter(center);
+
+ return {
+ clonedScene: clone,
+ boundingBox: { size, center },
+ meshes: collectedMeshes,
+ };
+ }, [scene]);
+
+ useEffect(() => {
+ meshRefsRef.current = meshes;
+ }, [meshes]);
+
+ useEffect(() => {
+ for (const mesh of meshRefsRef.current) {
+ const uniforms = mesh.userData.uniforms;
+ if (uniforms) {
+ uniforms.hueShiftRed.value = model.hueShift.red;
+ uniforms.hueShiftGreen.value = model.hueShift.green;
+ uniforms.hueShiftBlue.value = model.hueShift.blue;
+ }
+ }
+ }, [model.hueShift]);
+
+ const handleClick = (event: { stopPropagation: () => void }) => {
+ event.stopPropagation();
+ selectModel(model.id);
+ };
+
+ const selectionBoxGeometry = useMemo(() => {
+ if (!boundingBox) return null;
+ return new THREE.BoxGeometry(
+ boundingBox.size.x * 1.1,
+ boundingBox.size.y * 1.1,
+ boundingBox.size.z * 1.1,
+ );
+ }, [boundingBox]);
+
+ return (
+ <group
+ ref={groupRef}
+ position={[model.position.x, model.position.y, model.position.z]}
+ rotation={[model.rotation.x, model.rotation.y, model.rotation.z]}
+ onClick={handleClick}
+ >
+ <primitive object={clonedScene} />
+ {isSelected && boundingBox && selectionBoxGeometry && (
+ <lineSegments
+ position={[
+ boundingBox.center.x,
+ boundingBox.center.y,
+ boundingBox.center.z,
+ ]}
+ >
+ <edgesGeometry args={[selectionBoxGeometry]} />
+ <lineBasicMaterial color="#00ff00" linewidth={2} />
+ </lineSegments>
+ )}
+ </group>
+ );
+};
+
+const MemoizedModel3DInner = React.memo(Model3DInner);
+MemoizedModel3DInner.displayName = "Model3DInner";
+
+export const Model3DComponent: React.FC<Model3DProps> = ({ model }) => {
+ return (
+ <Suspense fallback={null}>
+ <MemoizedModel3DInner model={model} />
+ </Suspense>
+ );
+};
diff --git a/src/components/ModelControls.test.tsx b/src/components/ModelControls.test.tsx
new file mode 100644
index 0000000..b38d460
--- /dev/null
+++ b/src/components/ModelControls.test.tsx
@@ -0,0 +1,204 @@
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert";
+import "../test-setup.ts";
+import { createRoot, type Root } from "react-dom/client";
+import { act } from "@testing-library/react";
+import { ModelControls } from "./ModelControls.tsx";
+import { useStore } from "../store/store.ts";
+
+describe("ModelControls", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ useStore.setState({
+ models: [],
+ selectedModelId: null,
+ cameraView: "perspective",
+ backdropUrl: null,
+ availableModels: [
+ { id: "servbot", name: "Servbot", url: "/models/servbot.gltf" },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("should not render when no model is selected", async () => {
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const controls = container.querySelector(".model-controls");
+ assert.ok(
+ !controls,
+ "Should not render controls when no model is selected",
+ );
+ });
+
+ it("should render when a model is selected", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const controls = container.querySelector(".model-controls");
+ assert.ok(controls, "Should render controls when model is selected");
+ });
+
+ it("should have position controls", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const positionXInput = container.querySelector('input[name="position-x"]');
+ const positionYInput = container.querySelector('input[name="position-y"]');
+ const positionZInput = container.querySelector('input[name="position-z"]');
+
+ assert.ok(positionXInput, "Should have position X input");
+ assert.ok(positionYInput, "Should have position Y input");
+ assert.ok(positionZInput, "Should have position Z input");
+ });
+
+ it("should have rotation controls", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const rotationXInput = container.querySelector('input[name="rotation-x"]');
+ const rotationYInput = container.querySelector('input[name="rotation-y"]');
+ const rotationZInput = container.querySelector('input[name="rotation-z"]');
+
+ assert.ok(rotationXInput, "Should have rotation X input");
+ assert.ok(rotationYInput, "Should have rotation Y input");
+ assert.ok(rotationZInput, "Should have rotation Z input");
+ });
+
+ it("should update position when input changes", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const positionXInput = container.querySelector(
+ 'input[name="position-x"]',
+ ) as HTMLInputElement;
+
+ await act(async () => {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )!.set!;
+ nativeInputValueSetter.call(positionXInput, "5");
+ positionXInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const model = useStore.getState().models[0];
+ assert.strictEqual(model.position.x, 5);
+ });
+
+ it("should have hue shift controls", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const hueRedInput = container.querySelector('input[name="hue-red"]');
+ const hueGreenInput = container.querySelector('input[name="hue-green"]');
+ const hueBlueInput = container.querySelector('input[name="hue-blue"]');
+
+ assert.ok(hueRedInput, "Should have red hue shift input");
+ assert.ok(hueGreenInput, "Should have green hue shift input");
+ assert.ok(hueBlueInput, "Should have blue hue shift input");
+ });
+
+ it("should update hue shift when slider changes", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const hueRedInput = container.querySelector(
+ 'input[name="hue-red"]',
+ ) as HTMLInputElement;
+
+ await act(async () => {
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )!.set!;
+ nativeInputValueSetter.call(hueRedInput, "0.5");
+ hueRedInput.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const model = useStore.getState().models[0];
+ assert.strictEqual(model.hueShift.red, 0.5);
+ });
+
+ it("should have a delete button", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const deleteButton = container.querySelector("button.delete-model");
+ assert.ok(deleteButton, "Should have delete button");
+ });
+
+ it("should remove model when delete button is clicked", async () => {
+ useStore.getState().addModel("servbot", { x: 0, y: 0, z: 0 });
+ const modelId = useStore.getState().models[0].id;
+ useStore.getState().selectModel(modelId);
+
+ await act(async () => {
+ root.render(<ModelControls />);
+ });
+
+ const deleteButton = container.querySelector(
+ "button.delete-model",
+ ) as HTMLButtonElement;
+
+ await act(async () => {
+ deleteButton.click();
+ });
+
+ const models = useStore.getState().models;
+ assert.strictEqual(models.length, 0, "Model should be removed");
+ assert.strictEqual(
+ useStore.getState().selectedModelId,
+ null,
+ "Selection should be cleared",
+ );
+ });
+});
diff --git a/src/components/ModelControls.tsx b/src/components/ModelControls.tsx
new file mode 100644
index 0000000..31366b0
--- /dev/null
+++ b/src/components/ModelControls.tsx
@@ -0,0 +1,158 @@
+import React from "react";
+import { useStore } from "../store/store.ts";
+import { ControlSlider } from "./ControlSlider.tsx";
+
+export const ModelControls: React.FC = () => {
+ const selectedModelId = useStore((state) => state.selectedModelId);
+ const models = useStore((state) => state.models);
+ const updateModel = useStore((state) => state.updateModel);
+ const removeModel = useStore((state) => state.removeModel);
+ const selectModel = useStore((state) => state.selectModel);
+
+ const selectedModel = models.find((model) => model.id === selectedModelId);
+
+ if (!selectedModel) {
+ return null;
+ }
+
+ const handlePositionChange = (axis: "x" | "y" | "z", value: string) => {
+ const numericValue = parseFloat(value) || 0;
+ updateModel(selectedModel.id, {
+ position: {
+ ...selectedModel.position,
+ [axis]: numericValue,
+ },
+ });
+ };
+
+ const handleRotationChange = (axis: "x" | "y" | "z", value: string) => {
+ const numericValue = parseFloat(value) || 0;
+ updateModel(selectedModel.id, {
+ rotation: {
+ ...selectedModel.rotation,
+ [axis]: numericValue,
+ },
+ });
+ };
+
+ const handleHueShiftChange = (
+ color: "red" | "green" | "blue",
+ value: string,
+ ) => {
+ const numericValue = parseFloat(value) || 0;
+ updateModel(selectedModel.id, {
+ hueShift: {
+ ...selectedModel.hueShift,
+ [color]: numericValue,
+ },
+ });
+ };
+
+ const handleDelete = () => {
+ removeModel(selectedModel.id);
+ selectModel(null);
+ };
+
+ return (
+ <div className="model-controls">
+ <h3 className="controls-title">{selectedModel.name}</h3>
+
+ <div className="control-section">
+ <h4>Position</h4>
+ <ControlSlider
+ label="X"
+ name="position-x"
+ min={-200}
+ max={200}
+ step={1}
+ value={selectedModel.position.x}
+ onChange={(value) => handlePositionChange("x", value)}
+ />
+ <ControlSlider
+ label="Y"
+ name="position-y"
+ min={0}
+ max={200}
+ step={1}
+ value={selectedModel.position.y}
+ onChange={(value) => handlePositionChange("y", value)}
+ />
+ <ControlSlider
+ label="Z"
+ name="position-z"
+ min={-200}
+ max={200}
+ step={1}
+ value={selectedModel.position.z}
+ onChange={(value) => handlePositionChange("z", value)}
+ />
+ </div>
+
+ <div className="control-section">
+ <h4>Rotation</h4>
+ <ControlSlider
+ label="X"
+ name="rotation-x"
+ min={-Math.PI}
+ max={Math.PI}
+ step={0.01}
+ value={selectedModel.rotation.x}
+ onChange={(value) => handleRotationChange("x", value)}
+ />
+ <ControlSlider
+ label="Y"
+ name="rotation-y"
+ min={-Math.PI}
+ max={Math.PI}
+ step={0.01}
+ value={selectedModel.rotation.y}
+ onChange={(value) => handleRotationChange("y", value)}
+ />
+ <ControlSlider
+ label="Z"
+ name="rotation-z"
+ min={-Math.PI}
+ max={Math.PI}
+ step={0.01}
+ value={selectedModel.rotation.z}
+ onChange={(value) => handleRotationChange("z", value)}
+ />
+ </div>
+
+ <div className="control-section">
+ <h4>Hue Shift</h4>
+ <ControlSlider
+ label="Red"
+ name="hue-red"
+ min={-1}
+ max={1}
+ step={0.01}
+ value={selectedModel.hueShift.red}
+ onChange={(value) => handleHueShiftChange("red", value)}
+ />
+ <ControlSlider
+ label="Green"
+ name="hue-green"
+ min={-1}
+ max={1}
+ step={0.01}
+ value={selectedModel.hueShift.green}
+ onChange={(value) => handleHueShiftChange("green", value)}
+ />
+ <ControlSlider
+ label="Blue"
+ name="hue-blue"
+ min={-1}
+ max={1}
+ step={0.01}
+ value={selectedModel.hueShift.blue}
+ onChange={(value) => handleHueShiftChange("blue", value)}
+ />
+ </div>
+
+ <button className="delete-model" onClick={handleDelete}>
+ Delete Model
+ </button>
+ </div>
+ );
+};
diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx
new file mode 100644
index 0000000..fd70708
--- /dev/null
+++ b/src/components/Scene.tsx
@@ -0,0 +1,78 @@
+import React, { useState, useCallback } from "react";
+import { Canvas } from "@react-three/fiber";
+import { OrbitControls, Grid } from "@react-three/drei";
+import { useStore } from "../store/store.ts";
+import { Model3DComponent } from "./Model3D.tsx";
+import { CameraController } from "./CameraController.tsx";
+import { SceneBackground } from "./SceneBackground.tsx";
+
+interface SceneContentProps {
+ autoRotate: boolean;
+}
+
+const SceneContentInner: React.FC<SceneContentProps> = ({ autoRotate }) => {
+ const models = useStore((state) => state.models);
+
+ return (
+ <>
+ <SceneBackground />
+ <CameraController />
+ <OrbitControls
+ enableDamping
+ dampingFactor={0.05}
+ minDistance={20}
+ maxDistance={500}
+ autoRotate={autoRotate}
+ autoRotateSpeed={4}
+ enableRotate={!autoRotate}
+ />
+
+ <Grid
+ position={[0, 0, 0]}
+ infiniteGrid
+ cellSize={10}
+ cellThickness={0.5}
+ cellColor="#6f6f6f"
+ sectionSize={50}
+ sectionThickness={1}
+ sectionColor="#9d4b4b"
+ fadeDistance={500}
+ fadeStrength={1}
+ />
+
+ {models.map((model) => (
+ <Model3DComponent key={model.id} model={model} />
+ ))}
+ </>
+ );
+};
+
+const SceneContent = React.memo(SceneContentInner);
+SceneContent.displayName = "SceneContent";
+
+export const Scene: React.FC = () => {
+ const selectModel = useStore((state) => state.selectModel);
+ const [autoRotate, setAutoRotate] = useState(false);
+
+ const handlePointerMissed = useCallback(() => {
+ selectModel(null);
+ }, [selectModel]);
+
+ const handleToggleAutoRotate = useCallback(() => {
+ setAutoRotate((prev) => !prev);
+ }, []);
+
+ return (
+ <div className="scene-container">
+ <Canvas onPointerMissed={handlePointerMissed}>
+ <SceneContent autoRotate={autoRotate} />
+ </Canvas>
+ <button
+ className={`spin-button ${autoRotate ? "active" : ""}`}
+ onClick={handleToggleAutoRotate}
+ >
+ {autoRotate ? "Stop" : "Spin"}
+ </button>
+ </div>
+ );
+};
diff --git a/src/components/SceneBackground.tsx b/src/components/SceneBackground.tsx
new file mode 100644
index 0000000..c2dd2e2
--- /dev/null
+++ b/src/components/SceneBackground.tsx
@@ -0,0 +1,33 @@
+import React, { useEffect } from "react";
+import { useThree } from "@react-three/fiber";
+import * as THREE from "three";
+import { useStore } from "../store/store.ts";
+
+const textureLoader = new THREE.TextureLoader();
+
+const SceneBackgroundInner: React.FC = () => {
+ const backdropUrl = useStore((state) => state.backdropUrl);
+ const { scene } = useThree();
+
+ useEffect(() => {
+ if (backdropUrl) {
+ textureLoader.load(backdropUrl, (texture) => {
+ texture.colorSpace = THREE.SRGBColorSpace;
+ scene.background = texture;
+ });
+ } else {
+ scene.background = new THREE.Color(0x4a4a4a);
+ }
+
+ return () => {
+ if (scene.background instanceof THREE.Texture) {
+ scene.background.dispose();
+ }
+ };
+ }, [backdropUrl, scene]);
+
+ return null;
+};
+
+export const SceneBackground = React.memo(SceneBackgroundInner);
+SceneBackground.displayName = "SceneBackground";
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx
new file mode 100644
index 0000000..c85d0a6
--- /dev/null
+++ b/src/components/Sidebar.test.tsx
@@ -0,0 +1,104 @@
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert";
+import "../test-setup.ts";
+import { createRoot, type Root } from "react-dom/client";
+import { act } from "@testing-library/react";
+import { Sidebar } from "./Sidebar.tsx";
+import { useStore } from "../store/store.ts";
+
+describe("Sidebar", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ 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" },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("should render available models list", async () => {
+ await act(async () => {
+ root.render(<Sidebar />);
+ });
+
+ const sidebar = container.querySelector(".sidebar");
+ assert.ok(sidebar, "Sidebar should be rendered");
+
+ const modelItems = container.querySelectorAll(".model-item");
+ assert.strictEqual(modelItems.length, 3, "Should render 3 model items");
+ });
+
+ it("should display model names", async () => {
+ await act(async () => {
+ root.render(<Sidebar />);
+ });
+
+ const modelNames = container.querySelectorAll(".model-name");
+ const names = Array.from(modelNames).map((item) => item.textContent);
+
+ assert.ok(names.includes("Servbot"), "Should display Servbot");
+ assert.ok(names.includes("Megaman"), "Should display Megaman");
+ assert.ok(names.includes("Tron Bonne"), "Should display Tron Bonne");
+ });
+
+ it("should have Add buttons for each model", async () => {
+ await act(async () => {
+ root.render(<Sidebar />);
+ });
+
+ const addButtons = container.querySelectorAll(".add-model-btn");
+ assert.strictEqual(addButtons.length, 3, "Should have 3 Add buttons");
+
+ addButtons.forEach((btn) => {
+ assert.strictEqual(btn.textContent, "Add", 'Button should say "Add"');
+ });
+ });
+
+ it("should add model to scene when Add button is clicked", async () => {
+ await act(async () => {
+ root.render(<Sidebar />);
+ });
+
+ const addButtons = container.querySelectorAll(".add-model-btn");
+ const firstAddButton = addButtons[0] as HTMLButtonElement;
+
+ await act(async () => {
+ firstAddButton.click();
+ });
+
+ const models = useStore.getState().models;
+ assert.strictEqual(models.length, 1, "Should have 1 model in scene");
+ assert.strictEqual(models[0].sourceId, "servbot", "Should be a servbot");
+ });
+
+ it("should have a title", async () => {
+ await act(async () => {
+ root.render(<Sidebar />);
+ });
+
+ const title = container.querySelector(".sidebar-title");
+ assert.ok(title, "Should have a title");
+ assert.ok(
+ title?.textContent?.includes("Models"),
+ 'Title should contain "Models"',
+ );
+ });
+});
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..97d923b
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { useStore } from "../store/store.ts";
+
+export const Sidebar: React.FC = () => {
+ const availableModels = useStore((state) => state.availableModels);
+ const addModel = useStore((state) => state.addModel);
+
+ const handleAdd = (modelId: string) => {
+ addModel(modelId, { x: 0, y: 0, z: 0 });
+ };
+
+ return (
+ <section className="sidebar">
+ <h2 className="sidebar-title">Models</h2>
+ <nav className="model-list">
+ {availableModels.map((model) => (
+ <article key={model.id} className="model-item">
+ <span className="model-name">{model.name}</span>
+ <button
+ className="add-model-btn"
+ onClick={() => handleAdd(model.id)}
+ >
+ Add
+ </button>
+ </article>
+ ))}
+ </nav>
+ </section>
+ );
+};