import { useState, useEffect } from 'react' 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, saveManualOverrides, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' import { previewColorForEntry, pbrColorHex } from './cadUtils' // --------------------------------------------------------------------------- // MaterialOut — matches GET /api/materials response // --------------------------------------------------------------------------- export interface MaterialOut { id: string name: string description: string | null hartomat_code: number | null source: string } // --------------------------------------------------------------------------- // MaterialPanel — floating panel for assigning a material/color to a part // --------------------------------------------------------------------------- export type IsolateMode = 'none' | 'ghost' | 'hide' export interface MaterialPanelProps { partName: string cadFileId: string currentEntry: PartMaterialEntry | undefined partMaterials: PartMaterialMap 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 /** PBR material map from Blender asset library */ pbrMap?: MaterialPBRMap } export default function MaterialPanel({ partName, cadFileId, currentEntry, partMaterials, onClose, isolateMode = 'none', onIsolateModeChange, sourcePartName, assignmentProvenance, isPartKeyMode = false, manualOverrides = {}, pbrMap: pbrMapProp, }: MaterialPanelProps) { const queryClient = useQueryClient() // Fetch PBR data if not passed via props const { data: pbrMapFetched } = useQuery({ queryKey: ['material-pbr'], queryFn: fetchMaterialPBR, staleTime: 300_000, enabled: !pbrMapProp, }) const pbrMap = pbrMapProp ?? pbrMapFetched ?? {} // Fetch all tenant materials (no filter — user sees their full library) const { data: allMaterials = [] } = useQuery({ queryKey: ['materials'], queryFn: async () => { const res = await api.get('/materials') return res.data }, staleTime: 60_000, }) const [assignType, setAssignType] = useState<'library' | 'hex'>( currentEntry?.type ?? 'library', ) const [hexValue, setHexValue] = useState( currentEntry?.type === 'hex' ? currentEntry.value : '#888888', ) const [libValue, setLibValue] = useState( currentEntry?.type === 'library' ? currentEntry.value : (allMaterials[0]?.name ?? ''), ) // Set default library value once materials load useEffect(() => { 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: () => { queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] }) toast.success(`Material assigned to "${partName}"`) onClose() }, onError: () => toast.error('Failed to save material assignment'), }) const removeMut = useMutation({ mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] }) toast.success(`Assignment removed from "${partName}"`) onClose() }, onError: () => toast.error('Failed to remove assignment'), }) // partKey mode save (manual_material_overrides, keyed by partKey slug) const manualSaveMut = useMutation({ mutationFn: (updated: Record) => 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) => 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 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() { 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 || manualSaveMut.isPending || manualRemoveMut.isPending // Preview color for selected material const selectedPbr = pbrMap[libValue] const previewHex = assignType === 'hex' ? hexValue : (selectedPbr ? pbrColorHex(selectedPbr) : '#888888') return (
e.stopPropagation()} > {/* Header */}
{partName} {sourcePartName && sourcePartName !== partName && ( {sourcePartName} )}
{assignmentProvenance && assignmentProvenance !== 'default' && ( {assignmentProvenance} )}
{/* Isolation toggles — ghost or hide all other parts */} {onIsolateModeChange && (
)} {/* Type tabs */}
{(['library', 'hex'] as const).map((t) => ( ))}
{assignType === 'library' ? (
) : (
setHexValue(e.target.value)} className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5" /> setHexValue(e.target.value)} className="flex-1 bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 font-mono focus:outline-none focus:border-accent" placeholder="#888888" />
)} {/* Preview swatch with PBR info */}
Preview {assignType === 'library' && selectedPbr && ( M:{selectedPbr.metallic.toFixed(1)} R:{selectedPbr.roughness.toFixed(1)} )}
{/* Current assignment */} {currentEntry && (
Current: {currentEntry.value}
)} {/* Actions */}
{currentEntry && ( )}
) }