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

1224 lines
52 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, useRef, useState } from 'react';
import { motion } from 'motion/react';
import {
Dices,
Settings2,
Maximize2,
Download,
Rotate3d,
AlertCircle,
Play,
Eye,
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
}
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 DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
type DicomOpacityLevel = 'low' | 'medium' | 'high';
type ModelPoseKey = keyof ModelPose;
const displayOptions: Array<{ id: DisplayLevel; 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 dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
{ id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
];
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
translateX: { min: -2, max: 2, step: 0.05, minus: '-X', plus: '+X' },
translateY: { min: -2, max: 2, step: 0.05, minus: '-Y', plus: '+Y' },
translateZ: { min: -2, max: 2, step: 0.05, minus: '-Z', plus: '+Z' },
scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' },
};
const defaultModelPose: ModelPose = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
};
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function createDicomTexture(frame: string, width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
const binary = atob(frame);
const imageData = context.createImageData(width, height);
for (let index = 0; index < binary.length; index += 1) {
const value = binary.charCodeAt(index);
const offset = index * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = value > 4 ? 235 : 0;
}
context.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
function FusionThreeView({
project,
volume,
modelPose,
moduleStyles,
detailLimit,
solidMode,
dicomOpacity,
showBounds,
cutEnabled,
cutSlice,
}: {
project: Project;
volume: DicomFusionVolume | null;
modelPose: ModelPose;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
solidMode: boolean;
dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
showBounds: boolean;
cutEnabled: boolean;
cutSlice: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
const [loadProgress, setLoadProgress] = useState(0);
useEffect(() => {
modelPoseRef.current = modelPose;
}, [modelPose]);
useEffect(() => {
const container = containerRef.current;
if (!container || !volume) return;
container.innerHTML = '';
setStatus('正在构建三维融合场景...');
setLoadProgress(8);
let disposed = false;
let animationId = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#030712');
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000);
camera.position.set(0, -6.2, 4.6);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.localClippingEnabled = true;
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, 5);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
fillLight.position.set(-4, 3, 2);
scene.add(fillLight);
const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
fusionRoot.add(dicomGroup);
fusionRoot.add(modelPoseGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
const baseExtent = 4.6;
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight);
const box = new THREE.Mesh(
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }),
);
dicomGroup.add(box);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(box.geometry),
new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
);
edges.visible = showBounds;
dicomGroup.add(edges);
const cutZ = volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(cutSlice, 0, volume.total - 1)) / (volume.total - 1);
const clippingPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), cutZ);
const cutPlane = new THREE.Mesh(
new THREE.PlaneGeometry(dicomWidth, dicomHeight),
new THREE.MeshBasicMaterial({
color: '#f97316',
transparent: true,
opacity: cutEnabled ? 0.24 : 0,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
cutPlane.position.set(0, 0, cutZ);
cutPlane.visible = cutEnabled;
dicomGroup.add(cutPlane);
const textures: THREE.Texture[] = [];
volume.frames.forEach((frame, index) => {
const texture = createDicomTexture(frame, volume.width, volume.height);
if (!texture) return;
textures.push(texture);
const isLast = index === volume.frames.length - 1;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
});
const slicePlane = new THREE.Mesh(planeGeometry, material);
const z = volume.frames.length <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1);
slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z);
dicomGroup.add(slicePlane);
});
setLoadProgress(42);
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
let modelBaseScale = 1;
let loadedModels = 0;
let failedModels = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
.then((response) => {
if (!response.ok) throw new Error('模型预览加载失败');
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals();
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
const material = new THREE.MeshStandardMaterial({
color: style.color,
transparent: true,
opacity: materialOpacity,
roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [clippingPlane] : [],
clipShadows: true,
});
const mesh = new THREE.Mesh(geometry, material);
modelPivot.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),
});
}
loadedModels += 1;
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
})
))).then(() => {
if (disposed) return;
const modelBox = new THREE.Box3();
if (loadedBounds.length) {
loadedBounds.forEach((bounds) => {
modelBox.expandByPoint(bounds.min);
modelBox.expandByPoint(bounds.max);
});
} else {
modelBox.setFromObject(modelPivot);
}
const center = modelBox.getCenter(new THREE.Vector3());
const size = modelBox.getSize(new THREE.Vector3());
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
modelPivot.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();
}
});
const modelBounds = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }),
);
modelBounds.visible = showBounds;
modelPivot.add(modelBounds);
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
setLoadProgress(100);
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
});
const rootPose = {
rotateX: THREE.MathUtils.degToRad(58),
rotateY: 0,
rotateZ: THREE.MathUtils.degToRad(-18),
translateX: 0,
translateY: 0,
scale: 1,
};
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
pointerId: 0,
startX: 0,
startY: 0,
root: { ...rootPose },
};
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.root = { ...rootPose };
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') {
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
return;
}
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
};
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();
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.45, 2.2);
};
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);
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;
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
fusionRoot.scale.setScalar(rootPose.scale);
if (cutEnabled) {
fusionRoot.updateMatrixWorld(true);
const cutNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(fusionRoot.getWorldQuaternion(new THREE.Quaternion())).normalize();
const cutPoint = new THREE.Vector3(0, 0, cutZ).applyMatrix4(fusionRoot.matrixWorld);
clippingPlane.setFromNormalAndCoplanarPoint(cutNormal, cutPoint);
}
const pose = modelPoseRef.current;
modelPoseGroup.rotation.set(
THREE.MathUtils.degToRad(pose.rotateX),
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(
pose.translateX,
pose.translateY,
pose.translateZ,
);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
animate();
return () => {
disposed = true;
window.cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', stopPointerDrag);
container.removeEventListener('pointercancel', stopPointerDrag);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('contextmenu', preventContextMenu);
textures.forEach((texture) => texture.dispose());
scene.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();
}
}
});
renderer.dispose();
container.innerHTML = '';
};
}, [
project.id,
project.stlFiles?.join('|'),
volume,
JSON.stringify(moduleStyles),
detailLimit,
solidMode,
dicomOpacity.sliceOpacity,
dicomOpacity.volumeOpacity,
dicomOpacity.boxOpacity,
showBounds,
cutEnabled,
cutSlice,
]);
return (
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
<div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
{status}
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
</div>
{loadProgress < 100 && (
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
<span></span>
<span>{loadProgress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${loadProgress}%` }} />
</div>
</div>
)}
{!volume && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
DICOM ...
</div>
)}
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceEnd, setSliceEnd] = useState(49);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
const [showBounds, setShowBounds] = useState(true);
const [cutEnabled, setCutEnabled] = useState(false);
const [cutSlice, setCutSlice] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
{ id: 'default', name: '默认', pose: defaultModelPose },
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
]);
const [selectedPoseId, setSelectedPoseId] = useState('default');
const [isRegistering, setIsRegistering] = useState(false);
const [progress, setProgress] = useState(0);
const [project, setProject] = useState<Project | null>(null);
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
const [preloadMessage, setPreloadMessage] = useState('缓存空闲');
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const [mappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
{ className: '神经根', color: '#52c41a', maskId: 2 },
{ className: '血管', color: '#1890ff', maskId: 3 },
]);
const handleStartRegistration = () => {
setIsRegistering(true);
setProgress(0);
};
const handleExport = async (format: 'nii' | 'nii.gz') => {
setExporting(true);
setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`);
try {
await downloadMask(projectId, format);
setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`);
} catch (err) {
setExportMessage(err instanceof Error ? err.message : '导出失败');
} finally {
setExporting(false);
}
};
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
visible: fallback?.visible ?? true,
color: fallback?.color ?? moduleColors[index % moduleColors.length],
opacity: fallback?.opacity ?? 0.72,
partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
});
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
setModuleStyles(next);
if (!project) {
return;
}
api.updateProjectModuleStyles(project.id, next)
.then((updated) => {
setProject(updated);
})
.catch(() => {
setFusionError('构件样式保存失败,请稍后重试');
});
};
const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`;
const loadFusionVolume = async (end: number, useCache = true) => {
if (!project?.dicomCount) return null;
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
const safeEnd = clamp(end, 0, maxSliceValue);
const cacheKey = getFusionCacheKey(project.id, safeEnd);
const cached = fusionVolumeCacheRef.current.get(cacheKey);
if (useCache && cached) {
setFusionVolume(cached);
setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`);
return cached;
}
const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft');
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
return volumePayload;
};
useEffect(() => {
api.getProject(projectId).then((item) => {
setProject(item);
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
setSliceEnd(end);
setCutSlice(end);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
}).catch(() => {
setProject(null);
setFusionVolume(null);
});
}, [projectId]);
useEffect(() => {
if (!project?.dicomCount) return;
const maxSlice = Math.max(project.dicomCount - 1, 0);
const safeEnd = clamp(sliceEnd, 0, maxSlice);
const timer = window.setTimeout(() => {
setFusionError('');
loadFusionVolume(safeEnd)
.then(setFusionVolume)
.catch((error) => {
setFusionVolume(null);
setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败');
});
}, 180);
return () => window.clearTimeout(timer);
}, [project?.id, project?.dicomCount, sliceEnd]);
useEffect(() => () => {
if (poseRepeatRef.current.timeout !== null) {
window.clearTimeout(poseRepeatRef.current.timeout);
}
if (poseRepeatRef.current.interval !== null) {
window.clearInterval(poseRepeatRef.current.interval);
}
}, []);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
return () => clearTimeout(timer);
}
if (progress >= 100) {
setIsRegistering(false);
}
return undefined;
}, [isRegistering, progress]);
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
...partial,
}));
setSelectedPoseId('custom');
};
const clampPoseValue = (key: ModelPoseKey, value: number) => {
const limit = poseStepConfig[key];
return clamp(value, limit.min, limit.max);
};
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
setModelPose((current) => ({
...current,
[key]: clampPoseValue(key, current[key] + delta),
}));
setSelectedPoseId('custom');
};
const stopPoseRepeat = () => {
if (poseRepeatRef.current.timeout !== null) {
window.clearTimeout(poseRepeatRef.current.timeout);
poseRepeatRef.current.timeout = null;
}
if (poseRepeatRef.current.interval !== null) {
window.clearInterval(poseRepeatRef.current.interval);
poseRepeatRef.current.interval = null;
}
};
const startPoseRepeat = (key: ModelPoseKey, delta: number) => {
stopPoseRepeat();
poseRepeatRef.current.timeout = window.setTimeout(() => {
nudgeModelPose(key, delta);
poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, delta), 90);
}, 360);
};
const resetRotationPose = () => {
setModelPose((current) => ({
...current,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
}));
setSelectedPoseId('custom');
};
const resetTransformPose = () => {
setModelPose((current) => ({
...current,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
}));
setSelectedPoseId('custom');
};
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
const stlFiles = project?.stlFiles ?? [];
const index = Math.max(0, stlFiles.indexOf(fileName));
const next = {
...moduleStyles,
[fileName]: makeDefaultModuleStyle(index, {
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
...partial,
}),
};
commitModuleStyles(next);
};
const updateModulePartId = (fileName: string, value: number) => {
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
};
const saveCurrentPose = () => {
const nextPose = {
id: `pose-${Date.now()}`,
name: `位姿${savedPoses.length - 2}`,
pose: { ...modelPose },
};
setSavedPoses((current) => [...current, nextPose]);
setSelectedPoseId(nextPose.id);
};
const renamePose = (poseId: string, name: string) => {
if (poseId === 'default') return;
const nextName = name.trim();
setSavedPoses((current) => current.map((item) => (
item.id === poseId ? { ...item, name: nextName || item.name } : item
)));
};
const selectPose = (poseId: string) => {
const selected = savedPoses.find((item) => item.id === poseId);
if (!selected) return;
setSelectedPoseId(poseId);
setModelPose(selected.pose);
};
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const displayStart = 0;
const displayEnd = clamp(sliceEnd, 0, maxSlice);
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice));
const preloadFusionPoint = async (end: number) => {
if (!project) return;
const safeEnd = clamp(end, 0, maxSlice);
setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`);
try {
await loadFusionVolume(safeEnd, false);
setPreloadMessage(`已预存第 ${safeEnd + 1}`);
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '预存失败');
}
};
const preloadAllFusionPoints = async () => {
setPreloadMessage('正在预存五个点位...');
try {
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true)));
setPreloadMessage('五个点位已预存');
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '五点预存失败');
}
};
return (
<div className="h-full flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
{project && (
<div className="flex flex-wrap gap-3 text-sm font-bold">
<span className="rounded-xl bg-blue-50 px-4 py-2 text-blue-700">{project.name}</span>
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">DICOM {project.dicomCount}</span>
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">STL {project.modelCount ?? 0}</span>
</div>
)}
{!project && <p className="text-sm text-slate-500"> DICOM </p>}
</div>
<div className="flex gap-2">
<button
onClick={handleStartRegistration}
disabled={isRegistering}
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
{isRegistering ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : <Dices size={18} />}
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
>
<Download size={18} />
{exporting ? '正在导出' : '导出 NII.GZ'}
</button>
</div>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
<div className="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
</span>
</div>
{project ? (
<FusionThreeView
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
solidMode={displayLevel === 'solid'}
dicomOpacity={selectedDicomOpacity}
showBounds={showBounds}
cutEnabled={cutEnabled}
cutSlice={cutSlice}
/>
) : (
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
...
</div>
)}
{fusionError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 flex items-center gap-2">
<AlertCircle size={16} />
{fusionError}
</div>
)}
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">DICOM </p>
<span className="text-[10px] font-mono text-blue-600">
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
</span>
</div>
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={displayEnd}
onChange={(event) => setSliceEnd(Number(event.target.value))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{displayEnd + 1} </span>
</label>
<p className="mt-3 text-[10px] leading-5 text-slate-400">
1 使
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{preloadPoints.map((point, index) => {
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false;
return (
<button
key={`${point}-${index}`}
onClick={() => {
setSliceEnd(point);
preloadFusionPoint(point);
}}
className={`rounded-lg border px-2 py-1 text-[10px] font-bold transition-all ${
cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
}`}
>
{index + 1} · {point + 1}
</button>
);
})}
<button
onClick={preloadAllFusionPoints}
className="rounded-lg bg-blue-600 px-2 py-1 text-[10px] font-bold text-white hover:bg-blue-700"
>
</button>
<span className="text-[10px] font-bold text-slate-400">{preloadMessage}</span>
</div>
</div>
</div>
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
<div className="px-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Settings2 size={18} className="text-emerald-500" />
</h3>
</div>
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
<div className="flex-1 overflow-auto space-y-4 pr-1">
<div>
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
{displayOptions.map((option) => (
<button
key={option.id}
onClick={() => setDisplayLevel(option.id)}
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{dicomOpacityOptions.map((option) => (
<button
key={option.id}
onClick={() => setDicomOpacityLevel(option.id)}
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
dicomOpacityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
DICOM {option.label}
</button>
))}
</div>
<label className="mt-2 flex items-center justify-between rounded-lg bg-slate-50 px-2 py-1.5 text-[10px] font-bold text-slate-500">
DICOM/
<input
type="checkbox"
checked={showBounds}
onChange={(event) => setShowBounds(event.target.checked)}
className="accent-blue-600"
/>
</label>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<label className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
<input
type="checkbox"
checked={cutEnabled}
onChange={(event) => setCutEnabled(event.target.checked)}
className="accent-orange-500"
/>
</label>
</div>
<label className="grid grid-cols-[42px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={clamp(cutSlice, 0, maxSlice)}
onChange={(event) => setCutSlice(Number(event.target.value))}
className="accent-orange-500"
/>
<span className="text-right font-mono">{clamp(cutSlice, 0, maxSlice) + 1}</span>
</label>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">姿</p>
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
<Save size={12} />
</button>
</div>
<select
value={selectedPoseId}
onChange={(event) => selectPose(event.target.value)}
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
>
{selectedPoseId === 'custom' && <option value="custom">姿</option>}
{savedPoses.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
{selectedPoseId !== 'default' && selectedPoseId !== 'custom' && (
<input
value={savedPoses.find((item) => item.id === selectedPoseId)?.name ?? ''}
onChange={(event) => renamePose(selectedPoseId, event.target.value)}
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
placeholder="位姿名称"
/>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={resetRotationPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
<button
onClick={resetTransformPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
</div>
<div className="mt-3 space-y-2">
{[
{ key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX },
{ key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY },
{ key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ },
{ key: 'translateX' as const, label: '平移 X', value: modelPose.translateX },
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
].map((item) => (
<div key={item.key} className="grid grid-cols-[44px_32px_1fr_32px_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
>
-
</button>
<input
type="range"
min={poseStepConfig[item.key].min}
max={poseStepConfig[item.key].max}
step={poseStepConfig[item.key].step}
value={item.value}
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
className="accent-blue-600"
/>
<button
onMouseDown={() => startPoseRepeat(item.key, poseStepConfig[item.key].step)}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
>
+
</button>
<span className="text-right font-mono">{Number(item.value).toFixed(poseStepConfig[item.key].step < 1 ? 2 : 0)}</span>
{poseStepConfig[item.key].quick && (
<div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
<button
onClick={() => nudgeModelPose(item.key, -(poseStepConfig[item.key].quick ?? 0))}
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
>
-90°
</button>
<button
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].quick ?? 0)}
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
>
+90°
</button>
</div>
)}
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
</div>
<div className="space-y-2">
{(project?.stlFiles ?? []).map((fileName, index) => {
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
return (
<div key={fileName} className={`rounded-xl bg-slate-50 p-2 ${!style.visible ? 'opacity-50' : ''}`}>
<div className="mb-2 flex items-center gap-2">
<input
type="color"
value={style.color}
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5"
title="模型颜色"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-bold text-slate-700">{fileName.replace(/\.stl$/i, '')}</p>
<label className="mt-1 flex 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))}
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 font-mono text-slate-600"
/>
</label>
</div>
<button
onClick={() => updateModuleStyle(fileName, { visible: !style.visible })}
className={`rounded p-1 ${style.visible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white`}
title={style.visible ? '隐藏构件' : '显示构件'}
>
<Eye size={14} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] text-slate-400"></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="w-7 text-right text-[9px] text-slate-400">{Math.round(style.opacity * 100)}%</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Play size={18} className="text-blue-500" />
Mask
</h3>
<div className="flex gap-2">
<button
onClick={() => handleExport('nii')}
disabled={exporting}
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
<Download size={12} />
NII
</button>
<button
onClick={() => handleExport('nii.gz')}
disabled={exporting}
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
<Download size={12} />
NII.GZ
</button>
</div>
</div>
<div className="flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center">
<div className="relative w-64 h-64">
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
{mappings.map((mapping, index) => (
<motion.div
key={mapping.maskId}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 0.8 }}
transition={{ delay: index * 0.2 }}
className="absolute inset-0 border-2"
style={{
borderColor: mapping.color,
borderRadius: index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
background: `${mapping.color}20`,
boxShadow: `inset 0 0 20px ${mapping.color}40`,
transform: `rotate(${index * 45 + displayStart}deg) scale(${1 - index * 0.1})`,
}}
/>
))}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
</div>
</div>
<div className="absolute top-4 left-4 z-20 flex gap-2">
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
</div>
<div className="absolute bottom-4 right-4">
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
<Maximize2 size={16} />
</button>
</div>
</div>
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700">{exportMessage} {mappings.length} </span>
</div>
<div className="w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-full" />
</div>
</div>
</div>
</div>
</div>
);
}