From 66ad99f99680dddff341bb48c2eefcd317f7e420 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 02:20:52 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-02-15-10=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=9E=8D=E5=90=88=E8=A7=86=E8=A7=92=E6=96=B9=E5=90=91=E6=A0=87?= =?UTF-8?q?=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/components/ReverseWorkspace.tsx | 142 +++++++++++++++++--- 工程分析/实现方案-2026-05-20-02-15-10.md | 56 ++++++++ 工程分析/测试方案-2026-05-20-02-15-10.md | 52 +++++++ 工程分析/经验记录.md | 18 +++ 工程分析/需求分析-2026-05-20-02-15-10.md | 46 +++++++ 5 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-02-15-10.md create mode 100644 工程分析/测试方案-2026-05-20-02-15-10.md create mode 100644 工程分析/需求分析-2026-05-20-02-15-10.md diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index fda675d..4f10410 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -30,6 +30,15 @@ type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; type DicomOpacityLevel = 'low' | 'medium' | 'high'; type ModelPoseKey = keyof ModelPose; type PoseDraftValues = Record; +type AxisKey = 'x' | 'y' | 'z'; + +interface AxisVector2D { + dx: number; + dy: number; + opacity: number; +} + +type AxisProjection = Record; const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale']; @@ -76,6 +85,12 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description ]; const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const fusionBaseExtent = 4.6; +const axisInsetLength = 17; +const defaultAxisProjection: AxisProjection = { + x: { dx: axisInsetLength, dy: 0, opacity: 0.95 }, + y: { dx: -10, dy: 10, opacity: 0.82 }, + z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 }, +}; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); @@ -198,6 +213,50 @@ function parseImportedPosePayload(payload: unknown) { return { activePose, importedModelPoses }; } +function projectModelAxisDirections(camera: THREE.Camera, object: THREE.Object3D): AxisProjection { + const origin = object.getWorldPosition(new THREE.Vector3()); + const originProjected = origin.clone().project(camera); + const quaternion = object.getWorldQuaternion(new THREE.Quaternion()); + const axisDirections: Record = { + x: new THREE.Vector3(1, 0, 0), + y: new THREE.Vector3(0, 1, 0), + z: new THREE.Vector3(0, 0, 1), + }; + + const projectAxis = (direction: THREE.Vector3): AxisVector2D => { + const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72)); + const endProjected = end.project(camera); + const dx = endProjected.x - originProjected.x; + const dy = originProjected.y - endProjected.y; + const magnitude = Math.hypot(dx, dy); + + if (magnitude < 0.0001) { + return { dx: 0, dy: -5, opacity: 0.5 }; + } + + return { + dx: (dx / magnitude) * axisInsetLength, + dy: (dy / magnitude) * axisInsetLength, + opacity: endProjected.z < originProjected.z ? 1 : 0.58, + }; + }; + + return { + x: projectAxis(axisDirections.x), + y: projectAxis(axisDirections.y), + z: projectAxis(axisDirections.z), + }; +} + +function axisProjectionSignature(projection: AxisProjection) { + return (['x', 'y', 'z'] as AxisKey[]) + .map((key) => { + const item = projection[key]; + return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`; + }) + .join('|'); +} + function createDicomTexture(frame: string, width: number, height: number) { const canvas = document.createElement('canvas'); canvas.width = width; @@ -227,28 +286,58 @@ function createDicomTexture(frame: string, width: number, height: number) { return texture; } -function CoordinateAxesInset() { +function CoordinateAxesInset({ projection }: { projection: AxisProjection }) { + const origin = { x: 25, y: 31 }; + const axisItems: Array<{ key: AxisKey; label: string; color: string; labelColor: string; markerId: string }> = [ + { key: 'x', label: 'X', color: '#ef4444', labelColor: '#fecaca', markerId: 'fusion-axis-arrow-x' }, + { key: 'y', label: 'Y', color: '#22c55e', labelColor: '#bbf7d0', markerId: 'fusion-axis-arrow-y' }, + { key: 'z', label: 'Z', color: '#38bdf8', labelColor: '#bae6fd', markerId: 'fusion-axis-arrow-z' }, + ]; + return ( -
-
+
); @@ -283,6 +372,8 @@ function FusionThreeView({ const modelPoseRef = useRef(modelPose); const [status, setStatus] = useState('准备融合 DICOM 与 STL'); const [loadProgress, setLoadProgress] = useState(0); + const [axisProjection, setAxisProjection] = useState(defaultAxisProjection); + const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection)); useEffect(() => { modelPoseRef.current = modelPose; @@ -295,6 +386,8 @@ function FusionThreeView({ container.innerHTML = ''; setStatus('正在构建三维融合场景...'); setLoadProgress(8); + setAxisProjection(defaultAxisProjection); + axisProjectionSignatureRef.current = axisProjectionSignature(defaultAxisProjection); let disposed = false; let animationId = 0; @@ -548,8 +641,8 @@ function FusionThreeView({ fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ); fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0); fusionRoot.scale.setScalar(rootPose.scale); + fusionRoot.updateMatrixWorld(true); 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(); @@ -571,6 +664,13 @@ function FusionThreeView({ pose.translateZ, ); modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale); + modelPoseGroup.updateMatrixWorld(true); + const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup); + const nextAxisSignature = axisProjectionSignature(nextAxisProjection); + if (axisProjectionSignatureRef.current !== nextAxisSignature) { + axisProjectionSignatureRef.current = nextAxisSignature; + setAxisProjection(nextAxisProjection); + } renderer.render(scene, camera); animationId = window.requestAnimationFrame(animate); }; @@ -626,7 +726,7 @@ function FusionThreeView({
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
- + {loadProgress < 100 && (
diff --git a/工程分析/实现方案-2026-05-20-02-15-10.md b/工程分析/实现方案-2026-05-20-02-15-10.md new file mode 100644 index 0000000..6a05bb0 --- /dev/null +++ b/工程分析/实现方案-2026-05-20-02-15-10.md @@ -0,0 +1,56 @@ +# 实现方案-2026-05-20-02-15-10 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-20-02-15-10.md` + +## 修改目标 + +缩小“影像与模型融合视角”右下角 XYZ 标识,并将其从静态 SVG 改为随模型位姿和三维场景旋转联动的方向指示器。 + +## 涉及路径 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- `工程分析/需求分析-2026-05-20-02-15-10.md` +- `工程分析/实现方案-2026-05-20-02-15-10.md` +- `工程分析/测试方案-2026-05-20-02-15-10.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 新增轴向投影数据结构: + - 定义 X/Y/Z 三个方向在屏幕 inset SVG 中的投影向量。 + - 提供默认轴向,避免 DICOM/模型未加载时空白。 +2. 改造 `CoordinateAxesInset`: + - 视觉尺寸由 72px 缩小到约 54px。 + - 线段终点、标签位置和透明度由投影数据驱动。 +3. 在 `FusionThreeView` 中计算动态方向: + - 在 Three.js animation loop 中让 `fusionRoot` 和 `modelPoseGroup` 更新矩阵。 + - 读取 `modelPoseGroup.getWorldPosition()` 与 `getWorldQuaternion()`。 + - 将模型局部 X/Y/Z 方向投影到当前 camera 屏幕空间。 + - 仅在投影签名变化时更新 React 状态,减少无意义重渲染。 + +## 执行步骤 + +- 增加 AxisProjection 类型、默认值和投影辅助函数。 +- 改造坐标轴 inset 的 SVG 尺寸和绘制逻辑。 +- 在融合视图动画中调用投影计算并传给 inset。 +- 执行 `npm run lint` 和 `npm run build`。 +- 追加经验记录、提交、推送并重新部署。 + +## 兼容性与回滚方案 + +- 如果动态投影出现异常,默认轴向仍可显示。 +- 不修改 DICOM/STL 真实数据、模型位姿状态和实际渲染变换,仅增加可视化辅助。 +- 回滚只需恢复 `CoordinateAxesInset` 为静态 SVG。 + +## 预计文件变更 + +- `ReverseWorkspace.tsx`:动态坐标轴投影与缩小样式。 +- 工程分析文档:新增本次三份文档,更新经验记录。 + +## 提交与部署策略 + +- Commit message:`2026-05-20-02-15-10 优化融合视角方向标识` +- 显式暂存本次相关文件,避免提交历史删除状态。 +- 推送到 Gitea `origin/main`,并使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。 diff --git a/工程分析/测试方案-2026-05-20-02-15-10.md b/工程分析/测试方案-2026-05-20-02-15-10.md new file mode 100644 index 0000000..33069d2 --- /dev/null +++ b/工程分析/测试方案-2026-05-20-02-15-10.md @@ -0,0 +1,52 @@ +# 测试方案-2026-05-20-02-15-10 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-20-02-15-10.md` + +## 静态检查 + +- 在 `WebSite/` 下执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 下执行 `npm run build`。 + +## 关键业务场景验证 + +- “影像与模型融合视角”右下角 XYZ 标识尺寸比旧版更小。 +- 调整模型旋转 X/Y/Z 后,右下角 XYZ 方向随模型世界方向变化。 +- 拖拽旋转三维融合视角后,右下角 XYZ 方向随场景旋转变化。 +- 右下角标识不阻挡画布拖拽、滚轮缩放、右键/Shift 平移。 + +## 医学影像数据相关边界验证 + +- 该改动不改变 DICOM 体数据、STL 模型加载、FOV、右侧映射视图和 NIfTI 导出逻辑。 +- 模型位姿状态仍是右侧映射和分割导出的权威输入。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证: + - `curl http://127.0.0.1:4000/api/health` + - `curl -I http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- 显式暂存本次相关代码和文档。 +- 创建包含时间戳和描述的 commit。 +- 推送到 Gitea `origin/main`。 + +## 实测结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;仅保留 Vite chunk size 提醒。 +- 代码检查确认右下角方向标识尺寸从 72px 缩小为 54px。 +- 代码检查确认方向标识由 `modelPoseGroup.getWorldPosition()` 和 `getWorldQuaternion()` 投影生成,会包含模型位姿和 `fusionRoot` 场景旋转。 +- 代码检查确认方向投影使用签名去重,避免每帧无条件触发 React 状态更新。 + +## 风险与回归关注点 + +- 避免每帧无条件触发 React 状态更新。 +- 避免提交历史工程文档删除状态。 +- 保持右下角标识在 DICOM 未加载时也有默认可见状态。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index cdb105a..b135c5b 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1081,3 +1081,21 @@ C. 解决问题方案 D. 后续如何避免问题 成对设计导入/导出功能时,应把导出样例作为导入测试用例,同时兼容最小可用对象。导入端必须对字段缺失、字符串数字、越界数值和重复 id 做归一化处理。 + +## 2026-05-20-02-15-10 三维方向标识必须来自真实世界矩阵 + +A. 具体问题 + +右下角 XYZ 标识原先是静态 SVG,无法随模型旋转位姿和三维融合场景拖拽旋转变化;用户调整模型后,标识仍显示固定方向,容易误导“平移 X/Y/Z 后模型会往哪里移动”的判断。 + +B. 产生问题原因 + +旧标识只是界面装饰,没有读取 Three.js 中 `fusionRoot` 和 `modelPoseGroup` 的实时变换。模型实际显示方向由场景根节点旋转、模型位姿旋转和相机投影共同决定,静态二维箭头无法表达这个组合结果。 + +C. 解决问题方案 + +新增轴向投影辅助函数,在动画循环中读取 `modelPoseGroup` 的 world position 与 world quaternion,将模型局部 X/Y/Z 方向投影到当前 camera 屏幕空间,再驱动右下角小 SVG 的线段终点。使用投影签名去重,只有方向变化时才更新 React 状态。 + +D. 后续如何避免问题 + +凡是三维视图中的方向、法向、切面或平移提示,都应从 Three.js 真实对象矩阵或统一坐标变换链路推导,不能手写静态示意。若该提示会随拖拽视角变化,还必须包含场景根节点和相机投影。 diff --git a/工程分析/需求分析-2026-05-20-02-15-10.md b/工程分析/需求分析-2026-05-20-02-15-10.md new file mode 100644 index 0000000..7498dcf --- /dev/null +++ b/工程分析/需求分析-2026-05-20-02-15-10.md @@ -0,0 +1,46 @@ +# 需求分析-2026-05-20-02-15-10 + +## 开始时间 + +2026-05-20-02-15-10 + +## 原始需求摘要 + +用户要求将“影像与模型融合视角”右下角的 XYZ 标识缩小,并让该标识根据模型旋转位姿和三维场景视角变化实时旋转,用来表达模型执行平移 X/Y/Z 后的实际移动方向。 + +## 业务目标 + +- 降低右下角方向标识对三维融合视图的遮挡。 +- 将静态方向标识升级为动态方向指示器。 +- 让用户在调整模型位姿和拖拽三维场景后,仍能理解平移 X/Y/Z 在当前视角下的方向含义。 + +## 输入与输出 + +- 输入: + - 可视化工具栏中的模型旋转位姿。 + - 三维融合视图内鼠标拖拽产生的场景旋转。 +- 输出: + - 右下角更小的 XYZ 指示器。 + - 指示器中的 X/Y/Z 方向随当前模型世界方向投影实时更新。 + +## 影响范围 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- 工程分析文档与经验记录。 + +## 关键约束 + +- 坐标轴标识不能影响三维场景交互,仍然保持 `pointer-events-none`。 +- 标识需要使用当前 `modelPoseGroup` 世界矩阵/四元数,不做另一套独立的视觉猜测。 +- 不修改实际模型平移逻辑,只让方向标识准确反映当前视角下的方向。 + +## 风险点 + +- 如果每帧强制 React 大量重渲染,可能影响三维场景流畅度。 +- 如果只读取模型位姿而不读取场景根节点旋转,用户拖动三维视角后标识会再次失真。 +- 如果标识过小,标签可读性可能下降。 + +## 默认假设 + +- “三维场景中的移动”按当前融合视图的场景旋转/拖拽视角理解;场景平移不会改变方向,因此方向标识主要跟随旋转。 +- 方向标识表达的是当前屏幕视角下 X/Y/Z 平移方向,不作为绝对医学坐标系标尺。