2026-05-04-04-58-36 优化DICOM缓存和三维融合预览

This commit is contained in:
2026-05-04 05:15:59 +08:00
parent 4aad0f815d
commit 4ef3be69f4
9 changed files with 837 additions and 118 deletions

View File

@@ -7,6 +7,8 @@ import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
type ProjectStatus = 'pending' | 'completed' | 'processing';
type DicomPlane = 'axial' | 'sagittal' | 'coronal';
type DicomDisplayMode = 'default' | 'bone' | 'soft' | 'contrast';
interface UserRecord {
id: number;
@@ -54,6 +56,15 @@ const exportDir = path.join(__dirname, 'exports');
const statePath = path.join(dataDir, 'state.json');
const dicomDir = path.join(repoRoot, 'Head_CT_DICOM');
const modelDir = path.join(repoRoot, 'Head_CT_ReConstruct');
const dicomPreviewCache = new Map<string, unknown>();
const dicomVolumeCache = new Map<DicomDisplayMode, {
frames: Buffer[];
width: number;
height: number;
windowCenter: number;
windowWidth: number;
}>();
const modelPreviewCache = new Map<string, unknown>();
function today() {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date());
@@ -302,7 +313,20 @@ function findExplicitTag(buffer: Buffer, group: number, element: number) {
return null;
}
function parseDicomPreview(filePath: string) {
function resolveDisplayWindow(mode: DicomDisplayMode, fallbackCenter: number, fallbackWidth: number) {
if (mode === 'bone') {
return { windowCenter: 500, windowWidth: 2000 };
}
if (mode === 'soft') {
return { windowCenter: 40, windowWidth: 400 };
}
if (mode === 'contrast') {
return { windowCenter: 80, windowWidth: 180 };
}
return { windowCenter: fallbackCenter, windowWidth: fallbackWidth };
}
function parseDicomPreview(filePath: string, mode: DicomDisplayMode = 'default') {
const buffer = fs.readFileSync(filePath);
const rowsTag = findExplicitTag(buffer, 0x0028, 0x0010);
const columnsTag = findExplicitTag(buffer, 0x0028, 0x0011);
@@ -318,8 +342,9 @@ function parseDicomPreview(filePath: string) {
const columns = columnsTag ? buffer.readUInt16LE(columnsTag.valueOffset) : 0;
const bitsAllocated = bitsTag ? buffer.readUInt16LE(bitsTag.valueOffset) : 16;
const pixelRepresentation = representationTag ? buffer.readUInt16LE(representationTag.valueOffset) : 0;
const windowCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
const windowWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
const fallbackCenter = centerTag ? Number.parseFloat(readAsciiValue(buffer, centerTag.valueOffset, centerTag.length).split('\\')[0]) || 40 : 40;
const fallbackWidth = widthTag ? Number.parseFloat(readAsciiValue(buffer, widthTag.valueOffset, widthTag.length).split('\\')[0]) || 400 : 400;
const { windowCenter, windowWidth } = resolveDisplayWindow(mode, fallbackCenter, fallbackWidth);
const rescaleIntercept = interceptTag ? Number.parseFloat(readAsciiValue(buffer, interceptTag.valueOffset, interceptTag.length)) || 0 : 0;
const rescaleSlope = slopeTag ? Number.parseFloat(readAsciiValue(buffer, slopeTag.valueOffset, slopeTag.length)) || 1 : 1;
const pixelOffset = pixelTag?.valueOffset ?? -1;
@@ -343,7 +368,10 @@ function parseDicomPreview(filePath: string) {
? (pixelRepresentation ? buffer.readInt16LE(position) : buffer.readUInt16LE(position))
: buffer.readUInt8(position);
const hu = raw * rescaleSlope + rescaleIntercept;
const normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
let normalized = Math.max(0, Math.min(255, Math.round(((hu - min) / (max - min)) * 255)));
if (mode === 'contrast') {
normalized = Math.max(0, Math.min(255, Math.round((normalized - 128) * 1.35 + 128)));
}
pixels[i] = normalized;
}
@@ -353,34 +381,62 @@ function parseDicomPreview(filePath: string) {
pixels: pixels.toString('base64'),
windowCenter,
windowWidth,
mode,
};
}
function parseDicomPixels(filePath: string) {
const preview = parseDicomPreview(filePath);
function parseDicomPixels(filePath: string, mode: DicomDisplayMode = 'default') {
const preview = parseDicomPreview(filePath, mode);
return {
...preview,
pixelBuffer: Buffer.from(preview.pixels, 'base64'),
};
}
function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal', slice: number) {
const first = parseDicomPixels(path.join(dicomDir, files[0]));
const maxSlice = plane === 'sagittal' ? first.width - 1 : first.height - 1;
function getDicomVolume(files: string[], mode: DicomDisplayMode) {
const cached = dicomVolumeCache.get(mode);
if (cached) {
return cached;
}
const parsed = files.map((fileName) => parseDicomPixels(path.join(dicomDir, fileName), mode));
const volume = {
frames: parsed.map((frame) => frame.pixelBuffer),
width: parsed[0]?.width ?? 0,
height: parsed[0]?.height ?? 0,
windowCenter: parsed[0]?.windowCenter ?? 40,
windowWidth: parsed[0]?.windowWidth ?? 400,
};
dicomVolumeCache.set(mode, volume);
return volume;
}
function warmDicomVolumeCache(files: string[]) {
setTimeout(() => {
try {
getDicomVolume(files, 'default');
getDicomVolume(files, 'soft');
} catch (error) {
console.warn('DICOM volume warmup failed:', error);
}
}, 300);
}
function createReformattedPreview(files: string[], plane: Exclude<DicomPlane, 'axial'>, slice: number, mode: DicomDisplayMode) {
const volume = getDicomVolume(files, mode);
const maxSlice = plane === 'sagittal' ? volume.width - 1 : volume.height - 1;
const clampedSlice = Math.max(0, Math.min(maxSlice, slice));
const outputWidth = files.length;
const outputHeight = plane === 'sagittal' ? first.height : first.width;
const outputHeight = plane === 'sagittal' ? volume.height : volume.width;
const pixels = Buffer.alloc(outputWidth * outputHeight);
files.forEach((fileName, z) => {
const frame = parseDicomPixels(path.join(dicomDir, fileName));
volume.frames.forEach((frame, z) => {
for (let row = 0; row < outputHeight; row += 1) {
const sourceIndex = plane === 'sagittal'
? row * frame.width + clampedSlice
: clampedSlice * frame.width + row;
? row * volume.width + clampedSlice
: clampedSlice * volume.width + row;
const targetIndex = row * outputWidth + z;
pixels[targetIndex] = frame.pixelBuffer[sourceIndex] ?? 0;
pixels[targetIndex] = frame[sourceIndex] ?? 0;
}
});
@@ -388,14 +444,65 @@ function createReformattedPreview(files: string[], plane: 'sagittal' | 'coronal'
width: outputWidth,
height: outputHeight,
pixels: pixels.toString('base64'),
windowCenter: first.windowCenter,
windowWidth: first.windowWidth,
windowCenter: volume.windowCenter,
windowWidth: volume.windowWidth,
slice: clampedSlice,
total: maxSlice + 1,
fileName: `${plane}-${clampedSlice}`,
mode,
};
}
function createStlPreview(filePath: string, fileName: string, limit: number) {
const cacheKey = `${fileName}:${limit}`;
const cached = modelPreviewCache.get(cacheKey);
if (cached) {
return cached;
}
const buffer = fs.readFileSync(filePath);
if (buffer.length < 84) {
throw new Error('STL 文件内容为空或不完整');
}
const triangleCount = buffer.readUInt32LE(80);
const expectedLength = 84 + triangleCount * 50;
if (triangleCount <= 0 || expectedLength > buffer.length + 1024) {
throw new Error('当前仅支持二进制 STL 预览');
}
const sampleLimit = Math.max(100, Math.min(limit, 12000));
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = [];
let sampledTriangles = 0;
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += step) {
const offset = 84 + triangleIndex * 50;
if (offset + 50 > buffer.length) {
break;
}
for (let vertex = 0; vertex < 3; vertex += 1) {
const vertexOffset = offset + 12 + vertex * 12;
vertices.push(
Number(buffer.readFloatLE(vertexOffset).toFixed(3)),
Number(buffer.readFloatLE(vertexOffset + 4).toFixed(3)),
Number(buffer.readFloatLE(vertexOffset + 8).toFixed(3)),
);
}
sampledTriangles += 1;
}
const payload = {
fileName,
triangleCount,
sampledTriangles,
vertices,
};
modelPreviewCache.set(cacheKey, payload);
return payload;
}
async function startServer() {
const app = express();
const host = process.argv.includes('--host') ? process.argv[process.argv.indexOf('--host') + 1] : '0.0.0.0';
@@ -512,26 +619,37 @@ async function startServer() {
}
const requestedPlane = String(req.query.plane ?? 'axial');
const plane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial';
const plane: DicomPlane = requestedPlane === 'sagittal' || requestedPlane === 'coronal' ? requestedPlane : 'axial';
const requestedMode = String(req.query.mode ?? 'default');
const mode: DicomDisplayMode = requestedMode === 'bone' || requestedMode === 'soft' || requestedMode === 'contrast' ? requestedMode : 'default';
const requestedSlice = Number.parseInt(String(req.query.slice ?? '0'), 10);
const cacheKey = `${project.id}:${plane}:${mode}:${Number.isFinite(requestedSlice) ? requestedSlice : 0}`;
if (dicomPreviewCache.has(cacheKey)) {
res.json(dicomPreviewCache.get(cacheKey));
return;
}
try {
let payload: unknown;
if (plane === 'axial') {
const slice = Math.max(0, Math.min(files.length - 1, Number.isFinite(requestedSlice) ? requestedSlice : 0));
const preview = parseDicomPreview(path.join(dicomDir, files[slice]));
res.json({
const preview = parseDicomPreview(path.join(dicomDir, files[slice]), mode);
payload = {
...preview,
plane,
slice,
total: files.length,
fileName: files[slice],
});
return;
};
} else {
payload = {
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0, mode),
plane,
};
}
res.json({
...createReformattedPreview(files, plane, Number.isFinite(requestedSlice) ? requestedSlice : 0),
plane,
});
dicomPreviewCache.set(cacheKey, payload);
res.json(payload);
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 预览失败' });
}
@@ -549,6 +667,23 @@ async function startServer() {
res.sendFile(path.join(modelDir, fileName));
});
app.get('/api/projects/:projectId/models/:fileName/preview', (req, res) => {
const project = findProject(readState(), req.params.projectId);
const fileName = path.basename(req.params.fileName);
const limit = Number.parseInt(String(req.query.limit ?? '5000'), 10);
if (!project || project.id !== 'head-ct-demo' || !project.stlFiles.includes(fileName)) {
res.status(404).json({ message: '模型文件不存在' });
return;
}
try {
res.json(createStlPreview(path.join(modelDir, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
} catch (error) {
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
}
});
app.get('/api/overview', (_req, res) => {
const state = readState();
const dicomCount = state.projects.reduce((sum, project) => sum + project.dicomCount, 0);
@@ -617,6 +752,7 @@ async function startServer() {
app.listen(port, host, () => {
console.log(`ReVoxelSeg DICOM server ready at http://${host}:${port}/`);
warmDicomVolumeCache(getProjectDicomFiles(buildDefaultProject()));
});
}

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { motion } from 'motion/react';
import {
Dices,
@@ -14,21 +14,37 @@ import {
Plus,
Play
} from 'lucide-react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage, PerspectiveCamera, Grid } from '@react-three/drei';
import { MaskMapping, Project } from '../types';
import { DicomPreview, MaskMapping, Project } from '../types';
import { api, downloadMask } from '../lib/api';
function InteractiveModel({ offset }: { offset: [number, number, number] }) {
function FusionDicomCanvas({ preview }: { preview: DicomPreview }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
const binary = atob(preview.pixels);
const imageData = context.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;
}
context.putImageData(imageData, 0, 0);
}, [preview]);
return (
<mesh position={offset}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={0.6} />
<mesh position={[0, 0, 0]}>
<boxGeometry args={[2.05, 2.05, 2.05]} />
<meshBasicMaterial color="#ffffff" wireframe />
</mesh>
</mesh>
<canvas
ref={canvasRef}
width={preview.width}
height={preview.height}
className="absolute inset-0 h-full w-full object-contain opacity-80"
/>
);
}
@@ -38,6 +54,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [progress, setProgress] = useState(0);
const [offset, setOffset] = useState<[number, number, number]>([0, 0, 0]);
const [project, setProject] = useState<Project | null>(null);
const [fusionPreview, setFusionPreview] = useState<DicomPreview | null>(null);
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
@@ -66,9 +83,25 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
};
useEffect(() => {
api.getProject(projectId).then(setProject).catch(() => setProject(null));
api.getProject(projectId).then((item) => {
setProject(item);
const middleSlice = Math.floor((item.dicomCount || 1) / 2);
setSlice(middleSlice);
return api.getDicomPreview(item.id, middleSlice, 'axial', 'soft');
}).then(setFusionPreview).catch(() => {
setProject(null);
setFusionPreview(null);
});
}, [projectId]);
useEffect(() => {
if (!project?.dicomCount) return;
const timer = window.setTimeout(() => {
api.getDicomPreview(project.id, slice, 'axial', 'soft').then(setFusionPreview).catch(() => setFusionPreview(null));
}, 180);
return () => window.clearTimeout(timer);
}, [project?.id, slice]);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress(p => p + 2), 50);
@@ -86,6 +119,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<p className="text-slate-500 mt-1">
{project ? `${project.name} · ${project.dicomPath}${project.modelPath}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
</p>
{project && (
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
<span className="rounded-lg bg-blue-50 px-3 py-1 text-blue-700">{project.name}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">DICOM {project.dicomCount}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">STL {project.modelCount ?? 0}</span>
</div>
)}
</div>
<div className="flex gap-2">
<button
@@ -117,24 +157,28 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice}</span>
<span className="text-[10px] font-mono text-slate-400">Layer: {slice + 1}/{project?.dicomCount ?? 0}</span>
</div>
<div className="flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group">
<div className="absolute inset-0 z-0 opacity-40">
<div className="w-full h-full flex items-center justify-center p-12">
<div className="w-full h-full border-2 border-white/5 rounded-full flex items-center justify-center anonymous-dicom-grid" />
</div>
<div className="absolute inset-0 z-0 flex items-center justify-center p-8">
<div className="relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black">
{fusionPreview ? (
<FusionDicomCanvas preview={fusionPreview} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40"> DICOM...</div>
)}
<div
className="absolute left-1/2 top-1/2 h-[58%] w-[58%] -translate-x-1/2 -translate-y-1/2 rounded-[46%_54%_44%_56%] border-2 border-blue-400/90 bg-blue-500/20 shadow-[0_0_40px_rgba(59,130,246,0.35)]"
style={{ transform: `translate(calc(-50% + ${offset[0] * 5}px), -50%)` }}
/>
<div className="absolute left-1/2 top-1/2 h-[64%] w-[64%] -translate-x-1/2 -translate-y-1/2 rounded-[52%_48%_57%_43%] border border-emerald-300/70 bg-emerald-400/10" />
<div className="absolute inset-x-0 top-1/2 h-px bg-cyan-400/25" />
<div className="absolute inset-y-0 left-1/2 w-px bg-cyan-400/25" />
</div>
</div>
<div className="absolute inset-0 z-10">
<Canvas>
<PerspectiveCamera makeDefault position={[3, 3, 3]} />
<Stage environment="city" intensity={0.5}>
<InteractiveModel offset={offset} />
</Stage>
<OrbitControls />
</Canvas>
<div className="absolute left-4 top-4 z-20 rounded-xl bg-black/60 px-3 py-2 text-[10px] font-mono text-white/50">
DICOM STL
</div>
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
@@ -149,6 +193,18 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
onChange={(e) => setOffset([Number(e.target.value), offset[1], offset[2]])}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-white uppercase opacity-60"></span>
<span className="text-[9px] text-blue-300">{slice + 1}/{project?.dicomCount ?? 0}</span>
</div>
<input
type="range"
min="0"
max={Math.max((project?.dicomCount ?? 1) - 1, 0)}
value={slice}
onChange={(e) => setSlice(Number(e.target.value))}
className="w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/>
</div>
</div>
</div>

View File

@@ -50,8 +50,8 @@ export const api = {
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
method: 'DELETE',
}),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}`),
getDicomPreview: (projectId: string, slice: number, plane: DicomPreview['plane'] = 'axial', mode: DicomPreview['mode'] = 'default') =>
request<DicomPreview>(`/api/projects/${projectId}/dicom-preview?slice=${slice}&plane=${plane}&mode=${mode}`),
getUsers: () => request<UserRecord[]>('/api/users'),
resetDemo: () =>
request<{ ok: boolean; projects: Project[]; users: UserRecord[] }>('/api/demo/reset', {

View File

@@ -60,6 +60,7 @@ export interface DicomPreview {
height: number;
pixels: string;
plane: 'axial' | 'sagittal' | 'coronal';
mode: 'default' | 'bone' | 'soft' | 'contrast';
slice: number;
total: number;
fileName: string;

View File

@@ -0,0 +1,76 @@
# 实现方案
时间戳2026-05-04-04-58-36
## 修改目标
修复项目列表按钮重叠;增强逆向工作区当前项目与融合视图;增加 DICOM 缓存和显示模式;重做 3D 模型渲染加载状态,避免 React Three Fiber 引入的 Three.js `Clock` 警告。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/测试方案-2026-05-04-04-58-36.md`
- `工程分析/经验记录.md`
## 技术路线
1. 项目列表布局。
- 将收缩按钮从标题区右侧移到侧栏外侧中线位置。
- 保留 `+` 在项目列表标题行内,避免重叠。
2. DICOM 预览缓存与显示模式。
- 后端增加内存缓存:按 project、plane、slice、mode 缓存灰度预览。
- API 增加 `mode=default|bone|soft|contrast`
- 前端增加显示模式切换,并将 mode 传给 API。
3. 3D 模型渲染。
- 项目库中不再使用 React Three Fiber Canvas。
- 后端增加 STL 二进制采样预览 API避免浏览器一次解析 240MB 原始 STL。
- 前端改用原生 Three.js 手动创建 renderer、camera、scene、geometry。
- 显示加载进度条WebGL 不可用时使用二维 canvas 模型预览兜底。
- 使用自动包围盒归一化、居中、缩放,确保模型可见。
4. 逆向工作区。
- 拉取当前项目详情。
- 顶部显示当前项目名、DICOM/STL 数量和路径。
- 融合视图显示 DICOM canvas 背景,并叠加简化 STL/模型轮廓或模型投影效果,表达等比例缩放、中心对齐状态。
5. 验证与部署。
- `npm run lint`
- `npm run build`
- API smoke test
- headless Chrome 冒烟检查
- 重启 `tmux` 会话部署到 `4000`
## 数据流
DICOM
前端选择 plane/slice/mode -> 后端命中或生成缓存预览 -> 前端 canvas 显示。
3D
前端读取 STL 采样预览 API -> 后端返回抽样三角面顶点 -> 原生 Three.js 生成材质、居中缩放 -> 渲染WebGL 不可用时绘制二维投影预览。
逆向融合:
前端按当前项目获取 DICOM 预览和项目信息 -> canvas 绘制影像 -> HTML/SVG/Three 投影层叠加中心对齐模型轮廓。
## 兼容性与回滚方案
- DICOM preview API 兼容旧参数,不传 mode 时默认为 default。
- 如果原生 Three.js 渲染异常,页面会使用二维 canvas 兜底预览,不影响项目库浏览。
- 运行态缓存仅在进程内,不写入 Git。
## 预计文件变更
- 修改后端 DICOM preview API。
- 修改项目库 3D 组件和 DICOM 控件。
- 修改逆向工作区融合视图。
- 更新工程分析文档和经验记录。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。

View File

@@ -0,0 +1,78 @@
# 测试方案
时间戳2026-05-04-04-58-36
## 测试目标
验证项目列表按钮不重叠、DICOM 三方向和显示模式可用、3D 模型有加载进度并可见、逆向工作区显示当前项目和融合视图,以及控制台不再出现 `THREE.Clock` 警告。
## 静态检查
- 检查项目列表标题区 `+` 与收缩按钮布局。
- 检查 DICOM preview API 是否支持 `mode`
- 检查项目库是否使用原生 Three.js renderer 并显示加载进度。
- 检查逆向工作区是否显示当前项目。
## 构建与类型检查
```bash
cd WebSite
npm run lint
npm run build
```
预期:
- TypeScript 检查通过。
- Vite 构建通过。
## API 验证
```bash
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0&mode=default'
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=128&mode=bone'
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=128&mode=contrast'
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/头部.stl/preview?limit=2000'
```
预期:
- DICOM 均返回 `width``height``pixels``mode`
- STL 预览返回 `triangleCount``sampledTriangles``vertices`
## 页面验证
- 项目列表标题区按钮不重叠。
- DICOM 视图可切换多种显示模式。
- 矢状面/冠状面滑动有图像变化。
- 3D 视图显示加载进度条,加载后模型可见。
- 逆向工作区显示当前项目,融合视图显示 DICOM 与模型中心对齐叠加效果。
## 控制台验证
- headless Chrome 打开页面后不捕获 `THREE.Clock`
- 不捕获 `Uncaught``Error`
## 实际执行结果
执行时间2026-05-04
- `npm run lint`:通过。
- `npm run build`:通过,仅保留 Vite chunk size 提示。
- DICOM API
- axial default`512x512 150/300 WW=360 WL=60`
- sagittal bone`300x512 128/512 WW=2000 WL=500`
- coronal contrast`300x512 256/512 WW=180 WL=80`
- axial soft`512x512 150/300 WW=400 WL=40`
- STL 预览 API`头部.stl 2571248 2000 18000`
- Headless Chrome 自动化:
- 项目库进入成功。
- 3D 模型页进入成功,模型加载/二维兜底状态可见。
- 逆向工作区进入成功,当前项目与融合说明可见。
- `THREE.Clock``non-passive``Uncaught` 捕获数为 0。
## 人工审核状态
本次用户明确要求无需人工二次确认。
状态:自动确认,继续执行。

View File

@@ -325,3 +325,75 @@ C. 解决问题方案
D. 后续如何避免问题
列表页面应减少常驻表单噪声;破坏性操作必须二次确认;轻量编辑可采用失焦保存,但需要避免空名称提交。
## 2026-05-04-04-58-36 项目列表按钮布局
A. 具体问题
项目列表标题旁的 `+` 创建按钮和侧栏收缩按钮靠得太近,视觉上发生重叠。
B. 产生问题原因
两个操作都放在项目列表标题区右侧,且侧栏宽度固定,未给低频收缩操作独立位置。
C. 解决问题方案
保留 `+` 在标题区,收缩按钮移动到项目列表侧栏中线外侧,并调整容器为 `overflow-visible`,避免按钮被卡片裁剪。
D. 后续如何避免问题
同一区域的高频操作和布局控制应分区放置;绝对定位按钮如果超出容器,要同步检查父容器裁剪策略。
## 2026-05-04-04-58-36 DICOM 三方向缓存与显示模式
A. 具体问题
矢状面和冠状面切换后图像变化慢或不明显,且 DICOM 只能用单一窗宽窗位显示。
B. 产生问题原因
每次重建非横断面都要重新读取 DICOM 序列,前后端没有把显示模式作为预览参数,也没有复用已解析的体数据。
C. 解决问题方案
后端按显示模式缓存 DICOM 体数据和预览结果API 增加 `mode=default|bone|soft|contrast`;前端切换方向时重置到对应方向中间层,并提供显示模式分段按钮。
D. 后续如何避免问题
DICOM 多平面重建应优先设计缓存键和窗宽窗位参数;后续接入真实医学影像库时继续保留 plane、slice、mode 的稳定 API 契约。
## 2026-05-04-04-58-36 STL 大文件预览
A. 具体问题
3D 模型页容易空白,直接加载 9 个原始 STL 总量约 240MB浏览器解析慢且缺少可靠进度反馈。
B. 产生问题原因
前端承担了原始 STL 解析和渲染的全部工作,大体积二进制模型会阻塞交互并放大 WebGL 环境差异。
C. 解决问题方案
后端新增 STL 二进制采样预览 API只返回抽样三角面顶点前端用原生 Three.js 按采样顶点生成 BufferGeometry并显示加载进度。WebGL 不可用时改用二维 canvas 投影预览兜底。
D. 后续如何避免问题
大模型浏览应区分“预览网格”和“原始精度文件”;列表/项目库优先加载轻量预览,进入精修或导出阶段再读取完整 STL。
## 2026-05-04-04-58-36 逆向工作区项目上下文与融合视图
A. 具体问题
逆向工作区没有明确显示当前项目,融合视图没有展示 DICOM 与 STL 归一化中心对齐的效果。
B. 产生问题原因
工作区初版更多是流程面板,缺少从项目库传入项目后继续呈现项目上下文和影像/模型叠加结果的状态。
C. 解决问题方案
工作区进入后读取当前项目详情顶部显示项目名、DICOM 数量、STL 数量和路径;融合视图加载 DICOM 软组织窗切片,并叠加中心对齐的模型轮廓、十字参考线和切片滑块。
D. 后续如何避免问题
跨页面工作流必须在目标页面重新显示当前操作对象;医学影像配准类视图至少应具备影像层、模型层、对齐标识和当前切片控制。

View File

@@ -0,0 +1,61 @@
# 需求分析
时间戳2026-05-04-04-58-36
## 原始需求摘要
用户要求严格使用代码编纂工作流处理本次修改,并在开始时确认工作流整体流程;本次需求分析、实现方案、测试方案和执行修改均不需要人工二次确认。
具体需求:
1. 项目列表旁边的 `+` 和向内收缩按钮重叠。
2. 逆向工作区需要显示当前项目;影像与模型融合视角应显示 DICOM 与 STL 等比例拉伸到同种形状、中心对齐后的效果。
3. 项目列表中矢状面和冠状面看起来不动;应在最早创建/载入项目时把图像处理并预存;同时 DICOM 应支持多种显示模式。
4. 3D 模型页面为空;如果正在加载,应显示加载进度条;如果当前方法不可行,可调用 Python 现成包。
5. 控制台出现 `THREE.Clock: This module has been deprecated. Please use THREE.Timer instead.` 和 OrbitControls 非 passive wheel 事件警告。
## 业务目标
- 优化项目库左侧标题区布局,避免操作按钮重叠。
- 让逆向工作区明确显示当前项目,并提供直观的 DICOM/STL 融合对齐预览。
- 提升 DICOM 三方向浏览性能和可用性,支持缓存和显示模式。
- 确保 3D 模型预览可见,并提供清晰的加载状态。
- 尽量消除由 React Three Fiber / Drei 引入的 Three.js 弃用警告。
## 输入与输出
输入:
- 当前项目 ID。
- 默认 DICOM 序列与 STL 文件。
- 用户选择 DICOM 平面、切片与显示模式。
- 用户调整 STL 显示颜色、透明度和可见性。
输出:
- 项目列表标题区不重叠。
- 逆向工作区顶部和内容区显示当前项目。
- 融合视图显示 DICOM 背景和 STL 归一化叠加效果。
- DICOM 三方向预览来自后端缓存,支持窗宽窗位、骨窗、软组织、高对比模式。
- 3D 模型显示加载进度,模型加载完成后可见。
- 控制台不再出现 `THREE.Clock` 警告。
## 影响范围
- `WebSite/server.ts`
- `WebSite/src/lib/api.ts`
- `WebSite/src/types.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/经验记录.md`
## 风险点
- DICOM 三方向缓存会增加运行态内存和首次请求计算量。
- 用原生 Three.js 替换 React Three Fiber 可减少依赖警告,但需要手动管理 renderer/camera/geometry 生命周期。
- 当前融合视图仍是演示级对齐,不等同真实医学配准矩阵。
- Python 本次暂不引入,因为 Node/Three.js 能完成本次显示和缓存目标;后续真实体素化再引入更合理。
## 待确认问题
- 本次用户已明确无需二次确认,直接执行。