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>
)}