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
+68 -31
View File
@@ -108,10 +108,10 @@ export default function AdminPage() {
gltf_material_quality: string
gltf_pbr_roughness: number
gltf_pbr_metallic: number
gltf_preview_linear_deflection: number
gltf_preview_angular_deflection: number
gltf_production_linear_deflection: number
gltf_production_angular_deflection: number
scene_linear_deflection: number
scene_angular_deflection: number
render_linear_deflection: number
render_angular_deflection: number
tessellation_engine: string
}
@@ -224,6 +224,18 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const generateMissingUsdMastersMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-usd-masters'),
onSuccess: (res) => toast.success(res.data.message || 'USD master export queued'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const generateMissingCanonicalScenesMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-canonical-scenes'),
onSuccess: (res) => toast.success(res.data.message || 'Canonical scene export queued'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
@@ -921,6 +933,30 @@ export default function AdminPage() {
</div>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingUsdMastersMut.mutate()}
disabled={generateMissingUsdMastersMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue USD master export for all completed CAD files without a USD master asset"
>
<RefreshCw size={14} className={generateMissingUsdMastersMut.isPending ? 'animate-spin' : ''} />
{generateMissingUsdMastersMut.isPending ? 'Queueing…' : 'Generate Missing USD Masters'}
</button>
<p className="text-xs text-content-muted">Exports USD canonical scene for all completed CAD files missing one.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingCanonicalScenesMut.mutate()}
disabled={generateMissingCanonicalScenesMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue geometry GLB + USD master export for all completed CAD files without a geometry GLB"
>
<RefreshCw size={14} className={generateMissingCanonicalScenesMut.isPending ? 'animate-spin' : ''} />
{generateMissingCanonicalScenesMut.isPending ? 'Queueing…' : 'Generate Missing Canonical Scenes'}
</button>
<p className="text-xs text-content-muted">Queues geometry GLB + USD master for all completed CAD files missing a canonical scene.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => importMediaAssetsMut.mutate()}
@@ -947,11 +983,12 @@ export default function AdminPage() {
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => {
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
cleanupOrphanedCadMut.mutate()
}
}}
onClick={() => setConfirmState({
open: true,
title: 'Delete Orphaned STEP Files',
message: 'Delete all orphaned STEP files (not linked to any product)? This cannot be undone.',
onConfirm: () => { cleanupOrphanedCadMut.mutate(); setConfirmState(s => ({ ...s, open: false })) },
})}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
@@ -1416,26 +1453,26 @@ export default function AdminPage() {
label: 'Draft',
description: 'Fast export, visible faceting on large curves',
color: 'border-amber-400 text-amber-700',
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3, render_linear_deflection: 0.05, render_angular_deflection: 0.1 },
},
{
label: 'Standard',
description: 'Smooth curves, no fan artifacts recommended',
color: 'border-blue-400 text-blue-700',
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1, render_linear_deflection: 0.03, render_angular_deflection: 0.05 },
},
{
label: 'Fine',
description: 'Maximum quality, very large files, slow export',
color: 'border-emerald-400 text-emerald-700',
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05, render_linear_deflection: 0.01, render_angular_deflection: 0.02 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
tess.scene_linear_deflection === preset.values.scene_linear_deflection &&
tess.scene_angular_deflection === preset.values.scene_angular_deflection &&
tess.render_linear_deflection === preset.values.render_linear_deflection &&
tess.render_angular_deflection === preset.values.render_angular_deflection
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
@@ -1450,8 +1487,8 @@ export default function AdminPage() {
<div className="font-semibold text-sm">{preset.label}</div>
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
<div>scene: {preset.values.scene_angular_deflection} rad / {preset.values.scene_linear_deflection} mm</div>
<div>render: {preset.values.render_angular_deflection} rad / {preset.values.render_linear_deflection} mm</div>
</div>
</button>
))}
@@ -1491,7 +1528,7 @@ export default function AdminPage() {
{/* Manual inputs */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Preview (Geometry GLB)</p>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Scene / Viewer</p>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
@@ -1499,8 +1536,8 @@ export default function AdminPage() {
step="0.01"
min="0.001"
max="10"
value={tess.gltf_preview_linear_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_linear_deflection: parseFloat(e.target.value) }))}
value={tess.scene_linear_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, scene_linear_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">mm</span>
@@ -1512,16 +1549,16 @@ export default function AdminPage() {
step="0.01"
min="0.01"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
value={tess.scene_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, scene_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Geometry GLB".</p>
<p className="text-xs text-content-muted">Used for the 3D viewer (canonical scene). Smaller = smoother surfaces.</p>
</div>
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Production (Production GLB)</p>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render output</p>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
@@ -1529,8 +1566,8 @@ export default function AdminPage() {
step="0.005"
min="0.001"
max="10"
value={tess.gltf_production_linear_deflection ?? 0.03}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_linear_deflection: parseFloat(e.target.value) }))}
value={tess.render_linear_deflection ?? 0.03}
onChange={e => setTessellationDraft(d => ({ ...d, render_linear_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">mm</span>
@@ -1542,13 +1579,13 @@ export default function AdminPage() {
step="0.005"
min="0.005"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
value={tess.render_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, render_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Production GLB". Smaller = smoother surfaces.</p>
<p className="text-xs text-content-muted">Used for final render output. Smaller = smoother surfaces, larger file sizes.</p>
</div>
</div>
<div className="flex gap-2">
@@ -1621,7 +1658,7 @@ export default function AdminPage() {
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
Last checked: {gpuProbeResult.timestamp ? new Date(gpuProbeResult.timestamp).toLocaleString() : ''}
</span>
)}
</div>
+1 -1
View File
@@ -197,7 +197,7 @@ export default function OrdersPage() {
<div className="flex items-center gap-3 mb-4">
<h1 className="text-2xl font-bold text-content">Orders</h1>
<div className="ml-auto flex items-center gap-2">
<div className="flex border border-border-default rounded-md overflow-hidden">
<div className="hidden md:flex border border-border-default rounded-md overflow-hidden">
<button
onClick={() => setView('kanban')}
title="Kanban view"
+15 -34
View File
@@ -19,7 +19,7 @@ import { listGlobalRenderPositions } from '../api/renderPositions'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials } from '../components/cad/cadUtils'
@@ -186,25 +186,15 @@ export default function ProductDetailPage() {
staleTime: 0,
})
const [productionGlbGenerating, setProductionGlbGenerating] = useState(false)
const { data: productionGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
const { data: usdMasterAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'usd_master'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['usd_master'] }),
enabled: !!cadFileId,
staleTime: 0,
refetchInterval: productionGlbGenerating ? 3000 : false,
})
// Stop polling once the freshly-generated asset has arrived
useEffect(() => {
if (productionGlbGenerating && productionGlbAssets.length > 0) {
setProductionGlbGenerating(false)
}
}, [productionGlbAssets, productionGlbGenerating])
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
const usdMasterUrl = usdMasterAssets[0]?.download_url ?? null
const { data: renders = [] } = useQuery<ProductRender[]>({
queryKey: ['product-renders', id],
@@ -353,21 +343,11 @@ export default function ProductDetailPage() {
onSuccess: () => {
toast.info('Geometry GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_geometry'] })
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'usd_master'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
})
const generateProductionGlbMut = useMutation({
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
onSuccess: () => {
toast.info('Production GLB export queued')
setProductionGlbGenerating(true)
// Remove stale asset immediately so the button doesn't show an outdated download
qc.removeQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
})
const resetStuckMut = useMutation({
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
onSuccess: (res) => {
@@ -737,21 +717,22 @@ export default function ProductDetailPage() {
</div>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<div className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-1">Canonical Scene</div>
<GlbDownloadButton
label="Geometry GLB"
label="Viewer GLB"
url={geometryGlbUrl}
filename={`${product.name ?? product.pim_id}_geometry.glb`}
onGenerate={() => generateGeometryGlbMut.mutate()}
isGenerating={generateGeometryGlbMut.isPending}
title="Export geometry GLB directly from STEP via OCC (no Blender)"
title="Regenerate canonical scene (geometry GLB + auto-chains USD master)"
/>
<GlbDownloadButton
label="Production GLB"
url={productionGlbUrl}
filename={`${product.name ?? product.pim_id}_production.glb`}
onGenerate={() => generateProductionGlbMut.mutate()}
isGenerating={generateProductionGlbMut.isPending || productionGlbGenerating}
title="Export production GLB with PBR materials via Blender"
label="USD Master"
url={usdMasterUrl}
filename={`${product.name ?? product.pim_id}_master.usd`}
onGenerate={() => generateGeometryGlbMut.mutate()}
isGenerating={generateGeometryGlbMut.isPending}
title="USD canonical scene (auto-generated after Viewer GLB)"
/>
</div>
</>