2026-05-04-04-58-36 优化DICOM缓存和三维融合预览
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
@@ -15,14 +15,12 @@ import {
|
||||
Trash2,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import { Canvas, useLoader } from '@react-three/fiber';
|
||||
import { Bounds, Center, OrbitControls, Stage } from '@react-three/drei';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
import * as THREE from 'three';
|
||||
import { DicomPreview, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
type Plane = 'axial' | 'sagittal' | 'coronal';
|
||||
type DisplayMode = DicomPreview['mode'];
|
||||
|
||||
interface ModuleStyle {
|
||||
visible: boolean;
|
||||
@@ -30,23 +28,76 @@ interface ModuleStyle {
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface ModelPreviewPayload {
|
||||
fileName: string;
|
||||
triangleCount: number;
|
||||
sampledTriangles: number;
|
||||
vertices: number[];
|
||||
}
|
||||
|
||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
|
||||
function StlModel({ url, color, opacity }: { url: string; color: string; opacity: number }) {
|
||||
const geometry = useLoader(STLLoader, url);
|
||||
function drawFallbackModelPreview(
|
||||
canvas: HTMLCanvasElement,
|
||||
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
|
||||
) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(Math.floor(rect.width), 1);
|
||||
const height = Math.max(Math.floor(rect.height), 1);
|
||||
canvas.width = width * window.devicePixelRatio;
|
||||
canvas.height = height * window.devicePixelRatio;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry as THREE.BufferGeometry}>
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
opacity={opacity}
|
||||
transparent={opacity < 1}
|
||||
roughness={0.48}
|
||||
metalness={0.08}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
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 DicomCanvas({ preview }: { preview: DicomPreview }) {
|
||||
@@ -85,6 +136,210 @@ function DicomCanvas({ preview }: { preview: DicomPreview }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NativeStlViewer({
|
||||
projectId,
|
||||
files,
|
||||
styles,
|
||||
}: {
|
||||
projectId: string;
|
||||
files: string[];
|
||||
styles: Record<string, ModuleStyle>;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [status, setStatus] = useState('准备加载模型');
|
||||
|
||||
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);
|
||||
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) =>
|
||||
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=3500`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('模型预览数据加载失败');
|
||||
return response.json() as Promise<ModelPreviewPayload>;
|
||||
})
|
||||
.then((payload) => ({
|
||||
payload,
|
||||
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
|
||||
})),
|
||||
),
|
||||
).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 group = new THREE.Group();
|
||||
scene.add(group);
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
visibleFiles.forEach((fileName) => {
|
||||
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=6000`)
|
||||
.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 = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true };
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
opacity: style.opacity,
|
||||
transparent: style.opacity < 1,
|
||||
roughness: 0.48,
|
||||
metalness: 0.08,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
group.add(mesh);
|
||||
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().setFromObject(group);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
group.position.sub(center);
|
||||
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||
group.scale.setScalar(4 / maxSize);
|
||||
camera.position.set(4.5, 3.5, 5);
|
||||
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;
|
||||
group.rotation.y += 0.004;
|
||||
renderer.render(scene, camera);
|
||||
animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.cancelAnimationFrame(animationId);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.dispose();
|
||||
group.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)]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
{progress < 100 && (
|
||||
<div className="absolute inset-x-8 top-8 z-10 rounded-xl bg-white/90 p-4 shadow-sm border border-slate-100">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-600 mb-2">
|
||||
<span>{status}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div className="h-full bg-blue-600 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{progress >= 100 && (
|
||||
<div className="absolute left-4 top-4 rounded-lg bg-white/80 px-3 py-1.5 text-[10px] font-bold text-slate-500 shadow-sm">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
@@ -94,6 +349,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [sliceIndex, setSliceIndex] = useState(0);
|
||||
const [plane, setPlane] = useState<Plane>('axial');
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [dicomError, setDicomError] = useState('');
|
||||
@@ -137,6 +393,12 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
{ 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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,7 +422,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
|
||||
let cancelled = false;
|
||||
setDicomError('');
|
||||
api.getDicomPreview(selectedProject.id, sliceIndex, plane)
|
||||
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setDicomPreview(preview);
|
||||
@@ -176,7 +438,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, viewMode]);
|
||||
}, [selectedProject?.id, selectedProject?.dicomCount, sliceIndex, plane, displayMode, viewMode]);
|
||||
|
||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||
setModuleStyles(prev => ({
|
||||
@@ -268,11 +530,11 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<div
|
||||
className={`${
|
||||
isSidebarCollapsed ? 'w-12' : 'w-72'
|
||||
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-hidden shrink-0`}
|
||||
} flex flex-col bg-white rounded-2xl border border-slate-100 shadow-sm transition-all duration-300 relative overflow-visible shrink-0`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className="absolute right-1 top-4 z-10 p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 transition-colors"
|
||||
className="absolute -right-3 top-1/2 z-10 h-7 w-7 -translate-y-1/2 bg-white border border-slate-100 shadow-md hover:bg-slate-50 rounded-full text-slate-400 transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isSidebarCollapsed ? <ChevronRight size={18} /> : <ChevronRight className="rotate-180" size={18} />}
|
||||
</button>
|
||||
@@ -427,7 +689,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
setPlane(option.id);
|
||||
setSliceIndex(0);
|
||||
setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
||||
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
|
||||
@@ -437,6 +699,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-16 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
|
||||
{displayModes.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setDisplayMode(mode.id)}
|
||||
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
||||
displayMode === mode.id ? 'bg-emerald-600 text-white' : 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
|
||||
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
|
||||
<p>SCAN DATE: {selectedProject.createTime}</p>
|
||||
@@ -450,7 +725,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40}</span>
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||
<span>第 {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,43 +753,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
||||
<div className="h-full flex gap-8">
|
||||
{/* Left: 3D Visualization */}
|
||||
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
|
||||
<Canvas>
|
||||
<color attach="background" args={['#f8fafc']} />
|
||||
<ambientLight intensity={0.65} />
|
||||
<directionalLight position={[3, 6, 5]} intensity={1.1} />
|
||||
<Suspense fallback={null}>
|
||||
{stlFiles.some((fileName) => moduleStyles[fileName]?.visible !== false) ? (
|
||||
<Bounds fit clip observe margin={1.25}>
|
||||
<Center>
|
||||
<group>
|
||||
{stlFiles.map((fileName) => {
|
||||
const style = moduleStyles[fileName] ?? { visible: true, color: '#3b82f6', opacity: 0.72 };
|
||||
if (!style.visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<StlModel
|
||||
key={fileName}
|
||||
url={`/api/projects/${selectedProject.id}/models/${encodeURIComponent(fileName)}`}
|
||||
color={style.color}
|
||||
opacity={style.opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
</Center>
|
||||
</Bounds>
|
||||
) : (
|
||||
<Stage environment="city" intensity={0.5}>
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#94a3b8" />
|
||||
</mesh>
|
||||
</Stage>
|
||||
)}
|
||||
</Suspense>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
<NativeStlViewer projectId={selectedProject.id} files={stlFiles} styles={moduleStyles} />
|
||||
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
|
||||
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user