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:
2026-03-13 10:37:23 +01:00
parent 577dd1ca7e
commit d843162e5f
12 changed files with 764 additions and 351 deletions
+84 -59
View File
@@ -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}
/>
)}