b583b0d7a2
- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
641 lines
25 KiB
TypeScript
641 lines
25 KiB
TypeScript
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<THREE.Object3D | null>
|
|
controlsRef: React.RefObject<any>
|
|
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<THREE.Object3D | null>
|
|
onReady: () => void
|
|
onPointerOver?: (e: any) => void
|
|
onPointerOut?: () => void
|
|
onClick?: (e: any) => void
|
|
}) {
|
|
const { scene } = useGLTF(url)
|
|
const cloned = useRef<THREE.Group | null>(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 (
|
|
<primitive
|
|
object={cloned.current}
|
|
onPointerOver={onPointerOver}
|
|
onPointerOut={onPointerOut}
|
|
onClick={onClick}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const HEIGHT = 560
|
|
|
|
function ToolbarBtn({
|
|
active, onClick, children, title,
|
|
}: { active: boolean; onClick: () => void; children: React.ReactNode; title?: string }) {
|
|
return (
|
|
<button
|
|
title={title}
|
|
onClick={onClick}
|
|
className={`px-2 py-1 text-[11px] flex items-center gap-1 transition-colors rounded ${
|
|
active ? 'bg-white/20 text-white' : 'text-white/50 hover:text-white/80 hover:bg-white/10'
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
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<string | null>(null)
|
|
const [loadingGlb, setLoadingGlb] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
const [viewMode, setViewMode] = useState<ViewMode>('solid')
|
|
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
|
|
const [modelReady, setModelReady] = useState(false)
|
|
const [fitTrigger, setFitTrigger] = useState(0)
|
|
|
|
// Material assignment state
|
|
const [pinnedPart, setPinnedPart] = useState<string | null>(null)
|
|
const [showUnassigned, setShowUnassigned] = useState(false)
|
|
const [hideAssigned, setHideAssigned] = useState(false)
|
|
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
|
|
const [perfMode, setPerfMode] = useState(false)
|
|
const [totalMeshCount, setTotalMeshCount] = useState(0)
|
|
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
|
|
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
|
|
|
|
const sceneRef = useRef<THREE.Object3D | null>(null)
|
|
const controlsRef = useRef<any>(null)
|
|
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
|
|
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
|
|
|
|
// 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<string>(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 (
|
|
<div
|
|
className="w-full rounded-lg border border-border-default bg-gray-950 flex flex-col overflow-hidden"
|
|
style={{ height: HEIGHT }}
|
|
onClick={() => setPinnedPart(null)}
|
|
>
|
|
{/* ── Toolbar row — real block element above the canvas ── */}
|
|
<div
|
|
className="shrink-0 flex items-center gap-0.5 px-2 py-1 bg-black/70 border-b border-white/10 flex-wrap"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* View mode */}
|
|
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
|
|
<Layers size={11} /> Solid
|
|
</ToolbarBtn>
|
|
<ToolbarBtn active={viewMode === 'wireframe'} onClick={() => setViewMode('wireframe')} title="Wireframe">
|
|
<Grid3X3 size={11} /> Wire
|
|
</ToolbarBtn>
|
|
|
|
<div className="w-px h-4 bg-white/10 mx-0.5" />
|
|
|
|
{/* Lighting */}
|
|
<Sun size={11} className="text-white/30 mx-1" />
|
|
{LIGHT_PRESETS.map((p) => (
|
|
<ToolbarBtn key={p.id} active={lightPreset === p.id} onClick={() => setLightPreset(p.id)} title={p.label}>
|
|
{p.label}
|
|
</ToolbarBtn>
|
|
))}
|
|
|
|
<div className="w-px h-4 bg-white/10 mx-0.5" />
|
|
|
|
{/* Performance mode */}
|
|
<ToolbarBtn active={perfMode} onClick={() => setPerfMode(v => !v)} title="Performance mode — merges geometries, disables per-part hover">
|
|
<Zap size={11} /> Perf
|
|
</ToolbarBtn>
|
|
|
|
{/* Show unassigned + hide assigned toggles */}
|
|
{modelReady && (
|
|
<>
|
|
<div className="w-px h-4 bg-white/10 mx-0.5" />
|
|
<ToolbarBtn
|
|
active={showUnassigned}
|
|
onClick={() => setShowUnassigned(v => !v)}
|
|
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
|
|
>
|
|
<AlertCircle size={11} />
|
|
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
|
|
</ToolbarBtn>
|
|
{assignedCount > 0 && (
|
|
<ToolbarBtn
|
|
active={hideAssigned}
|
|
onClick={() => setHideAssigned(v => !v)}
|
|
title="Hide parts that already have a material assigned"
|
|
>
|
|
<EyeOff size={11} />
|
|
<span className="text-[10px]">Hide assigned</span>
|
|
</ToolbarBtn>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Canvas area ── */}
|
|
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
|
|
<WebGLErrorBoundary>
|
|
<Canvas
|
|
gl={{ powerPreference: 'high-performance', antialias: true }}
|
|
dpr={[1, 1.5]}
|
|
camera={{ position: [0, 0, 2], fov: 45 }}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<Environment preset={lightPreset} background={false} />
|
|
<GlbModelWithFit
|
|
key={glbBlobUrl}
|
|
url={glbBlobUrl}
|
|
wireframe={viewMode === 'wireframe'}
|
|
sceneRef={sceneRef}
|
|
onReady={() => {
|
|
// Extract partKeyMap from GLB extras
|
|
const glbExtras = (sceneRef.current as any)?.userData ?? {}
|
|
const map = glbExtras.partKeyMap as Record<string, string> | 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<string>()
|
|
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}
|
|
/>
|
|
</Suspense>
|
|
<OrbitControls ref={controlsRef} makeDefault />
|
|
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
|
|
</Canvas>
|
|
</WebGLErrorBoundary>
|
|
|
|
{/* Material assignment panel */}
|
|
{pinnedPart && (
|
|
<MaterialPanel
|
|
partName={pinnedPart}
|
|
cadFileId={cadFileId}
|
|
currentEntry={resolvePartMaterial(pinnedPart, pm)}
|
|
partMaterials={pm}
|
|
onClose={() => setPinnedPart(null)}
|
|
isolateMode={isolateMode}
|
|
onIsolateModeChange={setIsolateMode}
|
|
pbrMap={pbrMap}
|
|
/>
|
|
)}
|
|
|
|
{/* Hint */}
|
|
<div className="absolute bottom-1.5 right-2 text-gray-600 text-[10px] pointer-events-none select-none">
|
|
click part to assign material
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Render: loading ───────────────────────────────────────────────────────
|
|
|
|
if (loadingGlb) {
|
|
return (
|
|
<div
|
|
className="w-full rounded-lg border border-border-default bg-surface-muted flex items-center justify-center"
|
|
style={{ height: HEIGHT }}
|
|
>
|
|
<div className="flex flex-col items-center gap-2 text-content-muted">
|
|
<Loader2 size={28} className="animate-spin" />
|
|
<span className="text-xs">Loading 3D model…</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Render: no GLB yet ────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div
|
|
className="w-full rounded-lg border border-border-default bg-surface-muted flex flex-col items-center justify-center gap-3"
|
|
style={{ height: HEIGHT }}
|
|
>
|
|
{thumbnailUrl ? (
|
|
<img src={thumbnailUrl} alt="CAD thumbnail" className="max-h-52 object-contain" />
|
|
) : (
|
|
<Box size={48} className="text-content-muted" />
|
|
)}
|
|
<button
|
|
className="btn-secondary text-xs"
|
|
onClick={() => generateMut.mutate()}
|
|
disabled={generateMut.isPending || generating}
|
|
title="Export geometry GLB from STEP via OCC and load 3D viewer"
|
|
>
|
|
<RefreshCw size={12} className={generating ? 'animate-spin' : ''} />
|
|
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|