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:
@@ -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 ?? {}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export type MediaAssetType =
|
||||
| 'stl_high'
|
||||
| 'gltf_geometry'
|
||||
| 'gltf_production'
|
||||
| 'usd_master'
|
||||
| 'blend_production'
|
||||
|
||||
// ── Media Browser (server-side filtered + paginated) ──────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user