2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user