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
+26 -33
View File
@@ -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>