From 5ed2c0280983f639607ddc3c92e324ad368b8373 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 03:40:07 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-03-35-22=20=E5=B1=95=E7=A4=BASTL?= =?UTF-8?q?=E5=88=87=E5=89=B2=E5=AE=9E=E4=BD=93=E5=88=87=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/components/ReverseWorkspace.tsx | 372 ++++++++++++++++---- 工程分析/实现方案-2026-05-08-03-35-22.md | 50 +++ 工程分析/测试方案-2026-05-08-03-35-22.md | 42 +++ 工程分析/经验记录.md | 18 + 工程分析/需求分析-2026-05-08-03-35-22.md | 41 +++ 5 files changed, 463 insertions(+), 60 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-03-35-22.md create mode 100644 工程分析/测试方案-2026-05-08-03-35-22.md create mode 100644 工程分析/需求分析-2026-05-08-03-35-22.md diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 58ce9cc..906bc86 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -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; + detailLimit: number; + cutEnabled: boolean; + cutStart: number; + cutEnd: number; +}) { + const containerRef = useRef(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; + }) + .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 ( +
+
+ {(!project || !volume) && ( +
+ 正在载入 STL 切面... +
+ )} +
+ ); +} + 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(null); const [fusionError, setFusionError] = useState(''); const [exporting, setExporting] = useState(false); - const [exportMessage, setExportMessage] = useState('准备就绪'); const fusionVolumeCacheRef = useRef(new Map()); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); - const [mappings] = useState([ - { 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 }) {
-
-
-
- {mappings.map((mapping, index) => ( - - ))} -
-
-
-
-
- -
- Inferred Mask - Verified -
- -
- -
-
- -
-
- 导出进度 - {exportMessage},包含 {mappings.length} 个标注层级 -
-
-
-
-
+
diff --git a/工程分析/实现方案-2026-05-08-03-35-22.md b/工程分析/实现方案-2026-05-08-03-35-22.md new file mode 100644 index 0000000..bb0ccb9 --- /dev/null +++ b/工程分析/实现方案-2026-05-08-03-35-22.md @@ -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`。 +- 新增本次需求、实现、测试文档。 +- 追加经验记录。 + +## 人工审核状态 + +用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。 diff --git a/工程分析/测试方案-2026-05-08-03-35-22.md b/工程分析/测试方案-2026-05-08-03-35-22.md new file mode 100644 index 0000000..6935167 --- /dev/null +++ b/工程分析/测试方案-2026-05-08-03-35-22.md @@ -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 展示` 区域。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index b8ce0dc..f32835a 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -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 形状。 diff --git a/工程分析/需求分析-2026-05-08-03-35-22.md b/工程分析/需求分析-2026-05-08-03-35-22.md new file mode 100644 index 0000000..2f3ce41 --- /dev/null +++ b/工程分析/需求分析-2026-05-08-03-35-22.md @@ -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 的映射,避免视觉不一致。 +- 模型位姿、构件隐藏和颜色需要在两个视图中保持同步。 + +## 待确认事项 + +- 用户已确认后续直接执行,本次不等待二次人工审核。