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 | |
Initial implementation
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.css | 337 | ||||
| -rw-r--r-- | src/App.tsx | 23 | ||||
| -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 | ||||
| -rw-r--r-- | src/data/availableModels.ts | 77 | ||||
| -rw-r--r-- | src/main.tsx | 9 | ||||
| -rw-r--r-- | src/shaders/hueShift.ts | 133 | ||||
| -rw-r--r-- | src/store/store.test.ts | 307 | ||||
| -rw-r--r-- | src/store/store.ts | 119 | ||||
| -rw-r--r-- | src/test-setup.ts | 161 | ||||
| -rw-r--r-- | src/vite-env.d.ts | 1 |
22 files changed, 2351 insertions, 0 deletions
diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..12535df --- /dev/null +++ b/src/App.css @@ -0,0 +1,337 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + width: 100%; + overflow: hidden; +} + +.app { + display: flex; + height: 100vh; + width: 100vw; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; +} + +/* Sidebar */ +.sidebar { + width: 200px; + background: #1a1a2e; + color: #fff; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + border-right: 1px solid #333; + overflow-y: auto; +} + +.sidebar::-webkit-scrollbar { + width: 6px; +} + +.sidebar::-webkit-scrollbar-track { + background: #16213e; +} + +.sidebar::-webkit-scrollbar-thumb { + background: #333; + border-radius: 3px; +} + +.sidebar::-webkit-scrollbar-thumb:hover { + background: #444; +} + +.sidebar-title { + font-size: 18px; + font-weight: 600; + padding-bottom: 12px; + border-bottom: 1px solid #333; +} + +.model-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.model-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #16213e; + border-radius: 8px; + transition: all 0.2s ease; +} + +.model-item:hover { + background: #0f3460; +} + +.model-name { + font-size: 14px; +} + +.add-model-btn { + padding: 6px 12px; + background: #e94560; + color: #fff; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-model-btn:hover { + background: #ff6b6b; +} + +/* Main Content */ +.main-content { + flex: 1; + position: relative; + display: flex; + flex-direction: column; +} + +.scene-container { + flex: 1; + background: #4a4a4a; + position: relative; +} + +.spin-button { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + background: #16213e; + color: #fff; + border: 1px solid #333; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + z-index: 100; +} + +.spin-button:hover { + background: #0f3460; +} + +.spin-button:active, +.spin-button.active { + background: #e94560; +} + +/* Overlay Controls */ +.overlay-controls { + position: absolute; + top: 16px; + left: 16px; + display: flex; + gap: 12px; + z-index: 100; +} + +.camera-selector-wrapper { + background: rgba(26, 26, 46, 0.9); + border-radius: 8px; + padding: 8px 12px; +} + +.camera-selector { + background: #16213e; + color: #fff; + border: 1px solid #333; + border-radius: 4px; + padding: 8px 12px; + font-size: 14px; + cursor: pointer; + outline: none; +} + +.camera-selector:hover { + border-color: #0f3460; +} + +.camera-selector:focus { + border-color: #e94560; +} + +/* Backdrop Upload */ +.backdrop-upload { + background: rgba(26, 26, 46, 0.9); + border-radius: 8px; + padding: 8px 12px; + display: flex; + gap: 8px; +} + +.upload-backdrop, +.clear-backdrop { + background: #16213e; + color: #fff; + border: 1px solid #333; + border-radius: 4px; + padding: 8px 12px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.upload-backdrop:hover, +.clear-backdrop:hover { + background: #0f3460; + border-color: #0f3460; +} + +.clear-backdrop { + background: #3d1a1a; + border-color: #5a2a2a; +} + +.clear-backdrop:hover { + background: #5a2a2a; +} + +/* Model Controls */ +.model-controls { + position: absolute; + top: 16px; + right: 16px; + width: 280px; + background: rgba(26, 26, 46, 0.95); + border-radius: 12px; + padding: 16px; + color: #fff; + z-index: 100; + max-height: calc(100vh - 100px); + overflow-y: auto; +} + +.controls-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #333; +} + +.control-section { + margin-bottom: 16px; +} + +.control-section h4 { + font-size: 12px; + text-transform: uppercase; + color: #888; + margin-bottom: 8px; + letter-spacing: 0.5px; +} + +.control-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.control-row label { + width: 24px; + font-size: 12px; + color: #aaa; +} + +.control-row input[type="number"] { + flex: 1; + background: #16213e; + border: 1px solid #333; + border-radius: 4px; + padding: 8px; + color: #fff; + font-size: 14px; +} + +.control-row input[type="number"]:focus { + outline: none; + border-color: #e94560; +} + +.control-row input[type="range"] { + flex: 1; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: #16213e; + border-radius: 3px; + outline: none; +} + +.control-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: #e94560; + border-radius: 50%; + cursor: pointer; +} + +.control-row input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + background: #e94560; + border-radius: 50%; + cursor: pointer; + border: none; +} + +.delete-model { + width: 100%; + padding: 12px; + background: #5a2a2a; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 8px; +} + +.delete-model:hover { + background: #7a3a3a; +} + +/* Scrollbar Styling */ +.model-controls::-webkit-scrollbar { + width: 6px; +} + +.model-controls::-webkit-scrollbar-track { + background: #16213e; + border-radius: 3px; +} + +.model-controls::-webkit-scrollbar-thumb { + background: #333; + border-radius: 3px; +} + +.model-controls::-webkit-scrollbar-thumb:hover { + background: #444; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5426184 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Scene } from "./components/Scene.tsx"; +import { Sidebar } from "./components/Sidebar.tsx"; +import { CameraSelector } from "./components/CameraSelector.tsx"; +import { ModelControls } from "./components/ModelControls.tsx"; +import { BackdropUpload } from "./components/BackdropUpload.tsx"; +import "./App.css"; + +export const App: React.FC = () => { + return ( + <section className="app"> + <Sidebar /> + <main className="main-content"> + <Scene /> + <aside className="overlay-controls"> + <CameraSelector /> + <BackdropUpload /> + </aside> + <ModelControls /> + </main> + </section> + ); +}; 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> + ); +}; diff --git a/src/data/availableModels.ts b/src/data/availableModels.ts new file mode 100644 index 0000000..b1946bb --- /dev/null +++ b/src/data/availableModels.ts @@ -0,0 +1,77 @@ +import type { AvailableModel } from "../store/store.ts"; + +export const availableModels: AvailableModel[] = [ + { + id: "megaman_volnutt", + name: "Megaman Volnutt", + url: "/models/megaman_volnutt.gltf", + }, + { + id: "megaman_juno", + name: "Megaman Juno", + url: "/models/megaman_juno.gltf", + }, + { + id: "roll_caskett", + name: "Roll Caskett", + url: "/models/roll_caskett.gltf", + }, + { id: "tron_bonne", name: "Tron Bonne", url: "/models/tron_bonne.gltf" }, + { + id: "teisel_bonne", + name: "Teisel Bonne", + url: "/models/teisel_bonne.gltf", + }, + { id: "bon_bonne", name: "Bon Bonne", url: "/models/bon_bonne.gltf" }, + { id: "servbot", name: "Servbot", url: "/models/servbot.gltf" }, + { id: "barrell", name: "Barrell", url: "/models/barrell.gltf" }, + { id: "wily", name: "Wily", url: "/models/wily.gltf" }, + { id: "paprika", name: "Paprika", url: "/models/paprika.gltf" }, + { id: "flutter", name: "Flutter", url: "/models/flutter.gltf" }, + { id: "support_car", name: "Support Car", url: "/models/support_car.gltf" }, + { id: "eden", name: "Eden", url: "/models/eden.gltf" }, + { id: "chest", name: "Chest", url: "/models/chest.gltf" }, + { + id: "city_gate_key", + name: "City Gate Key", + url: "/models/city_gate_key.gltf", + }, + { id: "easel", name: "Easel", url: "/models/easel.gltf" }, + { id: "frog", name: "Frog", url: "/models/frog.gltf" }, + { id: "hawk", name: "Hawk", url: "/models/hawk.gltf" }, + { id: "seagull", name: "Seagull", url: "/models/seagull.gltf" }, + { + id: "old_city_hound", + name: "Old City Hound", + url: "/models/old_city_hound.gltf", + }, + { id: "arukoitan", name: "Arukoitan", url: "/models/arukoitan.gltf" }, + { id: "cannam", name: "Cannam", url: "/models/cannam.gltf" }, + { id: "firushudot", name: "Firushudot", url: "/models/firushudot.gltf" }, + { id: "foo-roo", name: "Foo-Roo", url: "/models/foo-roo.gltf" }, + { id: "garudoriten", name: "Garudoriten", url: "/models/garudoriten.gltf" }, + { id: "gorubesshu", name: "Gorubesshu", url: "/models/gorubesshu.gltf" }, + { + id: "hanmuru_doll", + name: "Hanmuru Doll", + url: "/models/hanmuru_doll.gltf", + }, + { id: "horokko", name: "Horokko", url: "/models/horokko.gltf" }, + { id: "jakko", name: "Jakko", url: "/models/jakko.gltf" }, + { id: "karubun", name: "Karubun", url: "/models/karubun.gltf" }, + { + id: "karumuna_bash", + name: "Karumuna Bash", + url: "/models/karumuna_bash.gltf", + }, + { id: "kuruguru", name: "Kuruguru", url: "/models/kuruguru.gltf" }, + { id: "miroc", name: "Miroc", url: "/models/miroc.gltf" }, + { id: "orudakoitan", name: "Orudakoitan", url: "/models/orudakoitan.gltf" }, + { + id: "rotating_trap", + name: "Rotating Trap", + url: "/models/rotating_trap.gltf", + }, + { id: "sharukurusu", name: "Sharukurusu", url: "/models/sharukurusu.gltf" }, + { id: "shekuten", name: "Shekuten", url: "/models/shekuten.gltf" }, +]; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..65c0deb --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App.tsx"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + <React.StrictMode> + <App /> + </React.StrictMode>, +); diff --git a/src/shaders/hueShift.ts b/src/shaders/hueShift.ts new file mode 100644 index 0000000..1cfe105 --- /dev/null +++ b/src/shaders/hueShift.ts @@ -0,0 +1,133 @@ +export const hueShiftVertexShader = ` +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vPosition; + +void main() { + vUv = uv; + vNormal = normalize(normalMatrix * normal); + vPosition = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +export const hueShiftFragmentShader = ` +uniform sampler2D map; +uniform bool hasMap; +uniform vec3 baseColor; +uniform float hueShiftRed; +uniform float hueShiftGreen; +uniform float hueShiftBlue; + +varying vec2 vUv; +varying vec3 vNormal; +varying vec3 vPosition; + +vec3 rgb2hsl(vec3 color) { + float maxC = max(max(color.r, color.g), color.b); + float minC = min(min(color.r, color.g), color.b); + float l = (maxC + minC) / 2.0; + + if (maxC == minC) { + return vec3(0.0, 0.0, l); + } + + float d = maxC - minC; + float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC); + float h; + + if (maxC == color.r) { + h = (color.g - color.b) / d + (color.g < color.b ? 6.0 : 0.0); + } else if (maxC == color.g) { + h = (color.b - color.r) / d + 2.0; + } else { + h = (color.r - color.g) / d + 4.0; + } + + h /= 6.0; + return vec3(h, s, l); +} + +float hue2rgb(float p, float q, float t) { + if (t < 0.0) t += 1.0; + if (t > 1.0) t -= 1.0; + if (t < 1.0/6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0/2.0) return q; + if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0; + return p; +} + +vec3 hsl2rgb(vec3 hsl) { + if (hsl.y == 0.0) { + return vec3(hsl.z); + } + + float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y; + float p = 2.0 * hsl.z - q; + + return vec3( + hue2rgb(p, q, hsl.x + 1.0/3.0), + hue2rgb(p, q, hsl.x), + hue2rgb(p, q, hsl.x - 1.0/3.0) + ); +} + +vec3 applyHueShift(vec3 color) { + // Determine dominant color channel + float maxChannel = max(max(color.r, color.g), color.b); + float threshold = 0.1; + + vec3 hsl = rgb2hsl(color); + float shift = 0.0; + + // Apply shift based on dominant color + if (color.r > color.g + threshold && color.r > color.b + threshold) { + // Red dominant + shift = hueShiftRed; + } else if (color.g > color.r + threshold && color.g > color.b + threshold) { + // Green dominant + shift = hueShiftGreen; + } else if (color.b > color.r + threshold && color.b > color.g + threshold) { + // Blue dominant + shift = hueShiftBlue; + } else { + // Mixed - apply weighted average + float total = color.r + color.g + color.b; + if (total > 0.0) { + shift = (color.r * hueShiftRed + color.g * hueShiftGreen + color.b * hueShiftBlue) / total; + } + } + + hsl.x = mod(hsl.x + shift, 1.0); + return hsl2rgb(hsl); +} + +void main() { + vec3 color; + + if (hasMap) { + color = texture2D(map, vUv).rgb; + } else { + color = baseColor; + } + + // Apply hue shift + color = applyHueShift(color); + + // Bright lighting from multiple directions + vec3 lightDir1 = normalize(vec3(1.0, 1.0, 1.0)); + vec3 lightDir2 = normalize(vec3(-1.0, 0.5, -0.5)); + vec3 lightDir3 = normalize(vec3(0.0, -1.0, 0.0)); + + float diff1 = max(dot(vNormal, lightDir1), 0.0); + float diff2 = max(dot(vNormal, lightDir2), 0.0) * 0.5; + float diff3 = max(dot(vNormal, lightDir3), 0.0) * 0.3; + + float ambient = 0.7; + float diffuse = diff1 + diff2 + diff3; + + vec3 finalColor = color * (ambient + diffuse * 0.5); + + gl_FragColor = vec4(finalColor, 1.0); +} +`; 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 }); + }, +})); diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..147c60e --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,161 @@ +import { JSDOM } from "jsdom"; + +const dom = new JSDOM( + '<!DOCTYPE html><html><body><div id="root"></div></body></html>', + { + url: "http://localhost", + pretendToBeVisual: true, + }, +); + +// Use Object.defineProperty to avoid getter-only issues +Object.defineProperty(globalThis, "window", { + value: dom.window, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "document", { + value: dom.window.document, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "HTMLElement", { + value: dom.window.HTMLElement, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "Element", { + value: dom.window.Element, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "HTMLCanvasElement", { + value: dom.window.HTMLCanvasElement, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "HTMLInputElement", { + value: dom.window.HTMLInputElement, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "Event", { + value: dom.window.Event, + writable: true, + configurable: true, +}); + +// Mock URL.createObjectURL for file uploads +URL.createObjectURL = () => `blob:http://localhost/${Math.random()}`; +URL.revokeObjectURL = () => {}; + +// Mock matchMedia +Object.defineProperty(globalThis, "matchMedia", { + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + }), + writable: true, + configurable: true, +}); + +// Mock ResizeObserver +Object.defineProperty(globalThis, "ResizeObserver", { + value: class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + writable: true, + configurable: true, +}); + +// Mock WebGL context +dom.window.HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + contextType: string, +) { + if (contextType === "webgl" || contextType === "webgl2") { + return { + canvas: this, + getExtension: () => null, + getParameter: () => null, + createShader: () => ({}), + shaderSource: () => {}, + compileShader: () => {}, + getShaderParameter: () => true, + createProgram: () => ({}), + attachShader: () => {}, + linkProgram: () => {}, + getProgramParameter: () => true, + useProgram: () => {}, + createBuffer: () => ({}), + bindBuffer: () => {}, + bufferData: () => {}, + enable: () => {}, + disable: () => {}, + clear: () => {}, + viewport: () => {}, + createTexture: () => ({}), + bindTexture: () => {}, + texParameteri: () => {}, + texImage2D: () => {}, + getUniformLocation: () => ({}), + getAttribLocation: () => 0, + enableVertexAttribArray: () => {}, + vertexAttribPointer: () => {}, + uniform1f: () => {}, + uniform2f: () => {}, + uniform3f: () => {}, + uniform4f: () => {}, + uniformMatrix4fv: () => {}, + drawArrays: () => {}, + drawElements: () => {}, + }; + } + if (contextType === "2d") { + return { + canvas: this as HTMLCanvasElement, + fillRect: () => {}, + clearRect: () => {}, + getImageData: () => ({ data: [] }), + putImageData: () => {}, + createImageData: () => [], + setTransform: () => {}, + drawImage: () => {}, + save: () => {}, + restore: () => {}, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + closePath: () => {}, + stroke: () => {}, + fill: () => {}, + translate: () => {}, + scale: () => {}, + rotate: () => {}, + arc: () => {}, + measureText: () => ({ width: 0 }), + fillText: () => {}, + }; + } + return null; +} as unknown as typeof HTMLCanvasElement.prototype.getContext; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> |