2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互

This commit is contained in:
2026-05-24 11:11:07 +08:00
parent f54dafb83d
commit f279770a0e
12 changed files with 799 additions and 147 deletions

View File

@@ -18,7 +18,11 @@ import {
Layers,
X,
Trash2,
Upload
Upload,
RefreshCcw,
FlipHorizontal2,
FlipVertical2,
Move3d
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
@@ -47,6 +51,9 @@ interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
}
interface ModelPreviewPayload {
@@ -60,7 +67,8 @@ interface ModelPreviewPayload {
};
}
type ModelPoseKey = keyof ModelPose;
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
@@ -75,7 +83,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri
];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 },
@@ -91,7 +99,15 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
];
const emptyOverlayStats: OverlayStats = {
activeModules: 0,
filledPixels: 0,
@@ -140,9 +156,22 @@ function clampModelPose(next: ModelPose): ModelPose {
translateY: clampModelPoseValue('translateY', next.translateY),
translateZ: clampModelPoseValue('translateZ', next.translateZ),
scale: clampModelPoseValue('scale', next.scale),
flipX: Boolean(next.flipX),
flipY: Boolean(next.flipY),
flipZ: Boolean(next.flipZ),
};
}
function normalizeModelPose(pose: Partial<ModelPose> | undefined): ModelPose {
return clampModelPose({
...defaultModelPose,
...(pose ?? {}),
flipX: typeof pose?.flipX === 'boolean' ? pose.flipX : defaultModelPose.flipX,
flipY: typeof pose?.flipY === 'boolean' ? pose.flipY : defaultModelPose.flipY,
flipZ: typeof pose?.flipZ === 'boolean' ? pose.flipZ : defaultModelPose.flipZ,
});
}
function formatPoseCompactValue(value: number, digits = 2) {
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
}
@@ -290,8 +319,26 @@ function displayDicomValue(value: string | number | null | undefined) {
return String(value);
}
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
const total = Math.max(Math.round(totalSlices), 0);
if (!total) {
return 0;
}
return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex)));
}
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
const [isPanning, setIsPanning] = useState(false);
const panRef = useRef({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
});
useEffect(() => {
const canvas = canvasRef.current;
@@ -301,11 +348,86 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
drawDicomPreviewToCanvas(canvas, preview, rotation);
}, [preview, rotation]);
const resetViewport = () => {
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setViewport((current) => ({
...current,
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
}));
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
panRef.current = {
active: true,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: viewport.offsetX,
offsetY: viewport.offsetY,
};
setIsPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = panRef.current;
if (!dragState.active || dragState.pointerId !== event.pointerId) {
return;
}
setViewport((current) => ({
...current,
offsetX: dragState.offsetX + event.clientX - dragState.startX,
offsetY: dragState.offsetY + event.clientY - dragState.startY,
}));
};
const stopPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = panRef.current;
if (!dragState.active || dragState.pointerId !== event.pointerId) {
return;
}
panRef.current = { ...dragState, active: false };
setIsPanning(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<canvas
ref={canvasRef}
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
/>
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopPointerDrag}
onPointerCancel={stopPointerDrag}
>
<div
className="flex max-h-full max-w-full items-center justify-center"
style={{
transform: `translate3d(${viewport.offsetX}px, ${viewport.offsetY}px, 0) scale(${viewport.scale})`,
transformOrigin: 'center center',
}}
>
<canvas
ref={canvasRef}
className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
/>
</div>
<button
onClick={resetViewport}
onPointerDown={(event) => event.stopPropagation()}
className="absolute right-3 top-3 z-10 flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-black/65 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
title="重置 DICOM 图片位置"
>
<RefreshCcw size={13} />
</button>
</div>
);
}
@@ -318,9 +440,9 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
'XYZ',
));
return [
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(1, 0, 0).applyMatrix4(rotation) },
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, 1, 0).applyMatrix4(rotation) },
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, 1).applyMatrix4(rotation) },
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(pose.flipX ? -1 : 1, 0, 0).applyMatrix4(rotation) },
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, pose.flipY ? -1 : 1, 0).applyMatrix4(rotation) },
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, pose.flipZ ? -1 : 1).applyMatrix4(rotation) },
]
.map((axis) => ({
...axis,
@@ -331,7 +453,7 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45,
}))
.sort((a, b) => b.vector.z - a.vector.z);
}, [pose.rotateX, pose.rotateY, pose.rotateZ]);
}, [pose.rotateX, pose.rotateY, pose.rotateZ, pose.flipX, pose.flipY, pose.flipZ]);
return (
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/90 px-3 py-2 shadow-sm">
@@ -609,7 +731,12 @@ function NativeStlViewer({
poseGroup.position.set(0, 0, 0);
pivotGroup.position.set(0, 0, 0);
baseScale = 4.2 / maxSize;
pivotGroup.scale.setScalar(baseScale * poseRef.current.scale);
const initialPoseScale = baseScale * poseRef.current.scale;
pivotGroup.scale.set(
poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
);
camera.lookAt(0, 0, 0);
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
}
@@ -639,7 +766,12 @@ function NativeStlViewer({
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
pivotGroup.scale.setScalar(baseScale * currentPose.scale);
const poseScale = baseScale * currentPose.scale;
pivotGroup.scale.set(
currentPose.flipX ? -poseScale : poseScale,
currentPose.flipY ? -poseScale : poseScale,
currentPose.flipZ ? -poseScale : poseScale,
);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
@@ -812,6 +944,12 @@ export default function ProjectLibrary({
];
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
const dicomSliceTotal = sliceTotal || selectedProject?.dicomCount || 0;
const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0);
const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex));
const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal);
const dicomSliderValue = dicomMaxSlice - safeDicomSlice;
const dicomSlicePercent = dicomMaxSlice > 0 ? (dicomSliderValue / dicomMaxSlice) * 100 : 0;
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
@@ -955,8 +1093,9 @@ export default function ProjectLibrary({
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
setModelPose(latestResult?.pose ?? defaultModelPose);
setResultPose(latestResult?.pose ?? defaultModelPose);
const nextPose = normalizeModelPose(latestResult?.pose);
setModelPose(nextPose);
setResultPose(nextPose);
setSliceIndex(0);
setDicomPreview(null);
setDicomError('');
@@ -994,15 +1133,17 @@ export default function ProjectLibrary({
useEffect(() => {
const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1];
const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
});
setModuleStyles(next);
setSliceIndex(0);
setModelPose(latestResult?.pose ?? defaultModelPose);
setResultPose(latestResult?.pose ?? defaultModelPose);
setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0)));
const nextPose = normalizeModelPose(latestResult?.pose);
setModelPose(nextPose);
setResultPose(nextPose);
setResultPreviewSlice(Math.max(0, Math.min(maxIndex, latestResult?.mappingSlice ?? maxIndex)));
setResultDisplayMode('soft');
setResultRotation(0);
}, [selectedProject?.id]);
@@ -1163,6 +1304,22 @@ export default function ProjectLibrary({
}));
};
const toggleModelFlip = (key: ModelPoseFlipKey) => {
setModelPose((current) => ({
...current,
[key]: !current[key],
}));
};
const resetModelFlipPose = () => {
setModelPose((current) => ({
...current,
flipX: false,
flipY: false,
flipZ: false,
}));
};
const rotateDicom = (delta: number) => {
setRotation((current) => ((current + delta) % 360 + 360) % 360);
};
@@ -1178,8 +1335,9 @@ export default function ProjectLibrary({
const link = document.createElement('a');
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
const displaySlice = getDicomDisplaySliceNumber(dicomPreview.slice, dicomPreview.total);
link.href = canvas.toDataURL('image/png');
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${displaySlice}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
document.body.appendChild(link);
link.click();
link.remove();
@@ -1618,7 +1776,7 @@ export default function ProjectLibrary({
key={option.id}
onClick={() => {
setPlane(option.id);
setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256);
setSliceIndex(0);
}}
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
@@ -1678,38 +1836,15 @@ export default function ProjectLibrary({
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
<span> {selectedProject.dicomCount ? dicomDisplaySlice : 0} / {dicomSliceTotal || selectedProject.dicomCount} </span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
<div className="w-24 h-full flex flex-col items-center py-4 bg-slate-50 rounded-2xl">
<span className="text-[10px] text-slate-400 font-bold mb-3"></span>
<span className="text-[10px] text-slate-500 font-bold mb-4 whitespace-nowrap">
{sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount}
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
</span>
<button
onMouseDown={() => startSliceStep(1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(1);
}}
onTouchEnd={stopSliceStep}
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向上移动切片"
>
<ChevronUp size={16} />
</button>
<input
type="range"
min="0"
max={Math.max((sliceTotal || selectedProject.dicomCount) - 1, 0)}
value={sliceIndex}
onChange={(e) => setSliceIndex(Number(e.target.value))}
className="flex-1 w-6 accent-blue-600 cursor-pointer"
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
/>
<button
onMouseDown={() => startSliceStep(-1)}
onMouseUp={stopSliceStep}
@@ -1719,12 +1854,42 @@ export default function ProjectLibrary({
startSliceStep(-1);
}}
onTouchEnd={stopSliceStep}
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向上移动切片"
>
<ChevronUp size={16} />
</button>
<div className="relative min-h-[260px] w-10 flex-1">
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800/70" />
<div
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
style={{ height: `${dicomSlicePercent}%` }}
/>
<input
type="range"
min="0"
max={dicomMaxSlice}
value={dicomSliderValue}
onChange={(event) => setSliceIndex(dicomMaxSlice - Number(event.target.value))}
className="mapping-slice-dark-vertical-input"
aria-label="项目库 DICOM 切片导航"
/>
</div>
<button
onMouseDown={() => startSliceStep(1)}
onMouseUp={stopSliceStep}
onMouseLeave={stopSliceStep}
onTouchStart={(event) => {
event.preventDefault();
startSliceStep(1);
}}
onTouchEnd={stopSliceStep}
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
title="长按向下移动切片"
>
<ChevronDown size={16} />
</button>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex + 1}</span>
<span className="text-[10px] text-blue-600 font-bold mt-4">#{dicomDisplaySlice}</span>
<div className="mt-5 flex w-full flex-col gap-2 px-2">
<button
onClick={downloadCurrentDicomPng}
@@ -1807,16 +1972,43 @@ export default function ProjectLibrary({
onClick={resetModelRotationPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
>
姿
</button>
<button
onClick={resetModelTransformPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
>
姿
</button>
<button
onClick={resetModelFlipPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
>
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
{modelPoseFlipOptions.map((item) => {
const Icon = item.icon;
const enabled = modelPose[item.key];
return (
<button
key={item.key}
onClick={() => toggleModelFlip(item.key)}
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
enabled
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
}`}
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
>
<Icon size={13} />
{item.label}
</button>
);
})}
</div>
{[
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
@@ -2048,6 +2240,9 @@ export default function ProjectLibrary({
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FX {latestResultPose.flipX ? '开' : '关'}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FY {latestResultPose.flipY ? '开' : '关'}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FZ {latestResultPose.flipZ ? '开' : '关'}</span>
</div>
</div>
</div>

View File

@@ -13,6 +13,9 @@ import {
RefreshCcw,
Save,
Upload,
FlipHorizontal2,
FlipVertical2,
Move3d,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
@@ -32,7 +35,8 @@ export interface ModelPreviewPayload {
export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
export type DicomOpacityLevel = 'low' | 'medium' | 'high';
export type MappingDisplayMode = DicomPreview['mode'];
type ModelPoseKey = keyof ModelPose;
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type PoseDraftValues = Record<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z';
@@ -55,6 +59,11 @@ interface WorkspaceLoadState {
}
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
];
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 },
@@ -91,6 +100,9 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const defaultSavedPoses: SavedModelPose[] = [
@@ -110,7 +122,7 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri
];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
];
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6;
@@ -195,6 +207,23 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
const total = Math.max(Math.round(totalSlices), 0);
if (!total) {
return 0;
}
return total - clamp(Math.round(sliceIndex), 0, total - 1);
}
function getDicomDisplayRange(startIndex: number, endIndex: number, totalSlices: number) {
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
return {
start: Math.min(first, second),
end: Math.max(first, second),
};
}
function getStepPrecision(step: number) {
if (step >= 1) {
return 0;
@@ -271,6 +300,14 @@ function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPo
normalized[key] = clamp(numericValue, limit.min, limit.max);
hasPoseValue = true;
});
modelPoseFlipOptions.forEach(({ key }) => {
const rawValue = input[key];
if (typeof rawValue !== 'boolean') {
return;
}
normalized[key] = rawValue;
hasPoseValue = true;
});
return hasPoseValue ? normalized : null;
}
@@ -327,7 +364,8 @@ function mergeImportedModelPoses(imported: SavedModelPose[]) {
}
function poseValuesMatch(left: ModelPose, right: ModelPose) {
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6);
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6)
&& modelPoseFlipOptions.every(({ key }) => left[key] === right[key]);
}
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
@@ -852,7 +890,12 @@ export function FusionThreeView({
pose.translateY,
pose.translateZ,
);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale,
pose.flipY ? -poseScale : poseScale,
pose.flipZ ? -poseScale : poseScale,
);
modelPoseGroup.updateMatrixWorld(true);
const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
@@ -908,6 +951,8 @@ export function FusionThreeView({
viewPreset,
]);
const volumeDisplayRange = volume ? getDicomDisplayRange(volume.start, volume.end, volume.total) : null;
return (
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
@@ -924,7 +969,7 @@ export function FusionThreeView({
{status}
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
</div>
<button
onClick={() => resetFusionViewRef.current()}
@@ -1207,7 +1252,12 @@ function CutSectionPreview({
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale,
pose.flipY ? -poseScale : poseScale,
pose.flipZ ? -poseScale : poseScale,
);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
@@ -1433,9 +1483,10 @@ function getModelSceneMetrics(
function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
const scalar = metrics.modelBaseScale * pose.scale;
let px = (x - metrics.center.x) * scalar;
let py = (y - metrics.center.y) * scalar;
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
pz += metrics.modelPivotOffsetZ * scalar;
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
@@ -1637,16 +1688,31 @@ function drawFallbackClosedRegion(
return 0;
}
const center = points.reduce((accumulator, point) => ({
x: accumulator.x + point.x / points.length,
y: accumulator.y + point.y / points.length,
}), { x: 0, y: 0 });
const ordered = [...points].sort((left, right) => (
Math.atan2(left.y - center.y, left.x - center.x) - Math.atan2(right.y - center.y, right.x - center.x)
const sorted = [...points].sort((left, right) => (
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
));
const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
);
const lower: Point2D[] = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
lower.pop();
}
lower.push(point);
});
const upper: Point2D[] = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
upper.pop();
}
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const ordered = hull.length >= 3 ? hull : points;
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.48;
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
context.fillStyle = color;
context.beginPath();
ordered.forEach((point, index) => {
@@ -1660,7 +1726,7 @@ function drawFallbackClosedRegion(
context.fill();
context.restore();
return Math.max(1, Math.round(points.length / 2));
return Math.max(1, Math.round(ordered.length / 2));
}
function fillSegmentsAsSolidMask(
@@ -1757,23 +1823,25 @@ function fillSegmentsAsSolidMask(
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
if (filledPixels === 0 && segments.length >= 3) {
filledPixels = drawFallbackClosedRegion(context, width, height, segments, color, opacity);
if (filledPixels < Math.max(12, Math.round(segments.length * 0.45)) && segments.length >= 3) {
filledPixels += drawFallbackClosedRegion(context, width, height, segments, color, opacity);
}
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.82;
context.strokeStyle = color;
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.003);
context.lineCap = 'round';
context.lineJoin = 'round';
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
if (filledPixels === 0) {
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = 'round';
context.lineJoin = 'round';
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
}
return filledPixels;
}
@@ -2059,6 +2127,9 @@ export function VoxelizationMappingView({
modelPose.translateY,
modelPose.translateZ,
modelPose.scale,
modelPose.flipX,
modelPose.flipY,
modelPose.flipZ,
safeSlice,
totalSlices,
]);
@@ -2066,7 +2137,9 @@ export function VoxelizationMappingView({
const stepSlice = (delta: number) => {
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
const sliderSliceValue = maxSlice - safeSlice;
const slicePercent = maxSlice > 0 ? (sliderSliceValue / maxSlice) * 100 : 0;
const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
const resetMappingViewport = () => {
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
};
@@ -2194,7 +2267,7 @@ export function VoxelizationMappingView({
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
<p className="text-[9px] font-bold text-white/45">DICOM </p>
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
{displaySliceNumber} / {Math.max(totalSlices, 1)}
</p>
</div>
</div>
@@ -2212,8 +2285,8 @@ export function VoxelizationMappingView({
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
value={sliderSliceValue}
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
className="mapping-slice-dark-vertical-input"
aria-label="项目库逆向分割映射视图切片导航"
/>
@@ -2236,7 +2309,7 @@ export function VoxelizationMappingView({
Overlay Label Map
</span>
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
</span>
</div>
<button
@@ -2310,7 +2383,7 @@ export function VoxelizationMappingView({
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
<p className="text-[10px] font-bold text-slate-700">DICOM </p>
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
{displaySliceNumber} / {Math.max(totalSlices, 1)}
</span>
</div>
<button
@@ -2331,8 +2404,8 @@ export function VoxelizationMappingView({
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
value={sliderSliceValue}
onChange={(event) => onSliceChange(maxSlice - Number(event.target.value))}
className="mapping-slice-vertical-input"
aria-label="逆向分割映射视图切片导航"
/>
@@ -2347,7 +2420,7 @@ export function VoxelizationMappingView({
</button>
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
<span> {Math.max(totalSlices, 1)}</span>
<span className="text-blue-600"> {safeSlice + 1}</span>
<span className="text-blue-600"> {displaySliceNumber}</span>
<span> 1</span>
</div>
</aside>
@@ -2707,9 +2780,14 @@ export default function ReverseWorkspace({
setSliceStart(restoredSliceStart);
setSliceEnd(restoredSliceEnd);
setMappingSlice(restoredMappingSlice);
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
const nextPoses = (item.modelPoses?.length ? item.modelPoses : defaultSavedPoses).map((pose) => ({
...pose,
pose: normalizePoseValue(pose.pose) ?? defaultModelPose,
}));
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
const restoredPose = normalizePoseValue(latestResult?.pose)
?? normalizePoseValue(preferredPose?.pose)
?? defaultModelPose;
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
setModelPose(restoredPose);
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
@@ -2919,6 +2997,30 @@ export default function ReverseWorkspace({
setPoseImportStatus('');
};
const toggleModelFlip = (key: ModelPoseFlipKey) => {
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
setModelPose((current) => ({
...current,
[key]: !current[key],
}));
setSelectedPoseId('custom');
setPoseImportStatus('');
restoreVisualToolbarScroll(scrollTop);
};
const resetModelFlipPose = () => {
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
setModelPose((current) => ({
...current,
flipX: false,
flipY: false,
flipZ: false,
}));
setSelectedPoseId('custom');
setPoseImportStatus('');
restoreVisualToolbarScroll(scrollTop);
};
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
const stlFiles = project?.stlFiles ?? [];
const index = Math.max(0, stlFiles.indexOf(fileName));
@@ -3019,6 +3121,7 @@ export default function ReverseWorkspace({
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
const displaySliceRange = getDicomDisplayRange(displayStart, displayEnd, project?.dicomCount ?? 0);
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
@@ -3339,7 +3442,7 @@ export default function ReverseWorkspace({
</h3>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<span className="text-[10px] font-mono text-slate-400">
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
</span>
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
@@ -3398,7 +3501,7 @@ export default function ReverseWorkspace({
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">DICOM </p>
<span className="text-[10px] font-mono text-blue-600">
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
{displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
</span>
</div>
<div className="py-1">
@@ -3433,9 +3536,9 @@ export default function ReverseWorkspace({
/>
</div>
<div className="mt-1 grid grid-cols-3 text-[10px] font-bold text-slate-500">
<span> {safeSliceStart + 1}</span>
<span> {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)}</span>
<span className="text-center text-blue-600"></span>
<span className="text-right"> {safeSliceEnd + 1}</span>
<span className="text-right"> {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}</span>
</div>
</div>
</div>
@@ -3508,7 +3611,7 @@ export default function ReverseWorkspace({
</label>
</div>
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
DICOM {displayStart + 1}-{displayEnd + 1}
DICOM {displaySliceRange.start}-{displaySliceRange.end}
</p>
</div>
@@ -3556,7 +3659,7 @@ export default function ReverseWorkspace({
{poseImportStatus}
</p>
)}
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<button
onClick={resetRotationPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
@@ -3569,6 +3672,33 @@ export default function ReverseWorkspace({
>
姿
</button>
<button
onClick={resetModelFlipPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{modelPoseFlipOptions.map((item) => {
const Icon = item.icon;
const enabled = modelPose[item.key];
return (
<button
key={item.key}
onClick={() => toggleModelFlip(item.key)}
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
enabled
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
}`}
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
>
<Icon size={13} />
{item.label}
</button>
);
})}
</div>
<div className="mt-3 space-y-2">
{[

View File

@@ -145,14 +145,15 @@
.mapping-slice-vertical-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #2563eb;
border: 3px solid #ffffff;
border-radius: 9999px;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
background: #60a5fa;
border: 2px solid #dbeafe;
border-radius: 5px;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
cursor: grab;
height: 22px;
margin-left: -7px;
width: 22px;
height: 18px;
margin-left: -5px;
transform: rotate(45deg);
width: 18px;
}
.mapping-slice-vertical-input::-moz-range-track {
@@ -162,13 +163,14 @@
}
.mapping-slice-vertical-input::-moz-range-thumb {
background: #2563eb;
border: 3px solid #ffffff;
border-radius: 9999px;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
background: #60a5fa;
border: 2px solid #dbeafe;
border-radius: 5px;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
cursor: grab;
height: 16px;
width: 16px;
height: 14px;
transform: rotate(45deg);
width: 14px;
}
.mapping-slice-vertical-input:active::-webkit-slider-thumb {
@@ -207,14 +209,15 @@
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
background: #60a5fa;
border: 2px solid #dbeafe;
border-radius: 5px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 20px;
margin-left: -7px;
width: 20px;
height: 18px;
margin-left: -6px;
transform: rotate(45deg);
width: 18px;
}
.mapping-slice-dark-vertical-input::-moz-range-track {
@@ -224,12 +227,13 @@
}
.mapping-slice-dark-vertical-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
background: #60a5fa;
border: 2px solid #dbeafe;
border-radius: 5px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 14px;
transform: rotate(45deg);
width: 14px;
}

View File

@@ -40,6 +40,9 @@ export interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
}
export interface SavedModelPose {