aboutsummaryrefslogtreecommitdiff
path: root/src/components/Model3D.tsx
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/components/Model3D.tsx
Initial implementation
Diffstat (limited to 'src/components/Model3D.tsx')
-rw-r--r--src/components/Model3D.tsx165
1 files changed, 165 insertions, 0 deletions
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>
+ );
+};