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