2026-05-08-03-35-22 展示STL切割实体切面

This commit is contained in:
2026-05-08 03:40:07 +08:00
parent 500a43dbe9
commit 5ed2c02809
5 changed files with 463 additions and 60 deletions

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import {
Dices,
Settings2,
Maximize2,
Download,
Rotate3d,
AlertCircle,
@@ -12,7 +10,7 @@ import {
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
@@ -495,6 +493,305 @@ function FusionThreeView({
);
}
function CutSectionPreview({
project,
volume,
modelPose,
moduleStyles,
detailLimit,
cutEnabled,
cutStart,
cutEnd,
}: {
project: Project | null;
volume: DicomFusionVolume | null;
modelPose: ModelPose;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
cutEnabled: boolean;
cutStart: number;
cutEnd: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
useEffect(() => {
modelPoseRef.current = modelPose;
}, [modelPose]);
useEffect(() => {
const container = containerRef.current;
if (!container || !project || !volume) return;
container.innerHTML = '';
let disposed = false;
let animationId = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#020617');
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000);
camera.position.set(0, -5.6, 3.4);
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.78));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
keyLight.position.set(3, -4, 5);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72);
rimLight.position.set(-4, 3, 2);
scene.add(rimLight);
const fusionRoot = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
fusionRoot.add(modelPoseGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
const baseExtent = 4.4;
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 sliceToZ = (sliceIndex: number) => (
volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
);
const cutRangeStart = Math.min(
clamp(cutStart, 0, volume.total - 1),
clamp(cutEnd, 0, volume.total - 1),
);
const cutRangeEnd = Math.max(
clamp(cutStart, 0, volume.total - 1),
clamp(cutEnd, 0, volume.total - 1),
);
const lowerCutZ = sliceToZ(cutRangeStart);
const upperCutZ = sliceToZ(cutRangeEnd);
const lowerClippingPlane = new THREE.Plane();
const upperClippingPlane = new THREE.Plane();
let modelBaseScale = 1;
let loadedModels = 0;
let failedModels = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
.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: 1,
partId: index + 1,
};
const material = new THREE.MeshStandardMaterial({
color: style.color,
roughness: 0.5,
metalness: 0.04,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
clipIntersection: false,
clipShadows: true,
});
modelPivot.add(new THREE.Mesh(geometry, material));
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;
})
.catch(() => {
failedModels += 1;
})
))).then(() => {
if (disposed || (loadedModels + failedModels === 0)) 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();
}
});
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
});
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.55, 2.4);
};
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
const handleResize = () => {
if (!container.clientWidth || !container.clientHeight) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
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);
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 rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
}
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);
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,
cutEnabled,
cutStart,
cutEnd,
]);
return (
<div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
{(!project || !volume) && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
STL ...
</div>
)}
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
@@ -516,16 +813,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = 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);
@@ -533,12 +823,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
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 : '导出失败');
} catch (error) {
setFusionError(error instanceof Error ? error.message : '导出失败');
} finally {
setExporting(false);
}
@@ -1151,52 +1439,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</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>
<CutSectionPreview
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
/>
</div>
</div>
</div>