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, Lock, Unlock } 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; type ModelPoseFlipKey = Extract; 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 = { 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> = { 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 | 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'; } function getProjectActivityTime(project: Project) { const latestResult = project.segmentationResults?.[project.segmentationResults.length - 1]; const candidates = [ project.lastProcessedAt, project.lockedAt, project.unlockedAt, latestResult?.createdAt, project.createTime, ]; return Math.max(...candidates.map((value) => (value ? Date.parse(value) : 0)).filter((value) => Number.isFinite(value)), 0); } function sortProjectsByActivity(projects: Project[]) { return [...projects].sort((a, b) => getProjectActivityTime(b) - getProjectActivityTime(a)); } function formatProjectActivity(project: Project) { const time = getProjectActivityTime(project); if (!time) { return project.createTime; } return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(new Date(time)); } 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, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) { const canvasRef = useRef(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]); useEffect(() => { setViewport({ scale: 1, offsetX: 0, offsetY: 0 }); }, [resetSignal]); const handleWheel = (event: React.WheelEvent) => { 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) => { 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) => { 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) => { 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 (
); } 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 (
{axes.map((axis) => ( {axis.id} ))}
X {Math.round(pose.rotateX)}°
Y {Math.round(pose.rotateY)}°
Z {Math.round(pose.rotateZ)}°
); } function NativeStlViewer({ projectId, files, styles, detailLimit, solidMode, pose, onPoseChange, }: { projectId: string; files: string[]; styles: Record; detailLimit: number; solidMode: boolean; pose: ModelPose; onPoseChange: React.Dispatch>; }) { const containerRef = useRef(null); const poseRef = useRef(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 (
{progress < 100 && (
{status} {progress}%
)} {progress >= 100 && (
{status}
)}
); } export default function ProjectLibrary({ onReverse, initialViewMode = 'dicom', }: { onReverse: (projId: string) => void; initialViewMode?: 'dicom' | 'model' | 'mask'; }) { const [search, setSearch] = useState(''); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [selectedProject, setSelectedProject] = useState(null); const [showUnlockedOnly, setShowUnlockedOnly] = useState(false); const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [sliceIndex, setSliceIndex] = useState(0); const [plane, setPlane] = useState('axial'); const [displayMode, setDisplayMode] = useState('default'); const [rotation, setRotation] = useState(0); const [dicomViewportResetSignal, setDicomViewportResetSignal] = useState(0); const [isSliceChanging, setIsSliceChanging] = useState(false); const [solidityLevel, setSolidityLevel] = useState('standard'); const [modelPose, setModelPose] = useState(defaultModelPose); const [resultPose, setResultPose] = useState(defaultModelPose); const [resultPreviewSlice, setResultPreviewSlice] = useState(0); const [resultDisplayMode, setResultDisplayMode] = useState('soft'); const [resultRotation, setResultRotation] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [dicomPreview, setDicomPreview] = useState(null); const [resultFusionVolume, setResultFusionVolume] = useState(null); const [resultFusionError, setResultFusionError] = useState(''); const [resultOverlayStats, setResultOverlayStats] = useState(emptyOverlayStats); const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0); const [dicomInfo, setDicomInfo] = useState(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(null); const [editingProjectId, setEditingProjectId] = useState(''); const [editingName, setEditingName] = useState(''); const [actionMessage, setActionMessage] = useState(''); const [showMaskExportMenu, setShowMaskExportMenu] = useState(false); const [maskExportSelection, setMaskExportSelection] = useState>({ dicom: false, segmentation: true, pose: true, stl: false, }); const [maskSegmentationScope, setMaskSegmentationScope] = useState('visible'); const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState('combined'); const [maskExporting, setMaskExporting] = useState(false); const [assetImporting, setAssetImporting] = useState(false); const [lockChangingProjectId, setLockChangingProjectId] = useState(''); const [assetImportProgress, setAssetImportProgress] = useState(null); const importInputRef = useRef(null); const importKindRef = useRef('dicom'); const sliceRepeatRef = useRef(null); const dicomRequestRef = useRef(0); const preloadedProjectIdsRef = useRef(new Set()); 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) => { const orderedItems = sortProjectsByActivity(items); setProjects(orderedItems); orderedItems.slice(0, 2).forEach(preloadProjectAssets); setSelectedProject((current) => { if (!current) { return orderedItems[0] ?? null; } return orderedItems.find((item) => item.id === current.id) ?? orderedItems[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(); return sortProjectsByActivity(projects).filter((project) => { const matchesKeyword = !keyword || project.name.toLowerCase().includes(keyword); const matchesLockFilter = !showUnlockedOnly || project.locked !== true; return matchesKeyword && matchesLockFilter; }); }, [projects, search, showUnlockedOnly]); 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 = safeDicomSlice; 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 => ({ 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) => { setModuleStyles(next); if (!selectedProject) { return; } api.updateProjectModuleStyles(selectedProject.id, next) .then((updated) => { setSelectedProject(updated); setProjects((items) => sortProjectsByActivity(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) => { 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) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item)))); const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1]; const nextStyles: Record = {}; (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 = {}; 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) => { 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) => { 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 handleToggleProjectLock = async (project: Project) => { setLockChangingProjectId(project.id); setActionMessage(''); try { const updated = await api.updateProjectLock(project.id, project.locked !== true); setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item)))); setSelectedProject((current) => (current?.id === updated.id ? updated : current)); setActionMessage(updated.locked ? `已锁定,位姿数据已保存到 ${updated.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}` : '项目已解锁,可以进入逆向工作区'); } catch (error) { setActionMessage(error instanceof Error ? error.message : '项目锁定状态更新失败'); } finally { setLockChangingProjectId(''); } }; const handleEnterReverseWorkspace = (project: Project) => { if (project.locked) { setActionMessage('项目已锁定,请先点击「解锁项目」后再进入逆向工作区'); return; } onReverse(project.id); }; 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') => (

导出内容

{exportOptions.map((option) => ( ))}
{maskExportSelection.segmentation && (

分割类别范围

附带 labels.json
{segmentationScopeOptions.map((option) => ( ))}

分割导出方式

{segmentationExportModeOptions.map((option) => ( ))}
)}
); const renderResultOverlaySummary = () => (

Overlay Label Map

{resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px

当前切片
{resultOverlayStats.modules.length ? (
{resultOverlayStats.modules.map((item) => (
{item.name} ID {item.partId} {item.segmentCount} 边 {item.filledPixels} px
))}
) : (
当前切片暂无可见构件
)}
); return (
{/* Project Sidebar - Collapsible */}
{!isSidebarCollapsed && (

项目列表

setSearch(e.target.value)} />
{loading &&

正在从后端载入项目...

} {filteredProjects.map((proj) => (
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' }`} >
{editingProjectId === proj.id ? ( 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" /> ) : (

{proj.name}

{proj.locked && ( )}
)}
{editingProjectId !== proj.id && (
)}

处理 {formatProjectActivity(proj)} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}

))}
{actionMessage &&

{actionMessage}

}
)} {isSidebarCollapsed && (
{filteredProjects.map(p => (
setSelectedProject(p)} className={`relative 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' }`} > {p.locked && }
))}
)}
{/* Main Content Area */}
{selectedProject ? ( <>
{tabs.map((tab) => ( ))}
{viewMode === 'mask' ? (
{showMaskExportMenu && renderMaskExportMenu('w-80')}
) : ( )}
{assetImportProgress && (
{assetImportProgress.phase === 'failed' ? : }

{assetImportProgress.phase === 'failed' ? `${describeImportKind(assetImportProgress.kind)}导入失败` : assetImportProgress.phase === 'done' ? `${describeImportKind(assetImportProgress.kind)}导入完成` : assetImportProgress.phase === 'processing' ? '上传完成,服务器正在解压与解析' : `正在上传${describeImportKind(assetImportProgress.kind)}`}

{assetImportProgress.fileCount} 个文件 · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)}

{assetImportProgress.phase === 'failed' && assetImportProgress.message && (

{assetImportProgress.message}

)}
{assetImportProgress.percent}%
)}
{viewMode === 'dicom' && (
{/* Left: DICOM Viewer */}
{planeOptions.map((option) => ( ))}
{displayModes.map((mode) => ( ))}

PATIENT ID: {selectedProject.id}_XYZ

SCAN DATE: {selectedProject.createTime}

DICOM PATH: {selectedProject.dicomPath}

{dicomPreview ? ( ) : (

{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}

)} {isSliceChanging && dicomPreview && ( 切片切换中 )}
WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label} 第 {selectedProject.dicomCount ? dicomDisplaySlice : 0} / {dicomSliceTotal || selectedProject.dicomCount} 张
{/* Right: Vertical Progress Bar */}
切片 {dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
setSliceIndex(Number(event.target.value))} className="mapping-slice-dark-vertical-input" aria-label="项目库 DICOM 切片导航" />
#{dicomDisplaySlice}
)} {viewMode === 'model' && (
{/* Left: 3D Visualization */}
{!stlFiles.length && (

请导入STL模型

)}
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
{/* Right: Sub-module List */}

模型显示

左键旋转 · 右键/Shift 平移 · 滚轮缩放
{solidityOptions.map((option) => ( ))}

模型位姿

{modelPoseFlipOptions.map((item) => { const Icon = item.icon; const enabled = modelPose[item.key]; return ( ); })}
{[ { 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) => (
{item.label} updateModelPose({ [item.key]: Number(event.target.value) } as Partial)} className="w-full accent-blue-600" /> {Number(item.value).toFixed(getControlStepPrecision(item.step))}
))}

构件层级 ({stlFiles.length})

{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 (
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="模型颜色" />

{name}

STL | {fileName}

透明度 updateModuleStyle(fileName, { opacity: Number(event.target.value) })} className="min-w-0 flex-1 accent-blue-600" /> {Math.round(style.opacity * 100)}%
)})}
)} {viewMode === 'mask' && (
{latestSegmentationResult ? ( ) : (
暂无保存结果,请在逆向工作区保存当前映射。
)} {resultFusionError && (

{resultFusionError}

)}
{latestSegmentationResult ? ( { setResultOverlayStats(stats); setResultVisibleModuleCount(visibleCount); }} toolbar={( <>
{displayModes.map((mode) => ( ))}
)} /> ) : (
暂无逆向分割映射视图。
)}

逆向分割结果

项目库仅保留最新一次保存结果,导出时默认沿用该结果的模型位姿与构件样式。

{latestSegmentationResult ? '已保存' : '未保存'}
构件总数:{selectedProject.modelCount ?? stlFiles.length} 最后保存:{latestSegmentationResult ? new Date(latestSegmentationResult.createdAt).toLocaleString('zh-CN', { hour12: false }) : '等待结果'}

模型位姿

RX {formatPoseCompactValue(latestResultPose.rotateX, 1)}° RY {formatPoseCompactValue(latestResultPose.rotateY, 1)}° RZ {formatPoseCompactValue(latestResultPose.rotateZ, 1)}° TX {formatPoseCompactValue(latestResultPose.translateX, 3)} TY {formatPoseCompactValue(latestResultPose.translateY, 3)} TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)} Scale {formatPoseCompactValue(latestResultPose.scale, 3)} FX {latestResultPose.flipX ? '开' : '关'} FY {latestResultPose.flipY ? '开' : '关'} FZ {latestResultPose.flipZ ? '开' : '关'}
{latestSegmentationResult && renderResultOverlaySummary()}
)}
) : (

请从左侧选择一个项目开始阅览

)}
{isCreateModalOpen && (

创建项目

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" />
)} {isDicomInfoOpen && (

DICOM 详细信息

包含基础元数据、像素间距、切片间距和物理尺寸

{dicomInfoError &&

{dicomInfoError}

} {!dicomInfo && !dicomInfoError &&

正在解析 DICOM 信息...

} {dicomInfo && (
{[ { 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) => (

{section.title}

{section.rows.map(([label, value]) => (
{label} {displayDicomValue(value)}
))}
))}
)}
)} {projectToDelete && (

确认删除项目

将删除项目“{projectToDelete.name}”。该操作会从项目列表移除项目,需要恢复默认演示项目时可使用出厂设置。

)}
); }