2026-05-08-03-35-22 展示STL切割实体切面

This commit is contained in:
2026-05-08 03:40:07 +08:00
parent 500a43dbe9
commit 5ed2c02809
5 changed files with 463 additions and 60 deletions

View File

@@ -1,9 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import {
Dices,
Settings2,
Maximize2,
Download,
Rotate3d,
AlertCircle,
@@ -12,7 +10,7 @@ import {
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
@@ -495,6 +493,305 @@ function FusionThreeView({
);
}
function CutSectionPreview({
project,
volume,
modelPose,
moduleStyles,
detailLimit,
cutEnabled,
cutStart,
cutEnd,
}: {
project: Project | null;
volume: DicomFusionVolume | null;
modelPose: ModelPose;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
cutEnabled: boolean;
cutStart: number;
cutEnd: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
useEffect(() => {
modelPoseRef.current = modelPose;
}, [modelPose]);
useEffect(() => {
const container = containerRef.current;
if (!container || !project || !volume) return;
container.innerHTML = '';
let disposed = false;
let animationId = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#020617');
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000);
camera.position.set(0, -5.6, 3.4);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.localClippingEnabled = true;
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.78));
const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
keyLight.position.set(3, -4, 5);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72);
rimLight.position.set(-4, 3, 2);
scene.add(rimLight);
const fusionRoot = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
fusionRoot.add(modelPoseGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
const baseExtent = 4.4;
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
const sliceToZ = (sliceIndex: number) => (
volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
);
const cutRangeStart = Math.min(
clamp(cutStart, 0, volume.total - 1),
clamp(cutEnd, 0, volume.total - 1),
);
const cutRangeEnd = Math.max(
clamp(cutStart, 0, volume.total - 1),
clamp(cutEnd, 0, volume.total - 1),
);
const lowerCutZ = sliceToZ(cutRangeStart);
const upperCutZ = sliceToZ(cutRangeEnd);
const lowerClippingPlane = new THREE.Plane();
const upperClippingPlane = new THREE.Plane();
let modelBaseScale = 1;
let loadedModels = 0;
let failedModels = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
Promise.allSettled(stlFiles.map((fileName, index) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
.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 = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 1,
partId: index + 1,
};
const material = new THREE.MeshStandardMaterial({
color: style.color,
roughness: 0.5,
metalness: 0.04,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
clipIntersection: false,
clipShadows: true,
});
modelPivot.add(new THREE.Mesh(geometry, material));
if (payload.bounds) {
loadedBounds.push({
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
});
}
loadedModels += 1;
})
.catch(() => {
failedModels += 1;
})
))).then(() => {
if (disposed || (loadedModels + failedModels === 0)) return;
const modelBox = new THREE.Box3();
if (loadedBounds.length) {
loadedBounds.forEach((bounds) => {
modelBox.expandByPoint(bounds.min);
modelBox.expandByPoint(bounds.max);
});
} else {
modelBox.setFromObject(modelPivot);
}
const center = modelBox.getCenter(new THREE.Vector3());
const size = modelBox.getSize(new THREE.Vector3());
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
modelPivot.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingBox();
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
});
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
});
const rootPose = {
rotateX: THREE.MathUtils.degToRad(58),
rotateY: 0,
rotateZ: THREE.MathUtils.degToRad(-18),
translateX: 0,
translateY: 0,
scale: 1,
};
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
pointerId: 0,
startX: 0,
startY: 0,
root: { ...rootPose },
};
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.root = { ...rootPose };
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') {
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
return;
}
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
};
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();
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.55, 2.4);
};
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
const handleResize = () => {
if (!container.clientWidth || !container.clientHeight) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
};
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);
window.addEventListener('resize', handleResize);
const animate = () => {
if (disposed) return;
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
fusionRoot.scale.setScalar(rootPose.scale);
if (cutEnabled) {
fusionRoot.updateMatrixWorld(true);
const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
}
const pose = modelPoseRef.current;
modelPoseGroup.rotation.set(
THREE.MathUtils.degToRad(pose.rotateX),
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
animate();
return () => {
disposed = true;
window.cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', stopPointerDrag);
container.removeEventListener('pointercancel', stopPointerDrag);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('contextmenu', preventContextMenu);
scene.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();
}
}
});
renderer.dispose();
container.innerHTML = '';
};
}, [
project?.id,
project?.stlFiles?.join('|'),
volume,
JSON.stringify(moduleStyles),
detailLimit,
cutEnabled,
cutStart,
cutEnd,
]);
return (
<div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
{(!project || !volume) && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
STL ...
</div>
)}
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
@@ -516,16 +813,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const [mappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
{ className: '神经根', color: '#52c41a', maskId: 2 },
{ className: '血管', color: '#1890ff', maskId: 3 },
]);
const handleStartRegistration = () => {
setIsRegistering(true);
setProgress(0);
@@ -533,12 +823,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const handleExport = async (format: 'nii' | 'nii.gz') => {
setExporting(true);
setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`);
try {
await downloadMask(projectId, format);
setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`);
} catch (err) {
setExportMessage(err instanceof Error ? err.message : '导出失败');
} catch (error) {
setFusionError(error instanceof Error ? error.message : '导出失败');
} finally {
setExporting(false);
}
@@ -1151,52 +1439,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<div className="flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center">
<div className="relative w-64 h-64">
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
{mappings.map((mapping, index) => (
<motion.div
key={mapping.maskId}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 0.8 }}
transition={{ delay: index * 0.2 }}
className="absolute inset-0 border-2"
style={{
borderColor: mapping.color,
borderRadius: index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
background: `${mapping.color}20`,
boxShadow: `inset 0 0 20px ${mapping.color}40`,
transform: `rotate(${index * 45 + displayStart}deg) scale(${1 - index * 0.1})`,
}}
<CutSectionPreview
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
/>
))}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
</div>
</div>
<div className="absolute top-4 left-4 z-20 flex gap-2">
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
</div>
<div className="absolute bottom-4 right-4">
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
<Maximize2 size={16} />
</button>
</div>
</div>
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest"></span>
<span className="text-xs font-bold text-slate-700">{exportMessage} {mappings.length} </span>
</div>
<div className="w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div className="bg-blue-600 h-full w-full" />
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
# 实现方案:右侧实体切面预览
时间戳2026-05-08-03-35-22
## 修改目标
`Mask 展示` 区域新增真实 STL 切割实体预览组件,替代旧的二维示意 Mask并删除底部导出进度栏。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/经验记录.md`
## 技术路线
1. 新增 `CutSectionPreview` 组件。
2. 组件使用 Three.js 渲染 STL preview 顶点数据。
3. 使用 DICOM `physicalSize.depth``total``displayStart/displayEnd` 映射到 Z 方向两张 clipping plane。
4. 当“模型切分”启用时,对 STL 材质应用两张 clipping plane保留中间实体区域未启用时展示完整实体模型。
5. 复用构件颜色、显示隐藏、模型位姿和高质量实体请求上限。
6. 删除旧 `mappings` 假 Mask 形状、`MaskMapping` 类型引用、`exportMessage` 和底部导出进度栏。
## 数据流或交互流程
- DICOM 范围条更新 `displayStart/displayEnd`
- `FusionThreeView``CutSectionPreview` 都接收相同范围。
- `CutSectionPreview` 请求 STL preview居中、缩放并应用当前模型位姿。
- 启用模型切分后,右侧实体预览显示裁切后的 STL 中间段。
## 兼容性与回滚方案
- 若实体预览性能不满足要求,可降低 `detailLimit` 或回滚到旧 Mask 展示区域。
- 本次不改 API 和数据文件,回滚只涉及前端组件。
## 风险控制
- 使用 `npm run lint` 验证类型。
- 使用 `npm run build` 验证构建。
- 使用 `rg` 确认旧 `导出进度``mappings`、假 Mask 结构已移除。
- 重启服务并验证 4000 端口。
## 预计文件变更
- 修改 `ReverseWorkspace.tsx`
- 新增本次需求、实现、测试文档。
- 追加经验记录。
## 人工审核状态
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。

View File

@@ -0,0 +1,42 @@
# 测试方案:右侧 STL 实体切面预览
时间戳2026-05-08-03-35-22
## 静态检查
- 执行 `npm run lint`
- 执行 `npm run build`
## 关键业务场景验证
- 逆向工作区右侧 `Mask 展示` 不再出现旧的二维彩色假 Mask。
- 右侧区域显示 STL 实体模型。
- 启用模型切分并调整 DICOM 范围后,右侧实体预览按同一范围裁切。
- 构件隐藏、颜色、模型位姿调整后,右侧实体预览同步更新。
- 底部“导出进度”栏不再显示。
## 回归风险
- 右侧新增 Three.js 渲染可能增加 GPU/CPU 占用。
- 当前无法自动截图确认 WebGL 视觉结果,需要人工刷新页面观察。
## 验收标准
- 源码不再包含 `导出进度`、旧 `mappings.map` 假 Mask 结构。
- `npm run lint``npm run build` 均通过。
- 重新部署后 `http://192.168.3.11:4000/` 返回 200。
## 无法测试的风险
- 无法在当前命令行直接确认 STL 切面视觉是否符合用户预期,需要用户浏览器中观察。
## 人工审核状态
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
## 执行结果
- `npm run lint`:通过。
- `npm run build`:通过;仅保留 Vite chunk 大小提示。
- `rg` 验证:`ReverseWorkspace.tsx` 不再包含 `mappings``exportMessage``导出进度``Maximize2``Inferred Mask``Verified` 等旧示意 Mask 和导出进度栏结构。
- `rg` 验证:`ReverseWorkspace.tsx` 已新增 `CutSectionPreview` 并挂载到右侧 `Mask 展示` 区域。

View File

@@ -775,3 +775,21 @@ C. 解决问题方案
D. 后续如何避免问题
新增导航入口前先判断是否是独立业务对象和独立工作流,避免用多个入口指向同一组件状态;辅助视觉元素应在用户确认有价值后保留,医学三维视图中默认少放装饰或调试平面,避免遮挡真实数据。
## 2026-05-08-03-35-22 Mask 展示改为真实 STL 切面
A. 具体问题
逆向工作区右侧 `Mask 展示` 仍然是二维彩色示意图,并且底部存在“导出进度”栏;用户希望看到切割后的 STL 实体切面。
B. 产生问题原因
`Mask 展示` 是早期演示 UI没有接入真实 STL 模型,也没有复用 DICOM 范围切割逻辑;导出进度栏展示的是导出状态文本,不是用户当前关注的切面实体结果。
C. 解决问题方案
新增 `CutSectionPreview` Three.js 组件,复用当前项目 STL preview、构件颜色/隐藏状态、模型位姿和 DICOM 切片范围;启用模型切分时使用两张 clipping plane 保留中间区域,并以实体材质展示裁切后的 STL。删除旧二维 Mask 示意结构和底部“导出进度”栏。
D. 后续如何避免问题
右侧结果展示区域应优先呈现真实数据或真实处理结果,避免使用占位式示意图长期冒充结果;如果尚未生成真实语义分割 Mask应明确展示当前可验证的 STL 切面实体,而不是伪造 Mask 形状。

View File

@@ -0,0 +1,41 @@
# 需求分析Mask 展示改为切割 STL 实体预览
时间戳2026-05-08-03-35-22
## 原始需求
1. 在逆向工作区右侧“Mask 展示”中展示切割后的 STL 切面,要求为实体展示。
2. 删除下方“导出进度”栏。
## 业务目标
- 将右侧旧的示意 Mask 图替换为真实 STL 模型切割结果预览。
- 右侧预览使用当前 DICOM 切片范围与模型切分状态,展示裁切后的 STL 实体。
- 删除底部导出进度信息栏,减少无意义 UI。
## 输入与输出
- 输入:当前项目 STL 文件、构件显示状态、构件颜色、模型位姿、DICOM 切片范围、模型切分开关。
- 输出:右侧 `Mask 展示` 区域中的 Three.js 实体模型切面预览。
## 影响范围
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/经验记录.md`
## 约束
- 不生成伪造 Mask 图片。
- 不改变后端 API 和导出接口。
- 保留顶部导出按钮;只删除下方“导出进度”栏。
- STL 实体切面需要沿用当前 DICOM 范围裁切逻辑。
## 风险点
- 新增一个 Three.js 视图会增加前端渲染负载。
- 切面预览与左侧融合视图需要共享同一套 DICOM range 到 clipping plane 的映射,避免视觉不一致。
- 模型位姿、构件隐藏和颜色需要在两个视图中保持同步。
## 待确认事项
- 用户已确认后续直接执行,本次不等待二次人工审核。