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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user