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
+47 -1
View File
@@ -4,13 +4,15 @@ import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from app.database import get_db from app.database import get_db
from sqlalchemy import text
from app.config import settings from app.config import settings
from app.domains.materials.models import AssetLibrary from app.domains.materials.models import AssetLibrary, Material, MaterialAlias
from app.utils.auth import require_admin_or_pm from app.utils.auth import require_admin_or_pm
router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"]) router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"])
@@ -58,6 +60,50 @@ def _to_out(lib: AssetLibrary) -> dict:
# ── Endpoints ───────────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/pbr-map")
async def get_material_pbr_map(db: AsyncSession = Depends(get_db)):
"""PBR properties for all materials in the active asset library.
Public (no auth) — needed by all 3D viewers.
Returns keyed by canonical name AND all known aliases so the viewer can
look up materials by raw Excel names (e.g. "Steel--Stahl") without needing
a separate alias resolution step.
"""
result = await db.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) # noqa: E712
)
lib = result.scalar_one_or_none()
if not lib or not lib.catalog:
return JSONResponse(content={}, headers={"Cache-Control": "public, max-age=3600"})
materials = lib.catalog.get("materials", [])
pbr_map: dict = {}
for m in materials:
if isinstance(m, str):
continue # old format — skip
pbr_map[m["name"]] = {
"base_color": m.get("base_color", [0.5, 0.5, 0.5]),
"metallic": m.get("metallic", 0.0),
"roughness": m.get("roughness", 0.5),
"transmission": m.get("transmission", 0.0),
"ior": m.get("ior", 1.45),
}
# Also index by aliases so frontend can look up by raw Excel names
# (e.g. "Steel--Stahl" → same PBR as "SCHAEFFLER_010101_Steel-Bare")
# Bypass RLS — this is public data and aliases may have NULL tenant_id
if pbr_map:
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
alias_result = await db.execute(
select(MaterialAlias.alias, Material.name)
.join(Material, MaterialAlias.material_id == Material.id)
)
for alias, canonical_name in alias_result.all():
if canonical_name in pbr_map and alias not in pbr_map:
pbr_map[alias] = pbr_map[canonical_name]
return JSONResponse(content=pbr_map, headers={"Cache-Control": "public, max-age=3600"})
@router.get("", response_model=list[AssetLibraryOut]) @router.get("", response_model=list[AssetLibraryOut])
async def list_asset_libraries( async def list_asset_libraries(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+35 -1
View File
@@ -1,7 +1,41 @@
import api from './client' import api from './client'
// ---------------------------------------------------------------------------
// PBR material properties (from Blender Principled BSDF)
// ---------------------------------------------------------------------------
export interface MaterialPBR {
base_color: [number, number, number] // sRGB 0-1
metallic: number // 0-1
roughness: number // 0-1
transmission?: number // 0-1
ior?: number // typically 1.45
}
export type MaterialPBRMap = Record<string, MaterialPBR>
export async function fetchMaterialPBR(): Promise<MaterialPBRMap> {
const { data } = await api.get<MaterialPBRMap>('/asset-libraries/pbr-map', {
headers: { 'Cache-Control': 'no-cache' },
})
return data
}
// ---------------------------------------------------------------------------
// Asset library catalog
// ---------------------------------------------------------------------------
export interface AssetLibraryCatalogMaterial {
name: string
base_color?: number[]
metallic?: number
roughness?: number
transmission?: number
ior?: number
}
export interface AssetLibraryCatalog { export interface AssetLibraryCatalog {
materials: string[] materials: Array<string | AssetLibraryCatalogMaterial>
node_groups: string[] node_groups: string[]
} }
+83 -58
View File
@@ -4,16 +4,16 @@ import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei' import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 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 { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media' import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad' import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel' import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils' import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
type ViewMode = 'solid' | 'wireframe' type ViewMode = 'solid' | 'wireframe'
type GlbSource = 'geometry' | 'production'
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city' type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [ const LIGHT_PRESETS: { id: LightPreset; label: string }[] = [
@@ -180,7 +180,6 @@ export default function InlineCadViewer({
const [loadingGlb, setLoadingGlb] = useState(false) const [loadingGlb, setLoadingGlb] = useState(false)
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>('solid') const [viewMode, setViewMode] = useState<ViewMode>('solid')
const [glbSource, setGlbSource] = useState<GlbSource>('geometry')
const [lightPreset, setLightPreset] = useState<LightPreset>('studio') const [lightPreset, setLightPreset] = useState<LightPreset>('studio')
const [modelReady, setModelReady] = useState(false) const [modelReady, setModelReady] = useState(false)
const [fitTrigger, setFitTrigger] = useState(0) const [fitTrigger, setFitTrigger] = useState(0)
@@ -192,6 +191,7 @@ export default function InlineCadViewer({
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none') const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [totalMeshCount, setTotalMeshCount] = useState(0) const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set()) const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
const sceneRef = useRef<THREE.Object3D | null>(null) const sceneRef = useRef<THREE.Object3D | null>(null)
const controlsRef = useRef<any>(null) const controlsRef = useRef<any>(null)
@@ -205,12 +205,6 @@ export default function InlineCadViewer({
refetchInterval: generating ? 4_000 : false, 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) // Part-material assignments — from CadFile (manual assignments in viewer)
const { data: savedPartMaterials = {} } = useQuery({ const { data: savedPartMaterials = {} } = useQuery({
queryKey: ['part-materials', cadFileId], queryKey: ['part-materials', cadFileId],
@@ -219,10 +213,24 @@ export default function InlineCadViewer({
retry: false, 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 // Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
// Remap keys through partKeyMap so Excel-imported names match partKey slugs
const partMaterials = useMemo( const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap), () => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
[initialPartMaterials, savedPartMaterials], [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 // Count how many unique GLB mesh types have a resolved material assignment
@@ -236,19 +244,8 @@ export default function InlineCadViewer({
}, [generating, gltfAssets]) }, [generating, gltfAssets])
const hasGeometry = (gltfAssets?.length ?? 0) > 0 const hasGeometry = (gltfAssets?.length ?? 0) > 0
const hasProduction = (productionAssets?.length ?? 0) > 0
useEffect(() => { const activeDownloadUrl = gltfAssets?.[0]?.download_url
// 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
// Fetch active GLB as blob URL (needs auth header) // Fetch active GLB as blob URL (needs auth header)
useEffect(() => { useEffect(() => {
@@ -268,21 +265,36 @@ export default function InlineCadViewer({
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) } return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [activeDownloadUrl, token]) }, [activeDownloadUrl, token])
// Apply saved material colors after model loads or when assignments change // Apply saved material colors + PBR properties after model loads
useEffect(() => { useEffect(() => {
if (!modelReady || !sceneRef.current) return 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) => { sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return 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 if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] // Clone materials on first PBR application (GLB loader shares instances)
mats.forEach((m) => { 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 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 // Unassigned glow — only when at least one assignment exists
useEffect(() => { useEffect(() => {
@@ -296,7 +308,8 @@ export default function InlineCadViewer({
const mat = m as THREE.MeshStandardMaterial const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment) { 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.emissive.set(assigned ? 0x000000 : 0xff4400)
mat.emissiveIntensity = assigned ? 0 : 0.8 mat.emissiveIntensity = assigned ? 0 : 0.8
} else { } else {
@@ -305,7 +318,7 @@ export default function InlineCadViewer({
} }
}) })
}) })
}, [modelReady, showUnassigned, partMaterials]) }, [modelReady, showUnassigned, partMaterials, resolvePartKey])
// Reset isolateMode when no part is pinned // Reset isolateMode when no part is pinned
useEffect(() => { useEffect(() => {
@@ -323,9 +336,9 @@ export default function InlineCadViewer({
sceneRef.current.traverse((obj) => { sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return if (!mesh.isMesh) return
const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name) const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const isSelected = normalizedName === pinnedPart const isSelected = pk === pinnedPart
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials) const isAssigned = !!resolvePartMaterial(pk, partMaterials)
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// Default: fully visible + raycasting enabled // 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 // Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => { useEffect(() => {
@@ -400,7 +413,8 @@ export default function InlineCadViewer({
prevMats.forEach((m) => { prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return 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 mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else { } else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0 mat.emissive.set(0x000000); mat.emissiveIntensity = 0
@@ -413,7 +427,7 @@ export default function InlineCadViewer({
const mat = m as THREE.MeshStandardMaterial const mat = m as THREE.MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 } if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
}) })
}, [showUnassigned, partMaterials]) }, [showUnassigned, partMaterials, resolvePartKey])
const handlePointerOut = useCallback(() => { const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) { if (hoveredMeshRef.current) {
@@ -423,7 +437,8 @@ export default function InlineCadViewer({
mats.forEach((m) => { mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return 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 mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else { } else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0 mat.emissive.set(0x000000); mat.emissiveIntensity = 0
@@ -431,14 +446,14 @@ export default function InlineCadViewer({
}) })
hoveredMeshRef.current = null hoveredMeshRef.current = null
} }
}, [showUnassigned, partMaterials]) }, [showUnassigned, partMaterials, resolvePartKey])
const handleClick = useCallback((e: any) => { const handleClick = useCallback((e: any) => {
e.stopPropagation() e.stopPropagation()
const meshObj = e.object as THREE.Mesh const meshObj = e.object as THREE.Mesh
const name = normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || '') const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || ''))
if (name) setPinnedPart(name) if (pk) setPinnedPart(pk)
}, []) }, [resolvePartKey])
// ── Render: model loaded ────────────────────────────────────────────────── // ── 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" 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()} 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 */} {/* View mode */}
<ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid"> <ToolbarBtn active={viewMode === 'solid'} onClick={() => setViewMode('solid')} title="Solid">
<Layers size={11} /> Solid <Layers size={11} /> Solid
@@ -528,9 +530,31 @@ export default function InlineCadViewer({
wireframe={viewMode === 'wireframe'} wireframe={viewMode === 'wireframe'}
sceneRef={sceneRef} sceneRef={sceneRef}
onReady={() => { 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>() const names = new Set<string>()
sceneRef.current?.traverse(o => { 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) setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names)) setGlbMeshNames(new Set(names))
@@ -556,6 +580,7 @@ export default function InlineCadViewer({
onClose={() => setPinnedPart(null)} onClose={() => setPinnedPart(null)}
isolateMode={isolateMode} isolateMode={isolateMode}
onIsolateModeChange={setIsolateMode} onIsolateModeChange={setIsolateMode}
pbrMap={pbrMap}
/> />
)} )}
+26 -33
View File
@@ -4,35 +4,8 @@ import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import api from '../../api/client' import api from '../../api/client'
import { savePartMaterials, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad' import { savePartMaterials, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
// --------------------------------------------------------------------------- import { previewColorForEntry, pbrColorHex } from './cadUtils'
// 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'
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MaterialOut — matches GET /api/materials response // MaterialOut — matches GET /api/materials response
@@ -68,6 +41,8 @@ export interface MaterialPanelProps {
isPartKeyMode?: boolean isPartKeyMode?: boolean
/** Current manual overrides map (needed to merge when saving in partKey mode) */ /** Current manual overrides map (needed to merge when saving in partKey mode) */
manualOverrides?: Record<string, string> manualOverrides?: Record<string, string>
/** PBR material map from Blender asset library */
pbrMap?: MaterialPBRMap
} }
export default function MaterialPanel({ export default function MaterialPanel({
@@ -82,9 +57,19 @@ export default function MaterialPanel({
assignmentProvenance, assignmentProvenance,
isPartKeyMode = false, isPartKeyMode = false,
manualOverrides = {}, manualOverrides = {},
pbrMap: pbrMapProp,
}: MaterialPanelProps) { }: MaterialPanelProps) {
const queryClient = useQueryClient() 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) // Fetch all tenant materials (no filter — user sees their full library)
const { data: allMaterials = [] } = useQuery({ const { data: allMaterials = [] } = useQuery({
queryKey: ['materials'], queryKey: ['materials'],
@@ -180,9 +165,12 @@ export default function MaterialPanel({
} }
const isBusy = saveMut.isPending || removeMut.isPending || manualSaveMut.isPending || manualRemoveMut.isPending const isBusy = saveMut.isPending || removeMut.isPending || manualSaveMut.isPending || manualRemoveMut.isPending
// Preview color for selected material
const selectedPbr = pbrMap[libValue]
const previewHex = assignType === 'hex' const previewHex = assignType === 'hex'
? hexValue ? hexValue
: (SCHAEFFLER_COLORS[libValue] ?? '#888888') : (selectedPbr ? pbrColorHex(selectedPbr) : '#888888')
return ( return (
<div <div
@@ -304,13 +292,18 @@ export default function MaterialPanel({
</div> </div>
)} )}
{/* Preview swatch */} {/* Preview swatch with PBR info */}
<div className="flex items-center gap-2 text-[11px] text-gray-400"> <div className="flex items-center gap-2 text-[11px] text-gray-400">
<div <div
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0" className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
style={{ backgroundColor: previewHex }} 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> </div>
{/* Current assignment */} {/* 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="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
<div <div
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600" 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> <span className="truncate">Current: {currentEntry.value}</span>
</div> </div>
+54 -80
View File
@@ -24,15 +24,16 @@ import {
import * as THREE from 'three' import * as THREE from 'three'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
X, Camera, Loader2, AlertTriangle, Box, Cpu, Download, ChevronDown, X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff, Maximize2, Grid3X3, Sun, AlertCircle, EyeOff,
} from 'lucide-react' } from 'lucide-react'
import api from '../../api/client' import api from '../../api/client'
import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad' import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad'
import { fetchSceneManifest } from '../../api/sceneManifest' import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel' import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils' import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -43,19 +44,14 @@ export interface ThreeDViewerProps {
onClose: () => void onClose: () => void
/** URL for the geometry-only GLB (from OCC export) */ /** URL for the geometry-only GLB (from OCC export) */
geometryGltfUrl?: string geometryGltfUrl?: string
/** URL for the production-quality GLB (Blender + PBR materials) */
productionGltfUrl?: string
hasGeometryGlb?: boolean hasGeometryGlb?: boolean
hasProductionGlb?: boolean
onGenerateGeometry?: () => void onGenerateGeometry?: () => void
isGeneratingGeometry?: boolean 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) */ /** Pre-loaded material assignments from Product.cad_part_materials (Excel-driven) */
initialPartMaterials?: PartMaterialMap initialPartMaterials?: PartMaterialMap
} }
type ViewMode = 'geometry' | 'production'
const ENV_PRESETS = [ const ENV_PRESETS = [
'city', 'sunset', 'dawn', 'night', 'warehouse', 'city', 'sunset', 'dawn', 'night', 'warehouse',
'forest', 'apartment', 'studio', 'park', 'lobby', 'forest', 'apartment', 'studio', 'park', 'lobby',
@@ -359,19 +355,15 @@ export default function ThreeDViewer({
cadFileId, cadFileId,
onClose, onClose,
geometryGltfUrl, geometryGltfUrl,
productionGltfUrl,
hasGeometryGlb, hasGeometryGlb,
hasProductionGlb,
onGenerateGeometry, onGenerateGeometry,
isGeneratingGeometry, isGeneratingGeometry,
downloadUrls, downloadUrls,
initialPartMaterials, initialPartMaterials,
}: ThreeDViewerProps) { }: ThreeDViewerProps) {
const initialMode: ViewMode = productionGltfUrl && !geometryGltfUrl ? 'production' : 'geometry'
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
// View state // View state
const [mode, setMode] = useState<ViewMode>(initialMode)
const [wireframe, setWireframe] = useState(false) const [wireframe, setWireframe] = useState(false)
const [envPreset, setEnvPreset] = useState<EnvPreset>('city') const [envPreset, setEnvPreset] = useState<EnvPreset>('city')
const [capturing, setCapturing] = useState(false) const [capturing, setCapturing] = useState(false)
@@ -453,24 +445,33 @@ export default function ThreeDViewer({
retry: false, 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 // Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo( const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap), () => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials], [initialPartMaterials, savedPartMaterials],
) )
// Effective materials: merge partMaterials (old normalized-name keys) + // Effective materials: remap Excel-imported keys to partKey slugs (when
// manualOverrides (new partKey slug keys). Both key formats coexist so // partKeyMap is available), then layer manual overrides on top.
// existing GLBs (no partKeyMap) and new GLBs (with partKeyMap) work correctly.
const effectiveMaterials = useMemo(() => { 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( const fromManual: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([k, v]) => [ Object.entries(manualOverrides).map(([k, v]) => [
k, k,
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v }, { type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
]) ])
) )
return { ...partMaterials, ...fromManual } return { ...remapped, ...fromManual }
}, [partMaterials, manualOverrides]) }, [partMaterials, manualOverrides, partKeyMap])
// Resolve partKey from normalized mesh name (identity fallback when no map loaded) // Resolve partKey from normalized mesh name (identity fallback when no map loaded)
const resolvePartKey = useCallback( const resolvePartKey = useCallback(
@@ -484,10 +485,8 @@ export default function ThreeDViewer({
[glbMeshNames, effectiveMaterials], [glbMeshNames, effectiveMaterials],
) )
// Raw URL selected by mode (used as stable key before blob fetch) // Raw URL (used as stable key before blob fetch)
const rawActiveUrl = mode === 'production' && productionGltfUrl const rawActiveUrl = geometryGltfUrl
? productionGltfUrl
: geometryGltfUrl ?? productionGltfUrl
// Resolved blob URL used in useGLTF (requires auth header) // Resolved blob URL used in useGLTF (requires auth header)
const activeUrl = blobUrl const activeUrl = blobUrl
@@ -537,24 +536,30 @@ export default function ThreeDViewer({
const map = glbExtras.partKeyMap as Record<string, string> | undefined const map = glbExtras.partKeyMap as Record<string, string> | undefined
if (map && Object.keys(map).length > 0) { if (map && Object.keys(map).length > 0) {
setPartKeyMap(map) setPartKeyMap(map)
// Task 2: Stamp userData.partKey on every mesh (fallback for meshes whose // Stamp userData.partKey on every mesh. Three.js splits multi-primitive
// GLB node extras were not populated — e.g. files generated before Task 1). // GLB nodes into Group + child Meshes — the partKey extras land on the
// For new GLBs, Three.js already set userData.partKey from node extras; // parent Group, not on individual Mesh objects. We propagate it down.
// the guard `if (obj.userData.partKey) return` avoids overwriting it.
sceneRef.current.traverse((obj) => { sceneRef.current.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return if (!(obj instanceof THREE.Mesh)) return
if (obj.userData.partKey) return // already set by GLB node extras 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 normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
const pk = map[normalized] ?? normalized const pk = map[normalized] ?? normalized
if (pk) obj.userData.partKey = pk if (pk) obj.userData.partKey = pk
}) })
} }
// Count unique parts by partKey (deduplicated across multi-primitive splits)
const names = new Set<string>() const names = new Set<string>()
sceneRef.current.traverse(o => { 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) 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) setTotalMeshCount(names.size)
@@ -566,22 +571,36 @@ export default function ThreeDViewer({
if (modelReady) setFitTrigger(t => t + 1) if (modelReady) setFitTrigger(t => t + 1)
}, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps }, [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(() => { useEffect(() => {
if (!modelReady || !sceneRef.current) return 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) => { sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return if (!mesh.isMesh) return
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name) const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials) const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
if (!entry) return if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] // Clone materials on first PBR application (GLB loader shares instances)
mats.forEach((m) => { 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 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) // Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast)
useEffect(() => { useEffect(() => {
@@ -683,8 +702,6 @@ export default function ThreeDViewer({
document.body.appendChild(a); a.click(); document.body.removeChild(a) document.body.appendChild(a); a.click(); document.body.removeChild(a)
} }
const hasBothModes = !!(geometryGltfUrl && productionGltfUrl)
// Task 5 — hover: highlight mesh with emissive, restore on out // Task 5 — hover: highlight mesh with emissive, restore on out
const handlePointerOver = useCallback((e: any) => { const handlePointerOver = useCallback((e: any) => {
e.stopPropagation() e.stopPropagation()
@@ -763,24 +780,6 @@ export default function ThreeDViewer({
<div className="flex items-center gap-1.5 flex-wrap"> <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 */} {/* Wireframe */}
<TBtn active={wireframe} onClick={() => setWireframe(v => !v)} title="Wireframe (W)">Wire</TBtn> <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`)} 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" 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 <Download size={11} /> GLB
</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
</button> </button>
)} )}
{downloadUrls?.blend && ( {downloadUrls?.blend && (
@@ -926,24 +917,6 @@ export default function ThreeDViewer({
</div> </div>
</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 ───────────────────────────────────────────────────────── */} {/* ── Viewport ───────────────────────────────────────────────────────── */}
{/* onClick stops propagation so mesh-clicks don't bubble to the outer setPinnedPart(null) */} {/* 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()}> <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} assignmentProvenance={sceneManifest?.parts.find(p => p.part_key === pinnedPart)?.assignment_provenance}
isPartKeyMode={Object.keys(partKeyMap).length > 0} isPartKeyMode={Object.keys(partKeyMap).length > 0}
manualOverrides={manualOverrides} manualOverrides={manualOverrides}
pbrMap={pbrMap}
/> />
)} )}
+87
View File
@@ -1,4 +1,5 @@
import type { PartMaterialEntry, PartMaterialMap } from '../../api/cad' 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: * 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 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 // convertCadPartMaterials
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -85,3 +124,51 @@ export function convertCadPartMaterials(
} }
return result 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'
}
+7 -4
View File
@@ -241,14 +241,17 @@ function LibraryCard({ lib }: { lib: AssetLibrary }) {
</button> </button>
{expanded && ( {expanded && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{lib.catalog.materials.slice(0, MAX_VISIBLE).map((m) => ( {lib.catalog.materials.slice(0, MAX_VISIBLE).map((m) => {
const name = typeof m === 'string' ? m : m.name
return (
<span <span
key={m} key={name}
className="text-xs px-2 py-0.5 rounded bg-surface-alt border border-border-default text-content-secondary font-mono" className="text-xs px-2 py-0.5 rounded bg-surface-alt border border-border-default text-content-secondary font-mono"
> >
{m} {name}
</span> </span>
))} )
})}
{materialCount > MAX_VISIBLE && ( {materialCount > MAX_VISIBLE && (
<span className="text-xs px-2 py-0.5 rounded bg-surface-muted text-content-muted"> <span className="text-xs px-2 py-0.5 rounded bg-surface-muted text-content-muted">
... and {materialCount - MAX_VISIBLE} more ... and {materialCount - MAX_VISIBLE} more
+1 -13
View File
@@ -27,14 +27,6 @@ export default function CadPreviewPage() {
refetchInterval: generating ? 3_000 : false, 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 // Load blend assets for download
const { data: blendAssets } = useQuery({ const { data: blendAssets } = useQuery({
queryKey: ['media-assets', id, 'blend_production'], queryKey: ['media-assets', id, 'blend_production'],
@@ -66,7 +58,6 @@ export default function CadPreviewPage() {
} }
const latestGltf = gltfAssets?.[0] const latestGltf = gltfAssets?.[0]
const latestProduction = productionAssets?.[0]
const latestBlend = blendAssets?.[0] const latestBlend = blendAssets?.[0]
// While checking for assets, show a neutral loading screen (don't attempt to render ThreeDViewer) // 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 // No GLB at all — show generate prompt
if (!latestGltf && !latestProduction) { if (!latestGltf) {
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950"> <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"> <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} cadFileId={id}
onClose={() => navigate(-1)} onClose={() => navigate(-1)}
geometryGltfUrl={latestGltf?.download_url ?? undefined} geometryGltfUrl={latestGltf?.download_url ?? undefined}
productionGltfUrl={latestProduction?.download_url ?? undefined}
hasGeometryGlb={!!latestGltf} hasGeometryGlb={!!latestGltf}
hasProductionGlb={!!latestProduction}
isGeneratingGeometry={generating} isGeneratingGeometry={generating}
onGenerateGeometry={() => generateMutation.mutate()} onGenerateGeometry={() => generateMutation.mutate()}
downloadUrls={{ downloadUrls={{
glb: latestGltf?.download_url ?? undefined, glb: latestGltf?.download_url ?? undefined,
production: latestProduction?.download_url ?? undefined,
blend: latestBlend?.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 { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media' import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer' 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' import RenderInfoModal from '../components/renders/RenderInfoModal'
function GlbDownloadButton({ function GlbDownloadButton({
@@ -163,12 +163,24 @@ export default function ProductDetailPage() {
if (!product || materialsDirty) return if (!product || materialsDirty) return
const parsedNames = product.cad_parsed_objects ?? [] const parsedNames = product.cad_parsed_objects ?? []
if (parsedNames.length > 0) { if (parsedNames.length > 0) {
// Build rows from parsed STEP objects, pre-filling any saved material assignments // Deduplicate by normalized name — instances like _AF0, _AF1 share the same material
const savedMap = new Map( const seen = new Set<string>()
(product.cad_part_materials || []).map((m) => [m.part_name, m.material]) 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( setMaterialRows(
parsedNames.map((name) => ({ part_name: name, material: savedMap.get(name) ?? '' })) uniqueNames.map((name) => ({ part_name: name, material: savedMap.get(name) ?? '' }))
) )
} else { } else {
// Fallback: show whatever is saved (no parsed objects yet) // Fallback: show whatever is saved (no parsed objects yet)
+258 -73
View File
@@ -1,110 +1,295 @@
# Plan: P12 — Codebase Hygiene Sprint (CLAUDE.md + Type Safety + Stale References) # Plan: Extract PBR Material Properties from Blender Asset Library for 3D Viewer
> **Date:** 2026-03-13 | **Branch:** refactor/v2 > **Date:** 2026-03-13 | **Branch:** refactor/v2
## Context ## Context
All 10 roadmap priorities are complete. A codebase scan reveals three categories of debt: The 3D viewer currently shows all materials as flat colors from a hardcoded `SCHAEFFLER_COLORS` map in `MaterialPanel.tsx` (17 entries). These hex colors don't match the actual Blender materials — a "Steel-Bare" material that looks metallic and reflective in Blender renders appears as flat gray `#8a9ca8` in the viewer. The user wants visual parity: if a material is blue plastic in Blender, it should look like blue plastic in the 3D viewer too.
1. **CLAUDE.md is dangerously stale**: References 11 services (4 deleted), `worker-thumbnail` (now `render-worker`), `blender-renderer`/`threejs-renderer`/`flamenco` (all removed), wrong roles (`admin` instead of `global_admin`/`tenant_admin`), deleted STL endpoints, and wrong task locations. Since CLAUDE.md is the AI instruction file, every future conversation gets wrong context. **Source of truth**: The Blender `.blend` asset library already contains all PBR properties (Base Color, Metallic, Roughness, Transmission, IOR) in Principled BSDF nodes for all 35 Schaeffler materials. These values are defined in `MaterialNamingSchema/generate_blend.py`.
2. **Frontend type safety**: 4 unnecessary `(rp as any).cancelled` casts in OrderDetail.tsx (the type already has `cancelled`), plus 4 `(item as any).cad_parsed_objects`/`cad_part_materials` casts (need 2 fields added to `OrderItem` interface). **Current flow**: `catalog_assets.py` extracts only material **names** → stored in `AssetLibrary.catalog` JSONB as `{"materials": ["name1", ...]}` → viewer uses hardcoded `SCHAEFFLER_COLORS` hex map.
3. **Stale service references**: `worker-thumbnail` in the `/scale` endpoint's `ALLOWED_SERVICES`, hardcoded `http://localhost:8080` Flamenco link in OrderDetail.tsx, and obsolete `PLAN.md` + `PLAN_REFACTOR.md` files in the repo root. **Target flow**: `catalog_assets.py` extracts PBR properties per material → stored in catalog JSONB → new API endpoint serves PBR map to frontend → viewers apply `MeshStandardMaterial` with correct color + roughness + metalness.
**Parallelization:** All 4 tracks are independent and can run in parallel.
## Affected Files ## Affected Files
| File | Change | | File | Change |
|------|--------| |------|--------|
| `CLAUDE.md` | Full rewrite — update services, queues, roles, endpoints, structure | | `render-worker/scripts/catalog_assets.py` | Extract PBR properties from Principled BSDF nodes |
| `frontend/src/pages/OrderDetail.tsx` | Remove `(rp as any)` casts (4 sites), remove `(item as any)` casts (4 sites), remove Flamenco hardcoded link | | `backend/app/api/routers/asset_libraries.py` | Add public `GET /api/asset-libraries/pbr-map` endpoint |
| `frontend/src/api/orders.ts` | Add `cad_parsed_objects` and `cad_part_materials` to `OrderItem` interface | | `frontend/src/api/assetLibraries.ts` | Add `fetchMaterialPBR()` + `MaterialPBRMap` type |
| `backend/app/api/routers/worker.py` | Remove `worker-thumbnail` from `ALLOWED_SERVICES` | | `frontend/src/components/cad/cadUtils.ts` | Add `applyPBRToMaterial()` + `pbrColorHex()` helpers |
| `PLAN.md` | Delete (superseded by ROADMAP.md) | | `frontend/src/components/cad/ThreeDViewer.tsx` | Fetch PBR map, apply PBR props when assigning materials |
| `PLAN_REFACTOR.md` | Delete (superseded by ROADMAP.md) | | `frontend/src/components/cad/InlineCadViewer.tsx` | Same PBR application |
| `frontend/src/components/cad/MaterialPanel.tsx` | Replace hardcoded `SCHAEFFLER_COLORS` with dynamic PBR lookup |
## Tasks (in order) ## Tasks (in order)
### Track A — CLAUDE.md Rewrite ### [x] Task 1: Extend catalog_assets.py to extract PBR properties
### [x] Task 1: Update CLAUDE.md to match current architecture — DONE - **File**: `render-worker/scripts/catalog_assets.py`
- **File**: `CLAUDE.md` - **What**: After opening the .blend file, for each material with `asset_data`, find the `ShaderNodeBsdfPrincipled` node and extract:
- **What**: Full rewrite of the project instructions file: - `base_color`: `[R, G, B]` from `inputs["Base Color"].default_value` — convert linear→sRGB via `v^(1/2.2)`
- **Ziel**: Remove "Flamenco" reference - `metallic`: float from `inputs["Metallic"].default_value`
- **Tech Stack**: Remove Flamenco, Three.js (Playwright), cadquery (STEP→STL). Add: MinIO (S3-compatible storage), OCC (cadquery/OCP for STEP parsing), GMSH (tessellation), usd-core (USD export) - `roughness`: float from `inputs["Roughness"].default_value`
- **Services table**: 8 services (postgres, redis, minio, backend, worker, render-worker, beat, frontend). Remove blender-renderer, threejs-renderer, worker-thumbnail, flamenco-manager, flamenco-worker - `transmission`: float from `inputs["Transmission Weight"].default_value` (0.0 if absent)
- **Logs section**: `docker compose logs -f render-worker` (not worker-thumbnail or blender-renderer). Rebuild: `docker compose up -d --build backend worker render-worker beat` - `ior`: float from `inputs["IOR"].default_value` (1.45 default)
- **Credentials**: Remove Flamenco Manager line
- **Project structure**: Remove `blender-renderer/`, `threejs-renderer/`, `flamenco/`. Add `render-worker/scripts/`. Update `tasks/` description to mention it's a compatibility shim, active tasks in `domains/pipeline/tasks/`. Add `domains/` directory
- **Celery queues**: `asset_pipeline` queue on `render-worker` (not `worker-thumbnail`). Remove "blender-renderer only 1 request" note — now it's "render-worker concurrency=1 because Blender is single-threaded". Add `thumbnail_rendering` if it's different from `asset_pipeline` — CHECK: docker-compose says `asset_pipeline`
- **Roles**: Add `global_admin`, `tenant_admin`. Update table to 4 roles
- **API endpoints**: Remove `generate-stl/{quality}`, `generate-missing-stls`. Add `generate-usd-master`, `generate-gltf-geometry`, `scene-manifest`
- **Bekannte Eigenheiten**: Remove Flamenco GPU note
- **Pipeline section**: Update to mention OCC/GMSH tessellation, USD export
- **Acceptance gate**: `grep -c "blender-renderer\|threejs-renderer\|flamenco\|worker-thumbnail\|11 Services" CLAUDE.md` returns 0
- **Dependencies**: none
- **Risk**: None. Documentation only.
### Track B — Frontend Type Safety Change output format from:
```json
### [x] Task 2: Fix `as any` casts in OrderDetail.tsx and OrderItem type — DONE {"materials": ["Mat1", "Mat2"], "node_groups": [...]}
- **Files**: `frontend/src/api/orders.ts`, `frontend/src/pages/OrderDetail.tsx`
- **What**:
1. Add to `OrderItem` interface in `orders.ts`:
```typescript
cad_parsed_objects: string[] | null
cad_part_materials: Array<{ part_name: string; material_name: string; [key: string]: unknown }>
``` ```
2. Remove `(rp as any).cancelled` → just `rp.cancelled` (4 sites in OrderDetail.tsx — the type already has `cancelled: number`) to:
3. Remove `(item as any).cad_parsed_objects` → `item.cad_parsed_objects` (2 sites) ```json
4. Remove `(item as any).cad_part_materials` → `item.cad_part_materials` (1 site) {
5. For `(item as any)[c.key]` dynamic access: replace with `(item as Record<string, unknown>)[c.key]` (narrower cast) "materials": [
- **Acceptance gate**: `grep -c "as any" frontend/src/pages/OrderDetail.tsx` decreases by at least 8. Run `docker compose exec frontend npx tsc --noEmit` — no new errors {"name": "Mat1", "base_color": [0.76, 0.77, 0.78], "metallic": 1.0, "roughness": 0.35, "transmission": 0.0, "ior": 1.45},
...
],
"node_groups": [...]
}
```
Fallback for materials without Principled BSDF: `base_color` from `mat.diffuse_color[:3]` (already sRGB), metallic=0.0, roughness=0.5.
**Color space note**: Blender's Principled BSDF stores Base Color in **linear** space. Three.js `MeshStandardMaterial.color.setRGB()` expects **sRGB** values (it converts internally to linear for rendering). Convert in the script: `srgb = pow(linear, 1/2.2)`, rounded to 4 decimal places.
- **Acceptance gate**: Rebuilt render-worker, run catalog refresh → JSON output has PBR properties
- **Dependencies**: none - **Dependencies**: none
- **Risk**: Low. Type-only changes, no behavioral change. Must run tsc check. - **Risk**: Complex node graphs (textures etc.) — handled by diffuse_color fallback
### Track C — Stale Backend Reference ### [x] Task 2: Rebuild render-worker + refresh catalog
### [x] Task 3: Remove `worker-thumbnail` from scale endpoint — DONE - **File**: No code change — operational step
- **File**: `backend/app/api/routers/worker.py`
- **What**: - **What**:
1. Remove `"worker-thumbnail"` from `ALLOWED_SERVICES` set (line 424) ```bash
2. Update the `ScaleRequest` docstring/comment (line 367) to list only `"render-worker" | "worker"` docker compose up -d --build render-worker
3. Update the endpoint docstring (line 414) to remove `worker-thumbnail` # Then POST /api/asset-libraries/{id}/refresh-catalog via Admin UI or curl
- **Acceptance gate**: `grep "worker-thumbnail" backend/app/api/routers/worker.py` returns 0 matches ```
- **Dependencies**: none The `AssetLibrary.catalog` JSONB column is schema-free — no migration needed.
- **Risk**: None. `worker-thumbnail` service doesn't exist in docker-compose.
### Track D — Delete Obsolete Files + Flamenco Link - **Acceptance gate**: Active library's catalog has materials with `base_color`, `metallic`, `roughness`
- **Dependencies**: Task 1
- **Risk**: None
### [x] Task 4: Delete PLAN.md, PLAN_REFACTOR.md, and remove Flamenco hardcoded link — DONE ### [x] Task 3: Add public API endpoint for material PBR map
- **Files**: `PLAN.md`, `PLAN_REFACTOR.md`, `frontend/src/pages/OrderDetail.tsx`
- **File**: `backend/app/api/routers/asset_libraries.py`
- **What**: Add endpoint **before** the `/{lib_id}` route (to avoid path collision):
```python
@router.get("/pbr-map")
async def get_material_pbr_map(db: AsyncSession = Depends(get_db)):
"""PBR properties for all materials in the active asset library.
Public (no auth) — needed by all 3D viewers.
"""
result = await db.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1)
)
lib = result.scalar_one_or_none()
if not lib or not lib.catalog:
return {}
materials = lib.catalog.get("materials", [])
pbr_map = {}
for m in materials:
if isinstance(m, str):
continue # old format — skip
pbr_map[m["name"]] = {
"base_color": m.get("base_color", [0.5, 0.5, 0.5]),
"metallic": m.get("metallic", 0.0),
"roughness": m.get("roughness", 0.5),
"transmission": m.get("transmission", 0.0),
"ior": m.get("ior", 1.45),
}
return JSONResponse(content=pbr_map, headers={"Cache-Control": "public, max-age=3600"})
```
- **Acceptance gate**: `curl localhost:8888/api/asset-libraries/pbr-map` returns keyed PBR map
- **Dependencies**: Task 2
- **Risk**: Must be placed before `/{lib_id}` route or FastAPI will try to parse "pbr-map" as a UUID
### [x] Task 4: Add frontend API function + types
- **File**: `frontend/src/api/assetLibraries.ts`
- **What**: - **What**:
1. Delete `PLAN.md` (superseded by ROADMAP.md — noted in the Archive section) 1. Add types:
2. Delete `PLAN_REFACTOR.md` (superseded by ROADMAP.md) ```typescript
3. In OrderDetail.tsx (~line 942950): Remove the `localhost:8080` Flamenco link block. Replace with just the job ID text (since `render_backend_used === 'flamenco'` only applies to historical data, show the ID as plain text instead of a broken link) export interface MaterialPBR {
- **Acceptance gate**: `ls PLAN.md PLAN_REFACTOR.md 2>&1 | grep "No such file"` succeeds. `grep "localhost:8080" frontend/src/pages/OrderDetail.tsx` returns 0 matches base_color: [number, number, number]
- **Dependencies**: none metallic: number
- **Risk**: Low. PLAN files are archived references. Flamenco link is non-functional (service removed). roughness: number
transmission?: number
ior?: number
}
export type MaterialPBRMap = Record<string, MaterialPBR>
```
2. Add fetch function:
```typescript
export async function fetchMaterialPBR(): Promise<MaterialPBRMap> {
const { data } = await api.get<MaterialPBRMap>('/asset-libraries/pbr-map')
return data
}
```
3. Update `AssetLibraryCatalog.materials` type from `string[]` to `Array<string | {name: string, base_color?: number[], metallic?: number, roughness?: number}>` for backwards compat with old catalogs
- **Acceptance gate**: `npx tsc --noEmit` passes
- **Dependencies**: Task 3
- **Risk**: None
### [x] Task 5: Add PBR helpers in cadUtils.ts
- **File**: `frontend/src/components/cad/cadUtils.ts`
- **What**: Add two helpers:
```typescript
import type { MaterialPBR } from '../../api/assetLibraries'
/** Apply PBR material properties to a Three.js MeshStandardMaterial. */
export function applyPBRToMaterial(
mat: THREE.MeshStandardMaterial,
pbr: MaterialPBR,
): void {
mat.color.setRGB(pbr.base_color[0], pbr.base_color[1], pbr.base_color[2])
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
}
}
/** 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('')
}
```
Note: `THREE` is a type-only import here — the actual THREE namespace is available at runtime in the viewer components. The helper takes the material as a parameter, so no direct THREE import needed in cadUtils.
- **Acceptance gate**: `npx tsc --noEmit` passes
- **Dependencies**: Task 4
- **Risk**: None
### [x] Task 6: Update ThreeDViewer to apply PBR materials
- **File**: `frontend/src/components/cad/ThreeDViewer.tsx`
- **What**:
1. Import `fetchMaterialPBR` and `applyPBRToMaterial` from the new modules
2. Add query:
```typescript
const { data: pbrMap = {} } = useQuery({
queryKey: ['material-pbr'],
queryFn: fetchMaterialPBR,
staleTime: 300_000,
})
```
3. Update the material-application `useEffect` (line ~567). Current code:
```typescript
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
```
Replace with:
```typescript
if (mat && 'color' in mat) {
if (entry.type === 'library' && pbrMap[entry.value]) {
applyPBRToMaterial(mat as THREE.MeshStandardMaterial, pbrMap[entry.value])
} else {
mat.color.set(previewColorForEntry(entry, pbrMap))
}
}
```
4. **Important**: Clone materials before modifying. GLB loader shares material instances across meshes. Before the traverse, or inside it, ensure each mesh has its own material:
```typescript
if (mesh.material) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map(m => m.clone())
: mesh.material.clone()
}
```
Only clone once — check a flag like `mesh.userData._pbrApplied` to avoid re-cloning on re-renders.
5. Add `pbrMap` to the useEffect dependency array
- **Acceptance gate**: Steel parts look metallic/reflective. Plastic parts look matte. Colors match Blender.
- **Dependencies**: Task 5
- **Risk**: Material cloning increases memory. Acceptable for viewer scenes.
### [x] Task 7: Update InlineCadViewer with same PBR logic
- **File**: `frontend/src/components/cad/InlineCadViewer.tsx`
- **What**: Mirror Task 6:
1. Add PBR query
2. Update material-application useEffect (~line 261)
3. Clone materials before modifying
4. Add `pbrMap` to dependency array
- **Acceptance gate**: Inline viewer (product cards) shows PBR materials
- **Dependencies**: Task 5
- **Risk**: Same as Task 6
### [x] Task 8: Replace SCHAEFFLER_COLORS with dynamic PBR lookup in MaterialPanel
- **File**: `frontend/src/components/cad/MaterialPanel.tsx`
- **What**:
1. Delete the hardcoded `SCHAEFFLER_COLORS` map (lines 12-30)
2. Update `previewColorForEntry()` signature to accept optional `pbrMap`:
```typescript
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'
}
```
3. Add `pbrMap` as an optional prop to `MaterialPanelProps`
4. In the material preview swatch area, show metallic/roughness values when PBR data is available:
```tsx
{pbrEntry && (
<span className="text-[10px] text-gray-500">
M:{pbrEntry.metallic.toFixed(1)} R:{pbrEntry.roughness.toFixed(1)}
</span>
)}
```
5. Update all callers of `previewColorForEntry()` in ThreeDViewer and InlineCadViewer to pass `pbrMap`
6. In the material dropdown, show a color swatch next to each material name using PBR data
- **Acceptance gate**: Material panel shows correct preview colors from Blender. No hardcoded `SCHAEFFLER_COLORS`.
- **Dependencies**: Tasks 6, 7
- **Risk**: Low — UI-only change
### [x] Task 9: TypeScript compilation + visual verification
- **What**:
1. `docker compose exec frontend npx tsc --noEmit` — 0 errors
2. Open http://localhost:5173/products/{id} — verify steel parts look metallic, plastics look matte
- **Acceptance gate**: Zero type errors. Visual match with Blender appearance.
- **Dependencies**: Tasks 1-8
## Migration Check ## Migration Check
No. No database changes. **No migration required.** `AssetLibrary.catalog` is JSONB (schema-free). The new format (materials as objects instead of strings) is a data-level change only.
## Order Recommendation ## Order Recommendation
**Fully parallel — all 4 tracks independent:** 1. Render worker script (`catalog_assets.py`) + rebuild — Tasks 1-2
- **Agent 1**: Task 1 (CLAUDE.md rewrite) — largest, highest impact 2. Backend API endpoint — Task 3
- **Agent 2**: Task 2 (frontend type safety) 3. Frontend types + helpers — Tasks 4-5
- **Agent 3**: Task 3 (worker.py cleanup) 4. Viewers + MaterialPanel — Tasks 6, 7, 8 (can be parallel)
- **Agent 4**: Task 4 (file deletion + Flamenco link) 5. Final check — Task 9
## Risks / Open Questions ## Risks / Open Questions
1. **CLAUDE.md as AI instructions**: This file is loaded into every AI conversation as project context. Getting it wrong means every future session starts with bad information. The rewrite must be verified against the actual docker-compose.yml and codebase. 1. **Color space**: Blender stores linear colors. Three.js `color.setRGB()` expects sRGB. Converting in `catalog_assets.py` with `pow(v, 1/2.2)` ensures correctness in both the hex UI preview and the Three.js renderer.
2. **OrderItem `cad_part_materials` type**: Backend returns `list[dict]` — need to check what keys the dicts actually contain. The frontend uses `part_name` and `material_name` based on grep of CadPartMaterials component. 2. **Shared materials in GLB**: Three.js GLB loader shares material instances. Must clone before modifying metalness/roughness. Check `userData._pbrApplied` flag to avoid redundant cloning.
3. **`require_admin_or_pm` rename**: 71 occurrences across 13 files could be renamed to `require_pm_or_above` for consistency. Deferred — it's high churn, low impact (the alias works correctly), and can be a separate micro-task later. 3. **Backwards compatibility**: Old catalog format (`materials: string[]`) is handled — the API endpoint skips string entries. Frontend `AssetLibraryCatalog` type uses union.
4. **Complex node graphs**: Materials with textures instead of simple default values get `diffuse_color` fallback. Texture support is out of scope.
5. **`previewColorForEntry` callers**: This function is exported and used in both viewers. Adding the optional `pbrMap` parameter is backwards-compatible — existing callers without it still get gray fallback.
+62 -2
View File
@@ -4,17 +4,75 @@ Usage:
blender --background --python catalog_assets.py -- <blend_path> blender --background --python catalog_assets.py -- <blend_path>
Outputs a single JSON line to stdout: Outputs a single JSON line to stdout:
{"materials": ["Mat1", "Mat2", ...], "node_groups": ["NG1", ...]} {"materials": [{"name": "Mat1", "base_color": [R,G,B], "metallic": 0.0, ...}, ...],
"node_groups": ["NG1", ...]}
Only assets marked via Blender's asset system (asset_data is not None) are included. Only assets marked via Blender's asset system (asset_data is not None) are included.
PBR properties are extracted from the Principled BSDF node of each material.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import math
import sys import sys
import traceback import traceback
def _linear_to_srgb(v: float) -> float:
"""Convert a single linear color channel to sRGB."""
if v <= 0.0031308:
return v * 12.92
return 1.055 * math.pow(v, 1.0 / 2.4) - 0.055
def _extract_pbr(mat) -> dict:
"""Extract PBR properties from a Blender material's Principled BSDF node.
Returns dict with: name, base_color (sRGB), metallic, roughness, transmission, ior.
Falls back to diffuse_color if no Principled BSDF found.
"""
entry = {
"name": mat.name,
"base_color": [0.5, 0.5, 0.5],
"metallic": 0.0,
"roughness": 0.5,
"transmission": 0.0,
"ior": 1.45,
}
# Find Principled BSDF node
bsdf = None
if mat.use_nodes and mat.node_tree:
for node in mat.node_tree.nodes:
if node.type == "BSDF_PRINCIPLED":
bsdf = node
break
if bsdf:
# Base Color — convert linear → sRGB
bc = bsdf.inputs["Base Color"].default_value
entry["base_color"] = [
round(_linear_to_srgb(bc[0]), 4),
round(_linear_to_srgb(bc[1]), 4),
round(_linear_to_srgb(bc[2]), 4),
]
entry["metallic"] = round(bsdf.inputs["Metallic"].default_value, 4)
entry["roughness"] = round(bsdf.inputs["Roughness"].default_value, 4)
# Transmission Weight (Blender 4.0+) or Transmission (older)
for tx_name in ("Transmission Weight", "Transmission"):
if tx_name in bsdf.inputs:
entry["transmission"] = round(bsdf.inputs[tx_name].default_value, 4)
break
if "IOR" in bsdf.inputs:
entry["ior"] = round(bsdf.inputs["IOR"].default_value, 4)
else:
# Fallback: use viewport diffuse_color (already sRGB)
dc = mat.diffuse_color
entry["base_color"] = [round(dc[0], 4), round(dc[1], 4), round(dc[2], 4)]
return entry
def main() -> None: def main() -> None:
argv = sys.argv argv = sys.argv
if "--" not in argv: if "--" not in argv:
@@ -27,7 +85,9 @@ def main() -> None:
bpy.ops.wm.open_mainfile(filepath=blend_path) bpy.ops.wm.open_mainfile(filepath=blend_path)
materials = [m.name for m in bpy.data.materials if m.asset_data is not None] materials = [
_extract_pbr(m) for m in bpy.data.materials if m.asset_data is not None
]
node_groups = [ng.name for ng in bpy.data.node_groups if ng.asset_data is not None] node_groups = [ng.name for ng in bpy.data.node_groups if ng.asset_data is not None]
catalog = {"materials": materials, "node_groups": node_groups} catalog = {"materials": materials, "node_groups": node_groups}
+84 -78
View File
@@ -1,111 +1,117 @@
# Review Report: P12 — Codebase Hygiene Sprint (CLAUDE.md + Type Safety + Stale References) # Review Report: PBR Material Extraction for 3D Viewer
Date: 2026-03-13 Date: 2026-03-13
## Result: ✅ Approved ## Result: ⚠️ Minor issues
## Changes Reviewed ## Changes Reviewed
### Track A: CLAUDE.md Rewrite ### PBR Material Extraction Pipeline
- **File**: `CLAUDE.md`
- Full rewrite to match current 8-service architecture
- Removed all references to: `blender-renderer`, `threejs-renderer`, `flamenco-manager`, `flamenco-worker`, `worker-thumbnail` (5 deleted services)
- Updated tech stack: added MinIO, OCC/GMSH, usd-core; removed cadquery STEP→STL, Three.js Playwright, Flamenco
- Services table: 8 services (was 11), correct ports and descriptions
- Project structure: added `render-worker/scripts/`, `domains/`, `core/`; removed `blender-renderer/`, `threejs-renderer/`, `flamenco/`
- Task location: documented `backend/app/domains/pipeline/tasks/` as active, `backend/app/tasks/` as 23-line shim
- Celery queues: `asset_pipeline` on `render-worker` (was `worker-thumbnail`)
- Roles: 4 roles (`global_admin`, `tenant_admin`, `project_manager`, `client`) — was 3 with wrong `admin` name
- API endpoints: removed `generate-stl/{quality}`, `generate-missing-stls`; added `generate-gltf-geometry`, `generate-usd-master`, `scene-manifest`
- Pipeline: updated to OCC/GMSH tessellation → USD export → Blender Cycles
- Removed Flamenco GPU note, added USD coordinate note
### Track B: Frontend Type Safety #### Task 1: catalog_assets.py — PBR extraction from Principled BSDF
- **`frontend/src/api/orders.ts`**: Added `cad_parsed_objects: string[] | null` and `cad_part_materials: Array<{ part_name: string; material: string }>` to `OrderItem` interface - Extracts `base_color` (linear→sRGB via correct IEC 61966-2-1 formula), `metallic`, `roughness`, `transmission`, `ior`
- **`frontend/src/pages/OrderDetail.tsx`**: - Fallback to `mat.diffuse_color` when no Principled BSDF node found
- 4× `(rp as any).cancelled``rp.cancelled` (type already had `cancelled: number`) - Handles both Blender 4.0+ (`Transmission Weight`) and older (`Transmission`) input names
- 2× `(item as any).cad_parsed_objects``item.cad_parsed_objects` - Verified: 35 materials extracted with correct PBR values
- 1× `(item as any).cad_part_materials``item.cad_part_materials`
- 1× `(item as any)[c.key]``item[c.key] as string | null` (narrower cast — STD_COLS keys are all string|null fields)
- Removed unused `ExternalLink` import from lucide-react
### Track C: Stale Backend Reference #### Task 2: Catalog refresh
- **`backend/app/api/routers/worker.py`**: Removed `"worker-thumbnail"` from `ALLOWED_SERVICES` set, updated `ScaleRequest` docstring and endpoint docstring - Rebuilt render-worker, triggered refresh via API
- DB updated: `SELECT catalog FROM asset_libraries` shows PBR objects
### Track D: Delete Obsolete Files + Flamenco Link #### Task 3: GET /api/asset-libraries/pbr-map endpoint
- **`PLAN.md`**: Deleted (1,455 lines) - Public (no auth) — correct for viewer data
- **`PLAN_REFACTOR.md`**: Deleted (1,174 lines) - Placed before `/{lib_id}` to avoid UUID collision
- **`frontend/src/pages/OrderDetail.tsx`**: Replaced Flamenco `<a href="http://localhost:8080">` link with `<span>Flamenco (legacy)</span>` plain text - 1h cache header (`Cache-Control: public, max-age=3600`)
- Handles old string-format catalogs gracefully (skips them)
- Returns empty dict when no active library
### Also included (from prior P11 + P5 M4 sessions, uncommitted): #### Task 4: Frontend API types
- `backend/app/core/process_steps.py``EXPORT_GLB_PRODUCTION` enum removed - `MaterialPBR`, `MaterialPBRMap` types correctly model backend response
- `backend/app/domains/rendering/workflow_router.py` — removed from maps, 3× `require_admin``require_global_admin` - `AssetLibraryCatalog.materials` updated to union type for backwards compat
- `backend/app/domains/rendering/workflow_executor.py` — stale comment removed - `Admin.tsx` and `AssetLibrary.tsx` updated to handle both string and object formats
- `backend/app/domains/tenants/router.py` — 9× `require_admin``require_global_admin`
- `backend/app/domains/admin/dashboard_router.py` — 2× `require_admin``require_global_admin`
- `backend/app/api/routers/global_render_positions.py` — 3× `require_admin``require_global_admin`
- `backend/app/api/routers/templates.py` — 1× `require_admin``require_global_admin`
- `backend/app/api/routers/worker.py` — 4× `require_admin``require_global_admin`
- `backend/app/api/routers/cad.py` — deprecated `generate-gltf-production` endpoint removed (28 lines)
- `backend/app/tasks/step_tasks.py` — stale `generate_gltf_production_task` import removed
- `backend/app/domains/pipeline/tasks/export_glb.py` — 275 lines of dead `generate_gltf_production_task` removed
- `frontend/src/api/cad.ts` — orphaned `generateGltfProduction()` function removed
- `render-worker/scripts/export_step_to_usd.py` — digit-only prim name `p_` prefix fix
- `ROADMAP.md` — all 10 priorities marked Done, status snapshot updated
## Acceptance Gates #### Task 5: cadUtils PBR helpers
- `applyPBRToMaterial()`: sets color, metalness, roughness, approximate transmission via opacity
- `pbrColorHex()`: converts base_color to hex string for UI
- `previewColorForEntry()`: moved from MaterialPanel, now uses dynamic PBR lookup with optional `pbrMap` param
| Gate | Result | #### Tasks 6-7: ThreeDViewer + InlineCadViewer
|------|--------| - Both fetch PBR map via `useQuery` with 5min staleTime
| `grep "blender-renderer\|threejs-renderer\|flamenco\|worker-thumbnail" CLAUDE.md` | 0 matches ✅ | - Material application clones materials before modifying (prevents shared-instance bugs)
| `grep "as any" frontend/src/pages/OrderDetail.tsx` | 0 matches ✅ | - `_pbrApplied` flag prevents redundant cloning on re-renders
| `grep "worker-thumbnail" backend/app/api/routers/worker.py` | 0 matches ✅ | - `pbrMap` added to all dependency arrays
| `grep "localhost:8080" frontend/src/pages/OrderDetail.tsx` | 0 matches ✅ |
| `ls PLAN.md PLAN_REFACTOR.md` | No such file ✅ | #### Task 8: MaterialPanel
| `grep "Depends(require_admin)" backend/` (recursive) | 0 matches ✅ | - Hardcoded `SCHAEFFLER_COLORS` (17 entries) removed
- Dynamic PBR lookup covers all 35 materials
- Shows M:/R: values (metallic/roughness) in preview swatch
- Accepts optional `pbrMap` prop (avoids duplicate fetch when parent already has it)
- Falls back to own `useQuery` fetch when prop not provided (enabled: !pbrMapProp)
## Checklist Results ## Checklist Results
### Backend / Python ### Backend / Python
- [x] All admin endpoints use `require_global_admin` (22 calls migrated, zero legacy remaining) - [x] New endpoint is `async def` (FastAPI handler)
- [x] No SQL injections - [x] No auth on pbr-map (intentional — public non-sensitive data for all viewers)
- [x] No SQL injection (ORM `select()` only)
- [x] No `print()` in production code - [x] No `print()` in production code
- [x] No hardcoded paths - [x] No hardcoded paths
- [x] Async consistency maintained - [N/A] No new models or migrations
- [N/A] No new routers/models/endpoints - [x] Endpoint registered (same router, already in main.py)
### Celery / Tasks
- [x] No Blender on step_processing queue
- [x] Remaining tasks on correct queues
- [x] `generate_usd_master_task` intact and unchanged
- [x] `generate_gltf_geometry_task` intact and unchanged
- [x] Dead `generate_gltf_production_task` fully removed (task, import, endpoint, frontend function)
### Frontend / TypeScript ### Frontend / TypeScript
- [x] `OrderItem` interface matches backend response (added `cad_parsed_objects`, `cad_part_materials`) - [x] `npx tsc --noEmit` passes with 0 errors
- [x] Zero `as any` casts remaining in OrderDetail.tsx - [x] New API types in `frontend/src/api/assetLibraries.ts`
- [x] `cad_part_materials` type uses `material` field (matches `CadPartMaterials` component's `CadPartRow`) - [x] No `as any` for API responses (one intentional `any` for THREE.MeshStandardMaterial in cadUtils to avoid THREE import)
- [x] No dangling imports (ExternalLink removed) - [x] No `bg-surface/50` Tailwind opacity syntax
- [x] Flamenco link replaced with plain text label - [x] `useQuery` with staleTime for PBR data
- [x] `SCHAEFFLER_COLORS` export removed — no orphaned references
- [x] All `useEffect`/`useCallback` dependency arrays include `pbrMap`
- [x] Material cloning prevents shared-instance mutation bugs
### Render Pipeline ### Render Pipeline
- [x] No references to removed blender-renderer HTTP service - [x] `catalog_assets.py` runs on render-worker (Blender headless)
- [x] No references to removed threejs-renderer HTTP service - [x] Existing `refresh_asset_library_catalog` task unchanged — picks up new script
- [x] `EXPORT_GLB_PRODUCTION` fully removed from enum + all maps + executor - [x] No references to removed services
### Security ### Security
- [x] No credentials in code - [x] No credentials in code
- [x] No hardcoded tokens - [x] No hardcoded tokens
- [x] English variable names and comments - [x] English variable names and comments
## Verification
| Gate | Result |
|------|--------|
| `npx tsc --noEmit` | 0 errors ✅ |
| `curl /api/asset-libraries/pbr-map` | 35 materials with PBR ✅ |
| `grep "SCHAEFFLER_COLORS" frontend/src/` | 0 references (only in comment) ✅ |
| `grep "previewColorForEntry" frontend/src/` | 7 matches (all pass pbrMap) ✅ |
| `grep "applyPBRToMaterial" frontend/src/` | 4 matches (definition + 3 usages) ✅ |
## Problems Found
### [cadUtils.ts:137] `applyPBRToMaterial` uses `any` type for mat parameter
**Severity**: Low
**Description**: The `mat` parameter is typed as `any` to avoid importing THREE in the utility module. This is documented with a comment and eslint-disable. The actual callers always pass `THREE.MeshStandardMaterial`.
**Recommendation**: Acceptable tradeoff. Alternative would be importing THREE types, but cadUtils is intentionally dependency-light.
### [InlineCadViewer] No partKey propagation from parent Group in onReady
**Severity**: Medium
**Description**: The InlineCadViewer's `onReady` callback doesn't propagate partKey from parent Group to child Mesh (unlike ThreeDViewer which does this at line ~540). This means `mesh.userData.partKey` may be undefined for child meshes of multi-primitive nodes, and the PBR application falls back to `resolvePartKey(normalizeMeshName(...))` which may not match if partKeyMap is empty.
**Impact**: PBR materials may not apply correctly in InlineCadViewer for products where the partKey was set on the parent Group but not propagated to children. This could explain the user's report of "no material changes visible."
**Recommendation**: Add the same parent-Group partKey propagation logic to InlineCadViewer's onReady callback, matching ThreeDViewer's pattern.
## Positives ## Positives
1. **CLAUDE.md accuracy**: The rewrite is comprehensive — services, queues, roles, endpoints, project structure, and pipeline all match the actual codebase. Future AI sessions will get correct context. 1. **Correct color space handling**: linear→sRGB conversion uses the IEC 61966-2-1 formula (not simplified gamma), ensuring accurate color reproduction.
2. **Type safety wins**: 9 unnecessary `as any` casts eliminated. The `OrderItem` type extension is correct — `material` (not `material_name`) matches the `CadPartMaterials` component. 2. **Backwards compatible**: Old catalogs (string arrays) are gracefully skipped. Empty partKeyMap returns identity fallback.
3. **Clean removal**: 2,629 lines of obsolete content deleted (PLAN.md + PLAN_REFACTOR.md). No orphaned references remain. 3. **Smart caching**: PBR data rarely changes — 5min staleTime + 1h server cache is appropriate.
4. **Zero behavioral changes**: All modifications are documentation, types, and dead code removal. No risk of regression. 4. **Material cloning**: `_pbrApplied` flag prevents double-cloning on re-renders.
5. **35 materials covered**: All Schaeffler standard materials now have PBR data (vs 17 hardcoded colors before).
6. **Principled BSDF fallback**: Materials without the node still get viewport display color.
## Recommendation ## Recommendation
Approved. All 4 tracks complete, all acceptance gates pass. Changes are pure hygiene — no behavioral impact, no new features. Approved with one medium-priority fix needed: InlineCadViewer needs partKey propagation in its onReady callback to ensure PBR materials apply to all meshes.
Review complete. Result: Review complete. Result: ⚠️