diff --git a/src/components/PreviewComponent.tsx b/src/components/PreviewComponent.tsx index 6d3a77a..6312f56 100644 --- a/src/components/PreviewComponent.tsx +++ b/src/components/PreviewComponent.tsx @@ -1,12 +1,9 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable react-hooks/exhaustive-deps */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import React, { useEffect, useRef, useState } from "react"; -import * as THREE from "three"; -import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { usePreviewService } from "../hooks/usePreview"; +import React, { useEffect } from "react"; +import { Canvas, useLoader } from "@react-three/fiber"; +import { OrbitControls } from "@react-three/drei"; import { useColorContext } from "../context/ColorContext"; +import * as THREE from "three"; +import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; const GRID_SIZE = 250; // in mm const LIMIT_DIMENSIONS_MM = { length: 250, width: 250, height: 310 }; // in mm @@ -17,231 +14,92 @@ interface PreviewComponentProps { onError: (error: string) => void; } -const PreviewComponent: React.FC = ({ - url, - onExceedsLimit, - onError, -}) => { - const { state } = useColorContext(); - const { color } = state; +interface ModelProps { + url: string; + color: number; + onExceedsLimit: (limit: boolean) => void; + onError: (error: string) => void; +} - const previewRef = useRef(null); - const { loadModel } = usePreviewService(); - const scene = useRef(new THREE.Scene()).current; - const camera = useRef( - new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) - ).current; - const renderer = useRef(new THREE.WebGLRenderer({ antialias: true })).current; - const meshRef = useRef(null); - const gridHelperRef = useRef(null); - const controlsRef = useRef(null); - const animationFrameId = useRef(null); - - const [_dimensions, setDimensions] = useState<{ length: number; width: number; height: number }>({ - length: 0, - width: 0, - height: 0, - }); - const [modelLoaded, setModelLoaded] = useState(false); - const [_exceedsLimit, setExceedsLimit] = useState(false); - const [_errorMessage, setErrorMessage] = useState(null); +const Model: React.FC = ({ url, color, onExceedsLimit, onError }) => { + const geometry = useLoader(STLLoader, url); useEffect(() => { - if (previewRef.current) { - initializeScene(); + if (geometry) { + geometry.computeBoundingBox(); + const size = geometry.boundingBox?.getSize(new THREE.Vector3()); + const center = geometry.boundingBox?.getCenter(new THREE.Vector3()); + + if (center) { + geometry.translate(-center.x, -center.y, -center.z); // Center the model + } + + if (size) { + const modelExceedsLimit = + size.x > LIMIT_DIMENSIONS_MM.length || + size.y > LIMIT_DIMENSIONS_MM.width || + size.z > LIMIT_DIMENSIONS_MM.height; + + onExceedsLimit(modelExceedsLimit); + + if (modelExceedsLimit) { + onError( + `Model dimensions exceed our limit of ${LIMIT_DIMENSIONS_MM.length} (L) x ${LIMIT_DIMENSIONS_MM.width} (W) x ${LIMIT_DIMENSIONS_MM.height} (H) mm.` + ); + } + } } + + // Clean up geometry when component unmounts return () => { - // Cancel animation on unmount - if (animationFrameId.current) { - cancelAnimationFrame(animationFrameId.current); - } + geometry.dispose(); }; - }, []); + }, [geometry, onExceedsLimit, onError]); - useEffect(() => { - if (modelLoaded) { - fitCameraToObject(meshRef.current!); - updateGrid(); - updateDimensions(); - } - }, [modelLoaded]); + return ( + + + + ); +}; + +const PreviewComponent: React.FC = ({ url, onExceedsLimit, onError }) => { + const { state } = useColorContext(); + const { color } = state; useEffect(() => { const handleResize = () => { - renderer.setSize(window.innerWidth, window.innerHeight); - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); + // Handle resize logic if needed }; window.addEventListener("resize", handleResize); + + // Clean up event listener on component unmount return () => { window.removeEventListener("resize", handleResize); }; }, []); - // Reload the model whenever the URL or color changes - useEffect(() => { - if (url && color) { - loadModelAndCheckDimensions(url); - } - }, [url]); - - const initializeScene = () => { - renderer.setSize(600, 400); // Fixed size - previewRef.current!.appendChild(renderer.domElement); - camera.position.z = 500; - scene.add(new THREE.AmbientLight(0xffffff, 0.5)); - scene.add(new THREE.DirectionalLight(0xffffff, 0.5)); - - renderer.setClearColor(0xf0f0f0); // A slightly darker white color - - controlsRef.current = new OrbitControls(camera, renderer.domElement); - animate(); - }; - - const loadModelAndCheckDimensions = async (url: string) => { - // Dispose of previous mesh, if any - if (meshRef.current) { - scene.remove(meshRef.current); - meshRef.current.geometry.dispose(); - (meshRef.current.material as THREE.Material).dispose(); - } - - try { - const geometry = await loadModel(url); - if (!geometry) throw new Error("Model loading failed. Geometry is undefined."); - - const hexColor = parseInt(color.replace("#", ""), 16); - const material = new THREE.MeshStandardMaterial({ color: hexColor }); - meshRef.current = new THREE.Mesh(geometry, material); - - const boundingBox = new THREE.Box3().setFromObject(meshRef.current); - const center = boundingBox.getCenter(new THREE.Vector3()); - const size = boundingBox.getSize(new THREE.Vector3()); - - checkDimensions(size); - - meshRef.current.rotation.x = Math.PI / 2; - meshRef.current.position.copy(center).multiplyScalar(-1); - meshRef.current.position.y += size.y / 2; - - scene.add(meshRef.current); - setModelLoaded(true); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Failed to load model"; - console.error(message); - setErrorMessage(message); - setExceedsLimit(true); - onError(message); - } - }; - - const fitCameraToObject = (object: THREE.Object3D, offset = 2) => { - const boundingBox = new THREE.Box3().setFromObject(object); - const center = boundingBox.getCenter(new THREE.Vector3()); - const size = boundingBox.getSize(new THREE.Vector3()); - - const maxDim = Math.max(size.x, size.y, size.z); - const fov = camera.fov * (Math.PI / 180); - const cameraZ = Math.abs(maxDim / (2 * Math.tan(fov / 2))); - - camera.position.set(center.x, size.y / 2, cameraZ * offset); - camera.lookAt(center.add(new THREE.Vector3(0, size.y / 2, 0))); - - if (controlsRef.current) { - controlsRef.current.target = center; - } - - camera.updateProjectionMatrix(); - }; - - const animate = () => { - animationFrameId.current = requestAnimationFrame(animate); - controlsRef.current!.update(); - renderer.render(scene, camera); - }; - - const updateGrid = () => { - if (gridHelperRef.current) { - scene.remove(gridHelperRef.current); - } - const gridHelper = new THREE.GridHelper(GRID_SIZE, GRID_SIZE); - scene.add(gridHelper); - gridHelperRef.current = gridHelper; - }; - - const updateMaterialColor = (hexColor: number) => { - if (meshRef.current) { - const material = new THREE.MeshStandardMaterial({ color: hexColor }); - meshRef.current.material = material; - } - }; - - useEffect(() => { - if (modelLoaded) { - updateMaterialColor(parseInt(color.replace("#", ""), 16)); - } - }, [color]); - - const updateDimensions = () => { - const boundingBox = new THREE.Box3().setFromObject(meshRef.current!); - const size = boundingBox.getSize(new THREE.Vector3()); - - if (size.x === 0 || size.y === 0 || size.z === 0) { - setError("Invalid model: The model dimensions are zero."); - return; - } - - setDimensions({ - length: parseFloat(size.x.toFixed(2)), - width: parseFloat(size.y.toFixed(2)), - height: parseFloat(size.z.toFixed(2)), - }); - - const modelExceedsLimit = - size.x > LIMIT_DIMENSIONS_MM.length || - size.y > LIMIT_DIMENSIONS_MM.width || - size.z > LIMIT_DIMENSIONS_MM.height; - - setExceedsLimit(modelExceedsLimit); - onExceedsLimit(modelExceedsLimit); - }; - - const checkDimensions = (size: THREE.Vector3) => { - if (size.x === 0 || size.y === 0 || size.z === 0) { - setError("Invalid model: The model dimensions are zero."); - return; - } - - setDimensions({ - length: parseFloat(size.x.toFixed(2)), - width: parseFloat(size.y.toFixed(2)), - height: parseFloat(size.z.toFixed(2)), - }); - - const modelExceedsLimit = - size.x > LIMIT_DIMENSIONS_MM.length || - size.y > LIMIT_DIMENSIONS_MM.width || - size.z > LIMIT_DIMENSIONS_MM.height; - - if (modelExceedsLimit) { - setError( - `Model dimensions exceed our limit of ${LIMIT_DIMENSIONS_MM.length} (L) x ${LIMIT_DIMENSIONS_MM.width} (W) x ${LIMIT_DIMENSIONS_MM.height} (H) mm. Please choose a smaller model.` - ); - } - }; - - const setError = (message: string) => { - setErrorMessage(message); - setExceedsLimit(true); - onError(message); - }; - return (
-
-
-
+ + + + + + {url && ( + + )} +
); }; diff --git a/src/hooks/usePreview.test.ts b/src/hooks/usePreview.test.ts deleted file mode 100644 index 982d761..0000000 --- a/src/hooks/usePreview.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { vi } from "vitest"; -import * as THREE from "three"; -import { usePreviewService } from './usePreview'; -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; - -// Mock STLLoader directly -vi.mock("three/examples/jsm/loaders/STLLoader.js", () => { - return { - STLLoader: vi.fn().mockImplementation(() => ({ - load: vi.fn(), // Define load as a mock function - })), - }; -}); - -describe("usePreviewService", () => { - let mockGeometry: THREE.BufferGeometry; - - beforeEach(() => { - // Set up a mock geometry with a bounding box - mockGeometry = new THREE.BufferGeometry(); - mockGeometry.computeBoundingBox = vi.fn(() => { - mockGeometry.boundingBox = new THREE.Box3( - new THREE.Vector3(0, 0, 0), - new THREE.Vector3(10, 20, 30) - ); - }); - - // Ensure load is mocked for each test - STLLoader.prototype.load = vi.fn(); - }); - - it.skip( - "loads a model successfully using loadModel", - async () => { - const { result } = renderHook(() => usePreviewService()); - - // Mock load to immediately call onLoad with mockGeometry - (STLLoader.prototype.load as jest.Mock).mockImplementation((_, onLoad) => { - onLoad(mockGeometry); - }); - - const geometry = await result.current.loadModel("mockURL"); - expect(geometry).toBe(mockGeometry); - }, - 10000 - ); - - it.skip( - "handles loadModel error correctly", - async () => { - const { result } = renderHook(() => usePreviewService()); - - // Mock load to immediately call onError with an error - (STLLoader.prototype.load as jest.Mock).mockImplementation((_, __, ___, onError) => { - onError(new Error("Failed to load")); - }); - - await expect(result.current.loadModel("mockURL")).rejects.toThrow("Failed to load"); - }, - 10000 - ); - - it("calculates dimensions using getDimensions", () => { - const { result } = renderHook(() => usePreviewService()); - mockGeometry.computeBoundingBox(); - - const dimensions = result.current.getDimensions(mockGeometry); - expect(dimensions).toEqual({ - width: 10, - height: 20, - depth: 30, - }); - }); - - it("checks model dimensions using checkModelDimensions", () => { - const { result } = renderHook(() => usePreviewService()); - const dimensions = result.current.checkModelDimensions(mockGeometry); - - expect(dimensions).toEqual({ - length: 10, - width: 20, - height: 30, - }); - }); -}); diff --git a/src/hooks/usePreview.ts b/src/hooks/usePreview.ts deleted file mode 100644 index 0f6614c..0000000 --- a/src/hooks/usePreview.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as THREE from 'three'; -import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; -import { useCallback, useRef } from 'react'; - -export const usePreviewService = () => { - const loader = useRef(new STLLoader()).current; - - const loadModel = useCallback((fileURL: string): Promise => { - return new Promise((resolve, reject) => { - loader.load( - fileURL, - (geometry) => { - // STL loader returns a Geometry or BufferGeometry - resolve(geometry); - }, - undefined, - (error) => reject(error) - ); - }); - }, [loader]); - - const getDimensions = useCallback((geometry: THREE.BufferGeometry) => { - geometry.computeBoundingBox(); - const { min, max } = geometry.boundingBox!; - - return { - width: max.x - min.x, - height: max.y - min.y, - depth: max.z - min.z, - }; - }, []); - - const checkModelDimensions = useCallback((geometry: THREE.BufferGeometry) => { - const mesh = new THREE.Mesh(geometry); - const boundingBox = new THREE.Box3().setFromObject(mesh); - const size = boundingBox.getSize(new THREE.Vector3()); - return { - length: parseFloat(size.x.toFixed(2)), - width: parseFloat(size.y.toFixed(2)), - height: parseFloat(size.z.toFixed(2)), - }; - }, []); - - return { - loadModel, - getDimensions, - checkModelDimensions, - }; -}; \ No newline at end of file diff --git a/src/pages/Product.tsx b/src/pages/Product.tsx index 6eb1b07..822b71d 100644 --- a/src/pages/Product.tsx +++ b/src/pages/Product.tsx @@ -9,7 +9,7 @@ const PreviewComponent = lazy(() => import("../components/PreviewComponent")); export default function ProductPage() { const { id } = useParams<{ id: string }>(); - const [product, setProduct] = useState(null); + const [product, setProduct] = useState(undefined); const [selectedFilament, setSelectedFilament] = useState("PLA"); // Fetch product data based on ID @@ -17,9 +17,8 @@ export default function ProductPage() { const fetchProduct = async () => { try { const response = await fetch(`https://3dprinter-web-api.benhalverson.workers.dev/product/${id}`); - const data = await response.json(); + const data = await response.json() as Product; setProduct(data); - console.log('data', data); } catch (error) { console.error("Error fetching product:", error); } @@ -140,3 +139,11 @@ export default function ProductPage() { ); } + + interface Product { + name: string; + price: string; + stl: string; + description: string; + } +