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 (
)
}
// ---------------------------------------------------------------------------
// 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 (
setOpen((o) => !o)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
{value}
{open && (
{ENV_PRESETS.map((p) => (
{ onChange(p); setOpen(false) }}
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-700 transition-colors ${
p === value ? 'text-accent font-semibold' : 'text-gray-300'
}`}
>
{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 && (
setMode('geometry')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
mode === 'geometry'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
Geometry
setMode('production')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
mode === 'production'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
Production
)}
{/* Wireframe toggle */}
setWireframe((w) => !w)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors border ${
wireframe
? 'bg-accent border-accent text-white'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:bg-gray-700'
}`}
>
Wireframe
{/* Environment preset */}
{/* Download buttons */}
{downloadUrls?.glb && (
handleDownload(downloadUrls.glb!, `${cadFileId}_geometry.glb`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
Geometry GLB
)}
{downloadUrls?.production && (
handleDownload(downloadUrls.production!, `${cadFileId}_production.glb`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
Production GLB
)}
{downloadUrls?.blend && (
handleDownload(downloadUrls.blend!, `${cadFileId}.blend`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium transition-colors"
>
.blend
)}
{/* Capture button */}
setCapturing(true)}
disabled={capturing || !modelReady || loadError !== null}
className="flex items-center gap-2 px-4 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{capturing ? : }
{capturing ? 'Capturing…' : 'Capture Angle'}
{/* 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…
) : (
Generate Geometry GLB
)}
)}
{/* Viewport */}
{loadError && (
Failed to load 3D model
{loadError}
Close
)}
{!modelReady && !loadError &&
}
{activeUrl && (
)}
{capturing && (
)}
)
}