diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/BackdropUpload.test.tsx | 113 | ||||
| -rw-r--r-- | src/components/BackdropUpload.tsx | 53 | ||||
| -rw-r--r-- | src/components/CameraController.tsx | 74 | ||||
| -rw-r--r-- | src/components/CameraSelector.test.tsx | 107 | ||||
| -rw-r--r-- | src/components/CameraSelector.tsx | 29 | ||||
| -rw-r--r-- | src/components/ControlSlider.tsx | 36 | ||||
| -rw-r--r-- | src/components/Model3D.tsx | 165 | ||||
| -rw-r--r-- | src/components/ModelControls.test.tsx | 204 | ||||
| -rw-r--r-- | src/components/ModelControls.tsx | 158 | ||||
| -rw-r--r-- | src/components/Scene.tsx | 78 | ||||
| -rw-r--r-- | src/components/SceneBackground.tsx | 33 | ||||
| -rw-r--r-- | src/components/Sidebar.test.tsx | 104 | ||||
| -rw-r--r-- | src/components/Sidebar.tsx | 30 |
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> + ); +}; |