diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 40d68ca..fda675d 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -9,6 +9,7 @@ import { Eye, Layers, Save, + Upload, } from 'lucide-react'; import * as THREE from 'three'; import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types'; @@ -28,6 +29,9 @@ interface ModelPreviewPayload { type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; type DicomOpacityLevel = 'low' | 'medium' | 'high'; type ModelPoseKey = keyof ModelPose; +type PoseDraftValues = Record; + +const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale']; const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, @@ -90,6 +94,110 @@ function getStepPrecision(step: number) { return text.split('.')[1]?.length ?? 0; } +function formatPoseValue(key: ModelPoseKey, value: number) { + return Number(value).toFixed(getStepPrecision(poseStepConfig[key].step)); +} + +function formatPoseDraftValues(pose: ModelPose): PoseDraftValues { + return modelPoseKeys.reduce((accumulator, key) => ({ + ...accumulator, + [key]: formatPoseValue(key, pose[key]), + }), {} as PoseDraftValues); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPose): ModelPose | null { + if (!isRecord(input)) { + return null; + } + + let hasPoseValue = false; + const normalized = { ...fallback }; + modelPoseKeys.forEach((key) => { + const rawValue = input[key]; + const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue); + if (!Number.isFinite(numericValue)) { + return; + } + + const limit = poseStepConfig[key]; + normalized[key] = clamp(numericValue, limit.min, limit.max); + hasPoseValue = true; + }); + + return hasPoseValue ? normalized : null; +} + +function normalizeImportedModelPoses(input: unknown): SavedModelPose[] | null { + if (!Array.isArray(input)) { + return null; + } + + const normalized = input + .map((item, index) => { + if (!isRecord(item)) { + return null; + } + + const pose = normalizePoseValue(item.pose); + if (!pose) { + return null; + } + + const rawId = typeof item.id === 'string' && item.id.trim() + ? item.id.trim() + : `imported-pose-${Date.now()}-${index}`; + const rawName = typeof item.name === 'string' && item.name.trim() + ? item.name.trim() + : `导入位姿${index + 1}`; + + return { + id: rawId.slice(0, 80), + name: rawName.slice(0, 80), + pose, + }; + }) + .filter((item): item is SavedModelPose => Boolean(item)); + + if (!normalized.length) { + return null; + } + + const deduped = new Map(); + normalized.forEach((item) => { + deduped.set(item.id, item); + }); + + return [...deduped.values()]; +} + +function mergeImportedModelPoses(imported: SavedModelPose[]) { + const importedById = new Map(imported.map((pose) => [pose.id, pose])); + const defaults = defaultSavedPoses.map((pose) => importedById.get(pose.id) ?? pose); + const custom = imported.filter((pose) => !defaultSavedPoses.some((item) => item.id === pose.id)); + + return [...defaults, ...custom]; +} + +function poseValuesMatch(left: ModelPose, right: ModelPose) { + return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6); +} + +function parseImportedPosePayload(payload: unknown) { + const record = isRecord(payload) ? payload : {}; + const importedModelPoses = normalizeImportedModelPoses(record.modelPoses); + const activePose = normalizePoseValue(record.activePose) + ?? normalizePoseValue(record.pose) + ?? normalizePoseValue(payload) + ?? importedModelPoses?.[0]?.pose + ?? null; + + return { activePose, importedModelPoses }; +} + function createDicomTexture(frame: string, width: number, height: number) { const canvas = document.createElement('canvas'); canvas.width = width; @@ -1701,6 +1809,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [sliceEnd, setSliceEnd] = useState(49); const [mappingSlice, setMappingSlice] = useState(0); const [modelPose, setModelPose] = useState(defaultModelPose); + const [poseValueDrafts, setPoseValueDrafts] = useState(() => formatPoseDraftValues(defaultModelPose)); + const [focusedPoseInput, setFocusedPoseInput] = useState(null); + const [poseImportStatus, setPoseImportStatus] = useState(''); const [displayLevel, setDisplayLevel] = useState('standard'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); const [showBounds, setShowBounds] = useState(true); @@ -1720,6 +1831,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [exporting, setExporting] = useState(false); const fusionVolumeCacheRef = useRef(new Map()); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); + const poseImportInputRef = useRef(null); const handleExport = async (format: 'nii' | 'nii.gz') => { setExporting(true); @@ -1841,25 +1953,83 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { } }, []); - const updateModelPose = (partial: Partial) => { - setModelPose((current) => ({ - ...current, - ...partial, - })); - setSelectedPoseId('custom'); - }; + useEffect(() => { + setPoseValueDrafts((current) => { + const next = { ...current }; + modelPoseKeys.forEach((key) => { + if (focusedPoseInput !== key) { + next[key] = formatPoseValue(key, modelPose[key]); + } + }); + return next; + }); + }, [ + focusedPoseInput, + modelPose.rotateX, + modelPose.rotateY, + modelPose.rotateZ, + modelPose.translateX, + modelPose.translateY, + modelPose.translateZ, + modelPose.scale, + ]); const clampPoseValue = (key: ModelPoseKey, value: number) => { const limit = poseStepConfig[key]; return clamp(value, limit.min, limit.max); }; + const updateModelPose = (partial: Partial) => { + setModelPose((current) => { + const next = { ...current }; + modelPoseKeys.forEach((key) => { + const value = partial[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + next[key] = clampPoseValue(key, value); + } + }); + return next; + }); + setSelectedPoseId('custom'); + setPoseImportStatus(''); + }; + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { setModelPose((current) => ({ ...current, [key]: clampPoseValue(key, current[key] + delta), })); setSelectedPoseId('custom'); + setPoseImportStatus(''); + }; + + const handlePoseInputChange = (key: ModelPoseKey, value: string) => { + setPoseValueDrafts((current) => ({ ...current, [key]: value })); + if (!value.trim()) { + return; + } + + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return; + } + + updateModelPose({ [key]: numericValue } as Partial); + }; + + const commitPoseInputValue = (key: ModelPoseKey) => { + const draftValue = poseValueDrafts[key]; + const numericValue = draftValue.trim() ? Number(draftValue) : NaN; + if (Number.isFinite(numericValue)) { + const nextValue = clampPoseValue(key, numericValue); + if (Math.abs(nextValue - modelPose[key]) > 1e-9) { + updateModelPose({ [key]: nextValue } as Partial); + } + setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, nextValue) })); + } else { + setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, modelPose[key]) })); + } + setFocusedPoseInput(null); }; const stopPoseRepeat = () => { @@ -1889,6 +2059,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { rotateZ: 0, })); setSelectedPoseId('custom'); + setPoseImportStatus(''); }; const resetTransformPose = () => { @@ -1900,6 +2071,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { scale: 1, })); setSelectedPoseId('custom'); + setPoseImportStatus(''); }; const updateModuleStyle = (fileName: string, partial: Partial) => { @@ -1957,6 +2129,43 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { if (!selected) return; setSelectedPoseId(poseId); setModelPose(selected.pose); + setPoseImportStatus(''); + }; + + const handleImportPoseFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + + try { + const payload = JSON.parse(await file.text()) as unknown; + const { activePose, importedModelPoses } = parseImportedPosePayload(payload); + if (!activePose && !importedModelPoses?.length) { + throw new Error('未找到可用位姿数据'); + } + + const nextSavedPoses = importedModelPoses?.length + ? mergeImportedModelPoses(importedModelPoses) + : savedPoses; + if (importedModelPoses?.length) { + commitSavedPoses(nextSavedPoses); + } + + if (activePose) { + setModelPose(activePose); + setPoseValueDrafts(formatPoseDraftValues(activePose)); + const matchedPose = nextSavedPoses.find((item) => poseValuesMatch(item.pose, activePose)); + setSelectedPoseId(matchedPose?.id ?? 'custom'); + } + + setFusionError(''); + setPoseImportStatus(importedModelPoses?.length ? '位姿数据已导入并保存' : '当前位姿已导入'); + } catch (error) { + setPoseImportStatus(''); + setFusionError(error instanceof Error ? `位姿导入失败:${error.message}` : '位姿导入失败'); + } }; const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); @@ -2193,10 +2402,23 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

模型位姿

- +
+ + + +
setFocusedPoseInput(item.key)} + onChange={(event) => handlePoseInputChange(item.key, event.target.value)} + onBlur={() => commitPoseInputValue(item.key)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + className="h-7 min-w-0 rounded-md border border-slate-200 bg-white px-1.5 text-right font-mono text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400 focus:bg-blue-50/40" + /> {poseStepConfig[item.key].quick && (