2026-05-07-16-20-46 修正DICOM比例和3D默认位姿

This commit is contained in:
2026-05-07 16:26:57 +08:00
parent 1cc750b7e4
commit aa0d51316e
11 changed files with 1012 additions and 87 deletions

View File

@@ -8,6 +8,7 @@ import {
RotateCcw,
Box,
Image as ImageIcon,
Info,
ChevronRight,
ChevronUp,
ChevronDown,
@@ -20,12 +21,12 @@ import {
Upload
} from 'lucide-react';
import * as THREE from 'three';
import { DicomPreview, Project } from '../types';
import { DicomInfo, DicomPreview, Project } from '../types';
import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'preview' | 'standard' | 'fine';
type SolidityLevel = 'preview' | 'standard' | 'fine' | 'ultra';
interface ModuleStyle {
visible: boolean;
@@ -41,7 +42,6 @@ interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
autoRotate: boolean;
}
interface ModelPreviewPayload {
@@ -56,6 +56,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }
{ id: 'preview', label: '预览', limit: 6000 },
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
{ id: 'ultra', label: '超精细', limit: 72000 },
];
const defaultModelPose: ModelPose = {
rotateX: 0,
@@ -65,7 +66,6 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
autoRotate: true,
};
function drawFallbackModelPreview(
@@ -181,6 +181,13 @@ 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 DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -205,18 +212,19 @@ function NativeStlViewer({
files,
styles,
detailLimit,
solidWhite,
pose,
onPoseChange,
}: {
projectId: string;
files: string[];
styles: Record<string, ModuleStyle>;
detailLimit: number;
solidWhite: boolean;
pose: ModelPose;
onPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const poseRef = useRef<ModelPose>(pose);
const onPoseChangeRef = useRef(onPoseChange);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('准备加载模型');
@@ -224,6 +232,92 @@ function NativeStlViewer({
poseRef.current = pose;
}, [pose]);
useEffect(() => {
onPoseChangeRef.current = onPoseChange;
}, [onPoseChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const clampPose = (next: ModelPose): ModelPose => ({
rotateX: Math.max(-180, Math.min(180, next.rotateX)),
rotateY: Math.max(-180, Math.min(180, next.rotateY)),
rotateZ: Math.max(-180, Math.min(180, next.rotateZ)),
translateX: Math.max(-2, Math.min(2, next.translateX)),
translateY: Math.max(-2, Math.min(2, next.translateY)),
translateZ: Math.max(-2, Math.min(2, next.translateZ)),
scale: Math.max(0.5, Math.min(2.5, next.scale)),
});
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(clampPose({
...dragState.startPose,
translateX: dragState.startPose.translateX + deltaX * 0.006,
translateY: dragState.startPose.translateY - deltaY * 0.006,
}));
return;
}
onPoseChangeRef.current(clampPose({
...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(clampPose({
...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;
@@ -242,7 +336,8 @@ function NativeStlViewer({
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.position.set(4.5, 3.5, 5);
camera.up.set(0, 1, 0);
camera.position.set(0, 0, 6);
camera.lookAt(0, 0, 0);
let renderer: THREE.WebGLRenderer | null = null;
try {
@@ -263,10 +358,7 @@ function NativeStlViewer({
})
.then((payload) => ({
payload,
style: {
...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }),
color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6',
},
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
})),
),
).then((results) => {
@@ -308,7 +400,6 @@ function NativeStlViewer({
const group = new THREE.Group();
let baseScale = 1;
let autoSpin = 0;
scene.add(group);
let loaded = 0;
let failed = 0;
@@ -330,11 +421,11 @@ function NativeStlViewer({
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color: solidWhite ? '#f4f4f2' : style.color,
color: style.color,
opacity: style.opacity,
transparent: style.opacity < 1,
roughness: solidWhite ? 0.34 : 0.48,
metalness: solidWhite ? 0.02 : 0.08,
roughness: 0.42,
metalness: 0.04,
side: THREE.DoubleSide,
}),
);
@@ -381,12 +472,9 @@ function NativeStlViewer({
const animate = () => {
if (disposed) return;
const currentPose = poseRef.current;
if (currentPose.autoRotate) {
autoSpin += 0.004;
}
group.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin,
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
@@ -414,10 +502,10 @@ function NativeStlViewer({
});
container.innerHTML = '';
};
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]);
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit]);
return (
<div className="h-full w-full relative">
<div className="h-full w-full relative cursor-grab active:cursor-grabbing">
<div ref={containerRef} className="absolute inset-0" />
{progress < 100 && (
<div className="absolute inset-x-8 top-8 z-10 rounded-xl bg-white/90 p-4 shadow-sm border border-slate-100">
@@ -452,10 +540,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [rotation, setRotation] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [solidWhite, setSolidWhite] = useState(true);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomInfo, setDicomInfo] = useState<DicomInfo | null>(null);
const [dicomInfoError, setDicomInfoError] = useState('');
const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false);
const [dicomError, setDicomError] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@@ -619,7 +709,6 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
autoRotate: partial.autoRotate ?? false,
...partial,
}));
};
@@ -651,6 +740,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
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) {
@@ -992,6 +1093,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
>
<FileArchive size={12} /> DCM
</button>
<button
onClick={openDicomInfo}
className="h-8 rounded-lg bg-white text-slate-600 text-[10px] font-bold flex items-center justify-center gap-1 border border-slate-200 hover:bg-slate-100"
title="查询 DICOM 详细信息"
>
<Info size={12} />
</button>
</div>
</div>
</div>
@@ -1006,8 +1114,8 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
files={stlFiles}
styles={moduleStyles}
detailLimit={selectedSolidity.limit}
solidWhite={solidWhite}
pose={modelPose}
onPoseChange={setModelPose}
/>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
@@ -1019,14 +1127,9 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
<span className="text-[10px] text-slate-400"> · /Shift · </span>
</div>
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1 mb-3">
<div className="grid grid-cols-4 gap-1 rounded-xl bg-slate-100 p-1">
{solidityOptions.map((option) => (
<button
key={option.id}
@@ -1039,28 +1142,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setSolidWhite((current) => !current)}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
solidWhite ? 'bg-white text-slate-800 border-slate-200 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
<button
onClick={() => updateModelPose({ autoRotate: !modelPose.autoRotate })}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
modelPose.autoRotate ? 'bg-blue-600 text-white border-blue-600 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
</div>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<p className="text-xs font-bold text-slate-700">姿</p>
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-slate-700">姿</p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
</div>
{[
{ key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
@@ -1068,7 +1161,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
{ key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
{ key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
@@ -1246,6 +1339,105 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
)}
{isDicomInfoOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-3xl max-h-[82vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-slate-100 flex flex-col">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h3 className="font-bold text-slate-900">DICOM </h3>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
<button
onClick={() => setIsDicomInfoOpen(false)}
className="text-slate-400 hover:text-slate-700"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="overflow-y-auto p-6">
{dicomInfoError && <p className="text-sm font-bold text-rose-600">{dicomInfoError}</p>}
{!dicomInfo && !dicomInfoError && <p className="text-sm text-slate-400"> DICOM ...</p>}
{dicomInfo && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
title: '患者与检查',
rows: [
['患者姓名', dicomInfo.patient.name],
['患者 ID', dicomInfo.patient.id],
['检查日期', dicomInfo.study.date],
['检查类型', dicomInfo.study.modality],
['设备厂商', dicomInfo.study.manufacturer],
],
},
{
title: '序列与文件',
rows: [
['序列描述', dicomInfo.series.description],
['文件数量', dicomInfo.series.files],
['首文件', dicomInfo.series.firstFile],
['末文件', dicomInfo.series.lastFile],
['DICOM 路径', dicomInfo.project.dicomPath],
],
},
{
title: '图像矩阵与窗宽窗位',
rows: [
['Rows', dicomInfo.image.rows],
['Columns', dicomInfo.image.columns],
['Bits Allocated', dicomInfo.image.bitsAllocated],
['Window Center', dicomInfo.image.windowCenter],
['Window Width', dicomInfo.image.windowWidth],
['Rescale', `${dicomInfo.image.rescaleSlope} / ${dicomInfo.image.rescaleIntercept}`],
],
},
{
title: '空间距离',
rows: [
['像素行间距', `${displayDicomValue(dicomInfo.spacing.row)} mm`],
['像素列间距', `${displayDicomValue(dicomInfo.spacing.column)} mm`],
['切片间距', `${displayDicomValue(dicomInfo.spacing.slice)} mm`],
['间距来源', dicomInfo.spacing.sliceSource],
['切片厚度', `${displayDicomValue(dicomInfo.spacing.sliceThickness)} mm`],
['Spacing Between Slices', `${displayDicomValue(dicomInfo.spacing.spacingBetweenSlices)} mm`],
],
},
{
title: '物理尺寸',
rows: [
['宽度', `${displayDicomValue(dicomInfo.physicalSize.width)} ${dicomInfo.physicalSize.unit}`],
['高度', `${displayDicomValue(dicomInfo.physicalSize.height)} ${dicomInfo.physicalSize.unit}`],
['深度', `${displayDicomValue(dicomInfo.physicalSize.depth)} ${dicomInfo.physicalSize.unit}`],
],
},
{
title: '空间位置',
rows: [
['首张位置', dicomInfo.position.firstImagePosition?.join(', ') ?? '未知'],
['末张位置', dicomInfo.position.lastImagePosition?.join(', ') ?? '未知'],
],
},
].map((section) => (
<div key={section.title} className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<h4 className="text-xs font-bold text-slate-800 mb-3">{section.title}</h4>
<div className="space-y-2">
{section.rows.map(([label, value]) => (
<div key={label} className="flex items-start justify-between gap-4 text-xs">
<span className="text-slate-400 shrink-0">{label}</span>
<span className="text-slate-700 font-semibold text-right break-all">{displayDicomValue(value)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{projectToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl border border-slate-100">