import { Box, Loader2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { readAssetBytes } from '../../services/assetReadUrlService'; type ThreeModule = typeof import('three'); type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF; type PreviewStatus = 'empty' | 'loading' | 'ready' | 'fallback'; type Match3DModelPreviewProps = { modelSrc?: string | null; className?: string; }; function hasWebGLSupport() { try { const canvas = document.createElement('canvas'); return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl')); } catch { return false; } } function disposeThreeObject(object: import('three').Object3D) { object.traverse((child) => { const mesh = child as import('three').Mesh; mesh.geometry?.dispose(); const material = mesh.material; if (Array.isArray(material)) { material.forEach((item) => item.dispose()); } else { material?.dispose(); } }); } function applyCanvasLayout(canvas: HTMLCanvasElement) { canvas.style.display = 'block'; canvas.style.height = '100%'; canvas.style.inset = '0'; canvas.style.position = 'absolute'; canvas.style.width = '100%'; } function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D) { const bounds = new three.Box3().setFromObject(model); const size = bounds.getSize(new three.Vector3()); const maxDimension = Math.max(size.x, size.y, size.z, 0.001); const scale = 1.45 / maxDimension; model.scale.setScalar(scale); const centeredBounds = new three.Box3().setFromObject(model); const center = centeredBounds.getCenter(new three.Vector3()); model.position.sub(center); } export function Match3DModelPreview({ modelSrc, className = '', }: Match3DModelPreviewProps) { const containerRef = useRef(null); const canvasHostRef = useRef(null); const runtimeRef = useRef<{ animationId: number | null; cleanup: (() => void) | null; renderer: import('three').WebGLRenderer; } | null>(null); const [status, setStatus] = useState( modelSrc ? 'loading' : 'empty', ); useEffect(() => { const container = containerRef.current; const canvasHost = canvasHostRef.current; if (!container || !canvasHost) { return undefined; } const source = modelSrc?.trim() ?? ''; if (!source) { setStatus('empty'); runtimeRef.current?.cleanup?.(); runtimeRef.current = null; canvasHost.replaceChildren(); return undefined; } let cancelled = false; let objectUrl: string | null = null; const teardown = () => { const runtime = runtimeRef.current; if (runtime?.animationId != null) { window.cancelAnimationFrame(runtime.animationId); } runtime?.cleanup?.(); runtime?.renderer.dispose(); runtime?.renderer.domElement.remove(); runtimeRef.current = null; if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = null; } canvasHost.replaceChildren(); }; const setup = async () => { if (!hasWebGLSupport()) { setStatus('fallback'); return; } setStatus('loading'); try { const [three, loaderModule, response] = await Promise.all([ import('three'), import('three/examples/jsm/loaders/GLTFLoader.js'), readAssetBytes(source, { expireSeconds: 600 }), ]); if (cancelled || !containerRef.current) { return; } const bytes = await response.arrayBuffer(); if (bytes.byteLength === 0) { throw new Error('empty model'); } const blob = new Blob([bytes], { type: 'model/gltf-binary', }); objectUrl = URL.createObjectURL(blob); const renderer = new three.WebGLRenderer({ alpha: true, antialias: true, }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); renderer.outputColorSpace = three.SRGBColorSpace; applyCanvasLayout(renderer.domElement); canvasHost.appendChild(renderer.domElement); const scene = new three.Scene(); scene.background = null; const camera = new three.PerspectiveCamera(35, 1, 0.1, 100); camera.position.set(0.06, 0.92, 2.8); camera.lookAt(0, 0.1, 0); scene.add(new three.AmbientLight(0xffffff, 1.55)); const keyLight = new three.DirectionalLight(0xffffff, 2.8); keyLight.position.set(-3.6, 4.8, 3.5); scene.add(keyLight); const fillLight = new three.DirectionalLight(0xfef3c7, 0.62); fillLight.position.set(2.5, 1.8, -3.2); scene.add(fillLight); const rimLight = new three.DirectionalLight(0xffffff, 0.8); rimLight.position.set(1.8, 3.6, -4.4); scene.add(rimLight); const modelRoot = new three.Group(); modelRoot.rotation.set(0.2, 0.45, 0.02); scene.add(modelRoot); const loader = new loaderModule.GLTFLoader(); const gltf = await new Promise( (resolve, reject) => { loader.load( objectUrl as string, (loaded: GltfPayload) => resolve(loaded), undefined, (error) => reject(error), ); }, ); if (cancelled) { const cancelledModel = gltf.scene ?? gltf.scenes[0]; if (cancelledModel) { disposeThreeObject(cancelledModel); } return; } const model = gltf.scene ?? gltf.scenes[0]; if (!model) { throw new Error('missing model scene'); } modelRoot.add(model); centerAndScaleModel(three, model); const resize = () => { const rect = container.getBoundingClientRect(); const width = Math.max(1, rect.width); const height = Math.max(1, rect.height); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.render(scene, camera); }; const resizeObserver = window.ResizeObserver ? new window.ResizeObserver(resize) : null; resizeObserver?.observe(container); resize(); const pointerState = { dragging: false, lastX: 0, lastY: 0, }; const handlePointerDown = (event: PointerEvent) => { if (event.button !== 0) { return; } pointerState.dragging = true; pointerState.lastX = event.clientX; pointerState.lastY = event.clientY; if (typeof container.setPointerCapture === 'function') { container.setPointerCapture(event.pointerId); } }; const handlePointerMove = (event: PointerEvent) => { if (!pointerState.dragging) { return; } const deltaX = event.clientX - pointerState.lastX; const deltaY = event.clientY - pointerState.lastY; pointerState.lastX = event.clientX; pointerState.lastY = event.clientY; modelRoot.rotation.y += deltaX * 0.01; modelRoot.rotation.x = Math.max( -1.15, Math.min(1.15, modelRoot.rotation.x + deltaY * 0.01), ); renderer.render(scene, camera); }; const handlePointerEnd = (event: PointerEvent) => { pointerState.dragging = false; if ( typeof container.hasPointerCapture === 'function' && container.hasPointerCapture(event.pointerId) ) { container.releasePointerCapture(event.pointerId); } }; container.addEventListener('pointerdown', handlePointerDown); container.addEventListener('pointermove', handlePointerMove); container.addEventListener('pointerup', handlePointerEnd); container.addEventListener('pointercancel', handlePointerEnd); const animate = () => { renderer.render(scene, camera); if (runtimeRef.current) { runtimeRef.current.animationId = window.requestAnimationFrame(animate); } }; runtimeRef.current = { animationId: window.requestAnimationFrame(animate), cleanup: () => { resizeObserver?.disconnect(); container.removeEventListener('pointerdown', handlePointerDown); container.removeEventListener('pointermove', handlePointerMove); container.removeEventListener('pointerup', handlePointerEnd); container.removeEventListener('pointercancel', handlePointerEnd); disposeThreeObject(modelRoot); }, renderer, }; setStatus('ready'); } catch { if (!cancelled) { setStatus('fallback'); } } }; void setup(); return () => { cancelled = true; teardown(); }; }, [modelSrc]); return (