2026-05-19-23-47-31 优化逆向分割映射视图

This commit is contained in:
2026-05-19 23:56:24 +08:00
parent f730a1c48b
commit 2e04e2d5f9
6 changed files with 671 additions and 10 deletions

View File

@@ -5,12 +5,14 @@ import {
Download,
Rotate3d,
AlertCircle,
Play,
ChevronLeft,
ChevronRight,
Eye,
Layers,
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
@@ -792,9 +794,398 @@ function CutSectionPreview({
);
}
interface ModelBounds {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
if (payload.bounds) {
return payload.bounds;
}
if (payload.vertices.length < 3) {
return null;
}
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
for (let index = 0; index < payload.vertices.length; index += 3) {
const x = payload.vertices[index];
const y = payload.vertices[index + 1];
const z = payload.vertices[index + 2];
bounds.min.x = Math.min(bounds.min.x, x);
bounds.min.y = Math.min(bounds.min.y, y);
bounds.min.z = Math.min(bounds.min.z, z);
bounds.max.x = Math.max(bounds.max.x, x);
bounds.max.y = Math.max(bounds.max.y, y);
bounds.max.z = Math.max(bounds.max.z, z);
}
return Number.isFinite(bounds.min.x) ? bounds : null;
}
function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
let hasBounds = false;
files.forEach((fileName) => {
const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
if (!payloadBounds) {
return;
}
hasBounds = true;
bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
});
return hasBounds ? bounds : null;
}
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
canvas.width = preview.width;
canvas.height = preview.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
const binary = atob(preview.pixels);
const imageData = context.createImageData(preview.width, preview.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] = 255;
}
context.putImageData(imageData, 0, 0);
}
function drawVoxelOverlayLayer(
canvas: HTMLCanvasElement,
preview: DicomPreview,
files: string[],
previews: Record<string, ModelPreviewPayload>,
moduleStyles: Record<string, ModuleStyle>,
slice: number,
totalSlices: number,
) {
canvas.width = preview.width;
canvas.height = preview.height;
const context = canvas.getContext('2d');
if (!context) {
return { activeModules: 0, paintedTriangles: 0 };
}
context.clearRect(0, 0, preview.width, preview.height);
const globalBounds = getGlobalModelBounds(files, previews);
if (!globalBounds) {
return { activeModules: 0, paintedTriangles: 0 };
}
const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
const targetZ = globalBounds.min.z + normalizedSlice * spanZ;
const sliceBand = Math.max(spanZ / Math.max(totalSlices, 1) * 1.85, spanZ * 0.014, 0.001);
const paddingX = preview.width * 0.08;
const paddingY = preview.height * 0.08;
const drawableWidth = Math.max(preview.width - paddingX * 2, 1);
const drawableHeight = Math.max(preview.height - paddingY * 2, 1);
const mapX = (x: number) => paddingX + ((x - globalBounds.min.x) / spanX) * drawableWidth;
const mapY = (y: number) => preview.height - paddingY - ((y - globalBounds.min.y) / spanY) * drawableHeight;
let activeModules = 0;
let paintedTriangles = 0;
context.save();
context.lineJoin = 'round';
context.lineCap = 'round';
context.globalCompositeOperation = 'source-over';
files.forEach((fileName, index) => {
const payload = previews[fileName];
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
if (!payload || style.visible === false) {
return;
}
let modulePainted = false;
context.fillStyle = style.color;
context.strokeStyle = style.color;
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
context.lineWidth = Math.max(preview.width, preview.height) * 0.0015;
for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
const z1 = payload.vertices[vertexIndex + 2];
const z2 = payload.vertices[vertexIndex + 5];
const z3 = payload.vertices[vertexIndex + 8];
const minZ = Math.min(z1, z2, z3);
const maxZ = Math.max(z1, z2, z3);
if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) {
continue;
}
context.beginPath();
context.moveTo(mapX(payload.vertices[vertexIndex]), mapY(payload.vertices[vertexIndex + 1]));
context.lineTo(mapX(payload.vertices[vertexIndex + 3]), mapY(payload.vertices[vertexIndex + 4]));
context.lineTo(mapX(payload.vertices[vertexIndex + 6]), mapY(payload.vertices[vertexIndex + 7]));
context.closePath();
context.fill();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.72;
context.stroke();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
modulePainted = true;
paintedTriangles += 1;
}
if (modulePainted) {
activeModules += 1;
}
});
context.restore();
return { activeModules, paintedTriangles };
}
function VoxelizationMappingView({
project,
moduleStyles,
detailLimit,
slice,
totalSlices,
onSliceChange,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
slice: number;
totalSlices: number;
onSliceChange: (slice: number) => void;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 });
const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
useEffect(() => {
if (!project?.dicomCount) {
setDicomPreview(null);
setDicomStatus('没有可显示的 DICOM 切片');
return;
}
let disposed = false;
setDicomStatus('正在载入 DICOM Base Layer...');
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
.then((preview) => {
if (disposed) return;
setDicomPreview(preview);
setDicomStatus('DICOM Base Layer 已就绪');
})
.catch((error) => {
if (disposed) return;
setDicomPreview(null);
setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
});
return () => {
disposed = true;
};
}, [project?.id, project?.dicomCount, safeSlice]);
useEffect(() => {
if (!project || !stlFiles.length) {
setModelPreviews({});
setOverlayStatus('当前项目没有 STL 构件');
return;
}
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
.then((response) => {
if (!response.ok) {
throw new Error('STL 构件预览加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => ({ fileName, payload }))
))).then((results) => {
if (disposed) return;
const nextPreviews: Record<string, ModelPreviewPayload> = {};
results.forEach((result) => {
if (result.status === 'fulfilled') {
nextPreviews[result.value.fileName] = result.value.payload;
}
});
setModelPreviews(nextPreviews);
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
});
return () => {
disposed = true;
};
}, [project?.id, stlFiles.join('|'), detailLimit]);
useEffect(() => {
const canvas = baseCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
drawDicomBaseLayer(canvas, dicomPreview);
}, [dicomPreview]);
useEffect(() => {
const canvas = overlayCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
const stats = drawVoxelOverlayLayer(
canvas,
dicomPreview,
stlFiles,
modelPreviews,
moduleStyles,
safeSlice,
Math.max(totalSlices, 1),
);
setOverlayStats(stats);
}, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]);
const stepSlice = (delta: number) => {
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
return (
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
Base DICOM
</span>
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
Overlay Label Map
</span>
</div>
<div className="absolute right-4 top-4 z-10 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</div>
{dicomPreview ? (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain mix-blend-screen" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
{dicomStatus}
</div>
)}
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-10 rounded-2xl border border-white/10 bg-black/70 p-3 backdrop-blur-sm">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
<span className="truncate">{overlayStatus}</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.paintedTriangles}
</span>
</div>
<div className="grid max-h-20 grid-cols-1 gap-1 overflow-auto pr-1">
{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={`flex items-center gap-2 text-[9px] font-bold ${style.visible ? 'text-white/70' : 'text-white/25'}`}>
<span className="h-2.5 w-2.5 rounded-sm border border-white/20" style={{ backgroundColor: style.color, opacity: style.visible ? style.opacity : 0.25 }} />
<span className="min-w-0 flex-1 truncate">{fileName.replace(/\.stl$/i, '')}</span>
<span className="font-mono">ID {style.partId}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<div className="relative h-8">
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-800" />
<div
className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-cyan-400"
style={{ left: 0, width: `${slicePercent}%` }}
/>
<input
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-input"
aria-label="逆向分割映射视图切片导航"
/>
</div>
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
</div>
</div>
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [mappingSlice, setMappingSlice] = useState(0);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
@@ -879,6 +1270,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(0);
setSliceEnd(maxIndex);
setMappingSlice(maxIndex);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
@@ -1034,6 +1426,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
@@ -1416,8 +1809,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<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
<Layers size={18} className="text-cyan-500" />
</h3>
<div className="flex gap-2">
<button
@@ -1439,15 +1832,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<CutSectionPreview
<VoxelizationMappingView
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0}
onSliceChange={setMappingSlice}
/>
</div>
</div>

View File

@@ -59,3 +59,60 @@
.dicom-range-input:active::-moz-range-thumb {
cursor: grabbing;
}
.mapping-slice-input {
appearance: none;
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.mapping-slice-input:focus {
outline: none;
}
.mapping-slice-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 22px;
margin-top: -7px;
width: 22px;
}
.mapping-slice-input::-moz-range-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 16px;
width: 16px;
}
.mapping-slice-input:active::-webkit-slider-thumb {
cursor: grabbing;
}
.mapping-slice-input:active::-moz-range-thumb {
cursor: grabbing;
}