import { Suspense, useRef, useCallback, useState, useEffect, Component, type ErrorInfo, type ReactNode, } from 'react' import { useQuery } from '@tanstack/react-query' import { Canvas, useThree, useFrame } from '@react-three/fiber' import { OrbitControls, useGLTF, Environment } from '@react-three/drei' import { toast } from 'sonner' import { X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown, } from 'lucide-react' import api from '../../api/client' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ThreeDViewerProps { cadFileId: string onClose: () => void /** URL for the geometry-only GLB (from OCC export) */ geometryGltfUrl?: string /** URL for the production-quality GLB (Blender + PBR materials) */ productionGltfUrl?: string /** Whether a geometry GLB exists (for hint display) */ hasGeometryGlb?: boolean /** Whether a production GLB exists (for hint display) */ hasProductionGlb?: boolean /** Called when the user clicks "Generate Geometry GLB" from the hint banner */ onGenerateGeometry?: () => void /** Whether a geometry GLB generation is in progress */ isGeneratingGeometry?: boolean /** Download URLs for assets */ downloadUrls?: { glb?: string production?: string blend?: string } } type ViewMode = 'geometry' | 'production' const ENV_PRESETS = ['city', 'sunset', 'dawn', 'night', 'warehouse', 'forest', 'apartment', 'studio', 'park', 'lobby'] as const type EnvPreset = typeof ENV_PRESETS[number] // --------------------------------------------------------------------------- // Inner model loader – separated so Suspense can catch it // --------------------------------------------------------------------------- function GltfModel({ url, wireframe }: { url: string; wireframe: boolean }) { const { scene } = useGLTF(url) useEffect(() => { scene.traverse((child: any) => { if (child.isMesh) { child.material = child.material.clone() child.material.wireframe = wireframe } }) }, [scene, wireframe]) return } // --------------------------------------------------------------------------- // Screenshot helper – lives inside Canvas so it can access gl / useThree // --------------------------------------------------------------------------- interface ScreenshotCaptureProps { enabled: boolean cadFileId: string onDone: () => void } function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProps) { const { gl } = useThree() const didCapture = useRef(false) useFrame(() => { if (!enabled || didCapture.current) return didCapture.current = true const dataUrl = gl.domElement.toDataURL('image/png') const [header, base64Data] = dataUrl.split(',') const mimeMatch = header.match(/:(.*?);/) const mimeType = mimeMatch ? mimeMatch[1] : 'image/png' const byteCharacters = atob(base64Data) const byteArray = new Uint8Array(byteCharacters.length) for (let i = 0; i < byteCharacters.length; i++) { byteArray[i] = byteCharacters.charCodeAt(i) } const blob = new Blob([byteArray], { type: mimeType }) const formData = new FormData() formData.append('thumbnail', blob, 'thumbnail.png') api .post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }) .then(() => toast.success('Thumbnail captured and saved')) .catch(() => toast.error('Failed to save thumbnail')) .finally(() => { didCapture.current = false onDone() }) }) return null } // --------------------------------------------------------------------------- // Error boundary // --------------------------------------------------------------------------- class GltfErrorBoundary extends Component< { children: ReactNode; onError: (msg: string) => void }, { hasError: boolean } > { constructor(props: { children: ReactNode; onError: (msg: string) => void }) { super(props) this.state = { hasError: false } } static getDerivedStateFromError(): { hasError: boolean } { return { hasError: true } } componentDidCatch(error: Error, _info: ErrorInfo): void { this.props.onError(error.message || 'Failed to parse GLTF') } render(): ReactNode { if (this.state.hasError) return null return this.props.children } } // --------------------------------------------------------------------------- // Loading overlay // --------------------------------------------------------------------------- function LoadingOverlay() { return (

Loading 3D model…

) } // --------------------------------------------------------------------------- // Model loader with ready tracking // --------------------------------------------------------------------------- interface ModelWithReadyProps { url: string wireframe: boolean onReady: () => void } function ModelWithReady({ url, wireframe, onReady }: ModelWithReadyProps) { const { scene } = useGLTF(url) useEffect(() => { scene.traverse((child: any) => { if (child.isMesh) { child.material = child.material.clone() child.material.wireframe = wireframe } }) }, [scene, wireframe]) useEffect(() => { onReady() }, [onReady]) return } // --------------------------------------------------------------------------- // Env preset dropdown // --------------------------------------------------------------------------- function EnvDropdown({ value, onChange, }: { value: EnvPreset onChange: (v: EnvPreset) => void }) { const [open, setOpen] = useState(false) return (
{open && (
{ENV_PRESETS.map((p) => ( ))}
)}
) } // --------------------------------------------------------------------------- // Main exported component // --------------------------------------------------------------------------- export default function ThreeDViewer({ cadFileId, onClose, geometryGltfUrl, productionGltfUrl, hasGeometryGlb, hasProductionGlb, onGenerateGeometry, isGeneratingGeometry, downloadUrls, }: ThreeDViewerProps) { // Default to production mode if only production GLB is available const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry' const [mode, setMode] = useState(initialMode) const [wireframe, setWireframe] = useState(false) const [envPreset, setEnvPreset] = useState('city') const [capturing, setCapturing] = useState(false) const [loadError, setLoadError] = useState(null) const [modelReady, setModelReady] = useState(false) const { data: settings3d } = useQuery({ queryKey: ['admin-settings'], queryFn: () => api.get('/admin/settings').then(r => r.data), staleTime: 60_000, }) // Resolve the active model URL: prefer selected mode, fall back to whichever URL exists const activeUrl = mode === 'production' && productionGltfUrl ? productionGltfUrl : geometryGltfUrl ?? productionGltfUrl const handleModelReady = useCallback(() => setModelReady(true), []) const handleError = useCallback((msg: string) => setLoadError(msg), []) const handleCaptureDone = useCallback(() => setCapturing(false), []) // Reset ready state when URL changes useEffect(() => { setModelReady(false) setLoadError(null) }, [activeUrl]) function handleDownload(url: string, filename: string) { const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) } const hasBothModes = !!(geometryGltfUrl && productionGltfUrl) return (
{/* Toolbar */}
3D Viewer
{/* Mode toggle */} {hasBothModes && (
)} {/* Wireframe toggle */} {/* Environment preset */} {/* Download buttons */} {downloadUrls?.glb && ( )} {downloadUrls?.production && ( )} {downloadUrls?.blend && ( )} {/* Capture button */} {/* Close */}
{/* Hint banners */} {!hasProductionGlb && (
No Production GLB yet. Go to the product page and click "Generate Production GLB" to create a high-quality version with PBR materials and proper mesh smoothing.
)} {!hasGeometryGlb && hasProductionGlb && onGenerateGeometry && (
Showing Production GLB. Generate a Geometry GLB to enable the mode toggle and compare geometry vs. production quality. {isGeneratingGeometry ? ( Generating… ) : ( )}
)} {/* Viewport */}
{loadError && (

Failed to load 3D model

{loadError}

)} {!modelReady && !loadError && } {activeUrl && ( )} {capturing && ( )}
) }