2026-05-08-01-53-07 修正DICOM范围和切割Mask预览

This commit is contained in:
2026-05-08 01:59:36 +08:00
parent 4ba85eba6e
commit 22b0a93654
7 changed files with 348 additions and 29 deletions

View File

@@ -657,12 +657,29 @@ export default function ProjectLibrary({
const [actionMessage, setActionMessage] = useState('');
const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0);
const preloadedProjectIdsRef = useRef(new Set<string>());
const preloadProjectAssets = (project: Project) => {
if (preloadedProjectIdsRef.current.has(project.id)) {
return;
}
preloadedProjectIdsRef.current.add(project.id);
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
if (project.dicomCount > 0) {
void api.getDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
void api.getDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
}
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
void fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=3500`).catch(() => undefined);
});
};
const refreshProjects = () => {
setLoading(true);
return api.getProjects()
.then((items) => {
setProjects(items);
items.slice(0, 2).forEach(preloadProjectAssets);
setSelectedProject((current) => {
if (!current) {
return items[0] ?? null;
@@ -677,6 +694,12 @@ export default function ProjectLibrary({
refreshProjects();
}, []);
useEffect(() => {
if (selectedProject) {
preloadProjectAssets(selectedProject);
}
}, [selectedProject?.id]);
const filteredProjects = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {

View File

@@ -106,6 +106,59 @@ function createDicomTexture(frame: string, width: number, height: number) {
return texture;
}
function createCutMaskTexture(frame: string, width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
const binary = atob(frame);
const imageData = context.createImageData(width, 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] = 245;
}
context.putImageData(imageData, 0, 0);
const cx = width * 0.52;
const cy = height * 0.52;
const rx = width * 0.19;
const ry = height * 0.15;
context.save();
context.beginPath();
context.ellipse(cx, cy, rx, ry, -0.12, 0, Math.PI * 2);
context.fillStyle = 'rgba(249, 115, 22, 0.36)';
context.fill();
context.lineWidth = Math.max(2, Math.round(Math.min(width, height) * 0.012));
context.strokeStyle = 'rgba(251, 191, 36, 0.95)';
context.stroke();
context.setLineDash([6, 4]);
context.lineWidth = Math.max(1, Math.round(Math.min(width, height) * 0.006));
context.strokeStyle = 'rgba(255, 255, 255, 0.72)';
context.stroke();
context.restore();
context.fillStyle = 'rgba(15, 23, 42, 0.72)';
context.fillRect(8, 8, 104, 22);
context.fillStyle = '#fed7aa';
context.font = 'bold 12px monospace';
context.fillText('CUT MASK', 16, 23);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
function FusionThreeView({
project,
volume,
@@ -224,6 +277,7 @@ function FusionThreeView({
if (!texture) return;
textures.push(texture);
const isLast = index === volume.frames.length - 1;
const dicomIndex = volume.indices[index] ?? (volume.start + index);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
@@ -232,12 +286,34 @@ function FusionThreeView({
depthWrite: false,
});
const slicePlane = new THREE.Mesh(planeGeometry, material);
const z = volume.frames.length <= 1
const z = volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1);
slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z);
: -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1);
slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0));
dicomGroup.add(slicePlane);
});
if (cutEnabled && volume.frames.length) {
const nearestFrameIndex = volume.indices.reduce((bestIndex, currentIndex, candidateIndex) => (
Math.abs(currentIndex - cutSlice) < Math.abs((volume.indices[bestIndex] ?? 0) - cutSlice) ? candidateIndex : bestIndex
), 0);
const cutMaskTexture = createCutMaskTexture(volume.frames[nearestFrameIndex] ?? volume.frames[0], volume.width, volume.height);
if (cutMaskTexture) {
textures.push(cutMaskTexture);
const cutMaskPlane = new THREE.Mesh(
planeGeometry,
new THREE.MeshBasicMaterial({
map: cutMaskTexture,
transparent: true,
opacity: 0.96,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
cutMaskPlane.position.set(0, 0, cutZ + 0.018);
dicomGroup.add(cutMaskPlane);
}
}
setLoadProgress(42);
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
@@ -486,6 +562,7 @@ function FusionThreeView({
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
@@ -556,20 +633,23 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
});
};
const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`;
const getFusionCacheKey = (projectIdValue: string, start: number, end: number, mode = 'soft') => `${projectIdValue}:${mode}:${start}:${end}`;
const loadFusionVolume = async (end: number, useCache = true) => {
const loadFusionVolume = async (start: number, end: number, useCache = true) => {
if (!project?.dicomCount) return null;
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
const safeEnd = clamp(end, 0, maxSliceValue);
const cacheKey = getFusionCacheKey(project.id, safeEnd);
const safeA = clamp(start, 0, maxSliceValue);
const safeB = clamp(end, 0, maxSliceValue);
const safeStart = Math.min(safeA, safeB);
const rangeEnd = Math.max(safeA, safeB);
const cacheKey = getFusionCacheKey(project.id, safeStart, rangeEnd);
const cached = fusionVolumeCacheRef.current.get(cacheKey);
if (useCache && cached) {
setFusionVolume(cached);
setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`);
setPreloadMessage(`已使用缓存范围 ${safeStart + 1}-${rangeEnd + 1}`);
return cached;
}
const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft');
const volumePayload = await api.getDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
return volumePayload;
};
@@ -577,9 +657,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
api.getProject(projectId).then((item) => {
setProject(item);
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
setSliceEnd(end);
setCutSlice(end);
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(maxIndex);
setSliceEnd(maxIndex);
setCutSlice(maxIndex);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
@@ -595,10 +676,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
if (!project?.dicomCount) return;
const maxSlice = Math.max(project.dicomCount - 1, 0);
const safeStart = clamp(sliceStart, 0, maxSlice);
const safeEnd = clamp(sliceEnd, 0, maxSlice);
const timer = window.setTimeout(() => {
setFusionError('');
loadFusionVolume(safeEnd)
loadFusionVolume(safeStart, safeEnd)
.then(setFusionVolume)
.catch((error) => {
setFusionVolume(null);
@@ -606,7 +688,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
});
}, 180);
return () => window.clearTimeout(timer);
}, [project?.id, project?.dicomCount, sliceEnd]);
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
useEffect(() => () => {
if (poseRepeatRef.current.timeout !== null) {
@@ -732,8 +814,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
};
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const displayStart = 0;
const displayEnd = clamp(sliceEnd, 0, maxSlice);
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice));
@@ -743,7 +827,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const safeEnd = clamp(end, 0, maxSlice);
setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`);
try {
await loadFusionVolume(safeEnd, false);
await loadFusionVolume(safeEnd, safeEnd, false);
setPreloadMessage(`已预存第 ${safeEnd + 1}`);
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '预存失败');
@@ -753,7 +837,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const preloadAllFusionPoints = async () => {
setPreloadMessage('正在预存五个点位...');
try {
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true)));
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, point, true)));
setPreloadMessage('五个点位已预存');
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '五点预存失败');
@@ -761,7 +845,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
};
return (
<div className="h-full flex flex-col gap-6">
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
{project && (
@@ -795,8 +879,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
<div className="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
<div className="min-h-[780px] lg:min-h-0 flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-7 min-h-0 flex flex-col gap-4">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Rotate3d size={18} className="text-blue-500" />
@@ -841,32 +925,45 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</span>
</div>
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={displayEnd}
value={safeSliceStart}
onChange={(event) => setSliceStart(Number(event.target.value))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{safeSliceStart + 1}</span>
</label>
<label className="mt-2 grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={safeSliceEnd}
onChange={(event) => setSliceEnd(Number(event.target.value))}
className="accent-blue-600"
/>
<span className="text-right font-mono">{displayEnd + 1} </span>
<span className="text-right font-mono">{safeSliceEnd + 1}</span>
</label>
<p className="mt-3 text-[10px] leading-5 text-slate-400">
1 使
M-N姿
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{preloadPoints.map((point, index) => {
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false;
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point, point)) : false;
return (
<button
key={`${point}-${index}`}
onClick={() => {
setSliceStart(point);
setSliceEnd(point);
preloadFusionPoint(point);
}}
className={`rounded-lg border px-2 py-1 text-[10px] font-bold transition-all ${
cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
project && cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
}`}
>
{index + 1} · {point + 1}
@@ -884,7 +981,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
<div className="lg:col-span-2 min-h-0 flex flex-col gap-4 overflow-hidden">
<div className="px-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Settings2 size={18} className="text-emerald-500" />