Files
REVOXELSEG_DICOM/WebSite/src/components/ProjectLibrary.tsx

2435 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}