aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-01-30 11:31:24 +0100
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-01-30 13:05:48 +0100
commit0d4b61993cba37c180ac91e40a0fb362711de8de (patch)
treef41dd68420b51766c1ca6fc1ee2c20dc5df830d1 /src
Initial implementation
Diffstat (limited to 'src')
-rw-r--r--src/App.css337
-rw-r--r--src/App.tsx23
-rw-r--r--src/components/BackdropUpload.test.tsx113
-rw-r--r--src/components/BackdropUpload.tsx53
-rw-r--r--src/components/CameraController.tsx74
-rw-r--r--src/components/CameraSelector.test.tsx107
-rw-r--r--src/components/CameraSelector.tsx29
-rw-r--r--src/components/ControlSlider.tsx36
-rw-r--r--src/components/Model3D.tsx165
-rw-r--r--src/components/ModelControls.test.tsx204
-rw-r--r--src/components/ModelControls.tsx158
-rw-r--r--src/components/Scene.tsx78
-rw-r--r--src/components/SceneBackground.tsx33
-rw-r--r--src/components/Sidebar.test.tsx104
-rw-r--r--src/components/Sidebar.tsx30
-rw-r--r--src/data/availableModels.ts77
-rw-r--r--src/main.tsx9
-rw-r--r--src/shaders/hueShift.ts133
-rw-r--r--src/store/store.test.ts307
-rw-r--r--src/store/store.ts119
-rw-r--r--src/test-setup.ts161
-rw-r--r--src/vite-env.d.ts1
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" />