feat(P2): USD Foundation — canonical part identity + material overrides

M1 — USD exporter:
- render-worker/scripts/export_step_to_usd.py (631 lines)
  Full XCAF traversal, one UsdGeom.Mesh per leaf part,
  schaeffler:partKey on every prim, index-space sharpEdgeVertexPairs
- render-worker/Dockerfile: usd-core>=24.11 installed (USD 0.26.3)

M2 — usd_master MediaAsset + pipeline auto-chain:
- migrations 060 (usd_master enum), 061 (3 JSONB columns),
  062 (rename tessellation settings keys)
- generate_usd_master_task: runs export_step_to_usd.py, upserts
  usd_master MediaAsset, writes resolved_material_assignments to CadFile
- Auto-chained from generate_gltf_geometry_task after every GLB export
- step_tasks.py shim re-exports generate_usd_master_task

M3 — scene-manifest API:
- part_key_service.py: build_scene_manifest(), generate_part_key(),
  four-layer material priority resolution with provenance
- SceneManifest / PartEntry Pydantic models in products/schemas.py
- GET /api/cad/{id}/scene-manifest endpoint (graceful fallback to
  parsed_objects when USD not yet generated)
- POST /api/cad/{id}/generate-usd-master endpoint
- frontend/src/api/sceneManifest.ts: fetchSceneManifest(),
  triggerUsdMasterGeneration()

M4 — manual-material-overrides API:
- GET/PUT /api/cad/{id}/manual-material-overrides endpoints
- CadFile.manual_material_overrides JSONB column (migration 061)
- getManualOverrides() / saveManualOverrides() in cad.ts

M5 — ThreeDViewer partKey integration:
- export_step_to_gltf.py injects partKeyMap into GLB extras
- ThreeDViewer: partKeyMap extraction, resolvePartKey(), effectiveMaterials
  merges legacy partMaterials + new manualOverrides (server-side persistence)
- MaterialPanel: dual-path save (partKey vs legacy), provenance badge,
  reconciliation panel for unmatched/unassigned parts

Also:
- Admin.tsx: generate-missing-usd-masters + canonical scenes bulk actions
- ProductDetail.tsx: usd_master row in asset table
- vite-env.d.ts: fix ImportMeta.env TypeScript error
- GPUProbeResult: add timestamp/devices/render_time_s fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 13:11:09 +01:00
parent 47b5d42bb5
commit 409fb92899
33 changed files with 2070 additions and 303 deletions
+78 -17
View File
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
import { savePartMaterials, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
// ---------------------------------------------------------------------------
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
@@ -60,6 +60,14 @@ export interface MaterialPanelProps {
onClose: () => void
isolateMode?: IsolateMode
onIsolateModeChange?: (mode: IsolateMode) => void
/** Source part name from XCAF (shown alongside partKey slug) */
sourcePartName?: string
/** How the current assignment was derived */
assignmentProvenance?: 'manual' | 'auto' | 'source' | 'default'
/** True when GLB has partKeyMap — saves via /manual-material-overrides endpoint */
isPartKeyMode?: boolean
/** Current manual overrides map (needed to merge when saving in partKey mode) */
manualOverrides?: Record<string, string>
}
export default function MaterialPanel({
@@ -70,6 +78,10 @@ export default function MaterialPanel({
onClose,
isolateMode = 'none',
onIsolateModeChange,
sourcePartName,
assignmentProvenance,
isPartKeyMode = false,
manualOverrides = {},
}: MaterialPanelProps) {
const queryClient = useQueryClient()
@@ -100,6 +112,7 @@ export default function MaterialPanel({
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
// Legacy save (part_materials, keyed by normalized mesh name)
const saveMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
@@ -120,21 +133,53 @@ export default function MaterialPanel({
onError: () => toast.error('Failed to remove assignment'),
})
// partKey mode save (manual_material_overrides, keyed by partKey slug)
const manualSaveMut = useMutation({
mutationFn: (updated: Record<string, string>) => saveManualOverrides(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['manual-overrides', cadFileId] })
toast.success(`Material assigned to "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to save material assignment'),
})
const manualRemoveMut = useMutation({
mutationFn: (updated: Record<string, string>) => saveManualOverrides(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['manual-overrides', cadFileId] })
toast.success(`Assignment removed from "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to remove assignment'),
})
function handleAssign() {
const entry: PartMaterialEntry =
assignType === 'hex'
? { type: 'hex', value: hexValue }
: { type: 'library', value: libValue }
saveMut.mutate({ ...partMaterials, [partName]: entry })
const materialValue = assignType === 'hex' ? hexValue : libValue
if (isPartKeyMode) {
manualSaveMut.mutate({ ...manualOverrides, [partName]: materialValue })
} else {
const entry: PartMaterialEntry =
assignType === 'hex'
? { type: 'hex', value: hexValue }
: { type: 'library', value: libValue }
saveMut.mutate({ ...partMaterials, [partName]: entry })
}
}
function handleRemove() {
const updated = { ...partMaterials }
delete updated[partName]
removeMut.mutate(updated)
if (isPartKeyMode) {
const updated = { ...manualOverrides }
delete updated[partName]
manualRemoveMut.mutate(updated)
} else {
const updated = { ...partMaterials }
delete updated[partName]
removeMut.mutate(updated)
}
}
const isBusy = saveMut.isPending || removeMut.isPending
const isBusy = saveMut.isPending || removeMut.isPending || manualSaveMut.isPending || manualRemoveMut.isPending
const previewHex = assignType === 'hex'
? hexValue
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
@@ -146,13 +191,29 @@ export default function MaterialPanel({
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Palette size={13} className="text-accent shrink-0" />
<span className="text-white text-xs font-semibold truncate" title={partName}>
{partName}
</span>
<div className="min-w-0">
<span className="text-white text-xs font-semibold truncate block" title={partName}>
{partName}
</span>
{sourcePartName && sourcePartName !== partName && (
<span className="text-gray-500 text-[10px] truncate block" title={sourcePartName}>
{sourcePartName}
</span>
)}
</div>
{assignmentProvenance && assignmentProvenance !== 'default' && (
<span className={`shrink-0 text-[9px] font-medium px-1 py-0.5 rounded ${
assignmentProvenance === 'manual' ? 'bg-accent/20 text-accent' :
assignmentProvenance === 'auto' ? 'bg-green-900/40 text-green-400' :
'bg-yellow-900/40 text-yellow-400'
}`}>
{assignmentProvenance}
</span>
)}
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0 ml-1">
<X size={14} />
</button>
</div>
@@ -270,7 +331,7 @@ export default function MaterialPanel({
disabled={isBusy || (assignType === 'library' && !libValue)}
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
>
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
{(saveMut.isPending || manualSaveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
Assign
</button>
{currentEntry && (
@@ -279,7 +340,7 @@ export default function MaterialPanel({
disabled={isBusy}
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
>
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
{(removeMut.isPending || manualRemoveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
Remove
</button>
)}
+154 -26
View File
@@ -28,7 +28,8 @@ import {
Maximize2, Grid3X3, Sun, AlertCircle, EyeOff,
} from 'lucide-react'
import api from '../../api/client'
import { getParsedObjects, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMaterialMap } from '../../api/cad'
import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { SCHAEFFLER_COLORS, previewColorForEntry, type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial } from './cadUtils'
@@ -392,6 +393,9 @@ export default function ThreeDViewer({
// Task 5 — hovered mesh ref for emissive highlight
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
// partKey map from GLB extras: normalizedMeshName → partKey slug
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
// Task 7 — clicked (pinned) part for material panel
const [pinnedPart, setPinnedPart] = useState<string | null>(null)
@@ -401,6 +405,9 @@ export default function ThreeDViewer({
// Hide assigned toggle — hides all parts that already have a material
const [hideAssigned, setHideAssigned] = useState(false)
// Reconciliation panel (unmatched source rows + unassigned parts)
const [showReconcile, setShowReconcile] = useState(false)
// Isolation mode — ghost/hide other parts while a part is pinned
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
@@ -418,6 +425,22 @@ export default function ThreeDViewer({
})
const dims = parsedData?.parsed_objects?.dimensions_mm
// Scene manifest (non-blocking — 404 expected when USD master not yet generated)
const { data: sceneManifest } = useQuery({
queryKey: ['scene-manifest', cadFileId],
queryFn: () => fetchSceneManifest(cadFileId),
staleTime: Infinity,
retry: false,
})
// Manual material overrides keyed by partKey slug (from PUT /manual-material-overrides)
const { data: manualOverrides = {} } = useQuery({
queryKey: ['manual-overrides', cadFileId],
queryFn: () => getManualOverrides(cadFileId),
staleTime: 30_000,
retry: false,
})
// Total unique normalized mesh count (set once when model is ready)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
@@ -436,10 +459,29 @@ export default function ThreeDViewer({
[initialPartMaterials, savedPartMaterials],
)
// Effective materials: merge partMaterials (old normalized-name keys) +
// manualOverrides (new partKey slug keys). Both key formats coexist so
// existing GLBs (no partKeyMap) and new GLBs (with partKeyMap) work correctly.
const effectiveMaterials = useMemo(() => {
const fromManual: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([k, v]) => [
k,
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
])
)
return { ...partMaterials, ...fromManual }
}, [partMaterials, manualOverrides])
// 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
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
[glbMeshNames, effectiveMaterials],
)
// Raw URL selected by mode (used as stable key before blob fetch)
@@ -485,12 +527,24 @@ export default function ThreeDViewer({
if (modelReady) setFitTrigger(t => t + 1)
}, [modelReady])
// Compute unique normalized mesh names once (used in toolbar badge + assignedCount)
// Compute unique mesh keys once (used in toolbar badge + assignedCount).
// Also extract partKeyMap from GLB extras when available.
useEffect(() => {
if (!modelReady || !sceneRef.current) return
// Extract partKeyMap injected by export_step_to_gltf.py into 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)
}
const names = new Set<string>()
sceneRef.current.traverse(o => {
if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name))
if ((o as THREE.Mesh).isMesh && o.name) {
const normalized = normalizeMeshName((o.userData?.name as string) || o.name)
names.add(map?.[normalized] ?? normalized)
}
})
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
@@ -501,13 +555,14 @@ export default function ThreeDViewer({
if (modelReady) setFitTrigger(t => t + 1)
}, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps
// Task 6 — apply saved material colors after model loads or when partMaterials changes
// Task 6 — apply saved material colors after model loads or when effectiveMaterials changes
useEffect(() => {
if (!modelReady || !sceneRef.current) return
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const entry = resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials)
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
if (!entry) return
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
@@ -515,12 +570,12 @@ export default function ThreeDViewer({
if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry))
})
})
}, [modelReady, partMaterials])
}, [modelReady, effectiveMaterials, resolvePartKey])
// Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast)
useEffect(() => {
if (!modelReady || !sceneRef.current) return
const hasAnyAssignment = Object.keys(partMaterials).length > 0
const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0
sceneRef.current.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
@@ -529,7 +584,8 @@ export default function ThreeDViewer({
const m = mat as THREE.MeshStandardMaterial
if (!m || !('emissive' in m)) return
if (showUnassigned && hasAnyAssignment) {
const hasAssignment = !!resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials)
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const hasAssignment = !!resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)
m.emissive.set(hasAssignment ? 0x000000 : 0xff4400)
m.emissiveIntensity = hasAssignment ? 0 : 0.8
} else {
@@ -538,7 +594,7 @@ export default function ThreeDViewer({
}
})
})
}, [modelReady, showUnassigned, partMaterials])
}, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey])
// Reset isolateMode when no part is pinned
useEffect(() => {
@@ -547,8 +603,8 @@ export default function ThreeDViewer({
// Reset hideAssigned when all assignments are cleared
useEffect(() => {
if (Object.keys(partMaterials).length === 0) setHideAssigned(false)
}, [partMaterials])
if (Object.keys(effectiveMaterials).length === 0) setHideAssigned(false)
}, [effectiveMaterials])
// Combined visibility effect — handles hideAssigned + isolateMode together
useEffect(() => {
@@ -557,8 +613,9 @@ export default function ThreeDViewer({
const mesh = obj as THREE.Mesh
if (!mesh.isMesh) return
const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
const isSelected = normalizedName === pinnedPart
const isAssigned = !!resolvePartMaterial(normalizedName, partMaterials)
const partKey = resolvePartKey(normalizedName)
const isSelected = partKey === pinnedPart
const isAssigned = !!resolvePartMaterial(partKey, effectiveMaterials)
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
// Default: fully visible + raycasting enabled
@@ -589,7 +646,7 @@ export default function ThreeDViewer({
}
}
})
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials, resolvePartKey])
// Keyboard shortcuts
useEffect(() => {
@@ -653,11 +710,12 @@ export default function ThreeDViewer({
if (hoveredMeshRef.current) {
const mesh = hoveredMeshRef.current
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(normalizeMeshName((mesh.userData?.name as string) || mesh.name), partMaterials)) {
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
@@ -665,19 +723,19 @@ export default function ThreeDViewer({
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials])
}, [showUnassigned, effectiveMaterials, resolvePartKey])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
setHoverInfo(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
}, [])
// Task 7 — click to pin material panel
// Task 7 — click to pin material panel (resolves to partKey slug when available)
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const name = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '')
if (name) setPinnedPart(name)
}, [])
const normalized = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '')
if (normalized) setPinnedPart(resolvePartKey(normalized))
}, [resolvePartKey])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950" onClick={() => setPinnedPart(null)}>
@@ -762,7 +820,7 @@ export default function ThreeDViewer({
)}
{/* Hide assigned toggle */}
{modelReady && Object.keys(partMaterials).length > 0 && (
{modelReady && Object.keys(effectiveMaterials).length > 0 && (
<TBtn
active={hideAssigned}
onClick={() => setHideAssigned(v => !v)}
@@ -773,6 +831,20 @@ export default function ThreeDViewer({
</TBtn>
)}
{/* Reconciliation button — shown when manifest has unmatched/unassigned items */}
{sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0) && (
<TBtn
active={showReconcile}
onClick={() => setShowReconcile(v => !v)}
title={`${sceneManifest.unmatched_source_rows.length} unmatched source rows · ${sceneManifest.unassigned_parts.length} unassigned parts`}
>
<AlertTriangle size={11} />
<span className="text-[10px] tabular-nums">
{sceneManifest.unmatched_source_rows.length + sceneManifest.unassigned_parts.length}
</span>
</TBtn>
)}
{/* Environment */}
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
@@ -899,14 +971,70 @@ export default function ThreeDViewer({
<MaterialPanel
partName={pinnedPart}
cadFileId={cadFileId}
currentEntry={resolvePartMaterial(pinnedPart, partMaterials)}
partMaterials={partMaterials}
currentEntry={resolvePartMaterial(pinnedPart, effectiveMaterials)}
partMaterials={effectiveMaterials}
onClose={() => setPinnedPart(null)}
isolateMode={isolateMode}
onIsolateModeChange={setIsolateMode}
sourcePartName={sceneManifest?.parts.find(p => p.part_key === pinnedPart)?.source_name}
assignmentProvenance={sceneManifest?.parts.find(p => p.part_key === pinnedPart)?.assignment_provenance}
isPartKeyMode={Object.keys(partKeyMap).length > 0}
manualOverrides={manualOverrides}
/>
)}
{/* Reconciliation panel */}
{showReconcile && sceneManifest && (
<div
className="absolute top-2 right-2 z-30 w-64 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<span className="text-white text-xs font-semibold flex items-center gap-1.5">
<AlertTriangle size={12} className="text-amber-400" /> Reconciliation
</span>
<button onClick={() => setShowReconcile(false)} className="text-gray-400 hover:text-white p-0.5">
<X size={14} />
</button>
</div>
<div className="p-3 space-y-3">
{sceneManifest.unassigned_parts.length > 0 && (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unassigned parts ({sceneManifest.unassigned_parts.length})
</p>
{sceneManifest.unassigned_parts.map(pk => (
<button
key={pk}
onClick={() => { setPinnedPart(pk); setShowReconcile(false) }}
className="block w-full text-left px-2 py-1 text-xs text-gray-300 hover:bg-gray-800 hover:text-white rounded transition-colors truncate"
title={pk}
>
{pk}
</button>
))}
</div>
)}
{sceneManifest.unmatched_source_rows.length > 0 && (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unmatched source rows ({sceneManifest.unmatched_source_rows.length})
</p>
{sceneManifest.unmatched_source_rows.map((row, i) => (
<div
key={i}
className="px-2 py-1 text-xs text-gray-500 truncate"
title={row}
>
{row}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Keyboard hint — bottom-right */}
<div className="absolute bottom-2 right-16 z-10 pointer-events-none select-none text-gray-600 text-[10px]">
F fit · W wire · G grid · S shadow · click part to assign · Esc close