feat(PBR): extract Blender PBR properties and apply in 3D viewer
Extract Base Color, Metallic, Roughness, Transmission, IOR from Blender asset library materials via catalog_assets.py. Store in catalog JSON and serve via /api/asset-libraries/pbr-map endpoint. Frontend viewers apply PBR properties to Three.js MeshStandardMaterial using hex color strings (avoiding Three.js ColorManagement sRGB/linear issues). Key fixes: - RLS bypass for material alias lookup in pbr-map endpoint - pbrMap empty guard prevents premature grey fallback in viewers - Cache-Control: no-cache on pbr-map requests to avoid stale data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,16 @@ 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, Cpu, AlertCircle, EyeOff } from 'lucide-react'
|
||||
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff } 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, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
|
||||
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
|
||||
type ViewMode = 'solid' | 'wireframe'
|
||||
type GlbSource = 'geometry' | 'production'
|
||||
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
|
||||
|
||||
const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
|
||||
@@ -180,7 +180,6 @@ export default function InlineCadViewer({
|
||||
const [loadingGlb, setLoadingGlb] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('solid')
|
||||
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
|
||||
const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
|
||||
const [modelReady, setModelReady] = useState(false)
|
||||
const [fitTrigger, setFitTrigger] = useState(0)
|
||||
@@ -192,6 +191,7 @@ export default function InlineCadViewer({
|
||||
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
|
||||
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)
|
||||
@@ -205,12 +205,6 @@ export default function InlineCadViewer({
|
||||
refetchInterval: generating ? 4_000 : false,
|
||||
})
|
||||
|
||||
const { data: productionAssets } = useQuery({
|
||||
queryKey: ['media-assets', cadFileId, 'gltf_production'],
|
||||
queryFn: () => getMediaAssets({ cad_file_id: cadFileId, asset_types: ['gltf_production'] }),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Part-material assignments — from CadFile (manual assignments in viewer)
|
||||
const { data: savedPartMaterials = {} } = useQuery({
|
||||
queryKey: ['part-materials', cadFileId],
|
||||
@@ -219,10 +213,24 @@ export default function InlineCadViewer({
|
||||
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(
|
||||
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
|
||||
[initialPartMaterials, savedPartMaterials],
|
||||
() => 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
|
||||
@@ -235,20 +243,9 @@ export default function InlineCadViewer({
|
||||
if (generating && gltfAssets && gltfAssets.length > 0) setGenerating(false)
|
||||
}, [generating, gltfAssets])
|
||||
|
||||
const hasGeometry = (gltfAssets?.length ?? 0) > 0
|
||||
const hasProduction = (productionAssets?.length ?? 0) > 0
|
||||
const hasGeometry = (gltfAssets?.length ?? 0) > 0
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer production GLB when available — it has correct materials and a clean
|
||||
// GMSH mesh. Fall back to geometry GLB only when no production GLB exists yet.
|
||||
if (hasProduction) setGlbSource('production')
|
||||
else setGlbSource('geometry')
|
||||
}, [hasGeometry, hasProduction])
|
||||
|
||||
const activeDownloadUrl =
|
||||
glbSource === 'production'
|
||||
? productionAssets?.[0]?.download_url
|
||||
: gltfAssets?.[0]?.download_url
|
||||
const activeDownloadUrl = gltfAssets?.[0]?.download_url
|
||||
|
||||
// Fetch active GLB as blob URL (needs auth header)
|
||||
useEffect(() => {
|
||||
@@ -268,21 +265,36 @@ export default function InlineCadViewer({
|
||||
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
|
||||
}, [activeDownloadUrl, token])
|
||||
|
||||
// Apply saved material colors after model loads or when assignments change
|
||||
// Apply saved material colors + PBR properties after model loads
|
||||
useEffect(() => {
|
||||
if (!modelReady || !sceneRef.current) return
|
||||
// Wait for PBR map to load — avoids setting grey fallback prematurely
|
||||
if (Object.keys(pbrMap).length === 0) return
|
||||
sceneRef.current.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const entry = resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
|
||||
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
|
||||
const entry = resolvePartMaterial(pk, partMaterials as PartMaterialMap)
|
||||
if (!entry) return
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
mats.forEach((m) => {
|
||||
// Clone materials on first PBR application (GLB loader shares instances)
|
||||
if (!mesh.userData._pbrApplied) {
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map(m => m.clone())
|
||||
: mesh.material.clone()
|
||||
mesh.userData._pbrApplied = true
|
||||
}
|
||||
const clonedMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
clonedMats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
|
||||
if (!mat || !('color' in mat)) return
|
||||
if (entry.type === 'library' && pbrMap[entry.value]) {
|
||||
applyPBRToMaterial(mat, pbrMap[entry.value])
|
||||
} else {
|
||||
mat.color.set(previewColorForEntry(entry, pbrMap))
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [modelReady, partMaterials])
|
||||
}, [modelReady, partMaterials, resolvePartKey, pbrMap])
|
||||
|
||||
// Unassigned glow — only when at least one assignment exists
|
||||
useEffect(() => {
|
||||
@@ -296,7 +308,8 @@ export default function InlineCadViewer({
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
if (showUnassigned && hasAnyAssignment) {
|
||||
const assigned = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)
|
||||
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
|
||||
const assigned = !!resolvePartMaterial(pk, partMaterials as PartMaterialMap)
|
||||
mat.emissive.set(assigned ? 0x000000 : 0xff4400)
|
||||
mat.emissiveIntensity = assigned ? 0 : 0.8
|
||||
} else {
|
||||
@@ -305,7 +318,7 @@ export default function InlineCadViewer({
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [modelReady, showUnassigned, partMaterials])
|
||||
}, [modelReady, showUnassigned, partMaterials, resolvePartKey])
|
||||
|
||||
// Reset isolateMode when no part is pinned
|
||||
useEffect(() => {
|
||||
@@ -323,9 +336,9 @@ export default function InlineCadViewer({
|
||||
sceneRef.current.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
const isSelected = normalizedName === pinnedPart
|
||||
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials)
|
||||
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
|
||||
const isSelected = pk === pinnedPart
|
||||
const isAssigned = !!resolvePartMaterial(pk, partMaterials)
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
// Default: fully visible + raycasting enabled
|
||||
@@ -356,7 +369,7 @@ export default function InlineCadViewer({
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey])
|
||||
|
||||
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
|
||||
useEffect(() => {
|
||||
@@ -400,7 +413,8 @@ export default function InlineCadViewer({
|
||||
prevMats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
if (showUnassigned && hasAny && !resolvePartMaterial(normalizeMeshName((prev.userData?.name as string) || prev.name), partMaterials as PartMaterialMap)) {
|
||||
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
|
||||
@@ -413,7 +427,7 @@ export default function InlineCadViewer({
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
|
||||
})
|
||||
}, [showUnassigned, partMaterials])
|
||||
}, [showUnassigned, partMaterials, resolvePartKey])
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
if (hoveredMeshRef.current) {
|
||||
@@ -423,7 +437,8 @@ export default function InlineCadViewer({
|
||||
mats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials as PartMaterialMap)) {
|
||||
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
|
||||
@@ -431,14 +446,14 @@ export default function InlineCadViewer({
|
||||
})
|
||||
hoveredMeshRef.current = null
|
||||
}
|
||||
}, [showUnassigned, partMaterials])
|
||||
}, [showUnassigned, partMaterials, resolvePartKey])
|
||||
|
||||
const handleClick = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
const meshObj = e.object as THREE.Mesh
|
||||
const name = normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '')
|
||||
if (name) setPinnedPart(name)
|
||||
}, [])
|
||||
const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || ''))
|
||||
if (pk) setPinnedPart(pk)
|
||||
}, [resolvePartKey])
|
||||
|
||||
// ── Render: model loaded ──────────────────────────────────────────────────
|
||||
|
||||
@@ -456,19 +471,6 @@ export default function InlineCadViewer({
|
||||
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()}
|
||||
>
|
||||
{/* Geo / PBR toggle */}
|
||||
{hasGeometry && hasProduction && (
|
||||
<>
|
||||
<ToolbarBtn active={glbSource === 'geometry'} onClick={() => setGlbSource('geometry')} title="Geometry GLB (OCC)">
|
||||
<Box size={11} /> Geo
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn active={glbSource === 'production'} onClick={() => setGlbSource('production')} title="Production GLB (Blender PBR)">
|
||||
<Cpu size={11} /> PBR
|
||||
</ToolbarBtn>
|
||||
<div className="w-px h-4 bg-white/10 mx-0.5" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View mode */}
|
||||
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
|
||||
<Layers size={11} /> Solid
|
||||
@@ -528,9 +530,31 @@ export default function InlineCadViewer({
|
||||
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)
|
||||
// Propagate partKey from parent Group to child Meshes
|
||||
sceneRef.current?.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
if (obj.userData.partKey) return
|
||||
const parentPk = obj.parent?.userData?.partKey as string | undefined
|
||||
if (parentPk) { obj.userData.partKey = parentPk; return }
|
||||
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
const pk = map[normalized] ?? normalized
|
||||
if (pk) obj.userData.partKey = pk
|
||||
})
|
||||
}
|
||||
// Count unique parts by partKey
|
||||
const names = new Set<string>()
|
||||
sceneRef.current?.traverse(o => {
|
||||
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
|
||||
if ((o as THREE.Mesh).isMesh) {
|
||||
const pk = o.userData?.partKey as string | undefined
|
||||
if (pk) { names.add(pk); return }
|
||||
const normalized = normalizeMeshName((o.userData?.name as string) || o.name)
|
||||
if (normalized) names.add(map?.[normalized] ?? normalized)
|
||||
}
|
||||
})
|
||||
setTotalMeshCount(names.size)
|
||||
setGlbMeshNames(new Set(names))
|
||||
@@ -556,6 +580,7 @@ export default function InlineCadViewer({
|
||||
onClose={() => setPinnedPart(null)}
|
||||
isolateMode={isolateMode}
|
||||
onIsolateModeChange={setIsolateMode}
|
||||
pbrMap={pbrMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,35 +4,8 @@ import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../../api/client'
|
||||
import { savePartMaterials, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SCHAEFFLER_COLORS: Record<string, string> = {
|
||||
'SCHAEFFLER_010101_Steel-Bare': '#8a9ca8',
|
||||
'SCHAEFFLER_010102_Steel-Polished': '#b0c4ce',
|
||||
'SCHAEFFLER_010103_Steel-Brushed': '#9aabb5',
|
||||
'SCHAEFFLER_010104_Steel-Painted': '#607080',
|
||||
'SCHAEFFLER_010201_Stainless-Bare': '#adb9bf',
|
||||
'SCHAEFFLER_010202_Stainless-Polished': '#cdd8dc',
|
||||
'SCHAEFFLER_010301_Iron-Cast': '#696969',
|
||||
'SCHAEFFLER_020101_Aluminium-Bare': '#c8c8c8',
|
||||
'SCHAEFFLER_020102_Aluminium-Anodized': '#b0b8c0',
|
||||
'SCHAEFFLER_030101_Brass': '#c9a84c',
|
||||
'SCHAEFFLER_030201_Bronze': '#a07040',
|
||||
'SCHAEFFLER_040101_Copper': '#b87333',
|
||||
'SCHAEFFLER_050101_Plastic-Black': '#202020',
|
||||
'SCHAEFFLER_050102_Plastic-White': '#f0f0f0',
|
||||
'SCHAEFFLER_050201_Rubber-Black': '#1a1a1a',
|
||||
'SCHAEFFLER_060101_Ceramic': '#e8dcc8',
|
||||
'SCHAEFFLER_070101_Glass': '#88bbcc',
|
||||
}
|
||||
|
||||
export function previewColorForEntry(entry: PartMaterialEntry): string {
|
||||
if (entry.type === 'hex') return entry.value
|
||||
return SCHAEFFLER_COLORS[entry.value] ?? '#888888'
|
||||
}
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
import { previewColorForEntry, pbrColorHex } from './cadUtils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MaterialOut — matches GET /api/materials response
|
||||
@@ -68,6 +41,8 @@ export interface MaterialPanelProps {
|
||||
isPartKeyMode?: boolean
|
||||
/** Current manual overrides map (needed to merge when saving in partKey mode) */
|
||||
manualOverrides?: Record<string, string>
|
||||
/** PBR material map from Blender asset library */
|
||||
pbrMap?: MaterialPBRMap
|
||||
}
|
||||
|
||||
export default function MaterialPanel({
|
||||
@@ -82,9 +57,19 @@ export default function MaterialPanel({
|
||||
assignmentProvenance,
|
||||
isPartKeyMode = false,
|
||||
manualOverrides = {},
|
||||
pbrMap: pbrMapProp,
|
||||
}: MaterialPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch PBR data if not passed via props
|
||||
const { data: pbrMapFetched } = useQuery({
|
||||
queryKey: ['material-pbr'],
|
||||
queryFn: fetchMaterialPBR,
|
||||
staleTime: 300_000,
|
||||
enabled: !pbrMapProp,
|
||||
})
|
||||
const pbrMap = pbrMapProp ?? pbrMapFetched ?? {}
|
||||
|
||||
// Fetch all tenant materials (no filter — user sees their full library)
|
||||
const { data: allMaterials = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
@@ -180,9 +165,12 @@ export default function MaterialPanel({
|
||||
}
|
||||
|
||||
const isBusy = saveMut.isPending || removeMut.isPending || manualSaveMut.isPending || manualRemoveMut.isPending
|
||||
|
||||
// Preview color for selected material
|
||||
const selectedPbr = pbrMap[libValue]
|
||||
const previewHex = assignType === 'hex'
|
||||
? hexValue
|
||||
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
|
||||
: (selectedPbr ? pbrColorHex(selectedPbr) : '#888888')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -304,13 +292,18 @@ export default function MaterialPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview swatch */}
|
||||
{/* Preview swatch with PBR info */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-400">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
|
||||
style={{ backgroundColor: previewHex }}
|
||||
/>
|
||||
<span>Viewport preview color</span>
|
||||
<span className="flex-1">Preview</span>
|
||||
{assignType === 'library' && selectedPbr && (
|
||||
<span className="text-[10px] text-gray-500 font-mono">
|
||||
M:{selectedPbr.metallic.toFixed(1)} R:{selectedPbr.roughness.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current assignment */}
|
||||
@@ -318,7 +311,7 @@ export default function MaterialPanel({
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
|
||||
style={{ backgroundColor: previewColorForEntry(currentEntry) }}
|
||||
style={{ backgroundColor: previewColorForEntry(currentEntry, pbrMap) }}
|
||||
/>
|
||||
<span className="truncate">Current: {currentEntry.value}</span>
|
||||
</div>
|
||||
|
||||
@@ -24,15 +24,16 @@ import {
|
||||
import * as THREE from 'three'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown,
|
||||
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
|
||||
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import api from '../../api/client'
|
||||
import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad'
|
||||
import { fetchSceneManifest } from '../../api/sceneManifest'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
|
||||
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -43,19 +44,14 @@ export interface ThreeDViewerProps {
|
||||
onClose: () => void
|
||||
/** URL for the geometry-only GLB (from OCC export) */
|
||||
geometryGltfUrl?: string
|
||||
/** URL for the production-quality GLB (Blender + PBR materials) */
|
||||
productionGltfUrl?: string
|
||||
hasGeometryGlb?: boolean
|
||||
hasProductionGlb?: boolean
|
||||
onGenerateGeometry?: () => void
|
||||
isGeneratingGeometry?: boolean
|
||||
downloadUrls?: { glb?: string; production?: string; blend?: string }
|
||||
downloadUrls?: { glb?: string; blend?: string }
|
||||
/** Pre-loaded material assignments from Product.cad_part_materials (Excel-driven) */
|
||||
initialPartMaterials?: PartMaterialMap
|
||||
}
|
||||
|
||||
type ViewMode = 'geometry' | 'production'
|
||||
|
||||
const ENV_PRESETS = [
|
||||
'city', 'sunset', 'dawn', 'night', 'warehouse',
|
||||
'forest', 'apartment', 'studio', 'park', 'lobby',
|
||||
@@ -359,19 +355,15 @@ export default function ThreeDViewer({
|
||||
cadFileId,
|
||||
onClose,
|
||||
geometryGltfUrl,
|
||||
productionGltfUrl,
|
||||
hasGeometryGlb,
|
||||
hasProductionGlb,
|
||||
onGenerateGeometry,
|
||||
isGeneratingGeometry,
|
||||
downloadUrls,
|
||||
initialPartMaterials,
|
||||
}: ThreeDViewerProps) {
|
||||
const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry'
|
||||
const token = useAuthStore((s) => s.token)
|
||||
|
||||
// View state
|
||||
const [mode, setMode] = useState<ViewMode>(initialMode)
|
||||
const [wireframe, setWireframe] = useState(false)
|
||||
const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
@@ -453,24 +445,33 @@ export default function ThreeDViewer({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// PBR material properties from Blender asset library (metallic, roughness, base_color)
|
||||
const { data: pbrMap = {} as MaterialPBRMap } = useQuery({
|
||||
queryKey: ['material-pbr'],
|
||||
queryFn: fetchMaterialPBR,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
|
||||
// Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides
|
||||
const partMaterials = useMemo(
|
||||
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
|
||||
[initialPartMaterials, savedPartMaterials],
|
||||
)
|
||||
|
||||
// Effective materials: merge partMaterials (old normalized-name keys) +
|
||||
// manualOverrides (new partKey slug keys). Both key formats coexist so
|
||||
// existing GLBs (no partKeyMap) and new GLBs (with partKeyMap) work correctly.
|
||||
// Effective materials: remap Excel-imported keys to partKey slugs (when
|
||||
// partKeyMap is available), then layer manual overrides on top.
|
||||
const effectiveMaterials = useMemo(() => {
|
||||
// Remap normalized OCC name keys → partKey slugs so they match mesh resolution
|
||||
const remapped = remapToPartKeys(partMaterials, partKeyMap)
|
||||
// Manual overrides are already keyed by partKey slug
|
||||
const fromManual: PartMaterialMap = Object.fromEntries(
|
||||
Object.entries(manualOverrides).map(([k, v]) => [
|
||||
k,
|
||||
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
|
||||
])
|
||||
)
|
||||
return { ...partMaterials, ...fromManual }
|
||||
}, [partMaterials, manualOverrides])
|
||||
return { ...remapped, ...fromManual }
|
||||
}, [partMaterials, manualOverrides, partKeyMap])
|
||||
|
||||
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
|
||||
const resolvePartKey = useCallback(
|
||||
@@ -484,10 +485,8 @@ export default function ThreeDViewer({
|
||||
[glbMeshNames, effectiveMaterials],
|
||||
)
|
||||
|
||||
// Raw URL selected by mode (used as stable key before blob fetch)
|
||||
const rawActiveUrl = mode === 'production' && productionGltfUrl
|
||||
? productionGltfUrl
|
||||
: geometryGltfUrl ?? productionGltfUrl
|
||||
// Raw URL (used as stable key before blob fetch)
|
||||
const rawActiveUrl = geometryGltfUrl
|
||||
|
||||
// Resolved blob URL used in useGLTF (requires auth header)
|
||||
const activeUrl = blobUrl
|
||||
@@ -537,24 +536,30 @@ export default function ThreeDViewer({
|
||||
const map = glbExtras.partKeyMap as Record<string, string> | undefined
|
||||
if (map && Object.keys(map).length > 0) {
|
||||
setPartKeyMap(map)
|
||||
// Task 2: Stamp userData.partKey on every mesh (fallback for meshes whose
|
||||
// GLB node extras were not populated — e.g. files generated before Task 1).
|
||||
// For new GLBs, Three.js already set userData.partKey from node extras;
|
||||
// the guard `if (obj.userData.partKey) return` avoids overwriting it.
|
||||
// Stamp userData.partKey on every mesh. Three.js splits multi-primitive
|
||||
// GLB nodes into Group + child Meshes — the partKey extras land on the
|
||||
// parent Group, not on individual Mesh objects. We propagate it down.
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
if (obj.userData.partKey) return // already set by GLB node extras
|
||||
// Check parent Group (Three.js multi-primitive split)
|
||||
const parentPk = obj.parent?.userData?.partKey as string | undefined
|
||||
if (parentPk) { obj.userData.partKey = parentPk; return }
|
||||
// Fallback: lookup in partKeyMap by normalized name
|
||||
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
const pk = map[normalized] ?? normalized
|
||||
if (pk) obj.userData.partKey = pk
|
||||
})
|
||||
}
|
||||
|
||||
// Count unique parts by partKey (deduplicated across multi-primitive splits)
|
||||
const names = new Set<string>()
|
||||
sceneRef.current.traverse(o => {
|
||||
if ((o as THREE.Mesh).isMesh && o.name) {
|
||||
if ((o as THREE.Mesh).isMesh) {
|
||||
const pk = o.userData?.partKey as string | undefined
|
||||
if (pk) { names.add(pk); return }
|
||||
const normalized = normalizeMeshName((o.userData?.name as string) || o.name)
|
||||
names.add(map?.[normalized] ?? normalized)
|
||||
if (normalized) names.add(map?.[normalized] ?? normalized)
|
||||
}
|
||||
})
|
||||
setTotalMeshCount(names.size)
|
||||
@@ -566,22 +571,36 @@ export default function ThreeDViewer({
|
||||
if (modelReady) setFitTrigger(t => t + 1)
|
||||
}, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Task 6 — apply saved material colors after model loads or when effectiveMaterials changes
|
||||
// Task 6 — apply saved material colors + PBR properties after model loads
|
||||
useEffect(() => {
|
||||
if (!modelReady || !sceneRef.current) return
|
||||
// Skip when pbrMap hasn't loaded yet — avoid setting grey fallback prematurely
|
||||
if (Object.keys(pbrMap).length === 0) return
|
||||
sceneRef.current.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
|
||||
if (!entry) return
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
mats.forEach((m) => {
|
||||
// Clone materials on first PBR application (GLB loader shares instances)
|
||||
if (!mesh.userData._pbrApplied) {
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map(m => m.clone())
|
||||
: mesh.material.clone()
|
||||
mesh.userData._pbrApplied = true
|
||||
}
|
||||
const clonedMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
clonedMats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
|
||||
if (!mat || !('color' in mat)) return
|
||||
if (entry.type === 'library' && pbrMap[entry.value]) {
|
||||
applyPBRToMaterial(mat, pbrMap[entry.value])
|
||||
} else {
|
||||
mat.color.set(previewColorForEntry(entry, pbrMap))
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [modelReady, effectiveMaterials, resolvePartKey])
|
||||
}, [modelReady, effectiveMaterials, resolvePartKey, pbrMap])
|
||||
|
||||
// Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast)
|
||||
useEffect(() => {
|
||||
@@ -683,8 +702,6 @@ export default function ThreeDViewer({
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const hasBothModes = !!(geometryGltfUrl && productionGltfUrl)
|
||||
|
||||
// Task 5 — hover: highlight mesh with emissive, restore on out
|
||||
const handlePointerOver = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
@@ -763,24 +780,6 @@ export default function ThreeDViewer({
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
|
||||
{/* Mode toggle: Geometry / Production */}
|
||||
{hasBothModes && (
|
||||
<div className="flex rounded-md overflow-hidden border border-gray-700">
|
||||
{(['geometry', 'production'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
mode === m ? 'bg-accent text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{m === 'geometry' ? <Box size={11} /> : <Cpu size={11} />}
|
||||
{m === 'geometry' ? 'Geo' : 'PBR'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wireframe */}
|
||||
<TBtn active={wireframe} onClick={() => setWireframe(v => !v)} title="Wireframe (W)">Wire</TBtn>
|
||||
|
||||
@@ -885,15 +884,7 @@ export default function ThreeDViewer({
|
||||
onClick={() => handleDownload(downloadUrls.glb!, `${cadFileId}_geometry.glb`)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium"
|
||||
>
|
||||
<Download size={11} /> Geo
|
||||
</button>
|
||||
)}
|
||||
{downloadUrls?.production && (
|
||||
<button
|
||||
onClick={() => handleDownload(downloadUrls.production!, `${cadFileId}_prod.glb`)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-md bg-gray-700 hover:bg-gray-600 text-white text-xs font-medium"
|
||||
>
|
||||
<Download size={11} /> Prod
|
||||
<Download size={11} /> GLB
|
||||
</button>
|
||||
)}
|
||||
{downloadUrls?.blend && (
|
||||
@@ -926,24 +917,6 @@ export default function ThreeDViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Hint banners ───────────────────────────────────────────────────── */}
|
||||
{!hasProductionGlb && (
|
||||
<div className="bg-amber-900/60 border-b border-amber-700/50 px-4 py-2 flex items-center gap-2 text-amber-200 text-xs shrink-0">
|
||||
<Cpu size={13} className="shrink-0" />
|
||||
<span><strong>No Production GLB yet.</strong> Generate a high-quality version with PBR materials from the product page.</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasGeometryGlb && hasProductionGlb && onGenerateGeometry && (
|
||||
<div className="bg-blue-900/50 border-b border-blue-700/50 px-4 py-2 flex items-center gap-3 text-blue-200 text-xs shrink-0">
|
||||
<Box size={13} className="shrink-0" />
|
||||
<span><strong>Showing Production GLB.</strong> Generate a Geometry GLB to enable mode toggle.</span>
|
||||
{isGeneratingGeometry
|
||||
? <span className="flex items-center gap-1 ml-auto shrink-0 text-blue-300"><Loader2 size={11} className="animate-spin" /> Generating…</span>
|
||||
: <button onClick={onGenerateGeometry} className="ml-auto shrink-0 px-3 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white text-xs font-medium">Generate Geometry GLB</button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Viewport ───────────────────────────────────────────────────────── */}
|
||||
{/* onClick stops propagation so mesh-clicks don't bubble to the outer setPinnedPart(null) */}
|
||||
<div className="relative flex-1" onPointerMove={handlePointerMove} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -994,6 +967,7 @@ export default function ThreeDViewer({
|
||||
assignmentProvenance={sceneManifest?.parts.find(p => p.part_key === pinnedPart)?.assignment_provenance}
|
||||
isPartKeyMode={Object.keys(partKeyMap).length > 0}
|
||||
manualOverrides={manualOverrides}
|
||||
pbrMap={pbrMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PartMaterialEntry, PartMaterialMap } from '../../api/cad'
|
||||
import type { MaterialPBR, MaterialPBRMap } from '../../api/assetLibraries'
|
||||
|
||||
/**
|
||||
* Normalize a GLB mesh name by stripping suffixes added by the export pipeline:
|
||||
@@ -61,6 +62,44 @@ export function resolvePartMaterial(
|
||||
return bestKey ? partMaterials[bestKey] : undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remapToPartKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remap a PartMaterialMap keyed by normalized OCC names to partKey slugs.
|
||||
*
|
||||
* When partKeyMap is available (from GLB extras), Excel-imported material keys
|
||||
* like "GE360-HF_000_P_ASM_1" are converted to partKey slugs like
|
||||
* "ge360_hf_000_p_asm_1" so they match what the viewer resolves each mesh to.
|
||||
*
|
||||
* Keys not found in partKeyMap are preserved as-is (backwards compat for old GLBs).
|
||||
*/
|
||||
export function remapToPartKeys(
|
||||
materials: PartMaterialMap,
|
||||
partKeyMap: Record<string, string>,
|
||||
): PartMaterialMap {
|
||||
if (!partKeyMap || Object.keys(partKeyMap).length === 0) return materials
|
||||
const mapKeys = Object.keys(partKeyMap)
|
||||
const result: PartMaterialMap = {}
|
||||
for (const [key, entry] of Object.entries(materials)) {
|
||||
// 1. Exact match
|
||||
if (partKeyMap[key]) { result[partKeyMap[key]] = entry; continue }
|
||||
// 2. Prefix match: cad_part_materials may have extra _1 instance suffixes
|
||||
// that partKeyMap doesn't (e.g. "PART_04_1" vs partKeyMap "PART_04")
|
||||
let matched = false
|
||||
for (const mk of mapKeys) {
|
||||
if (key.startsWith(mk + '_') || key === mk) {
|
||||
result[partKeyMap[mk]] = entry
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) result[key] = entry // preserve unmapped
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// convertCadPartMaterials
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,3 +124,51 @@ export function convertCadPartMaterials(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PBR material helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply PBR material properties from the Blender asset library to a
|
||||
* Three.js MeshStandardMaterial.
|
||||
*
|
||||
* The `mat` parameter is typed as `any` to avoid importing THREE in this
|
||||
* utility module — callers pass `THREE.MeshStandardMaterial` instances.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applyPBRToMaterial(mat: any, pbr: MaterialPBR): void {
|
||||
if (!mat || !('color' in mat)) return
|
||||
// Use hex string via color.set() — reliable across all Three.js versions and
|
||||
// avoids colorSpace/gamma issues with setRGB() + ColorManagement.
|
||||
mat.color.set(pbrColorHex(pbr))
|
||||
mat.metalness = pbr.metallic
|
||||
mat.roughness = pbr.roughness
|
||||
if (pbr.transmission && pbr.transmission > 0.1) {
|
||||
mat.transparent = true
|
||||
mat.opacity = 1 - pbr.transmission * 0.7
|
||||
}
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
/** Convert PBR base_color to hex string for UI swatches. */
|
||||
export function pbrColorHex(pbr: MaterialPBR): string {
|
||||
const [r, g, b] = pbr.base_color
|
||||
return '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview hex color for a material entry, using PBR data when available.
|
||||
* Replaces the old hardcoded SCHAEFFLER_COLORS lookup.
|
||||
*/
|
||||
export function previewColorForEntry(
|
||||
entry: PartMaterialEntry,
|
||||
pbrMap?: MaterialPBRMap,
|
||||
): string {
|
||||
if (entry.type === 'hex') return entry.value
|
||||
if (pbrMap) {
|
||||
const pbr = pbrMap[entry.value]
|
||||
if (pbr) return pbrColorHex(pbr)
|
||||
}
|
||||
return '#888888'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user