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
+11 -8
View File
@@ -241,14 +241,17 @@ function LibraryCard({ lib }: { lib: AssetLibrary }) {
</button>
{expanded && (
<div className="mt-2 flex flex-wrap gap-1">
{lib.catalog.materials.slice(0, MAX_VISIBLE).map((m) => (
<span
key={m}
className="text-xs px-2 py-0.5 rounded bg-surface-alt border border-border-default text-content-secondary font-mono"
>
{m}
</span>
))}
{lib.catalog.materials.slice(0, MAX_VISIBLE).map((m) => {
const name = typeof m === 'string' ? m : m.name
return (
<span
key={name}
className="text-xs px-2 py-0.5 rounded bg-surface-alt border border-border-default text-content-secondary font-mono"
>
{name}
</span>
)
})}
{materialCount > MAX_VISIBLE && (
<span className="text-xs px-2 py-0.5 rounded bg-surface-muted text-content-muted">
... and {materialCount - MAX_VISIBLE} more
+1 -13
View File
@@ -27,14 +27,6 @@ export default function CadPreviewPage() {
refetchInterval: generating ? 3_000 : false,
})
// Load production GLB if available
const { data: productionAssets } = useQuery({
queryKey: ['media-assets', id, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: id!, asset_types: ['gltf_production'] }),
enabled: !!id,
staleTime: 30_000,
})
// Load blend assets for download
const { data: blendAssets } = useQuery({
queryKey: ['media-assets', id, 'blend_production'],
@@ -66,7 +58,6 @@ export default function CadPreviewPage() {
}
const latestGltf = gltfAssets?.[0]
const latestProduction = productionAssets?.[0]
const latestBlend = blendAssets?.[0]
// While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer)
@@ -80,7 +71,7 @@ export default function CadPreviewPage() {
}
// No GLB at all — show generate prompt
if (!latestGltf && !latestProduction) {
if (!latestGltf) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800">
@@ -129,14 +120,11 @@ export default function CadPreviewPage() {
cadFileId={id}
onClose={() => navigate(-1)}
geometryGltfUrl={latestGltf?.download_url ?? undefined}
productionGltfUrl={latestProduction?.download_url ?? undefined}
hasGeometryGlb={!!latestGltf}
hasProductionGlb={!!latestProduction}
isGeneratingGeometry={generating}
onGenerateGeometry={() => generateMutation.mutate()}
downloadUrls={{
glb: latestGltf?.download_url ?? undefined,
production: latestProduction?.download_url ?? undefined,
blend: latestBlend?.download_url ?? undefined,
}}
/>
+18 -6
View File
@@ -22,7 +22,7 @@ import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivilege
import { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials } from '../components/cad/cadUtils'
import { convertCadPartMaterials, normalizeMeshName } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
function GlbDownloadButton({
@@ -163,12 +163,24 @@ export default function ProductDetailPage() {
if (!product || materialsDirty) return
const parsedNames = product.cad_parsed_objects ?? []
if (parsedNames.length > 0) {
// Build rows from parsed STEP objects, pre-filling any saved material assignments
const savedMap = new Map(
(product.cad_part_materials || []).map((m) => [m.part_name, m.material])
)
// Deduplicate by normalized name — instances like _AF0, _AF1 share the same material
const seen = new Set<string>()
const uniqueNames: string[] = []
for (const name of parsedNames) {
const normalized = normalizeMeshName(name)
if (!seen.has(normalized)) {
seen.add(normalized)
uniqueNames.push(normalized)
}
}
// Pre-fill from saved assignments (try both exact and normalized keys)
const savedMap = new Map<string, string>()
for (const m of (product.cad_part_materials || [])) {
const key = normalizeMeshName(m.part_name)
if (!savedMap.has(key) && m.material) savedMap.set(key, m.material)
}
setMaterialRows(
parsedNames.map((name) => ({ part_name: name, material: savedMap.get(name) ?? '' }))
uniqueNames.map((name) => ({ part_name: name, material: savedMap.get(name) ?? '' }))
)
} else {
// Fallback: show whatever is saved (no parsed objects yet)