import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Canvas, useThree } from '@react-three/fiber' import { OrbitControls, useGLTF, Environment } from '@react-three/drei' import * as THREE from 'three' import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff, Zap } from 'lucide-react' import { toast } from 'sonner' import { listMediaAssets as getMediaAssets } from '../../api/media' import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad' import { useAuthStore } from '../../store/auth' import MaterialPanel, { type IsolateMode } from './MaterialPanel' import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' import { useGeometryMerge } from './useGeometryMerge' import WebGLErrorBoundary from './WebGLErrorBoundary' type ViewMode = 'solid' | 'wireframe' type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city' const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [ { id: 'studio', label: 'Studio' }, { id: 'warehouse', label: 'Warehouse' }, { id: 'sunset', label: 'Sunset' }, { id: 'park', label: 'Park' }, { id: 'city', label: 'City' }, ] // --------------------------------------------------------------------------- // CameraAutoFit — auto-fits camera to model bounding box on first load // --------------------------------------------------------------------------- function CameraAutoFit({ sceneRef, controlsRef, fitTrigger, }: { sceneRef: React.MutableRefObject controlsRef: React.RefObject fitTrigger: number }) { const { camera, size } = useThree() useEffect(() => { if (fitTrigger === 0 || !sceneRef.current) return const box = new THREE.Box3() sceneRef.current.traverse((obj) => { if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj) }) if (box.isEmpty()) return const center = box.getCenter(new THREE.Vector3()) const sizeVec = box.getSize(new THREE.Vector3()) const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z) const pc = camera as THREE.PerspectiveCamera const fovRad = (pc.fov * Math.PI) / 180 const aspect = size.width / size.height const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect) const dist = (maxDim / 2) / Math.tan(Math.min(fovRad, fovH) / 2) * 1.6 camera.position.set(center.x + maxDim * 0.05, center.y + maxDim * 0.2, center.z + dist) camera.near = maxDim * 0.001 camera.far = maxDim * 100 camera.updateProjectionMatrix() camera.lookAt(center) if (controlsRef.current) { controlsRef.current.target.copy(center) controlsRef.current.minDistance = maxDim * 0.05 controlsRef.current.maxDistance = maxDim * 20 controlsRef.current.update() } }, [fitTrigger]) // eslint-disable-line react-hooks/exhaustive-deps return null } // --------------------------------------------------------------------------- // GlbModelWithFit — loads GLB, stores scene ref, signals ready, pointer events // --------------------------------------------------------------------------- function GlbModelWithFit({ url, wireframe, sceneRef, onReady, onPointerOver, onPointerOut, onClick, }: { url: string wireframe: boolean sceneRef: React.MutableRefObject onReady: () => void onPointerOver?: (e: any) => void onPointerOut?: () => void onClick?: (e: any) => void }) { const { scene } = useGLTF(url) const cloned = useRef(null) if (!cloned.current) { cloned.current = scene.clone(true) cloned.current.traverse((obj) => { if (obj instanceof THREE.Mesh) { if (obj.geometry) { let geo = obj.geometry.clone() if (!geo.index) geo = mergeVertices(geo) // Only compute normals if the geometry doesn't already have them. // GLBs from our pipeline include smooth normals — overwriting them // with computeVertexNormals() produces flat/faceted shading. if (!geo.attributes.normal) geo.computeVertexNormals() obj.geometry = geo } // Clone materials so emissive / color changes don't affect the shared GLTF cache if (obj.material) { obj.material = Array.isArray(obj.material) ? obj.material.map((m: THREE.Material) => m.clone()) : obj.material.clone() } } }) } useEffect(() => { cloned.current?.traverse((obj) => { if (obj instanceof THREE.Mesh && obj.material) { const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((m) => { ;(m as THREE.MeshStandardMaterial).wireframe = wireframe m.needsUpdate = true }) } }) }, [wireframe]) useEffect(() => { sceneRef.current = cloned.current onReady() }, []) // eslint-disable-line react-hooks/exhaustive-deps return ( ) } const HEIGHT = 560 function ToolbarBtn({ active, onClick, children, title, }: { active: boolean; onClick: () => void; children: React.ReactNode; title?: string }) { return ( ) } export default function InlineCadViewer({ cadFileId, thumbnailUrl, initialPartMaterials, }: { cadFileId: string thumbnailUrl?: string | null initialPartMaterials?: PartMaterialMap }) { const token = useAuthStore((s) => s.token) const qc = useQueryClient() // GLB source / display state const [glbBlobUrl, setGlbBlobUrl] = useState(null) const [loadingGlb, setLoadingGlb] = useState(false) const [generating, setGenerating] = useState(false) const [viewMode, setViewMode] = useState('solid') const [lightPreset, setLightPreset] = useState('studio') const [modelReady, setModelReady] = useState(false) const [fitTrigger, setFitTrigger] = useState(0) // Material assignment state const [pinnedPart, setPinnedPart] = useState(null) const [showUnassigned, setShowUnassigned] = useState(false) const [hideAssigned, setHideAssigned] = useState(false) const [isolateMode, setIsolateMode] = useState('none') const [perfMode, setPerfMode] = useState(false) const [totalMeshCount, setTotalMeshCount] = useState(0) const [glbMeshNames, setGlbMeshNames] = useState>(new Set()) const [partKeyMap, setPartKeyMap] = useState>({}) const sceneRef = useRef(null) const controlsRef = useRef(null) const hoveredMeshRef = useRef(null) const meshRegistryRef = useRef([]) // Media asset queries const { data: gltfAssets } = useQuery({ queryKey: ['media-assets', cadFileId, 'gltf_geometry'], queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_geometry'] }), staleTime: 0, refetchInterval: generating ? 4_000 : false, }) // Part-material assignments — from CadFile (manual assignments in viewer) const { data: savedPartMaterials = {} } = useQuery({ queryKey: ['part-materials', cadFileId], queryFn: () => getPartMaterials(cadFileId), staleTime: 30_000, retry: false, }) // PBR material properties from Blender asset library const { data: pbrMap = {} as MaterialPBRMap } = useQuery({ queryKey: ['material-pbr'], queryFn: fetchMaterialPBR, staleTime: 300_000, }) // Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides // Remap keys through partKeyMap so Excel-imported names match partKey slugs const partMaterials = useMemo( () => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap), [initialPartMaterials, savedPartMaterials, partKeyMap], ) // Resolve partKey from normalized mesh name (identity fallback when no map loaded) const resolvePartKey = useCallback( (normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName, [partKeyMap], ) // Count how many unique GLB mesh types have a resolved material assignment const assignedCount = useMemo( () => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length, [glbMeshNames, partMaterials], ) useEffect(() => { if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false) }, [generating, gltfAssets]) const hasGeometry = (gltfAssets?.length ?? 0) > 0 const activeDownloadUrl = gltfAssets?.[0]?.download_url // Fetch active GLB as blob URL (needs auth header) useEffect(() => { if (!activeDownloadUrl || !token) return setGlbBlobUrl(null) setModelReady(false) setLoadingGlb(true) let blobUrl = '' fetch(activeDownloadUrl, { headers: { Authorization: `Bearer ${token}` } }) .then((r) => r.blob()) .then((blob) => { blobUrl = URL.createObjectURL(blob) setGlbBlobUrl(blobUrl) }) .catch(() => toast.error('Failed to load 3D model')) .finally(() => setLoadingGlb(false)) return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) } }, [activeDownloadUrl, token]) // Apply saved material colors + PBR properties after model loads (uses MeshRegistry) useEffect(() => { if (!modelReady || meshRegistryRef.current.length === 0) return if (Object.keys(pbrMap).length === 0) return for (const { mesh, partKey } of meshRegistryRef.current) { const entry = resolvePartMaterial(partKey, partMaterials as PartMaterialMap) if (!entry) continue // Clone materials on first PBR application (GLB loader shares instances) if (!mesh.userData._pbrApplied) { mesh.material = Array.isArray(mesh.material) ? mesh.material.map((m: THREE.Material) => m.clone()) : mesh.material.clone() mesh.userData._pbrApplied = true } forEachMeshMaterial(mesh, (mat) => { if (entry.type === 'library' && pbrMap[entry.value]) { applyPBRToMaterial(mat, pbrMap[entry.value]) } else { mat.color.set(previewColorForEntry(entry, pbrMap)) } }) } }, [modelReady, partMaterials, resolvePartKey, pbrMap]) // Unassigned glow — uses MeshRegistry instead of traverse useEffect(() => { if (!modelReady || meshRegistryRef.current.length === 0) return const hasAnyAssignment = Object.keys(partMaterials).length > 0 for (const { mesh, partKey } of meshRegistryRef.current) { forEachMeshMaterial(mesh, (mat) => { if (!('emissive' in mat)) return if (showUnassigned && hasAnyAssignment) { const assigned = !!resolvePartMaterial(partKey, partMaterials as PartMaterialMap) mat.emissive.set(assigned ? 0x000000 : 0xff4400) mat.emissiveIntensity = assigned ? 0 : 0.8 } else { mat.emissive.set(0x000000) mat.emissiveIntensity = 0 } }) } }, [modelReady, showUnassigned, partMaterials, resolvePartKey]) // Reset isolateMode when no part is pinned useEffect(() => { if (!pinnedPart) setIsolateMode('none') }, [pinnedPart]) // Reset hideAssigned when all assignments are cleared useEffect(() => { if (Object.keys(partMaterials).length === 0) setHideAssigned(false) }, [partMaterials]) // Combined visibility effect — uses MeshRegistry instead of traverse useEffect(() => { if (!modelReady || meshRegistryRef.current.length === 0) return for (const { mesh, partKey } of meshRegistryRef.current) { const isSelected = partKey === pinnedPart const isAssigned = !!resolvePartMaterial(partKey, partMaterials) // Default: fully visible + raycasting enabled mesh.visible = true mesh.raycast = THREE.Mesh.prototype.raycast forEachMeshMaterial(mesh, (mat) => { if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true } }) // hideAssigned: hide all assigned meshes (except the currently selected part) if (hideAssigned && isAssigned && !isSelected) { mesh.visible = false mesh.raycast = () => {} continue } // isolateMode: ghost or hide non-selected meshes when a part is pinned if (!isSelected && pinnedPart && isolateMode !== 'none') { if (isolateMode === 'hide') { mesh.visible = false mesh.raycast = () => {} } else { forEachMeshMaterial(mesh, (mat) => { if ('opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true } }) } } } }, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey]) // Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches useEffect(() => { if (!import.meta.env.DEV || !modelReady || meshRegistryRef.current.length === 0) return const names = new Set(meshRegistryRef.current.map(e => e.partKey)) const keys = Object.keys(partMaterials) const matched = keys.filter(k => names.has(k)) const unmatched = keys.filter(k => !names.has(k)) console.debug('[CAD] Match status:', { totalGlbMeshes: names.size, totalStoredKeys: keys.length, matched: matched.length, unmatched: unmatched.length, unmatchedKeys: unmatched, glbNames: [...names].sort(), }) }, [modelReady, partMaterials]) const generateMut = useMutation({ mutationFn: () => generateGltfGeometry(cadFileId), onSuccess: () => { toast.info('Generating 3D model…') setGenerating(true) qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_geometry'] }) }, onError: () => toast.error('Failed to queue GLB generation'), }) // Performance mode: merge geometries by material to reduce draw calls useGeometryMerge({ meshRegistryRef, partMaterials, pbrMap, enabled: perfMode, sceneRef, }) // Hover highlight const handlePointerOver = useCallback((e: any) => { e.stopPropagation() const mesh = e.object as THREE.Mesh // Restore previous hovered mesh (correctly preserve unassigned glow) if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) { const prev = hoveredMeshRef.current const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material] const hasAny = Object.keys(partMaterials).length > 0 prevMats.forEach((m) => { const mat = m as THREE.MeshStandardMaterial if (!mat || !('emissive' in mat)) return const prevPk = (prev.userData?.partKey as string) || resolvePartKey(normalizeMeshName((prev.userData?.name as string) || prev.name)) if (showUnassigned && hasAny && !resolvePartMaterial(prevPk, partMaterials as PartMaterialMap)) { mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8 } else { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 } }) } hoveredMeshRef.current = mesh const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] mats.forEach((m) => { const mat = m as THREE.MeshStandardMaterial if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 } }) }, [showUnassigned, partMaterials, resolvePartKey]) const handlePointerOut = useCallback(() => { if (hoveredMeshRef.current) { const mesh = hoveredMeshRef.current const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] const hasAnyAssignment = Object.keys(partMaterials).length > 0 mats.forEach((m) => { const mat = m as THREE.MeshStandardMaterial if (!mat || !('emissive' in mat)) return const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name)) if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(pk, partMaterials as PartMaterialMap)) { mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8 } else { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 } }) hoveredMeshRef.current = null } }, [showUnassigned, partMaterials, resolvePartKey]) const handleClick = useCallback((e: any) => { e.stopPropagation() const meshObj = e.object as THREE.Mesh const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '')) if (pk) setPinnedPart(pk) }, [resolvePartKey]) // ── Render: model loaded ────────────────────────────────────────────────── if (glbBlobUrl) { const pm = partMaterials as PartMaterialMap return (
setPinnedPart(null)} > {/* ── Toolbar row — real block element above the canvas ── */}
e.stopPropagation()} > {/* View mode */} setViewMode('solid')} title="Solid"> Solid setViewMode('wireframe')} title="Wireframe"> Wire
{/* Lighting */} {LIGHT_PRESETS.map((p) => ( setLightPreset(p.id)} title={p.label}> {p.label} ))}
{/* Performance mode */} setPerfMode(v => !v)} title="Performance mode — merges geometries, disables per-part hover"> Perf {/* Show unassigned + hide assigned toggles */} {modelReady && ( <>
setShowUnassigned(v => !v)} title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`} > {assignedCount}/{totalMeshCount} {assignedCount > 0 && ( setHideAssigned(v => !v)} title="Hide parts that already have a material assigned" > Hide assigned )} )}
{/* ── Canvas area ── */}
e.stopPropagation()}> { // Extract partKeyMap from GLB extras const glbExtras = (sceneRef.current as any)?.userData ?? {} const map = glbExtras.partKeyMap as Record | undefined if (map && Object.keys(map).length > 0) { setPartKeyMap(map) } // Single traverse: stamp partKey, build registry, count unique parts const registry: MeshRegistryEntry[] = [] const names = new Set() sceneRef.current?.traverse((obj) => { if (!(obj instanceof THREE.Mesh)) return // Stamp partKey from parent Group or partKeyMap if (!obj.userData.partKey) { const parentPk = obj.parent?.userData?.partKey as string | undefined if (parentPk) { obj.userData.partKey = parentPk } else if (map) { const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name) obj.userData.partKey = map[normalized] ?? normalized } } const pk = (obj.userData?.partKey as string) || normalizeMeshName((obj.userData?.name as string) || obj.name) registry.push({ mesh: obj, partKey: pk }) if (pk) names.add(pk) }) meshRegistryRef.current = registry setTotalMeshCount(names.size) setGlbMeshNames(new Set(names)) setModelReady(true) setFitTrigger(t => t + 1) }} onPointerOver={perfMode ? undefined : handlePointerOver} onPointerOut={perfMode ? undefined : handlePointerOut} onClick={perfMode ? undefined : handleClick} /> {/* Material assignment panel */} {pinnedPart && ( setPinnedPart(null)} isolateMode={isolateMode} onIsolateModeChange={setIsolateMode} pbrMap={pbrMap} /> )} {/* Hint */}
click part to assign material
) } // ── Render: loading ─────────────────────────────────────────────────────── if (loadingGlb) { return (
Loading 3D model…
) } // ── Render: no GLB yet ──────────────────────────────────────────────────── return (
{thumbnailUrl ? ( CAD thumbnail ) : ( )}
) }