2435 lines
108 KiB
TypeScript
2435 lines
108 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
Plus,
|
||
Search,
|
||
Eye,
|
||
FileArchive,
|
||
RotateCw,
|
||
RotateCcw,
|
||
Box,
|
||
Image as ImageIcon,
|
||
Info,
|
||
ChevronRight,
|
||
ChevronUp,
|
||
ChevronDown,
|
||
Edit2,
|
||
FolderRoot,
|
||
Download,
|
||
Layers,
|
||
X,
|
||
Trash2,
|
||
Upload,
|
||
RefreshCcw,
|
||
FlipHorizontal2,
|
||
FlipVertical2,
|
||
Move3d
|
||
} from 'lucide-react';
|
||
import * as THREE from 'three';
|
||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
||
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectAssetImportProgress, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
|
||
import {
|
||
FusionThreeView,
|
||
OverlayStats,
|
||
VoxelizationMappingView,
|
||
clearCachedProjectAssets,
|
||
getCachedDicomFusionVolume,
|
||
getCachedDicomPreview,
|
||
getCachedModelPreview,
|
||
dicomOpacityOptions as reverseDicomOpacityOptions,
|
||
displayOptions as reverseDisplayOptions,
|
||
} from './ReverseWorkspace';
|
||
|
||
type Plane = 'axial' | 'sagittal' | 'coronal';
|
||
type DisplayMode = DicomPreview['mode'];
|
||
type SolidityLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||
|
||
interface ModelPose {
|
||
rotateX: number;
|
||
rotateY: number;
|
||
rotateZ: number;
|
||
translateX: number;
|
||
translateY: number;
|
||
translateZ: number;
|
||
scale: number;
|
||
flipX: boolean;
|
||
flipY: boolean;
|
||
flipZ: boolean;
|
||
}
|
||
|
||
interface ModelPreviewPayload {
|
||
fileName: string;
|
||
triangleCount: number;
|
||
sampledTriangles: number;
|
||
vertices: number[];
|
||
bounds?: {
|
||
min: { x: number; y: number; z: number };
|
||
max: { x: number; y: number; z: number };
|
||
};
|
||
}
|
||
|
||
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 }> = [
|
||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||
{ 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: '全部构件集中到同一目录' },
|
||
];
|
||
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
|
||
{ id: 'standard', label: '标准', limit: 16000 },
|
||
{ id: 'fine', label: '精细', limit: 36000 },
|
||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||
{ id: 'solid', label: '实体', limit: 200000 },
|
||
];
|
||
const defaultModelPose: ModelPose = {
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
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,
|
||
segmentCount: 0,
|
||
modules: [],
|
||
};
|
||
const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
|
||
rotateX: { min: -180, max: 180 },
|
||
rotateY: { min: -180, max: 180 },
|
||
rotateZ: { min: -180, max: 180 },
|
||
translateX: { min: -2, max: 2 },
|
||
translateY: { min: -2, max: 2 },
|
||
translateZ: { min: -2, max: 2 },
|
||
scale: { min: 0.5, max: 2.5 },
|
||
};
|
||
const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
|
||
scale: 3,
|
||
};
|
||
|
||
function clampModelPoseValue(key: ModelPoseKey, value: number) {
|
||
const limit = modelPoseLimits[key];
|
||
const clampedValue = Math.max(limit.min, Math.min(limit.max, value));
|
||
const precision = modelPoseStepPrecision[key];
|
||
return typeof precision === 'number' ? Number(clampedValue.toFixed(precision)) : clampedValue;
|
||
}
|
||
|
||
function getControlStepPrecision(step: number) {
|
||
if (step >= 1) {
|
||
return 0;
|
||
}
|
||
|
||
const text = step.toString();
|
||
if (text.includes('e-')) {
|
||
return Number(text.split('e-')[1] ?? 2);
|
||
}
|
||
|
||
return text.split('.')[1]?.length ?? 0;
|
||
}
|
||
|
||
function clampModelPose(next: ModelPose): ModelPose {
|
||
return {
|
||
rotateX: clampModelPoseValue('rotateX', next.rotateX),
|
||
rotateY: clampModelPoseValue('rotateY', next.rotateY),
|
||
rotateZ: clampModelPoseValue('rotateZ', next.rotateZ),
|
||
translateX: clampModelPoseValue('translateX', next.translateX),
|
||
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';
|
||
}
|
||
|
||
interface AssetImportProgressState {
|
||
kind: ProjectAssetImportKind;
|
||
fileCount: number;
|
||
totalBytes: number;
|
||
loadedBytes: number;
|
||
percent: number;
|
||
phase: 'uploading' | 'processing' | 'done' | 'failed';
|
||
message?: string;
|
||
}
|
||
|
||
function formatFileSize(value: number) {
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return '0 B';
|
||
}
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
|
||
return `${(value / (1024 ** index)).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||
}
|
||
|
||
function describeImportKind(kind: ProjectAssetImportKind) {
|
||
return kind === 'dicom' ? 'DICOM 影像' : '3D 模型';
|
||
}
|
||
|
||
function drawFallbackModelPreview(
|
||
canvas: HTMLCanvasElement,
|
||
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
||
) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const parentRect = canvas.parentElement?.getBoundingClientRect();
|
||
const width = Math.max(Math.floor(rect.width || parentRect?.width || 720), 1);
|
||
const height = Math.max(Math.floor(rect.height || parentRect?.height || 460), 1);
|
||
canvas.width = width * window.devicePixelRatio;
|
||
canvas.height = height * window.devicePixelRatio;
|
||
canvas.style.width = `${width}px`;
|
||
canvas.style.height = `${height}px`;
|
||
|
||
const context = canvas.getContext('2d');
|
||
if (!context) return;
|
||
context.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||
context.fillStyle = '#f8fafc';
|
||
context.fillRect(0, 0, width, height);
|
||
|
||
const allPoints = previews.flatMap(({ payload }) => {
|
||
const points: Array<[number, number]> = [];
|
||
for (let index = 0; index < payload.vertices.length; index += 3) {
|
||
points.push([payload.vertices[index], payload.vertices[index + 1]]);
|
||
}
|
||
return points;
|
||
});
|
||
|
||
if (!allPoints.length) return;
|
||
|
||
const xs = allPoints.map((point) => point[0]);
|
||
const ys = allPoints.map((point) => point[1]);
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = Math.min(...ys);
|
||
const maxY = Math.max(...ys);
|
||
const spanX = Math.max(maxX - minX, 1);
|
||
const spanY = Math.max(maxY - minY, 1);
|
||
const scale = Math.min((width * 0.78) / spanX, (height * 0.78) / spanY);
|
||
const offsetX = width / 2 - ((minX + maxX) / 2) * scale;
|
||
const offsetY = height / 2 + ((minY + maxY) / 2) * scale;
|
||
|
||
previews.forEach(({ payload, style }) => {
|
||
context.globalAlpha = Math.max(0.12, Math.min(style.opacity, 1));
|
||
context.fillStyle = style.color;
|
||
context.strokeStyle = style.color;
|
||
for (let index = 0; index < payload.vertices.length; index += 9) {
|
||
const x1 = payload.vertices[index] * scale + offsetX;
|
||
const y1 = -payload.vertices[index + 1] * scale + offsetY;
|
||
const x2 = payload.vertices[index + 3] * scale + offsetX;
|
||
const y2 = -payload.vertices[index + 4] * scale + offsetY;
|
||
const x3 = payload.vertices[index + 6] * scale + offsetX;
|
||
const y3 = -payload.vertices[index + 7] * scale + offsetY;
|
||
context.beginPath();
|
||
context.moveTo(x1, y1);
|
||
context.lineTo(x2, y2);
|
||
context.lineTo(x3, y3);
|
||
context.closePath();
|
||
context.fill();
|
||
context.stroke();
|
||
}
|
||
});
|
||
context.globalAlpha = 1;
|
||
}
|
||
|
||
function drawDicomPreviewToCanvas(canvas: HTMLCanvasElement, preview: DicomPreview, rotation: number) {
|
||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||
const sourceCanvas = document.createElement('canvas');
|
||
sourceCanvas.width = preview.width;
|
||
sourceCanvas.height = preview.height;
|
||
const sourceContext = sourceCanvas.getContext('2d');
|
||
const targetContext = canvas.getContext('2d');
|
||
if (!sourceContext || !targetContext) {
|
||
return;
|
||
}
|
||
|
||
const binary = atob(preview.pixels);
|
||
const imageData = sourceContext.createImageData(preview.width, preview.height);
|
||
for (let i = 0; i < binary.length; i += 1) {
|
||
const value = binary.charCodeAt(i);
|
||
const offset = i * 4;
|
||
imageData.data[offset] = value;
|
||
imageData.data[offset + 1] = value;
|
||
imageData.data[offset + 2] = value;
|
||
imageData.data[offset + 3] = 255;
|
||
}
|
||
sourceContext.putImageData(imageData, 0, 0);
|
||
|
||
const isQuarterTurn = normalizedRotation === 90 || normalizedRotation === 270;
|
||
canvas.width = isQuarterTurn ? preview.height : preview.width;
|
||
canvas.height = isQuarterTurn ? preview.width : preview.height;
|
||
targetContext.clearRect(0, 0, canvas.width, canvas.height);
|
||
targetContext.save();
|
||
targetContext.imageSmoothingEnabled = true;
|
||
|
||
if (normalizedRotation === 90) {
|
||
targetContext.translate(canvas.width, 0);
|
||
targetContext.rotate(Math.PI / 2);
|
||
} else if (normalizedRotation === 180) {
|
||
targetContext.translate(canvas.width, canvas.height);
|
||
targetContext.rotate(Math.PI);
|
||
} else if (normalizedRotation === 270) {
|
||
targetContext.translate(0, canvas.height);
|
||
targetContext.rotate(-Math.PI / 2);
|
||
}
|
||
|
||
targetContext.drawImage(sourceCanvas, 0, 0);
|
||
targetContext.restore();
|
||
}
|
||
|
||
function safeFilePart(value: string) {
|
||
return value.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'dicom';
|
||
}
|
||
|
||
function displayDicomValue(value: string | number | null | undefined) {
|
||
if (value === null || value === undefined || value === '') {
|
||
return '未知';
|
||
}
|
||
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;
|
||
if (!canvas) {
|
||
return;
|
||
}
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function OrientationGizmo({ pose }: { pose: ModelPose }) {
|
||
const axes = useMemo(() => {
|
||
const rotation = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
|
||
THREE.MathUtils.degToRad(pose.rotateX),
|
||
THREE.MathUtils.degToRad(pose.rotateY),
|
||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||
'XYZ',
|
||
));
|
||
return [
|
||
{ 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,
|
||
endX: 38 + axis.vector.x * 24 + axis.vector.z * 10,
|
||
endY: 38 - axis.vector.y * 24 + axis.vector.z * 8,
|
||
labelX: 38 + axis.vector.x * 30 + axis.vector.z * 12,
|
||
labelY: 38 - axis.vector.y * 30 + axis.vector.z * 10,
|
||
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.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">
|
||
<svg width="76" height="70" viewBox="0 0 76 70" className="mb-1 block">
|
||
<circle cx="38" cy="38" r="2.5" fill="#0f172a" opacity="0.28" />
|
||
{axes.map((axis) => (
|
||
<g key={axis.id} opacity={axis.opacity}>
|
||
<line x1="38" y1="38" x2={axis.endX} y2={axis.endY} stroke={axis.color} strokeWidth="2.2" strokeLinecap="round" />
|
||
<circle cx={axis.endX} cy={axis.endY} r="2.4" fill={axis.color} />
|
||
<text x={axis.labelX} y={axis.labelY} fill={axis.color} fontSize="10" fontWeight="900" textAnchor="middle" dominantBaseline="middle">
|
||
{axis.id}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
<div className="space-y-0.5 font-mono text-[9px] text-slate-500">
|
||
<div>X {Math.round(pose.rotateX)}°</div>
|
||
<div>Y {Math.round(pose.rotateY)}°</div>
|
||
<div>Z {Math.round(pose.rotateZ)}°</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NativeStlViewer({
|
||
projectId,
|
||
files,
|
||
styles,
|
||
detailLimit,
|
||
solidMode,
|
||
pose,
|
||
onPoseChange,
|
||
}: {
|
||
projectId: string;
|
||
files: string[];
|
||
styles: Record<string, ModuleStyle>;
|
||
detailLimit: number;
|
||
solidMode: boolean;
|
||
pose: ModelPose;
|
||
onPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
|
||
}) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const poseRef = useRef<ModelPose>(pose);
|
||
const onPoseChangeRef = useRef(onPoseChange);
|
||
const [progress, setProgress] = useState(0);
|
||
const [status, setStatus] = useState('准备加载模型');
|
||
|
||
useEffect(() => {
|
||
poseRef.current = pose;
|
||
}, [pose]);
|
||
|
||
useEffect(() => {
|
||
onPoseChangeRef.current = onPoseChange;
|
||
}, [onPoseChange]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
|
||
const dragState = {
|
||
active: false,
|
||
mode: 'rotate' as 'rotate' | 'pan',
|
||
pointerId: 0,
|
||
startX: 0,
|
||
startY: 0,
|
||
startPose: poseRef.current,
|
||
};
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
dragState.active = true;
|
||
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
|
||
dragState.pointerId = event.pointerId;
|
||
dragState.startX = event.clientX;
|
||
dragState.startY = event.clientY;
|
||
dragState.startPose = poseRef.current;
|
||
container.setPointerCapture(event.pointerId);
|
||
};
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
|
||
const deltaX = event.clientX - dragState.startX;
|
||
const deltaY = event.clientY - dragState.startY;
|
||
if (dragState.mode === 'pan') {
|
||
onPoseChangeRef.current(clampModelPose({
|
||
...dragState.startPose,
|
||
translateX: dragState.startPose.translateX + deltaX * 0.006,
|
||
translateY: dragState.startPose.translateY - deltaY * 0.006,
|
||
}));
|
||
return;
|
||
}
|
||
onPoseChangeRef.current(clampModelPose({
|
||
...dragState.startPose,
|
||
rotateY: dragState.startPose.rotateY + deltaX * 0.35,
|
||
rotateX: dragState.startPose.rotateX + deltaY * 0.35,
|
||
}));
|
||
};
|
||
const stopPointerDrag = (event: PointerEvent) => {
|
||
if (event.pointerId !== dragState.pointerId) return;
|
||
dragState.active = false;
|
||
if (container.hasPointerCapture(event.pointerId)) {
|
||
container.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
const handleWheel = (event: WheelEvent) => {
|
||
event.preventDefault();
|
||
onPoseChangeRef.current(clampModelPose({
|
||
...poseRef.current,
|
||
scale: poseRef.current.scale - event.deltaY * 0.001,
|
||
}));
|
||
};
|
||
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
|
||
|
||
container.addEventListener('pointerdown', handlePointerDown);
|
||
container.addEventListener('pointermove', handlePointerMove);
|
||
container.addEventListener('pointerup', stopPointerDrag);
|
||
container.addEventListener('pointercancel', stopPointerDrag);
|
||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||
container.addEventListener('contextmenu', preventContextMenu);
|
||
|
||
return () => {
|
||
container.removeEventListener('pointerdown', handlePointerDown);
|
||
container.removeEventListener('pointermove', handlePointerMove);
|
||
container.removeEventListener('pointerup', stopPointerDrag);
|
||
container.removeEventListener('pointercancel', stopPointerDrag);
|
||
container.removeEventListener('wheel', handleWheel);
|
||
container.removeEventListener('contextmenu', preventContextMenu);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
|
||
const visibleFiles = files.filter((file) => styles[file]?.visible !== false);
|
||
container.innerHTML = '';
|
||
setProgress(visibleFiles.length ? 5 : 0);
|
||
setStatus(visibleFiles.length ? '正在加载 STL 模型...' : '没有可显示的模型');
|
||
|
||
if (!visibleFiles.length) {
|
||
return;
|
||
}
|
||
|
||
let disposed = false;
|
||
let animationId = 0;
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color('#f8fafc');
|
||
const camera = new THREE.PerspectiveCamera(45, Math.max(container.clientWidth, 1) / Math.max(container.clientHeight, 1), 0.1, 1000);
|
||
camera.up.set(0, 1, 0);
|
||
camera.position.set(0, 0, 6);
|
||
camera.lookAt(0, 0, 0);
|
||
let renderer: THREE.WebGLRenderer | null = null;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
} catch {
|
||
const fallbackCanvas = document.createElement('canvas');
|
||
fallbackCanvas.className = 'absolute inset-0 h-full w-full';
|
||
container.appendChild(fallbackCanvas);
|
||
setStatus('WebGL 不可用,正在生成二维模型预览...');
|
||
let fallbackPreviews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }> = [];
|
||
|
||
Promise.allSettled(
|
||
visibleFiles.map((fileName) =>
|
||
getCachedModelPreview(projectId, fileName, 3500)
|
||
.then((payload) => ({
|
||
payload,
|
||
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 },
|
||
})),
|
||
),
|
||
).then((results) => {
|
||
if (disposed) return;
|
||
const previews = results
|
||
.filter((result): result is PromiseFulfilledResult<{ payload: ModelPreviewPayload; style: ModuleStyle }> => result.status === 'fulfilled')
|
||
.map((result) => result.value);
|
||
fallbackPreviews = previews;
|
||
drawFallbackModelPreview(fallbackCanvas, previews);
|
||
setProgress(100);
|
||
setStatus(previews.length ? '二维模型预览已生成' : '模型预览加载失败');
|
||
});
|
||
|
||
const handleFallbackResize = () => {
|
||
if (fallbackPreviews.length) {
|
||
drawFallbackModelPreview(fallbackCanvas, fallbackPreviews);
|
||
}
|
||
};
|
||
window.addEventListener('resize', handleFallbackResize);
|
||
|
||
return () => {
|
||
disposed = true;
|
||
window.removeEventListener('resize', handleFallbackResize);
|
||
container.innerHTML = '';
|
||
};
|
||
}
|
||
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
container.appendChild(renderer.domElement);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
|
||
keyLight.position.set(4, 5, 6);
|
||
scene.add(keyLight);
|
||
const fillLight = new THREE.DirectionalLight(0x9cc4ff, 0.55);
|
||
fillLight.position.set(-4, 2, -3);
|
||
scene.add(fillLight);
|
||
|
||
const poseGroup = new THREE.Group();
|
||
const pivotGroup = new THREE.Group();
|
||
poseGroup.add(pivotGroup);
|
||
let baseScale = 1;
|
||
scene.add(poseGroup);
|
||
let loaded = 0;
|
||
let failed = 0;
|
||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||
|
||
visibleFiles.forEach((fileName) => {
|
||
getCachedModelPreview(projectId, fileName, detailLimit)
|
||
.then((payload) => {
|
||
if (disposed) return;
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||
geometry.computeVertexNormals();
|
||
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 };
|
||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||
const mesh = new THREE.Mesh(
|
||
geometry,
|
||
new THREE.MeshStandardMaterial({
|
||
color: style.color,
|
||
opacity: materialOpacity,
|
||
transparent: materialOpacity < 1,
|
||
roughness: solidMode ? 0.56 : 0.42,
|
||
metalness: 0.04,
|
||
side: THREE.DoubleSide,
|
||
}),
|
||
);
|
||
pivotGroup.add(mesh);
|
||
if (payload.bounds) {
|
||
loadedBounds.push({
|
||
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
|
||
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
|
||
});
|
||
} else {
|
||
geometry.computeBoundingBox();
|
||
const geometryBox = geometry.boundingBox;
|
||
if (geometryBox) {
|
||
loadedBounds.push({
|
||
min: geometryBox.min.clone(),
|
||
max: geometryBox.max.clone(),
|
||
});
|
||
}
|
||
}
|
||
loaded += 1;
|
||
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
|
||
setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`);
|
||
|
||
if (loaded + failed === visibleFiles.length) {
|
||
const box = new THREE.Box3();
|
||
if (loadedBounds.length) {
|
||
loadedBounds.forEach((bounds) => {
|
||
box.expandByPoint(bounds.min);
|
||
box.expandByPoint(bounds.max);
|
||
});
|
||
} else {
|
||
box.setFromObject(pivotGroup);
|
||
}
|
||
const center = box.getCenter(new THREE.Vector3());
|
||
const size = box.getSize(new THREE.Vector3());
|
||
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||
pivotGroup.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.translate(-center.x, -center.y, -center.z);
|
||
object.geometry.computeBoundingBox();
|
||
object.geometry.computeBoundingSphere();
|
||
object.geometry.computeVertexNormals();
|
||
}
|
||
});
|
||
poseGroup.position.set(0, 0, 0);
|
||
pivotGroup.position.set(0, 0, 0);
|
||
baseScale = 4.2 / maxSize;
|
||
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} 个模型加载失败` : '模型加载完成');
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (disposed) return;
|
||
failed += 1;
|
||
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
|
||
setStatus(`有 ${failed} 个模型加载失败`);
|
||
});
|
||
});
|
||
|
||
const handleResize = () => {
|
||
if (!container.clientWidth || !container.clientHeight) return;
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
};
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
const animate = () => {
|
||
if (disposed) return;
|
||
const currentPose = poseRef.current;
|
||
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
|
||
pivotGroup.rotation.set(
|
||
THREE.MathUtils.degToRad(currentPose.rotateX),
|
||
THREE.MathUtils.degToRad(currentPose.rotateY),
|
||
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
||
);
|
||
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);
|
||
};
|
||
animate();
|
||
|
||
return () => {
|
||
disposed = true;
|
||
window.cancelAnimationFrame(animationId);
|
||
window.removeEventListener('resize', handleResize);
|
||
renderer.dispose();
|
||
poseGroup.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.dispose();
|
||
const material = object.material;
|
||
if (Array.isArray(material)) {
|
||
material.forEach((item) => item.dispose());
|
||
} else {
|
||
material.dispose();
|
||
}
|
||
}
|
||
});
|
||
container.innerHTML = '';
|
||
};
|
||
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidMode]);
|
||
|
||
return (
|
||
<div className="h-full w-full relative cursor-grab active:cursor-grabbing">
|
||
<div ref={containerRef} className="absolute inset-0" />
|
||
{progress < 100 && (
|
||
<div className="absolute inset-x-8 top-8 z-10 rounded-xl bg-white/90 p-4 shadow-sm border border-slate-100">
|
||
<div className="flex items-center justify-between text-xs font-bold text-slate-600 mb-2">
|
||
<span>{status}</span>
|
||
<span>{progress}%</span>
|
||
</div>
|
||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||
<div className="h-full bg-blue-600 transition-all" style={{ width: `${progress}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{progress >= 100 && (
|
||
<div className="absolute left-4 top-4 rounded-lg bg-white/80 px-3 py-1.5 text-[10px] font-bold text-slate-500 shadow-sm">
|
||
{status}
|
||
</div>
|
||
)}
|
||
<OrientationGizmo pose={pose} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ProjectLibrary({
|
||
onReverse,
|
||
initialViewMode = 'dicom',
|
||
}: {
|
||
onReverse: (projId: string) => void;
|
||
initialViewMode?: 'dicom' | 'model' | 'mask';
|
||
}) {
|
||
const [search, setSearch] = useState('');
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode);
|
||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||
const [sliceIndex, setSliceIndex] = useState(0);
|
||
const [plane, setPlane] = useState<Plane>('axial');
|
||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||
const [rotation, setRotation] = useState(0);
|
||
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
||
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||
const [resultPose, setResultPose] = useState<ModelPose>(defaultModelPose);
|
||
const [resultPreviewSlice, setResultPreviewSlice] = useState(0);
|
||
const [resultDisplayMode, setResultDisplayMode] = useState<DisplayMode>('soft');
|
||
const [resultRotation, setResultRotation] = useState(0);
|
||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||
const [resultFusionVolume, setResultFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||
const [resultFusionError, setResultFusionError] = useState('');
|
||
const [resultOverlayStats, setResultOverlayStats] = useState<OverlayStats>(emptyOverlayStats);
|
||
const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0);
|
||
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
|
||
const [dicomInfoError, setDicomInfoError] = useState('');
|
||
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
|
||
const [dicomError, setDicomError] = useState('');
|
||
const [newProjectName, setNewProjectName] = useState('');
|
||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||
const [editingProjectId, setEditingProjectId] = useState('');
|
||
const [editingName, setEditingName] = useState('');
|
||
const [actionMessage, setActionMessage] = useState('');
|
||
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
|
||
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||
dicom: false,
|
||
segmentation: true,
|
||
pose: true,
|
||
stl: false,
|
||
});
|
||
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
|
||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||
const [maskExporting, setMaskExporting] = useState(false);
|
||
const [assetImporting, setAssetImporting] = useState(false);
|
||
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
|
||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
|
||
const sliceRepeatRef = useRef<number | null>(null);
|
||
const dicomRequestRef = useRef(0);
|
||
const preloadedProjectIdsRef = useRef(new Set<string>());
|
||
|
||
const preloadProjectAssets = (project: Project) => {
|
||
if (preloadedProjectIdsRef.current.has(project.id)) {
|
||
return;
|
||
}
|
||
preloadedProjectIdsRef.current.add(project.id);
|
||
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
|
||
if (project.dicomCount > 0) {
|
||
void getCachedDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
|
||
void getCachedDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
|
||
}
|
||
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
|
||
void getCachedModelPreview(project.id, fileName, 3500).catch(() => undefined);
|
||
});
|
||
};
|
||
|
||
const refreshProjects = () => {
|
||
setLoading(true);
|
||
return api.getProjects()
|
||
.then((items) => {
|
||
setProjects(items);
|
||
items.slice(0, 2).forEach(preloadProjectAssets);
|
||
setSelectedProject((current) => {
|
||
if (!current) {
|
||
return items[0] ?? null;
|
||
}
|
||
return items.find((item) => item.id === current.id) ?? items[0] ?? null;
|
||
});
|
||
})
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => {
|
||
refreshProjects();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedProject) {
|
||
preloadProjectAssets(selectedProject);
|
||
const latestResult = selectedProject.segmentationResults?.[selectedProject.segmentationResults.length - 1];
|
||
setMaskSegmentationScope(latestResult?.segmentationScope ?? 'visible');
|
||
}
|
||
}, [selectedProject?.id]);
|
||
|
||
const filteredProjects = useMemo(() => {
|
||
const keyword = search.trim().toLowerCase();
|
||
if (!keyword) {
|
||
return projects;
|
||
}
|
||
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
|
||
}, [projects, search]);
|
||
|
||
const stlFiles = selectedProject?.stlFiles ?? [];
|
||
const planeOptions: Array<{ id: Plane; label: string }> = [
|
||
{ id: 'axial', label: '横断面' },
|
||
{ id: 'sagittal', label: '矢状面' },
|
||
{ id: 'coronal', label: '冠状面' },
|
||
];
|
||
const displayModes: Array<{ id: DisplayMode; label: string }> = [
|
||
{ id: 'default', label: '默认' },
|
||
{ id: 'bone', label: '骨窗' },
|
||
{ id: 'soft', label: '软组织' },
|
||
{ id: 'contrast', label: '高对比' },
|
||
];
|
||
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];
|
||
const latestResultPose = latestSegmentationResult ? resultPose : modelPose;
|
||
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
|
||
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
|
||
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
|
||
const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0];
|
||
const resultDicomOpacity = reverseDicomOpacityOptions.find((option) => option.id === 'high') ?? reverseDicomOpacityOptions[reverseDicomOpacityOptions.length - 1];
|
||
const resultCutStart = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceStart ?? 0));
|
||
const resultCutEnd = Math.max(0, Math.min(resultMaxSlice, latestSegmentationResult?.sliceEnd ?? resultMaxSlice));
|
||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||
visible: fallback?.visible ?? true,
|
||
color: fallback?.color ?? defaultModuleColors[index % defaultModuleColors.length],
|
||
opacity: fallback?.opacity ?? 0.72,
|
||
partId: Math.max(1, Math.min(255, Math.round(fallback?.partId ?? index + 1))),
|
||
});
|
||
|
||
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||
setModuleStyles(next);
|
||
if (!selectedProject) {
|
||
return;
|
||
}
|
||
api.updateProjectModuleStyles(selectedProject.id, next)
|
||
.then((updated) => {
|
||
setSelectedProject(updated);
|
||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||
})
|
||
.catch((error) => {
|
||
setActionMessage(error instanceof Error ? error.message : '构件样式保存失败');
|
||
});
|
||
};
|
||
|
||
const handleMaskBundleExport = async () => {
|
||
if (!selectedProject) {
|
||
return;
|
||
}
|
||
|
||
const selectedTargets = exportOptions
|
||
.filter((option) => maskExportSelection[option.id])
|
||
.map((option) => option.id);
|
||
if (!selectedTargets.length) {
|
||
setActionMessage('请至少选择一个导出内容');
|
||
return;
|
||
}
|
||
|
||
setMaskExporting(true);
|
||
setActionMessage('');
|
||
try {
|
||
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
|
||
pose: latestSegmentationResult?.pose ?? modelPose,
|
||
segmentationScope: maskSegmentationScope,
|
||
segmentationExportMode: maskSegmentationExportMode,
|
||
});
|
||
window.setTimeout(() => setMaskExporting(false), 900);
|
||
setShowMaskExportMenu(false);
|
||
} catch (error) {
|
||
setActionMessage(error instanceof Error ? error.message : '导出失败');
|
||
setMaskExporting(false);
|
||
}
|
||
};
|
||
|
||
const triggerProjectAssetImport = () => {
|
||
if (!selectedProject || viewMode === 'mask' || assetImporting) {
|
||
return;
|
||
}
|
||
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
|
||
const hasExistingAssets = kind === 'dicom'
|
||
? (selectedProject.dicomCount ?? 0) > 0
|
||
: (selectedProject.stlFiles?.length ?? selectedProject.modelCount ?? 0) > 0;
|
||
if (hasExistingAssets) {
|
||
const confirmed = window.confirm(
|
||
kind === 'dicom'
|
||
? '当前项目已有 DICOM 影像。继续导入会覆盖项目库中的现有 DICOM 影像,并清空当前逆向分割结果,是否继续?'
|
||
: '当前项目已有 3D 模型。继续导入会覆盖项目库中的现有 STL 模型,并清空当前逆向分割结果,是否继续?',
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
}
|
||
const input = importInputRef.current;
|
||
if (!input) {
|
||
setActionMessage('导入控件尚未就绪,请稍后重试');
|
||
return;
|
||
}
|
||
importKindRef.current = kind;
|
||
input.value = '';
|
||
const archiveAccept = '.zip,.tar,.tar.gz,.tgz,.gz,application/zip,application/gzip,application/x-tar';
|
||
input.accept = kind === 'dicom'
|
||
? `.dcm,.dicom,application/dicom,${archiveAccept}`
|
||
: `.stl,model/stl,${archiveAccept}`;
|
||
input.multiple = true;
|
||
input.click();
|
||
};
|
||
|
||
const handleProjectAssetImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!selectedProject) {
|
||
return;
|
||
}
|
||
const files = Array.from(event.target.files ?? []);
|
||
event.target.value = '';
|
||
if (!files.length) {
|
||
return;
|
||
}
|
||
|
||
const kind = importKindRef.current;
|
||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||
setAssetImporting(true);
|
||
setAssetImportProgress({
|
||
kind,
|
||
fileCount: files.length,
|
||
totalBytes,
|
||
loadedBytes: 0,
|
||
percent: 0,
|
||
phase: 'uploading',
|
||
});
|
||
setActionMessage(`正在导入 ${describeImportKind(kind)}...`);
|
||
try {
|
||
const updated = await api.importProjectAssets(
|
||
selectedProject.id,
|
||
kind,
|
||
files,
|
||
(progress: ProjectAssetImportProgress) => {
|
||
setAssetImportProgress({
|
||
kind,
|
||
fileCount: files.length,
|
||
totalBytes: progress.total || totalBytes,
|
||
loadedBytes: progress.loaded,
|
||
percent: progress.percent,
|
||
phase: progress.percent >= 100 ? 'processing' : 'uploading',
|
||
});
|
||
},
|
||
);
|
||
clearCachedProjectAssets(updated.id);
|
||
preloadedProjectIdsRef.current.delete(updated.id);
|
||
setSelectedProject(updated);
|
||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
|
||
const nextStyles: Record<string, ModuleStyle> = {};
|
||
(updated.stlFiles ?? []).forEach((fileName, index) => {
|
||
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
|
||
});
|
||
setModuleStyles(nextStyles);
|
||
const nextPose = normalizeModelPose(latestResult?.pose);
|
||
setModelPose(nextPose);
|
||
setResultPose(nextPose);
|
||
setSliceIndex(0);
|
||
setDicomPreview(null);
|
||
setDicomError('');
|
||
setResultFusionVolume(null);
|
||
setAssetImportProgress({
|
||
kind,
|
||
fileCount: files.length,
|
||
totalBytes,
|
||
loadedBytes: totalBytes,
|
||
percent: 100,
|
||
phase: 'done',
|
||
});
|
||
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
|
||
window.setTimeout(() => setAssetImportProgress(null), 1800);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '项目资产导入失败';
|
||
setAssetImportProgress((current) => ({
|
||
kind,
|
||
fileCount: files.length,
|
||
totalBytes: current?.totalBytes ?? totalBytes,
|
||
loadedBytes: current?.loadedBytes ?? 0,
|
||
percent: current?.percent ?? 0,
|
||
phase: 'failed',
|
||
message,
|
||
}));
|
||
setActionMessage(message);
|
||
} finally {
|
||
setAssetImporting(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
setViewMode(initialViewMode);
|
||
}, [initialViewMode]);
|
||
|
||
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);
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
|
||
setDicomPreview(null);
|
||
setIsSliceChanging(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const requestId = dicomRequestRef.current + 1;
|
||
dicomRequestRef.current = requestId;
|
||
setDicomError('');
|
||
setIsSliceChanging(true);
|
||
getCachedDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
||
.then((preview) => {
|
||
if (!cancelled && requestId === dicomRequestRef.current) {
|
||
setDicomPreview(preview);
|
||
setIsSliceChanging(false);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
if (!cancelled && requestId === dicomRequestRef.current) {
|
||
setDicomPreview(null);
|
||
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
|
||
setIsSliceChanging(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedProject || viewMode !== 'mask' || !latestSegmentationResult || !selectedProject.dicomCount) {
|
||
setResultFusionVolume(null);
|
||
setResultFusionError('');
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const start = Math.min(resultCutStart, resultCutEnd);
|
||
const end = Math.max(resultCutStart, resultCutEnd);
|
||
setResultFusionError('');
|
||
getCachedDicomFusionVolume(selectedProject.id, start, end, 'soft')
|
||
.then((volume) => {
|
||
if (!cancelled) {
|
||
setResultFusionVolume(volume);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
if (!cancelled) {
|
||
setResultFusionVolume(null);
|
||
setResultFusionError(error instanceof Error ? error.message : 'DICOM 三维融合体载入失败');
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [selectedProject?.id, selectedProject?.dicomCount, viewMode, latestSegmentationResult?.id, resultCutStart, resultCutEnd]);
|
||
|
||
useEffect(() => () => {
|
||
if (sliceRepeatRef.current !== null) {
|
||
window.clearInterval(sliceRepeatRef.current);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const max = Math.max(sliceTotal - 1, 0);
|
||
if (sliceIndex > max) {
|
||
setSliceIndex(max);
|
||
}
|
||
}, [sliceIndex, sliceTotal]);
|
||
|
||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||
const next = {
|
||
...moduleStyles,
|
||
[fileName]: makeDefaultModuleStyle(index, {
|
||
...(moduleStyles[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
|
||
...partial,
|
||
}),
|
||
};
|
||
commitModuleStyles(next);
|
||
};
|
||
|
||
const updateModulePartId = (fileName: string, value: number) => {
|
||
const nextId = Math.max(1, Math.min(255, Math.round(Number.isFinite(value) ? value : 1)));
|
||
updateModuleStyle(fileName, { partId: nextId });
|
||
};
|
||
|
||
const toggleAllModules = () => {
|
||
const nextVisible = !allModulesVisible;
|
||
const next = { ...moduleStyles };
|
||
stlFiles.forEach((fileName, index) => {
|
||
next[fileName] = makeDefaultModuleStyle(index, {
|
||
...(next[fileName] ?? selectedProject?.moduleStyles?.[fileName]),
|
||
visible: nextVisible,
|
||
});
|
||
});
|
||
commitModuleStyles(next);
|
||
};
|
||
|
||
const stepSlice = (delta: number) => {
|
||
setSliceIndex((current) => {
|
||
const max = Math.max((dicomPreview?.total ?? selectedProject?.dicomCount ?? 1) - 1, 0);
|
||
return Math.max(0, Math.min(max, current + delta));
|
||
});
|
||
};
|
||
|
||
const stopSliceStep = () => {
|
||
if (sliceRepeatRef.current !== null) {
|
||
window.clearInterval(sliceRepeatRef.current);
|
||
sliceRepeatRef.current = null;
|
||
}
|
||
};
|
||
|
||
const startSliceStep = (delta: number) => {
|
||
stopSliceStep();
|
||
stepSlice(delta);
|
||
sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 95);
|
||
};
|
||
|
||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||
setModelPose((current) => clampModelPose({
|
||
...current,
|
||
...partial,
|
||
}));
|
||
};
|
||
|
||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||
setModelPose((current) => clampModelPose({
|
||
...current,
|
||
[key]: clampModelPoseValue(key, current[key] + delta),
|
||
}));
|
||
};
|
||
|
||
const resetModelRotationPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
}));
|
||
};
|
||
|
||
const resetModelTransformPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
}));
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
const downloadCurrentDicomPng = () => {
|
||
if (!dicomPreview || !selectedProject) {
|
||
setActionMessage('当前没有可下载的 DICOM 图片');
|
||
return;
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
drawDicomPreviewToCanvas(canvas, dicomPreview, rotation);
|
||
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-${displaySlice}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setActionMessage('已生成当前 DICOM 图片 PNG');
|
||
};
|
||
|
||
const openDicomInfo = async () => {
|
||
if (!selectedProject) return;
|
||
setIsDicomInfoOpen(true);
|
||
setDicomInfoError('');
|
||
try {
|
||
setDicomInfo(await api.getDicomInfo(selectedProject.id));
|
||
} catch (error) {
|
||
setDicomInfo(null);
|
||
setDicomInfoError(error instanceof Error ? error.message : 'DICOM 信息查询失败');
|
||
}
|
||
};
|
||
|
||
const handleCreateProject = async () => {
|
||
const name = newProjectName.trim();
|
||
if (!name) {
|
||
setActionMessage('请输入项目名称');
|
||
return;
|
||
}
|
||
const created = await api.createProject(name);
|
||
setNewProjectName('');
|
||
setIsCreateModalOpen(false);
|
||
setActionMessage(`已创建项目:${created.name}`);
|
||
await refreshProjects();
|
||
setSelectedProject(created);
|
||
};
|
||
|
||
const handleRenameProject = async (projectId: string) => {
|
||
const name = editingName.trim();
|
||
if (!name) {
|
||
setActionMessage('项目名称不能为空');
|
||
return;
|
||
}
|
||
const updated = await api.renameProject(projectId, name);
|
||
setEditingProjectId('');
|
||
setEditingName('');
|
||
setActionMessage(`已更新项目名称:${updated.name}`);
|
||
await refreshProjects();
|
||
setSelectedProject(updated);
|
||
};
|
||
|
||
const handleEditBlur = (project: Project) => {
|
||
if (editingProjectId !== project.id) {
|
||
return;
|
||
}
|
||
if (editingName.trim() && editingName.trim() !== project.name) {
|
||
handleRenameProject(project.id);
|
||
} else {
|
||
setEditingProjectId('');
|
||
setEditingName('');
|
||
}
|
||
};
|
||
|
||
const handleDeleteProject = async () => {
|
||
if (!projectToDelete) {
|
||
return;
|
||
}
|
||
await api.deleteProject(projectToDelete.id);
|
||
setActionMessage(`已删除项目:${projectToDelete.name}`);
|
||
setProjectToDelete(null);
|
||
await refreshProjects();
|
||
};
|
||
|
||
const tabs = [
|
||
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
|
||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||
{ id: 'mask' as const, label: '逆向分割结果', icon: Layers },
|
||
];
|
||
const renderMaskExportMenu = (widthClass = 'w-80') => (
|
||
<div className={`absolute right-0 top-12 z-50 ${widthClass} rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl`}>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="font-bold text-slate-700">导出内容</p>
|
||
<button
|
||
onClick={() => setMaskExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||
>
|
||
全选
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{exportOptions.map((option) => (
|
||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={maskExportSelection[option.id]}
|
||
onChange={(event) => setMaskExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||
className="accent-emerald-600"
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block">{option.label}</span>
|
||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{maskExportSelection.segmentation && (
|
||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{segmentationScopeOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setMaskSegmentationScope(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||
maskSegmentationScope === option.id
|
||
? 'bg-emerald-600 text-white shadow-sm'
|
||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||
}`}
|
||
>
|
||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||
<span className={`block text-[9px] ${maskSegmentationScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||
{option.description}
|
||
</span>
|
||
</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={() => setMaskSegmentationExportMode(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||
maskSegmentationExportMode === 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] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||
{option.description}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={handleMaskBundleExport}
|
||
disabled={maskExporting}
|
||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||
>
|
||
导出所选压缩包
|
||
</button>
|
||
</div>
|
||
);
|
||
const renderResultOverlaySummary = () => (
|
||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-bold text-slate-800">Overlay Label Map</p>
|
||
<p className="mt-1 font-mono text-[11px] font-bold text-cyan-700">
|
||
{resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px
|
||
</p>
|
||
</div>
|
||
<span className="rounded-lg bg-white px-2 py-1 text-[10px] font-bold text-slate-400">当前切片</span>
|
||
</div>
|
||
{resultOverlayStats.modules.length ? (
|
||
<div className="grid max-h-52 grid-cols-1 gap-2 overflow-auto pr-1">
|
||
{resultOverlayStats.modules.map((item) => (
|
||
<div key={item.fileName} className="grid grid-cols-[12px_1fr_auto] items-center gap-2 rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-600">
|
||
<span className="h-3 w-3 rounded-full border border-white shadow-sm" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||
<span className="min-w-0 truncate">{item.name}</span>
|
||
<span className="font-mono text-cyan-700">ID {item.partId}</span>
|
||
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
|
||
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-xl border border-slate-100 bg-white px-3 py-2 text-[10px] font-bold text-slate-400">
|
||
当前切片暂无可见构件
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="h-full flex gap-6 overflow-hidden">
|
||
{/* Project Sidebar - Collapsible */}
|
||
<div
|
||
className={`${
|
||
isSidebarCollapsed ? 'w-12' : 'w-72'
|
||
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-visible shrink-0`}
|
||
>
|
||
<button
|
||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||
className="absolute -right-3 top-1/2 z-10 h-7 w-7 -translate-y-1/2 bg-white border border-slate-100 shadow-md hover:bg-slate-50 rounded-full text-slate-400 transition-colors flex items-center justify-center"
|
||
>
|
||
{isSidebarCollapsed ? <ChevronRight size={18} /> : <ChevronRight className="rotate-180" size={18} />}
|
||
</button>
|
||
|
||
{!isSidebarCollapsed && (
|
||
<div className="p-4 flex flex-col h-full overflow-hidden">
|
||
<div className="flex items-center justify-between mb-4 px-1">
|
||
<h3 className="font-bold text-slate-800">项目列表</h3>
|
||
<button
|
||
onClick={() => setIsCreateModalOpen(true)}
|
||
className="p-1.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors"
|
||
title="创建项目"
|
||
>
|
||
<Plus size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="relative mb-4">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
|
||
<input
|
||
type="text"
|
||
placeholder="搜索..."
|
||
className="w-full pl-8 pr-4 py-2 bg-slate-50 border-none rounded-lg text-xs focus:ring-1 focus:ring-blue-500 outline-none"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||
{loading && <p className="text-xs text-slate-400 px-2">正在从后端载入项目...</p>}
|
||
{filteredProjects.map((proj) => (
|
||
<div
|
||
key={proj.id}
|
||
onClick={() => setSelectedProject(proj)}
|
||
className={`w-full p-3 rounded-xl transition-all text-left cursor-pointer ${
|
||
selectedProject?.id === proj.id ? 'bg-blue-600 text-white shadow-md' : 'hover:bg-slate-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
{editingProjectId === proj.id ? (
|
||
<input
|
||
value={editingName}
|
||
autoFocus
|
||
onClick={(event) => event.stopPropagation()}
|
||
onBlur={() => handleEditBlur(proj)}
|
||
onChange={(event) => setEditingName(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') handleRenameProject(proj.id);
|
||
if (event.key === 'Escape') setEditingProjectId('');
|
||
}}
|
||
className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200"
|
||
/>
|
||
) : (
|
||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||
{proj.name}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{editingProjectId !== proj.id && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setEditingProjectId(proj.id);
|
||
setEditingName(proj.name);
|
||
}}
|
||
className={selectedProject?.id === proj.id ? 'text-blue-100 hover:text-white' : 'text-slate-300 hover:text-blue-600'}
|
||
title="修改项目名称"
|
||
>
|
||
<Edit2 size={14} />
|
||
</button>
|
||
<button
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setProjectToDelete(proj);
|
||
}}
|
||
className={selectedProject?.id === proj.id ? 'text-blue-100 hover:text-white' : 'text-slate-300 hover:text-rose-600'}
|
||
title="删除项目"
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
|
||
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{actionMessage && <p className="text-[10px] text-slate-400 mt-3 px-1">{actionMessage}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{isSidebarCollapsed && (
|
||
<div className="flex flex-col items-center py-12 gap-4">
|
||
{filteredProjects.map(p => (
|
||
<div
|
||
key={p.id}
|
||
onClick={() => setSelectedProject(p)}
|
||
className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
|
||
selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400'
|
||
}`}
|
||
>
|
||
<FolderRoot size={16} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||
{selectedProject ? (
|
||
<>
|
||
<input
|
||
ref={importInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
onChange={handleProjectAssetImport}
|
||
/>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex bg-slate-100 p-1 rounded-xl">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setViewMode(tab.id)}
|
||
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${
|
||
viewMode === tab.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
<tab.icon size={16} /> {tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-4">
|
||
<button
|
||
onClick={() => onReverse(selectedProject.id)}
|
||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
|
||
>
|
||
<RotateCw size={18} /> 进入逆向工作区
|
||
</button>
|
||
{viewMode === 'mask' ? (
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowMaskExportMenu((value) => !value)}
|
||
disabled={maskExporting || !latestSegmentationResult}
|
||
className="bg-emerald-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-emerald-700 transition-all shadow-lg disabled:opacity-50"
|
||
>
|
||
<Download size={18} />
|
||
{maskExporting ? '正在导出' : '导出项目及结果'}
|
||
</button>
|
||
{showMaskExportMenu && renderMaskExportMenu('w-80')}
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={triggerProjectAssetImport}
|
||
disabled={assetImporting}
|
||
className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all disabled:opacity-50"
|
||
>
|
||
<Upload size={18} /> {assetImporting ? '导入中' : '导入'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{assetImportProgress && (
|
||
<div className={`rounded-2xl border px-5 py-3 shadow-sm ${
|
||
assetImportProgress.phase === 'failed'
|
||
? 'border-rose-200 bg-rose-50'
|
||
: assetImportProgress.phase === 'done'
|
||
? 'border-emerald-100 bg-white'
|
||
: 'border-blue-100 bg-white'
|
||
}`}>
|
||
<div className="mb-2 flex items-center justify-between gap-4">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||
assetImportProgress.phase === 'failed'
|
||
? 'bg-rose-100 text-rose-600'
|
||
: assetImportProgress.phase === 'done'
|
||
? 'bg-emerald-50 text-emerald-600'
|
||
: 'bg-blue-50 text-blue-600'
|
||
}`}>
|
||
{assetImportProgress.phase === 'failed' ? <X size={17} /> : <FileArchive size={17} />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-bold text-slate-800">
|
||
{assetImportProgress.phase === 'failed'
|
||
? `${describeImportKind(assetImportProgress.kind)}导入失败`
|
||
: assetImportProgress.phase === 'done'
|
||
? `${describeImportKind(assetImportProgress.kind)}导入完成`
|
||
: assetImportProgress.phase === 'processing'
|
||
? '上传完成,服务器正在解压与解析'
|
||
: `正在上传${describeImportKind(assetImportProgress.kind)}`}
|
||
</p>
|
||
<p className="mt-0.5 text-[11px] font-bold text-slate-400">
|
||
{assetImportProgress.fileCount} 个文件 · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)}
|
||
</p>
|
||
{assetImportProgress.phase === 'failed' && assetImportProgress.message && (
|
||
<p className="mt-1 whitespace-normal text-[11px] font-bold leading-5 text-rose-700">
|
||
{assetImportProgress.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<span className={`shrink-0 font-mono text-sm font-black ${
|
||
assetImportProgress.phase === 'failed'
|
||
? 'text-rose-600'
|
||
: assetImportProgress.phase === 'done'
|
||
? 'text-emerald-600'
|
||
: 'text-blue-600'
|
||
}`}>
|
||
{assetImportProgress.percent}%
|
||
</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||
<div
|
||
className={`h-full rounded-full transition-all duration-300 ${
|
||
assetImportProgress.phase === 'failed'
|
||
? 'bg-rose-500'
|
||
: assetImportProgress.phase === 'done'
|
||
? 'bg-emerald-500'
|
||
: 'bg-blue-600'
|
||
}`}
|
||
style={{ width: `${assetImportProgress.percent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
|
||
{viewMode === 'dicom' && (
|
||
<div className="h-full min-h-0 flex gap-8">
|
||
{/* Left: DICOM Viewer */}
|
||
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
|
||
<div className="absolute top-4 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
|
||
{planeOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => {
|
||
setPlane(option.id);
|
||
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'
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="absolute top-16 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
|
||
{displayModes.map((mode) => (
|
||
<button
|
||
key={mode.id}
|
||
onClick={() => setDisplayMode(mode.id)}
|
||
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
||
displayMode === mode.id ? 'bg-emerald-600 text-white' : 'text-white/50 hover:text-white'
|
||
}`}
|
||
>
|
||
{mode.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="absolute top-28 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
|
||
<button
|
||
onClick={() => rotateDicom(-90)}
|
||
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
|
||
title="左旋转 90°"
|
||
>
|
||
<RotateCcw size={12} /> 左转
|
||
</button>
|
||
<button
|
||
onClick={() => rotateDicom(90)}
|
||
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
|
||
title="右旋转 90°"
|
||
>
|
||
<RotateCw size={12} /> 右转
|
||
</button>
|
||
</div>
|
||
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
|
||
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
|
||
<p>SCAN DATE: {selectedProject.createTime}</p>
|
||
<p>DICOM PATH: {selectedProject.dicomPath}</p>
|
||
</div>
|
||
<div className={`relative w-full h-full flex items-center justify-center transition-all duration-150 ${isSliceChanging ? 'scale-[1.01] opacity-85 brightness-110' : 'scale-100 opacity-100 brightness-100'}`}>
|
||
{dicomPreview ? (
|
||
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
||
) : (
|
||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
|
||
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
|
||
</p>
|
||
)}
|
||
{isSliceChanging && dicomPreview && (
|
||
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
|
||
切片切换中
|
||
</span>
|
||
)}
|
||
</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 ? 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">
|
||
{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>
|
||
<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">#{dicomDisplaySlice}</span>
|
||
<div className="mt-5 flex w-full flex-col gap-2 px-2">
|
||
<button
|
||
onClick={downloadCurrentDicomPng}
|
||
className="h-8 rounded-lg bg-blue-600 text-white text-[10px] font-bold flex items-center justify-center gap-1 hover:bg-blue-700"
|
||
title="下载当前图片 PNG"
|
||
>
|
||
<Download size={12} /> PNG
|
||
</button>
|
||
<button
|
||
onClick={() => selectedProject && downloadDicomArchive(selectedProject.id)}
|
||
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
|
||
title="下载 DICOM 影像压缩包"
|
||
>
|
||
<FileArchive size={12} /> DCM
|
||
</button>
|
||
<button
|
||
onClick={openDicomInfo}
|
||
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
|
||
title="查询 DICOM 详细信息"
|
||
>
|
||
<Info size={12} /> 信息
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{viewMode === 'model' && (
|
||
<div className="h-full flex gap-8">
|
||
{/* Left: 3D Visualization */}
|
||
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
|
||
<NativeStlViewer
|
||
projectId={selectedProject.id}
|
||
files={stlFiles}
|
||
styles={moduleStyles}
|
||
detailLimit={selectedSolidity.limit}
|
||
solidMode={solidityLevel === 'solid'}
|
||
pose={modelPose}
|
||
onPoseChange={setModelPose}
|
||
/>
|
||
{!stlFiles.length && (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<p className="rounded-2xl border border-slate-200 bg-white/85 px-5 py-3 text-xs font-bold text-slate-500 shadow-sm">
|
||
请导入STL模型
|
||
</p>
|
||
</div>
|
||
)}
|
||
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
|
||
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
|
||
</div>
|
||
</div>
|
||
{/* Right: Sub-module List */}
|
||
<div className="w-80 h-full min-h-0 overflow-y-auto pr-1 scrollbar-hide">
|
||
<div className="space-y-4 pb-4">
|
||
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-xs font-bold text-slate-700">模型显示</p>
|
||
<span className="text-[10px] text-slate-400">左键旋转 · 右键/Shift 平移 · 滚轮缩放</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1 rounded-xl bg-slate-100 p-1">
|
||
{solidityOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setSolidityLevel(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||
solidityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs font-bold text-slate-700">模型位姿</p>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
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 },
|
||
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 },
|
||
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 },
|
||
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY, minus: '-Y', plus: '+Y', delta: 0.25 },
|
||
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ, minus: '-Z', plus: '+Z', delta: 0.25 },
|
||
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.005, value: modelPose.scale, minus: '-0.005', plus: '+0.005', delta: 0.005 },
|
||
].map((item) => (
|
||
<div key={item.key} className="grid grid-cols-[48px_40px_1fr_40px_42px] items-center gap-2">
|
||
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, -item.delta)}
|
||
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||
title={`${item.label} ${item.minus}`}
|
||
>
|
||
{item.minus}
|
||
</button>
|
||
<input
|
||
type="range"
|
||
min={item.min}
|
||
max={item.max}
|
||
step={item.step}
|
||
value={item.value}
|
||
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) } as Partial<ModelPose>)}
|
||
className="w-full accent-blue-600"
|
||
/>
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, item.delta)}
|
||
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||
title={`${item.label} ${item.plus}`}
|
||
>
|
||
{item.plus}
|
||
</button>
|
||
<span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(getControlStepPrecision(item.step))}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-1 flex items-center justify-between mb-3 shrink-0">
|
||
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest">构件层级 ({stlFiles.length})</p>
|
||
<button
|
||
onClick={toggleAllModules}
|
||
className={`p-1 rounded hover:bg-slate-100 transition-colors ${allModulesVisible ? 'text-blue-500' : 'text-slate-400'}`}
|
||
title={allModulesVisible ? "全隐藏" : "全显示"}
|
||
>
|
||
<Eye size={16} />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 pb-4">
|
||
{stlFiles.map((fileName, i) => {
|
||
const name = fileName.replace(/\.stl$/i, '');
|
||
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 };
|
||
return (
|
||
<div
|
||
key={fileName}
|
||
className={`p-3 rounded-xl border flex items-start gap-3 group transition-all bg-slate-50 border-transparent hover:border-slate-200 ${!style.visible ? 'opacity-50' : ''}`}
|
||
>
|
||
<input
|
||
type="color"
|
||
value={style.color}
|
||
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
|
||
className="w-8 h-8 rounded-lg border border-white bg-white p-0.5 cursor-pointer shrink-0"
|
||
title="模型颜色"
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[11px] font-bold text-slate-700 truncate">{name}</p>
|
||
<div className="mt-0.5 flex items-center gap-2">
|
||
<p className="min-w-0 flex-1 text-[9px] text-slate-400 truncate">STL | {fileName}</p>
|
||
<label className="flex shrink-0 items-center gap-1 text-[9px] font-bold text-slate-400">
|
||
ID
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="255"
|
||
value={style.partId}
|
||
onChange={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||
onBlur={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 text-[9px] font-mono text-slate-600 outline-none focus:border-blue-400"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<span className="text-[9px] text-slate-400 shrink-0">透明度</span>
|
||
<input
|
||
type="range"
|
||
min="0.1"
|
||
max="1"
|
||
step="0.05"
|
||
value={style.opacity}
|
||
onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
|
||
className="min-w-0 flex-1 accent-blue-600"
|
||
/>
|
||
<span className="text-[9px] text-slate-400 w-7 text-right">{Math.round(style.opacity * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
updateModuleStyle(fileName, { visible: !style.visible });
|
||
}}
|
||
className={`p-1 rounded hover:bg-white transition-colors ${style.visible ? 'text-blue-500 underline decoration-2' : 'text-slate-300'}`}
|
||
>
|
||
<Eye size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{viewMode === 'mask' && (
|
||
<div className="grid h-full min-h-0 grid-cols-1 items-stretch gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
|
||
<div className="flex min-h-[620px] min-w-0 flex-col">
|
||
{latestSegmentationResult ? (
|
||
<FusionThreeView
|
||
project={selectedProject}
|
||
volume={resultFusionVolume}
|
||
modelPose={latestResultPose}
|
||
moduleStyles={latestResultStyles}
|
||
detailLimit={resultDisplayOption.limit}
|
||
solidMode={false}
|
||
dicomOpacity={resultDicomOpacity}
|
||
showBounds={latestSegmentationResult.showBounds ?? true}
|
||
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
|
||
cutStart={resultCutStart}
|
||
cutEnd={resultCutEnd}
|
||
viewPreset="libraryResult"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
||
暂无保存结果,请在逆向工作区保存当前映射。
|
||
</div>
|
||
)}
|
||
{resultFusionError && (
|
||
<p className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700">
|
||
{resultFusionError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex min-h-[620px] min-w-0 flex-col">
|
||
{latestSegmentationResult ? (
|
||
<VoxelizationMappingView
|
||
project={selectedProject}
|
||
moduleStyles={latestResultStyles}
|
||
modelPose={latestResultPose}
|
||
detailLimit={resultDisplayOption.limit}
|
||
slice={resultMappingSlice}
|
||
totalSlices={selectedProject.dicomCount}
|
||
onSliceChange={setResultPreviewSlice}
|
||
displayMode={resultDisplayMode}
|
||
rotation={resultRotation}
|
||
variant="library"
|
||
overlayPlacement="none"
|
||
onOverlayStatsChange={(stats, visibleCount) => {
|
||
setResultOverlayStats(stats);
|
||
setResultVisibleModuleCount(visibleCount);
|
||
}}
|
||
toolbar={(
|
||
<>
|
||
<div className="flex rounded-xl bg-white/10 p-1">
|
||
{displayModes.map((mode) => (
|
||
<button
|
||
key={mode.id}
|
||
onClick={() => setResultDisplayMode(mode.id)}
|
||
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
|
||
resultDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
|
||
}`}
|
||
>
|
||
{mode.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => setResultRotation((value) => (value + 270) % 360)}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="左转 90°"
|
||
>
|
||
<RotateCcw size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => setResultRotation((value) => (value + 90) % 360)}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="右转 90°"
|
||
>
|
||
<RotateCw size={14} />
|
||
</button>
|
||
</>
|
||
)}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
||
暂无逆向分割映射视图。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<div className="rounded-2xl border border-slate-100 bg-slate-50 p-5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h3 className="font-bold text-slate-800">逆向分割结果</h3>
|
||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||
项目库仅保留最新一次保存结果,导出时默认沿用该结果的模型位姿与构件样式。
|
||
</p>
|
||
</div>
|
||
<span className={`shrink-0 whitespace-nowrap rounded-lg px-2 py-1 text-[10px] font-bold ${latestSegmentationResult ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'}`}>
|
||
{latestSegmentationResult ? '已保存' : '未保存'}
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||
<span className="rounded-lg bg-white px-2 py-2">构件总数:{selectedProject.modelCount ?? stlFiles.length}</span>
|
||
<span className="rounded-lg bg-white px-2 py-2">
|
||
最后保存:{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 rounded-xl bg-white p-3">
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-slate-400">模型位姿</p>
|
||
<div className="grid grid-cols-3 gap-1.5 text-[9px] font-bold text-slate-500">
|
||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RX {formatPoseCompactValue(latestResultPose.rotateX, 1)}°</span>
|
||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RY {formatPoseCompactValue(latestResultPose.rotateY, 1)}°</span>
|
||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">RZ {formatPoseCompactValue(latestResultPose.rotateZ, 1)}°</span>
|
||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TX {formatPoseCompactValue(latestResultPose.translateX, 3)}</span>
|
||
<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>
|
||
|
||
{latestSegmentationResult && renderResultOverlaySummary()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex-1 bg-white rounded-3xl border border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
|
||
<div className="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-6">
|
||
<FolderRoot size={40} />
|
||
</div>
|
||
<p className="font-bold">请从左侧选择一个项目开始阅览</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isCreateModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
|
||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">
|
||
<div className="flex items-center justify-between mb-5">
|
||
<h3 className="font-bold text-slate-900">创建项目</h3>
|
||
<button
|
||
onClick={() => setIsCreateModalOpen(false)}
|
||
className="text-slate-400 hover:text-slate-700"
|
||
title="关闭"
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<input
|
||
value={newProjectName}
|
||
autoFocus
|
||
onChange={(event) => setNewProjectName(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
handleCreateProject();
|
||
}
|
||
}}
|
||
placeholder="请输入项目名称"
|
||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setIsCreateModalOpen(false)}
|
||
className="px-4 py-2 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-100"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleCreateProject}
|
||
className="px-4 py-2 rounded-xl text-sm font-bold bg-blue-600 text-white hover:bg-blue-700"
|
||
>
|
||
创建
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isDicomInfoOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
|
||
<div className="w-full max-w-3xl max-h-[82vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-slate-100 flex flex-col">
|
||
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||
<div>
|
||
<h3 className="font-bold text-slate-900">DICOM 详细信息</h3>
|
||
<p className="text-xs text-slate-400 mt-1">包含基础元数据、像素间距、切片间距和物理尺寸</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setIsDicomInfoOpen(false)}
|
||
className="text-slate-400 hover:text-slate-700"
|
||
title="关闭"
|
||
>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<div className="overflow-y-auto p-6">
|
||
{dicomInfoError && <p className="text-sm font-bold text-rose-600">{dicomInfoError}</p>}
|
||
{!dicomInfo && !dicomInfoError && <p className="text-sm text-slate-400">正在解析 DICOM 信息...</p>}
|
||
{dicomInfo && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{[
|
||
{
|
||
title: '患者与检查',
|
||
rows: [
|
||
['患者姓名', dicomInfo.patient.name],
|
||
['患者 ID', dicomInfo.patient.id],
|
||
['检查日期', dicomInfo.study.date],
|
||
['检查类型', dicomInfo.study.modality],
|
||
['设备厂商', dicomInfo.study.manufacturer],
|
||
],
|
||
},
|
||
{
|
||
title: '序列与文件',
|
||
rows: [
|
||
['序列描述', dicomInfo.series.description],
|
||
['文件数量', dicomInfo.series.files],
|
||
['首文件', dicomInfo.series.firstFile],
|
||
['末文件', dicomInfo.series.lastFile],
|
||
['DICOM 路径', dicomInfo.project.dicomPath],
|
||
],
|
||
},
|
||
{
|
||
title: '图像矩阵与窗宽窗位',
|
||
rows: [
|
||
['Rows', dicomInfo.image.rows],
|
||
['Columns', dicomInfo.image.columns],
|
||
['Bits Allocated', dicomInfo.image.bitsAllocated],
|
||
['Window Center', dicomInfo.image.windowCenter],
|
||
['Window Width', dicomInfo.image.windowWidth],
|
||
['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`],
|
||
],
|
||
},
|
||
{
|
||
title: '空间距离',
|
||
rows: [
|
||
['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`],
|
||
['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`],
|
||
['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`],
|
||
['间距来源', dicomInfo.spacing.sliceSource],
|
||
['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`],
|
||
['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`],
|
||
],
|
||
},
|
||
{
|
||
title: '物理尺寸',
|
||
rows: [
|
||
['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`],
|
||
['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`],
|
||
['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`],
|
||
],
|
||
},
|
||
{
|
||
title: '空间位置',
|
||
rows: [
|
||
['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'],
|
||
['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'],
|
||
],
|
||
},
|
||
].map((section) => (
|
||
<div key={section.title} className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
|
||
<h4 className="text-xs font-bold text-slate-800 mb-3">{section.title}</h4>
|
||
<div className="space-y-2">
|
||
{section.rows.map(([label, value]) => (
|
||
<div key={label} className="flex items-start justify-between gap-4 text-xs">
|
||
<span className="text-slate-400 shrink-0">{label}</span>
|
||
<span className="text-slate-700 font-semibold text-right break-all">{displayDicomValue(value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{projectToDelete && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
|
||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">
|
||
<h3 className="font-bold text-slate-900 mb-2">确认删除项目</h3>
|
||
<p className="text-sm text-slate-500 leading-6">
|
||
将删除项目“{projectToDelete.name}”。该操作会从项目列表移除项目,需要恢复默认演示项目时可使用出厂设置。
|
||
</p>
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setProjectToDelete(null)}
|
||
className="px-4 py-2 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-100"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={handleDeleteProject}
|
||
className="px-4 py-2 rounded-xl text-sm font-bold bg-rose-600 text-white hover:bg-rose-700"
|
||
>
|
||
确认删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|