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
+29
View File
@@ -151,3 +151,32 @@ export async function savePartMaterials(
const res = await api.put<PartMaterialsResponse>(`/cad/${cadFileId}/part-materials`, map)
return res.data.part_materials ?? {}
}
// ---------------------------------------------------------------------------
// Manual material overrides (partKey-keyed, Priority 4)
// ---------------------------------------------------------------------------
export interface ManualMaterialOverridesResponse {
cad_file_id: string
manual_material_overrides: Record<string, string> | null
}
/** Return the manual material overrides for a CAD file ({partKey: materialName}, empty if none). */
export async function getManualOverrides(cadFileId: string): Promise<Record<string, string>> {
const res = await api.get<ManualMaterialOverridesResponse>(
`/cad/${cadFileId}/manual-material-overrides`,
)
return res.data.manual_material_overrides ?? {}
}
/** Save manual material overrides keyed by partKey. Returns the saved map. */
export async function saveManualOverrides(
cadFileId: string,
overrides: Record<string, string>,
): Promise<Record<string, string>> {
const res = await api.put<ManualMaterialOverridesResponse>(
`/cad/${cadFileId}/manual-material-overrides`,
{ overrides },
)
return res.data.manual_material_overrides ?? {}
}
+1
View File
@@ -8,6 +8,7 @@ export type MediaAssetType =
| 'stl_high'
| 'gltf_geometry'
| 'gltf_production'
| 'usd_master'
| 'blend_production'
// ── Media Browser (server-side filtered + paginated) ──────────────────────────
+27
View File
@@ -0,0 +1,27 @@
import api from './client'
export interface PartEntry {
part_key: string
source_name: string
prim_path: string | null
effective_material: string | null
assignment_provenance: 'manual' | 'auto' | 'source' | 'default'
is_unassigned: boolean
}
export interface SceneManifest {
cad_file_id: string
parts: PartEntry[]
unmatched_source_rows: string[]
unassigned_parts: string[]
}
export async function fetchSceneManifest(cadFileId: string): Promise<SceneManifest> {
const res = await api.get<SceneManifest>(`/cad/${cadFileId}/scene-manifest`)
return res.data
}
export async function triggerUsdMasterGeneration(cadFileId: string): Promise<{ status: string; task_id: string; cad_file_id: string }> {
const res = await api.post(`/cad/${cadFileId}/generate-usd-master`)
return res.data
}
+3
View File
@@ -206,6 +206,9 @@ export interface GPUProbeResult {
device_type?: string | null
error?: string | null
probed_at?: string | null
timestamp?: string | null
devices?: string[] | null
render_time_s?: number | null
}
export async function getGpuProbeResult(): Promise<GPUProbeResult> {
+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
+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>
</>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />