2026-05-20-02-02-37 支持位姿输入与导入

This commit is contained in:
2026-05-20 02:09:39 +08:00
parent 7099bfde8d
commit 1ddca18116
5 changed files with 453 additions and 13 deletions

View File

@@ -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<ModelPoseKey, string>;
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<string, unknown> {
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<string, SavedModelPose>();
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<ModelPose>(defaultModelPose);
const [poseValueDrafts, setPoseValueDrafts] = useState<PoseDraftValues>(() => formatPoseDraftValues(defaultModelPose));
const [focusedPoseInput, setFocusedPoseInput] = useState<ModelPoseKey | null>(null);
const [poseImportStatus, setPoseImportStatus] = useState('');
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('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<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(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<ModelPose>) => {
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<ModelPose>) => {
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<ModelPose>);
};
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<ModelPose>);
}
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<ModuleStyle>) => {
@@ -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<HTMLInputElement>) => {
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 }) {
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">姿</p>
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
<Save size={12} />
</button>
<div className="flex items-center gap-2">
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
<Save size={12} />
</button>
<button onClick={() => poseImportInputRef.current?.click()} className="flex items-center gap-1 text-[10px] font-bold text-emerald-600 hover:text-emerald-700">
<Upload size={12} />
</button>
<input
ref={poseImportInputRef}
type="file"
accept="application/json,.json"
onChange={handleImportPoseFile}
className="hidden"
/>
</div>
</div>
<select
value={selectedPoseId}
@@ -2216,6 +2438,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
placeholder="位姿名称"
/>
)}
{poseImportStatus && (
<p className="mb-2 rounded-lg bg-emerald-50 px-2 py-1.5 text-[10px] font-bold text-emerald-700">
{poseImportStatus}
</p>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={resetRotationPose}
@@ -2240,7 +2467,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
].map((item) => (
<div key={item.key} className="grid grid-cols-[44px_32px_1fr_32px_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
@@ -2281,7 +2508,22 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
>
+
</button>
<span className="text-right font-mono">{Number(item.value).toFixed(getStepPrecision(poseStepConfig[item.key].step))}</span>
<input
type="number"
min={poseStepConfig[item.key].min}
max={poseStepConfig[item.key].max}
step={poseStepConfig[item.key].step}
value={poseValueDrafts[item.key]}
onFocus={() => 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 && (
<div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
<button