345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
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<string, string>
|
|
/** 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<MaterialOut[]>('/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<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 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 (
|
|
<div
|
|
className="absolute top-2 left-2 z-30 w-72 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 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 flex-1">
|
|
<Palette size={13} className="text-accent shrink-0" />
|
|
<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 ml-1">
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-3 space-y-3">
|
|
{/* Isolation toggles — ghost or hide all other parts */}
|
|
{onIsolateModeChange && (
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => onIsolateModeChange(isolateMode === 'ghost' ? 'none' : 'ghost')}
|
|
title="Ghost other parts (semi-transparent)"
|
|
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
|
|
isolateMode === 'ghost'
|
|
? 'bg-accent text-white'
|
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<Layers size={11} /> Ghost
|
|
</button>
|
|
<button
|
|
onClick={() => onIsolateModeChange(isolateMode === 'hide' ? 'none' : 'hide')}
|
|
title="Hide other parts"
|
|
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
|
|
isolateMode === 'hide'
|
|
? 'bg-accent text-white'
|
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<EyeOff size={11} /> Hide
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Type tabs */}
|
|
<div className="flex rounded-md overflow-hidden border border-gray-700 text-xs">
|
|
{(['library', 'hex'] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setAssignType(t)}
|
|
className={`flex-1 py-1.5 font-medium transition-colors ${
|
|
assignType === t
|
|
? 'bg-accent text-white'
|
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{t === 'library' ? 'Library Material' : 'Hex Color'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{assignType === 'library' ? (
|
|
<div>
|
|
<label className="block text-gray-400 text-[11px] mb-1">Material</label>
|
|
<select
|
|
value={libValue}
|
|
onChange={(e) => setLibValue(e.target.value)}
|
|
className="w-full bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 focus:outline-none focus:border-accent"
|
|
>
|
|
{allMaterials.map((m) => (
|
|
<option key={m.id} value={m.name}>
|
|
{m.name}
|
|
{m.description ? ` — ${m.description}` : ''}
|
|
</option>
|
|
))}
|
|
{allMaterials.length === 0 && (
|
|
<option value="">No materials found</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="block text-gray-400 text-[11px] mb-1">Hex Color</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={hexValue}
|
|
onChange={(e) => setHexValue(e.target.value)}
|
|
className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={hexValue}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview swatch with PBR info */}
|
|
<div className="flex items-center gap-2 text-[11px] text-gray-400">
|
|
<div
|
|
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
|
|
style={{ backgroundColor: previewHex }}
|
|
/>
|
|
<span className="flex-1">Preview</span>
|
|
{assignType === 'library' && selectedPbr && (
|
|
<span className="text-[10px] text-gray-500 font-mono">
|
|
M:{selectedPbr.metallic.toFixed(1)} R:{selectedPbr.roughness.toFixed(1)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Current assignment */}
|
|
{currentEntry && (
|
|
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
|
|
<div
|
|
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
|
|
style={{ backgroundColor: previewColorForEntry(currentEntry, pbrMap) }}
|
|
/>
|
|
<span className="truncate">Current: {currentEntry.value}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-1">
|
|
<button
|
|
onClick={handleAssign}
|
|
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 || manualSaveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
|
|
Assign
|
|
</button>
|
|
{currentEntry && (
|
|
<button
|
|
onClick={handleRemove}
|
|
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 || manualRemoveMut.isPending) && <Loader2 size={11} className="animate-spin" />}
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|