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