feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
@@ -0,0 +1,255 @@
import {
Suspense,
useRef,
useCallback,
useState,
useEffect,
Component,
type ErrorInfo,
type ReactNode,
} from 'react'
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 } from 'lucide-react'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ThreeDViewerProps {
cadFileId: string
onClose: () => void
}
// ---------------------------------------------------------------------------
// Inner model loader separated so Suspense can catch it
// ---------------------------------------------------------------------------
function GltfModel({ url }: { url: string }) {
const { scene } = useGLTF(url)
return <primitive object={scene} />
}
// ---------------------------------------------------------------------------
// 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
// Grab the canvas as a data-URL after the current frame has been rendered
const dataUrl = gl.domElement.toDataURL('image/png')
// Convert data-URL → Blob without a network fetch:
// data:[<mediatype>][;base64],<data>
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((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
console.error('Thumbnail upload failed', msg)
toast.error('Failed to save thumbnail')
})
.finally(() => {
didCapture.current = false
onDone()
})
})
return null
}
// ---------------------------------------------------------------------------
// Error boundary for the GLTF loader inside Suspense
// ---------------------------------------------------------------------------
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 (shown while model resolves inside Canvas)
// ---------------------------------------------------------------------------
function LoadingOverlay() {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-3 pointer-events-none z-10">
<Loader2 size={40} className="animate-spin text-accent" />
<p className="text-sm text-gray-300">Loading 3D model</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Model loader with resolved tracking
// ---------------------------------------------------------------------------
interface ModelWithReadyProps {
url: string
onReady: () => void
}
function ModelWithReady({ url, onReady }: ModelWithReadyProps) {
const { scene } = useGLTF(url)
useEffect(() => {
onReady()
}, [onReady])
return <primitive object={scene} />
}
// ---------------------------------------------------------------------------
// Main exported component
// ---------------------------------------------------------------------------
export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps) {
const modelUrl = `/api/cad/${cadFileId}/model`
const [capturing, setCapturing] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [modelReady, setModelReady] = useState(false)
const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), [])
const handleCaptureDone = useCallback(() => setCapturing(false), [])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
{/* ------------------------------------------------------------------ */}
{/* Toolbar */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
<div className="flex items-center gap-3">
<button
onClick={() => 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 ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Camera size={15} />
)}
{capturing ? 'Capturing…' : 'Capture Angle'}
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
aria-label="Close viewer"
>
<X size={20} />
</button>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Viewport */}
{/* ------------------------------------------------------------------ */}
<div className="relative flex-1">
{/* Error state */}
{loadError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-4 z-20">
<AlertTriangle size={48} className="text-red-400" />
<p className="text-lg font-semibold">Failed to load 3D model</p>
<p className="text-sm text-gray-400 max-w-sm text-center">{loadError}</p>
<button
onClick={onClose}
className="mt-2 px-4 py-2 rounded-md bg-gray-700 hover:bg-gray-600 text-sm transition-colors"
>
Close
</button>
</div>
)}
{/* Loading overlay visible until model signals ready */}
{!modelReady && !loadError && <LoadingOverlay />}
{/* Three.js Canvas */}
<Canvas
camera={{ position: [0, 2, 5], fov: 45 }}
gl={{ preserveDrawingBuffer: true }}
style={{ width: '100%', height: '100%', background: '#111827' }}
>
{/* Lights */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
{/* GLTF model */}
<GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}>
<ModelWithReady url={modelUrl} onReady={handleModelReady} />
</Suspense>
</GltfErrorBoundary>
{/* Camera controls */}
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
{/* Environment map for PBR materials */}
<Environment preset="city" />
{/* Screenshot capture only active when triggered */}
{capturing && (
<ScreenshotCapture
enabled={capturing}
cadFileId={cadFileId}
onDone={handleCaptureDone}
/>
)}
</Canvas>
</div>
</div>
)
}