feat(P2): USD Foundation — canonical part identity + material overrides
M1 — USD exporter:
- render-worker/scripts/export_step_to_usd.py (631 lines)
Full XCAF traversal, one UsdGeom.Mesh per leaf part,
schaeffler:partKey on every prim, index-space sharpEdgeVertexPairs
- render-worker/Dockerfile: usd-core>=24.11 installed (USD 0.26.3)
M2 — usd_master MediaAsset + pipeline auto-chain:
- migrations 060 (usd_master enum), 061 (3 JSONB columns),
062 (rename tessellation settings keys)
- generate_usd_master_task: runs export_step_to_usd.py, upserts
usd_master MediaAsset, writes resolved_material_assignments to CadFile
- Auto-chained from generate_gltf_geometry_task after every GLB export
- step_tasks.py shim re-exports generate_usd_master_task
M3 — scene-manifest API:
- part_key_service.py: build_scene_manifest(), generate_part_key(),
four-layer material priority resolution with provenance
- SceneManifest / PartEntry Pydantic models in products/schemas.py
- GET /api/cad/{id}/scene-manifest endpoint (graceful fallback to
parsed_objects when USD not yet generated)
- POST /api/cad/{id}/generate-usd-master endpoint
- frontend/src/api/sceneManifest.ts: fetchSceneManifest(),
triggerUsdMasterGeneration()
M4 — manual-material-overrides API:
- GET/PUT /api/cad/{id}/manual-material-overrides endpoints
- CadFile.manual_material_overrides JSONB column (migration 061)
- getManualOverrides() / saveManualOverrides() in cad.ts
M5 — ThreeDViewer partKey integration:
- export_step_to_gltf.py injects partKeyMap into GLB extras
- ThreeDViewer: partKeyMap extraction, resolvePartKey(), effectiveMaterials
merges legacy partMaterials + new manualOverrides (server-side persistence)
- MaterialPanel: dual-path save (partKey vs legacy), provenance badge,
reconciliation panel for unmatched/unassigned parts
Also:
- Admin.tsx: generate-missing-usd-masters + canonical scenes bulk actions
- ProductDetail.tsx: usd_master row in asset table
- vite-env.d.ts: fix ImportMeta.env TypeScript error
- GPUProbeResult: add timestamp/devices/render_time_s fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../../api/client'
|
||||
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
|
||||
import { savePartMaterials, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
|
||||
@@ -60,6 +60,14 @@ export interface MaterialPanelProps {
|
||||
onClose: () => void
|
||||
isolateMode?: IsolateMode
|
||||
onIsolateModeChange?: (mode: IsolateMode) => void
|
||||
/** Source part name from XCAF (shown alongside partKey slug) */
|
||||
sourcePartName?: string
|
||||
/** How the current assignment was derived */
|
||||
assignmentProvenance?: 'manual' | 'auto' | 'source' | 'default'
|
||||
/** True when GLB has partKeyMap — saves via /manual-material-overrides endpoint */
|
||||
isPartKeyMode?: boolean
|
||||
/** Current manual overrides map (needed to merge when saving in partKey mode) */
|
||||
manualOverrides?: Record<string, string>
|
||||
}
|
||||
|
||||
export default function MaterialPanel({
|
||||
@@ -70,6 +78,10 @@ export default function MaterialPanel({
|
||||
onClose,
|
||||
isolateMode = 'none',
|
||||
onIsolateModeChange,
|
||||
sourcePartName,
|
||||
assignmentProvenance,
|
||||
isPartKeyMode = false,
|
||||
manualOverrides = {},
|
||||
}: MaterialPanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -100,6 +112,7 @@ export default function MaterialPanel({
|
||||
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
|
||||
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Legacy save (part_materials, keyed by normalized mesh name)
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
|
||||
onSuccess: () => {
|
||||
@@ -120,21 +133,53 @@ export default function MaterialPanel({
|
||||
onError: () => toast.error('Failed to remove assignment'),
|
||||
})
|
||||
|
||||
// partKey mode save (manual_material_overrides, keyed by partKey slug)
|
||||
const manualSaveMut = useMutation({
|
||||
mutationFn: (updated: Record<string, string>) => saveManualOverrides(cadFileId, updated),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['manual-overrides', cadFileId] })
|
||||
toast.success(`Material assigned to "${partName}"`)
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.error('Failed to save material assignment'),
|
||||
})
|
||||
|
||||
const manualRemoveMut = useMutation({
|
||||
mutationFn: (updated: Record<string, string>) => saveManualOverrides(cadFileId, updated),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['manual-overrides', cadFileId] })
|
||||
toast.success(`Assignment removed from "${partName}"`)
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast.error('Failed to remove assignment'),
|
||||
})
|
||||
|
||||
function handleAssign() {
|
||||
const entry: PartMaterialEntry =
|
||||
assignType === 'hex'
|
||||
? { type: 'hex', value: hexValue }
|
||||
: { type: 'library', value: libValue }
|
||||
saveMut.mutate({ ...partMaterials, [partName]: entry })
|
||||
const materialValue = assignType === 'hex' ? hexValue : libValue
|
||||
if (isPartKeyMode) {
|
||||
manualSaveMut.mutate({ ...manualOverrides, [partName]: materialValue })
|
||||
} else {
|
||||
const entry: PartMaterialEntry =
|
||||
assignType === 'hex'
|
||||
? { type: 'hex', value: hexValue }
|
||||
: { type: 'library', value: libValue }
|
||||
saveMut.mutate({ ...partMaterials, [partName]: entry })
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
const updated = { ...partMaterials }
|
||||
delete updated[partName]
|
||||
removeMut.mutate(updated)
|
||||
if (isPartKeyMode) {
|
||||
const updated = { ...manualOverrides }
|
||||
delete updated[partName]
|
||||
manualRemoveMut.mutate(updated)
|
||||
} else {
|
||||
const updated = { ...partMaterials }
|
||||
delete updated[partName]
|
||||
removeMut.mutate(updated)
|
||||
}
|
||||
}
|
||||
|
||||
const isBusy = saveMut.isPending || removeMut.isPending
|
||||
const isBusy = saveMut.isPending || removeMut.isPending || manualSaveMut.isPending || manualRemoveMut.isPending
|
||||
const previewHex = assignType === 'hex'
|
||||
? hexValue
|
||||
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
|
||||
@@ -146,13 +191,29 @@ export default function MaterialPanel({
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Palette size={13} className="text-accent shrink-0" />
|
||||
<span className="text-white text-xs font-semibold truncate" title={partName}>
|
||||
{partName}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-white text-xs font-semibold truncate block" title={partName}>
|
||||
{partName}
|
||||
</span>
|
||||
{sourcePartName && sourcePartName !== partName && (
|
||||
<span className="text-gray-500 text-[10px] truncate block" title={sourcePartName}>
|
||||
{sourcePartName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{assignmentProvenance && assignmentProvenance !== 'default' && (
|
||||
<span className={`shrink-0 text-[9px] font-medium px-1 py-0.5 rounded ${
|
||||
assignmentProvenance === 'manual' ? 'bg-accent/20 text-accent' :
|
||||
assignmentProvenance === 'auto' ? 'bg-green-900/40 text-green-400' :
|
||||
'bg-yellow-900/40 text-yellow-400'
|
||||
}`}>
|
||||
{assignmentProvenance}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0 ml-1">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -270,7 +331,7 @@ export default function MaterialPanel({
|
||||
disabled={isBusy || (assignType === 'library' && !libValue)}
|
||||
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
|
||||
>
|
||||
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
|
||||
{(saveMut.isPending || manualSaveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
|
||||
Assign
|
||||
</button>
|
||||
{currentEntry && (
|
||||
@@ -279,7 +340,7 @@ export default function MaterialPanel({
|
||||
disabled={isBusy}
|
||||
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
|
||||
>
|
||||
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
|
||||
{(removeMut.isPending || manualRemoveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user