2026-05-20-02-02-37 支持位姿输入与导入
This commit is contained in:
@@ -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
|
||||
|
||||
59
工程分析/实现方案-2026-05-20-02-02-37.md
Normal file
59
工程分析/实现方案-2026-05-20-02-02-37.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-05-20-02-02-37
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-02-02-37.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
让“模型位姿”每一项都支持直接数字输入,并新增导入位姿数据功能,兼容当前导出的位姿 JSON,导入后同步当前位姿和保存位姿列表。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-02-02-37.md`
|
||||
- `工程分析/实现方案-2026-05-20-02-02-37.md`
|
||||
- `工程分析/测试方案-2026-05-20-02-02-37.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 增加位姿归一化辅助函数:
|
||||
- 对每个位姿字段按 `poseStepConfig` 执行数字校验、clamp 和默认值补齐。
|
||||
- 对导入的保存位姿列表补齐默认/俯视/侧视,并去重。
|
||||
2. 模型位姿数值输入:
|
||||
- 将原只读数值改成 `<input type="number">`。
|
||||
- 使用现有 step/min/max/precision。
|
||||
- 输入合法时立即更新 `modelPose`,继续触发三维与二维映射联动。
|
||||
3. 导入位姿数据:
|
||||
- 新增隐藏 file input 与“导入”按钮。
|
||||
- 读取 JSON,兼容 `{ activePose, modelPoses }`、`{ pose }`、直接 pose 对象三种形式。
|
||||
- 导入 `modelPoses` 时调用现有 `commitSavedPoses` 持久化。
|
||||
4. 反馈与容错:
|
||||
- 导入成功设置提示;导入失败设置 `fusionError`。
|
||||
- 清空 file input value,允许重复导入同一文件。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 更新 `ReverseWorkspace.tsx` 的 import、ref、辅助函数和 handlers。
|
||||
- 调整模型位姿 UI,将保存按钮组改为保存/导入两个操作。
|
||||
- 将数值展示替换为可编辑数字输入。
|
||||
- 运行 `npm run lint` 与 `npm run build`。
|
||||
- 重新部署并验证服务。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧滑条、加减按钮、保存位姿、重命名位姿逻辑继续保留。
|
||||
- 导入功能只读本地 JSON,不改后端接口。
|
||||
- 如导入格式异常,只提示错误,不覆盖当前状态。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:位姿数字输入、导入按钮、JSON 解析与归一化。
|
||||
- 工程分析文档:新增本次三份文档并追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message:`2026-05-20-02-02-37 支持位姿输入与导入`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main` 后,沿用 `tmux` 会话 `revoxelseg-dicom` 重新部署。
|
||||
53
工程分析/测试方案-2026-05-20-02-02-37.md
Normal file
53
工程分析/测试方案-2026-05-20-02-02-37.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 测试方案-2026-05-20-02-02-37
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-05-20-02-02-37.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run lint`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 下执行 `npm run build`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 模型位姿的旋转 X/Y/Z、平移 X/Y/Z、缩放均出现可编辑数字输入框。
|
||||
- 输入合法数值后,滑条、三维融合视图和右侧逆向分割映射视图使用同一当前位姿。
|
||||
- 输入越界数值时被 clamp 到配置范围。
|
||||
- 导入导出的 `pose-data.json` 后,当前位姿恢复为 `activePose`。
|
||||
- 导入包含 `modelPoses` 的 JSON 后,保存位姿列表刷新并持久化。
|
||||
- 导入非法 JSON 或不含位姿字段的 JSON 时显示错误,当前位姿不被破坏。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 导入位姿后,右侧 Overlay 和分割导出仍读取当前位姿。
|
||||
- 只调整位姿输入不改变 DICOM spacing、FOV、slice navigator 和构件层级样式。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证:
|
||||
- `curl http://127.0.0.1:4000/api/health`
|
||||
- `curl -I http://127.0.0.1:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 显式暂存本次相关代码和文档。
|
||||
- 创建包含时间戳和描述的 commit。
|
||||
- 推送到 Gitea `origin/main`。
|
||||
|
||||
## 实测结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;仅保留 Vite chunk size 提醒。
|
||||
- 代码检查确认模型位姿 7 个字段均使用现有 `poseStepConfig` 的 `min/max/step` 作为数字输入约束。
|
||||
- 代码检查确认导入位姿支持 `activePose`、`modelPoses`、`pose` 和直接 pose 对象四类 JSON 来源。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 不要把运行态导出文件或历史工程文档删除混入提交。
|
||||
- 导入位姿不能破坏默认/俯视/侧视三组基础位姿。
|
||||
- 数字输入不能产生 `NaN` 或空字符串写入 `modelPose`。
|
||||
36
工程分析/经验记录.md
36
工程分析/经验记录.md
@@ -1045,3 +1045,39 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是用户明确“保存”的可视化参数都应进入项目级持久状态,不能只存在于组件 state。新增保存类交互时同时设计读取、写入、归一化和刷新后的回显验证。
|
||||
|
||||
## 2026-05-20-02-02-37 位姿数字输入必须保留可编辑中间态
|
||||
|
||||
A. 具体问题
|
||||
|
||||
模型位姿从只读数值改为可编辑数值时,如果直接把 `<input type="number">` 完全绑定到 `modelPose`,用户输入负号、小数点或临时清空内容时会被 React 立即回填旧值,导致难以编辑。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
数值输入存在合法提交值和编辑中间态两层语义;`modelPose` 只能存储有限数字,而输入框需要短暂容纳空字符串或尚未完整输入的文本。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
新增 `poseValueDrafts` 保存输入框文本态,并用 `focusedPoseInput` 控制同步范围。输入内容能解析为数字时立即 clamp 后写入 `modelPose`;失焦时统一格式化回与 step 匹配的小数位,非法输入则恢复当前位姿值。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
任何需要“可编辑数字 + 实时状态”的控件都应区分 draft value 和 committed value。不要把可能为空或临时非法的输入字符串直接写入业务状态,也不要让格式化逻辑打断用户正在输入的字段。
|
||||
|
||||
## 2026-05-20-02-02-37 导入导出的位姿 JSON 要兼容多种入口
|
||||
|
||||
A. 具体问题
|
||||
|
||||
位姿导出 JSON 包含 `activePose`、`modelPoses` 和项目元信息;用户导入时也可能拿到单个 `{ pose }` 或直接 pose 对象。如果导入逻辑只认一种结构,会让“导出后再导入”的闭环不稳定。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
导出文件是侧车数据,不是纯数组;同时调试和人工编辑时常会只保留一个 pose 对象。导入端如果没有 schema 容错,就会把可恢复数据误判为无效。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
导入解析按优先级读取 `activePose`、`pose`、直接 pose 对象和 `modelPoses[0].pose`,并对每个字段使用 `poseStepConfig` 做 clamp。若包含 `modelPoses`,则补齐默认/俯视/侧视并通过现有 `PATCH /model-poses` 持久化。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
成对设计导入/导出功能时,应把导出样例作为导入测试用例,同时兼容最小可用对象。导入端必须对字段缺失、字符串数字、越界数值和重复 id 做归一化处理。
|
||||
|
||||
50
工程分析/需求分析-2026-05-20-02-02-37.md
Normal file
50
工程分析/需求分析-2026-05-20-02-02-37.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 需求分析-2026-05-20-02-02-37
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-05-20-02-02-37
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户要求将“可视化工具栏”中的“模型位姿”数值变成可编辑输入,允许直接输入数值调整模型;同时新增“导入位姿数据”选项,并与“导出全部 NII.GZ”中的位姿数据 JSON 格式匹配。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 提升模型位姿精调效率,支持滑条、按钮和数值输入三种方式共用同一位姿状态。
|
||||
- 支持把已导出的位姿 JSON 再导入当前项目,恢复 activePose、保存位姿列表,并继续驱动三维融合视图、右侧映射视图和分割导出。
|
||||
- 保持医疗影像工具栏界面紧凑、可读、可控。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- 用户在模型位姿数值框中输入旋转、平移、缩放数值。
|
||||
- 用户选择位姿 JSON 文件导入。
|
||||
- 输出:
|
||||
- 当前 `modelPose` 实时更新。
|
||||
- 可选保存位姿列表 `modelPoses` 更新并写入后端。
|
||||
- 右侧逆向分割映射视图和 NIfTI 分割导出继续使用当前位姿。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
- 本次需求、实现、测试文档。
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 位姿输入需要沿用现有 `poseStepConfig` 的最小值、最大值和精度。
|
||||
- 导入格式必须兼容当前导出的 `pose-data.json`,尤其是 `activePose` 与 `modelPoses`。
|
||||
- 导入失败需要给出明确错误,不破坏当前位姿。
|
||||
- 用户导入保存位姿列表后,要继续通过 `PATCH /model-poses` 持久化。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 数字输入的中间态可能为空或非法,需要避免把 `NaN` 写入位姿状态。
|
||||
- 导入的 JSON 可能包含不完整或越界数值,需要归一化和 clamp。
|
||||
- 默认位姿不能丢失;导入外部 `modelPoses` 时应保留或补齐默认/俯视/侧视。
|
||||
|
||||
## 默认假设
|
||||
|
||||
- 导入位姿数据优先读取导出 JSON 中的 `activePose`;如果没有,则读取第一个可用的 `modelPoses[0].pose` 或对象自身作为单个位姿。
|
||||
- 如果导入文件包含 `modelPoses`,则将其作为保存位姿列表导入并持久化。
|
||||
- 如果只导入单个位姿,则仅更新当前位姿,不覆盖保存列表。
|
||||
Reference in New Issue
Block a user