2026-05-20-22-35-42 导入资产与分割导出优化
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
|
||||
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api';
|
||||
|
||||
export interface ModelPreviewPayload {
|
||||
fileName: string;
|
||||
@@ -100,14 +100,18 @@ const defaultSavedPoses: SavedModelPose[] = [
|
||||
];
|
||||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
|
||||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||||
];
|
||||
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
|
||||
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
|
||||
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
|
||||
];
|
||||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||||
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
|
||||
];
|
||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const fusionBaseExtent = 4.6;
|
||||
const axisInsetLength = 17;
|
||||
@@ -177,6 +181,16 @@ export function getCachedModelPreview(projectId: string, fileName: string, limit
|
||||
);
|
||||
}
|
||||
|
||||
export function clearCachedProjectAssets(projectId: string) {
|
||||
[dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => {
|
||||
[...cache.keys()].forEach((key) => {
|
||||
if (key.startsWith(`${projectId}:`)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -2056,7 +2070,7 @@ export function VoxelizationMappingView({
|
||||
const renderOverlaySummary = (placement: 'bottom' | 'side') => (
|
||||
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
|
||||
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
|
||||
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
|
||||
<span className="truncate">Overlay Label Map</span>
|
||||
<span className="font-mono text-cyan-100">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</span>
|
||||
@@ -2219,7 +2233,7 @@ export function VoxelizationMappingView({
|
||||
|
||||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
|
||||
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
|
||||
<span className="truncate">Overlay Label Map</span>
|
||||
<span className="font-mono text-cyan-700">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||||
</span>
|
||||
@@ -2321,12 +2335,13 @@ export default function ReverseWorkspace({
|
||||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||||
dicom: true,
|
||||
dicom: false,
|
||||
segmentation: true,
|
||||
pose: true,
|
||||
stl: false,
|
||||
});
|
||||
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
|
||||
const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||
const [fusionError, setFusionError] = useState('');
|
||||
@@ -2347,6 +2362,7 @@ export default function ReverseWorkspace({
|
||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const saveToastTimerRef = useRef<number | null>(null);
|
||||
const savedWorkspaceSnapshotRef = useRef('');
|
||||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
const selectedItems = exportOptions
|
||||
@@ -2363,6 +2379,7 @@ export default function ReverseWorkspace({
|
||||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||||
pose: modelPose,
|
||||
segmentationScope: segmentationExportScope,
|
||||
segmentationExportMode,
|
||||
});
|
||||
window.setTimeout(() => setExporting(false), 900);
|
||||
setShowExportMenu(false);
|
||||
@@ -2552,7 +2569,7 @@ export default function ReverseWorkspace({
|
||||
return bounds;
|
||||
};
|
||||
|
||||
const applyModelStretchByAxis = async (axis: AxisKey) => {
|
||||
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
|
||||
if (!project || !fusionVolume) {
|
||||
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
|
||||
return;
|
||||
@@ -2588,8 +2605,23 @@ export default function ReverseWorkspace({
|
||||
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
|
||||
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
|
||||
const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale));
|
||||
updateModelPose({ scale: nextScale });
|
||||
const nextPose = { ...modelPose, scale: nextScale };
|
||||
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
|
||||
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`);
|
||||
if (options.silentInitial) {
|
||||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||||
modelPose: nextPose,
|
||||
segmentationExportScope,
|
||||
moduleStyles,
|
||||
sliceStart,
|
||||
sliceEnd,
|
||||
mappingSlice,
|
||||
displayLevel,
|
||||
dicomOpacityLevel,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
|
||||
} finally {
|
||||
@@ -2620,6 +2652,7 @@ export default function ReverseWorkspace({
|
||||
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
|
||||
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
|
||||
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
|
||||
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
|
||||
setModelPose(restoredPose);
|
||||
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
@@ -2715,7 +2748,7 @@ export default function ReverseWorkspace({
|
||||
return clamp(value, limit.min, limit.max);
|
||||
};
|
||||
|
||||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||||
const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
|
||||
setModelPose((current) => {
|
||||
const next = { ...current };
|
||||
modelPoseKeys.forEach((key) => {
|
||||
@@ -2726,8 +2759,12 @@ export default function ReverseWorkspace({
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setSelectedPoseId('custom');
|
||||
setPoseImportStatus('');
|
||||
if (options.markCustom !== false) {
|
||||
setSelectedPoseId('custom');
|
||||
}
|
||||
if (!options.keepStatus) {
|
||||
setPoseImportStatus('');
|
||||
}
|
||||
};
|
||||
|
||||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||||
@@ -3021,6 +3058,25 @@ export default function ReverseWorkspace({
|
||||
mappingDisplayMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !fusionVolume || !workspaceLoadState.ready) {
|
||||
return;
|
||||
}
|
||||
const stretchState = initialZStretchRef.current;
|
||||
if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) {
|
||||
return;
|
||||
}
|
||||
initialZStretchRef.current = { projectId: project.id, pending: false };
|
||||
void applyModelStretchByAxis('z', { silentInitial: true });
|
||||
}, [
|
||||
project?.id,
|
||||
fusionVolume,
|
||||
workspaceLoadState.ready,
|
||||
modelPose.rotateX,
|
||||
modelPose.rotateY,
|
||||
modelPose.rotateZ,
|
||||
]);
|
||||
|
||||
if (!workspaceLoadState.ready) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||||
@@ -3166,6 +3222,27 @@ export default function ReverseWorkspace({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{segmentationExportModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setSegmentationExportMode(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||||
segmentationExportMode === option.id
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||||
<span className={`block text-[9px] ${segmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user