diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index baee127..94b3c78 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -51,6 +51,8 @@ interface ModelPreviewPayload { vertices: number[]; } +type ModelPoseKey = keyof ModelPose; + const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [ { id: 'preview', label: '预览', limit: 6000 }, @@ -67,6 +69,32 @@ const defaultModelPose: ModelPose = { translateZ: 0, scale: 1, }; +const modelPoseLimits: Record = { + rotateX: { min: -180, max: 180 }, + rotateY: { min: -180, max: 180 }, + rotateZ: { min: -180, max: 180 }, + translateX: { min: -2, max: 2 }, + translateY: { min: -2, max: 2 }, + translateZ: { min: -2, max: 2 }, + scale: { min: 0.5, max: 2.5 }, +}; + +function clampModelPoseValue(key: ModelPoseKey, value: number) { + const limit = modelPoseLimits[key]; + return Math.max(limit.min, Math.min(limit.max, value)); +} + +function clampModelPose(next: ModelPose): ModelPose { + return { + rotateX: clampModelPoseValue('rotateX', next.rotateX), + rotateY: clampModelPoseValue('rotateY', next.rotateY), + rotateZ: clampModelPoseValue('rotateZ', next.rotateZ), + translateX: clampModelPoseValue('translateX', next.translateX), + translateY: clampModelPoseValue('translateY', next.translateY), + translateZ: clampModelPoseValue('translateZ', next.translateZ), + scale: clampModelPoseValue('scale', next.scale), + }; +} function drawFallbackModelPreview( canvas: HTMLCanvasElement, @@ -240,15 +268,6 @@ function NativeStlViewer({ const container = containerRef.current; if (!container) return; - const clampPose = (next: ModelPose): ModelPose => ({ - rotateX: Math.max(-180, Math.min(180, next.rotateX)), - rotateY: Math.max(-180, Math.min(180, next.rotateY)), - rotateZ: Math.max(-180, Math.min(180, next.rotateZ)), - translateX: Math.max(-2, Math.min(2, next.translateX)), - translateY: Math.max(-2, Math.min(2, next.translateY)), - translateZ: Math.max(-2, Math.min(2, next.translateZ)), - scale: Math.max(0.5, Math.min(2.5, next.scale)), - }); const dragState = { active: false, mode: 'rotate' as 'rotate' | 'pan', @@ -272,14 +291,14 @@ function NativeStlViewer({ const deltaX = event.clientX - dragState.startX; const deltaY = event.clientY - dragState.startY; if (dragState.mode === 'pan') { - onPoseChangeRef.current(clampPose({ + onPoseChangeRef.current(clampModelPose({ ...dragState.startPose, translateX: dragState.startPose.translateX + deltaX * 0.006, translateY: dragState.startPose.translateY - deltaY * 0.006, })); return; } - onPoseChangeRef.current(clampPose({ + onPoseChangeRef.current(clampModelPose({ ...dragState.startPose, rotateY: dragState.startPose.rotateY + deltaX * 0.35, rotateX: dragState.startPose.rotateX + deltaY * 0.35, @@ -294,7 +313,7 @@ function NativeStlViewer({ }; const handleWheel = (event: WheelEvent) => { event.preventDefault(); - onPoseChangeRef.current(clampPose({ + onPoseChangeRef.current(clampModelPose({ ...poseRef.current, scale: poseRef.current.scale - event.deltaY * 0.001, })); @@ -398,9 +417,11 @@ function NativeStlViewer({ fillLight.position.set(-4, 2, -3); scene.add(fillLight); - const group = new THREE.Group(); + const poseGroup = new THREE.Group(); + const pivotGroup = new THREE.Group(); + poseGroup.add(pivotGroup); let baseScale = 1; - scene.add(group); + scene.add(poseGroup); let loaded = 0; let failed = 0; @@ -429,26 +450,27 @@ function NativeStlViewer({ side: THREE.DoubleSide, }), ); - group.add(mesh); + pivotGroup.add(mesh); loaded += 1; setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100)); setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`); if (loaded + failed === visibleFiles.length) { - const box = new THREE.Box3().setFromObject(group); + const box = new THREE.Box3().setFromObject(pivotGroup); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxSize = Math.max(size.x, size.y, size.z) || 1; - group.traverse((object) => { + pivotGroup.traverse((object) => { if (object instanceof THREE.Mesh) { object.geometry.translate(-center.x, -center.y, -center.z); object.geometry.computeBoundingSphere(); object.geometry.computeVertexNormals(); } }); - group.position.set(0, 0, 0); + poseGroup.position.set(0, 0, 0); + pivotGroup.position.set(0, 0, 0); baseScale = 4.2 / maxSize; - group.scale.setScalar(baseScale * poseRef.current.scale); + pivotGroup.scale.setScalar(baseScale * poseRef.current.scale); camera.lookAt(0, 0, 0); setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成'); } @@ -472,13 +494,13 @@ function NativeStlViewer({ const animate = () => { if (disposed) return; const currentPose = poseRef.current; - group.rotation.set( + poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); + pivotGroup.rotation.set( THREE.MathUtils.degToRad(currentPose.rotateX), THREE.MathUtils.degToRad(currentPose.rotateY), THREE.MathUtils.degToRad(currentPose.rotateZ), ); - group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); - group.scale.setScalar(baseScale * currentPose.scale); + pivotGroup.scale.setScalar(baseScale * currentPose.scale); renderer.render(scene, camera); animationId = window.requestAnimationFrame(animate); }; @@ -489,7 +511,7 @@ function NativeStlViewer({ window.cancelAnimationFrame(animationId); window.removeEventListener('resize', handleResize); renderer.dispose(); - group.traverse((object) => { + poseGroup.traverse((object) => { if (object instanceof THREE.Mesh) { object.geometry.dispose(); const material = object.material; @@ -523,6 +545,23 @@ function NativeStlViewer({ {status} )} +
+
+
+ + + + X + Y + Z +
+
+
+
X {Math.round(pose.rotateX)}°
+
Y {Math.round(pose.rotateY)}°
+
Z {Math.round(pose.rotateZ)}°
+
+
); } @@ -707,14 +746,36 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri }; const updateModelPose = (partial: Partial) => { - setModelPose((current) => ({ + setModelPose((current) => clampModelPose({ ...current, ...partial, })); }; - const resetModelPose = () => { - setModelPose(defaultModelPose); + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { + setModelPose((current) => clampModelPose({ + ...current, + [key]: clampModelPoseValue(key, current[key] + delta), + })); + }; + + const resetModelRotationPose = () => { + setModelPose((current) => ({ + ...current, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + })); + }; + + const resetModelTransformPose = () => { + setModelPose((current) => ({ + ...current, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + })); }; const rotateDicom = (delta: number) => { @@ -1145,26 +1206,41 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
-
+

整体位姿

- +
+ + +
{[ - { key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX }, - { key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY }, - { key: 'rotateZ', label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ }, - { key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX }, - { key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY }, - { key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ }, - { key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale }, + { key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 }, + { key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 }, + { key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY, minus: '-Y', plus: '+Y', delta: 0.25 }, + { key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ, minus: '-Z', plus: '+Z', delta: 0.25 }, + { key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale, minus: '-0.1', plus: '+0.1', delta: 0.1 }, ].map((item) => ( -
+
{item.label} + updateModelPose({ [item.key]: Number(event.target.value) } as Partial)} className="w-full accent-blue-600" /> + {Number(item.value).toFixed(item.step < 1 ? 2 : 0)}
))} diff --git a/工程分析/代码编纂工作流.md b/工程分析/代码编纂工作流.md index 912c1d7..3df4c7b 100644 --- a/工程分析/代码编纂工作流.md +++ b/工程分析/代码编纂工作流.md @@ -94,3 +94,9 @@ - `确认执行` 如果用户对方案提出修改意见,则先更新对应文档,再等待新的确认。 + +## 默认执行确认更新 + +- 2026-05-07-16-35-52 起,用户已确认“都确认,后续直接搞”。 +- 后续项目修改需求仍必须记录时间、创建需求分析、实现方案、测试方案、读取经验记录、执行后更新经验记录、备份 commit、重新部署。 +- 除非用户明确要求暂停或人工审核,后续不再在实现方案和测试方案阶段停等二次确认。 diff --git a/工程分析/实现方案-2026-05-07-16-35-52.md b/工程分析/实现方案-2026-05-07-16-35-52.md new file mode 100644 index 0000000..3daee71 --- /dev/null +++ b/工程分析/实现方案-2026-05-07-16-35-52.md @@ -0,0 +1,111 @@ +# 实现方案 - 2026-05-07-16-35-52 + +## 修改目标 + +围绕项目库 3D 模型预览页,完善整体位姿控制、旋转中心和方位提示: + +- 将单个 `重置位姿` 拆为 `重置旋转位姿`、`重置平移缩放位姿`。 +- 旋转 X/Y/Z 增加 `-90°` 和 `+90°` 快捷按钮。 +- 平移 X/Y/Z 增加负向/正向快捷步进按钮,缩放增加缩小/放大快捷按钮。 +- 将模型旋转中心稳定在所有 STL 拼装后整体包围盒的中心。 +- 在 3D 模型画布右下角增加 X/Y/Z 方位坐标提示和当前旋转角度。 + +## 涉及路径 + +- `WebSite/src/components/ProjectLibrary.tsx` + +## 技术路线 + +### 1. 拆分重置按钮 + +新增两个函数: + +- `resetModelRotationPose()` + - 只重置 `rotateX`、`rotateY`、`rotateZ` 为 `0`。 + - 保留 `translateX/Y/Z` 和 `scale`。 +- `resetModelTransformPose()` + - 重置 `translateX`、`translateY`、`translateZ` 为 `0`,`scale` 为 `1`。 + - 保留 `rotateX/Y/Z`。 + +将 `整体位姿` 标题右侧按钮改为两个小按钮: + +- `重置旋转位姿` +- `重置平移缩放位姿` + +### 2. 增加快捷调整按钮 + +为位姿控制行增加快捷按钮: + +- 旋转 X/Y/Z: + - `-90°`:当前角度减 90,并限制在 `[-180, 180]`。 + - `+90°`:当前角度加 90,并限制在 `[-180, 180]`。 +- 平移 X/Y/Z: + - 负向按钮:每次减 `0.25`。 + - 正向按钮:每次加 `0.25`。 + - 仍限制在 `[-2, 2]`。 +- 缩放: + - 缩小按钮:每次减 `0.1`。 + - 放大按钮:每次加 `0.1`。 + - 仍限制在 `[0.5, 2.5]`。 + +为避免数值越界和重复逻辑,新增统一的 `clampModelPoseValue()` 和 `nudgeModelPose()`。 + +### 3. 修正模型旋转中心 + +当前 STL 预览已经会对 mesh geometry 进行中心化处理。本次进一步显式拆分 Three.js 层级: + +- `poseGroup`:负责整体平移。 +- `pivotGroup`:位于模型整体几何中心,负责旋转和缩放。 +- 所有 STL mesh 挂载在 `pivotGroup` 下,并在写入顶点时先减去整体包围盒中心。 + +动画循环中: + +- `poseGroup.position = translateX/Y/Z` +- `pivotGroup.rotation = rotateX/Y/Z` +- `pivotGroup.scale = baseScale * scale` + +这样模型始终围绕整体包围盒中心旋转,平移不会改变旋转轴心。 + +### 4. 增加右下角 X/Y/Z 方位提示 + +在 `NativeStlViewer` 容器中增加绝对定位 overlay: + +- 显示三个彩色轴向:X 红色、Y 绿色、Z 蓝色。 +- 显示当前 `rotateX/Y/Z` 数值,随滑块、快捷按钮、鼠标拖拽同步刷新。 +- 放置于画布右下角,避免遮挡左下角 `MODEL PATH` 状态信息。 + +## 数据流与交互流程 + +1. 用户点击快捷按钮或拖动滑块。 +2. 前端调用 `updateModelPose()` 或 `nudgeModelPose()`。 +3. `modelPose` React 状态更新。 +4. `NativeStlViewer` 通过 `poseRef` 在 Three.js 动画循环中读取最新状态。 +5. Three.js 更新 `poseGroup` 与 `pivotGroup`。 +6. 右下角方位提示读取同一份 `modelPose` 并更新显示。 + +## 兼容性与回滚方案 + +- 修改只影响 3D 模型页面,不影响 DICOM 影像、分割结果、后端 API。 +- 若旋转中心方案出现异常,可回滚到单 group 结构,同时保留 UI 快捷按钮。 +- 若 overlay 影响视觉,可只保留文本角度提示,不影响核心模型显示。 + +## 预计文件变更 + +- `WebSite/src/components/ProjectLibrary.tsx` + - 新增位姿 clamp/nudge/reset 方法。 + - 更新整体位姿控制区 UI。 + - 调整 `NativeStlViewer` 的 group 层级。 + - 增加右下角方位提示 overlay。 + +## 人工审核状态 + +- 实现方案:用户已确认。 +- 确认信息:用户回复“都确认,后续直接搞”。 + +## 执行结果 + +- 已在 `WebSite/src/components/ProjectLibrary.tsx` 完成实现。 +- 已拆分 `重置旋转位姿`、`重置平移缩放位姿`。 +- 已为旋转、平移、缩放增加快捷调整按钮。 +- 已将 3D 模型渲染层级拆为平移容器和中心旋转容器。 +- 已新增右下角 X/Y/Z 方位与旋转角度提示。 diff --git a/工程分析/测试方案-2026-05-07-16-35-52.md b/工程分析/测试方案-2026-05-07-16-35-52.md new file mode 100644 index 0000000..a3964fa --- /dev/null +++ b/工程分析/测试方案-2026-05-07-16-35-52.md @@ -0,0 +1,71 @@ +# 测试方案 - 2026-05-07-16-35-52 + +## 静态检查 + +1. 执行前确认工作区状态: + - `git status --short --branch` +2. 前端类型和构建检查: + - `cd WebSite && npm run build` +3. 如项目存在 lint 脚本,执行: + - `cd WebSite && npm run lint` + +## 单元或集成测试 + +当前项目未发现独立单元测试体系,本次以构建检查、运行时 API 检查和浏览器人工/截图验证为主。 + +## 关键业务场景验证 + +1. 打开 `http://192.168.3.11:4000/`。 +2. 进入 `项目库 - 3D 模型`。 +3. 验证 `整体位姿` 标题右侧出现: + - `重置旋转位姿` + - `重置平移缩放位姿` +4. 验证旋转 X/Y/Z: + - 点击 `-90°` 后角度减少 90。 + - 点击 `+90°` 后角度增加 90。 + - 数值不会超过 `[-180, 180]`。 +5. 验证平移 X/Y/Z: + - 点击负向按钮后对应轴负向移动。 + - 点击正向按钮后对应轴正向移动。 + - 数值不会超过 `[-2, 2]`。 +6. 验证缩放: + - 快捷按钮可放大/缩小。 + - 数值不会超过 `[0.5, 2.5]`。 +7. 验证两个重置按钮: + - `重置旋转位姿` 只影响旋转值。 + - `重置平移缩放位姿` 只影响平移和缩放值。 +8. 验证鼠标交互: + - 左键拖拽旋转模型。 + - 右键或 Shift 拖拽平移模型。 + - 滚轮缩放模型。 + - 控件数值与鼠标操作同步变化。 +9. 验证旋转中心: + - 旋转时模型围绕整体几何中心转动。 + - STL 构件相对拼装关系不被破坏。 +10. 验证右下角方位提示: + - 显示 X/Y/Z 三轴。 + - 显示当前旋转角度。 + - 不遮挡模型状态信息和右侧控制面板。 + +## 医学影像数据相关边界验证 + +- 本次不修改 DICOM 数据解析、空间 spacing、mask 导出等医学影像数据链路。 +- 回归确认项目列表、DICOM 影像页、分割结果页仍可进入。 + +## 回归风险 + +- Three.js group 层级调整可能影响模型初始视野。 +- 旋转中心修正可能暴露部分 STL 原始坐标异常,需要通过默认项目 `Head_CT_ReConstruct` 验证。 +- 快捷按钮布局可能在窄屏右侧面板中换行,需要确保不遮挡滑块和值。 + +## 人工审核状态 + +- 测试方案:用户已确认。 +- 确认信息:用户回复“都确认,后续直接搞”。 + +## 执行记录 + +- `npm run build`:通过。 +- `npm run lint`:通过,实际执行 `tsc --noEmit`。 +- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。 +- `curl -s http://127.0.0.1:4000/api/projects`:返回默认项目 `头部 CT 模型逆向体素化演示`,DICOM 300、STL 9。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index e6f1073..eabca40 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -577,3 +577,21 @@ C. 解决问题方案 D. 后续如何避免问题 默认位姿应该由相机预设和模型位姿共同定义;如果用户提供标准视图截图,应优先匹配相机视角,再决定是否需要固定模型 Z 轴校正。 + +## 2026-05-07-16-35-52 3D 位姿重置与旋转中心 + +A. 具体问题 + +整体位姿只有一个重置按钮,无法分别恢复旋转和平移缩放;同时用户感觉模型旋转中心不在模型正中间,缺少右下角 XYZ 方位参考。 + +B. 产生问题原因 + +前端位姿状态虽包含旋转、平移和缩放,但重置操作仍使用单一 `defaultModelPose`;Three.js 预览也使用单 group 同时承载旋转、平移和缩放,层级职责不够清晰;画布缺少固定的方位提示。 + +C. 解决问题方案 + +抽出统一的位姿 clamp 方法;新增 `重置旋转位姿` 与 `重置平移缩放位姿`;旋转项增加 ±90° 快捷按钮,平移和缩放增加正负方向快捷步进;Three.js 渲染拆为 `poseGroup` 负责平移、`pivotGroup` 负责围绕整体包围盒中心旋转和缩放;右下角增加 X/Y/Z 方位与旋转角度 overlay。 + +D. 后续如何避免问题 + +三维位姿控制应按旋转、平移、缩放分开管理;模型加载后必须先基于所有 STL 的整体包围盒做中心化,再把旋转和缩放应用到中心 pivot 上;涉及交互状态时,滑块、按钮和鼠标操作必须共享同一份 clamp 逻辑。 diff --git a/工程分析/需求分析-2026-05-07-16-35-52.md b/工程分析/需求分析-2026-05-07-16-35-52.md new file mode 100644 index 0000000..150a4d6 --- /dev/null +++ b/工程分析/需求分析-2026-05-07-16-35-52.md @@ -0,0 +1,56 @@ +# 需求分析 - 2026-05-07-16-35-52 + +## 原始需求摘要 + +用户要求继续严格使用代码编纂工作流处理本次 3D 模型位姿交互优化: + +1. 将 `整体位姿` 旁边的 `重置位姿` 拆分/改为两个按钮:`重置旋转位姿`、`平移缩放位姿`。 +2. 给 `整体位姿` 中的旋转和平移控制新增左右 90 度或快捷调整按钮。 +3. 将模型旋转中心设定为模型正中间,避免当前旋转中心偏移导致的交互异常。 +4. 在模型右下方增加 X/Y/Z 三个轴的旋转坐标或方位提示,方便查看模型当前方位。 + +## 业务目标 + +- 让 3D 模型位姿调整更加符合医学三维模型浏览习惯。 +- 区分旋转重置和平移/缩放重置,避免用户只想恢复一个维度时丢失全部位姿设置。 +- 让模型围绕自身几何中心旋转,提升旋转、平移、缩放的可控性。 +- 增加可视化方向参考,帮助用户判断当前 3D 模型朝向。 + +## 输入与输出 + +输入: + +- 用户在 `项目库 - 3D 模型` 页面对整体位姿滑块、快捷按钮、鼠标拖拽、滚轮等交互。 +- 当前项目中的 STL 文件及后端返回的 STL preview 数据。 + +输出: + +- UI 中出现 `重置旋转位姿` 和 `平移缩放位姿` 两类按钮。 +- 旋转项支持 ±90 度快捷调整;平移项支持正负方向快捷步进;缩放项支持快捷放大/缩小。 +- 3D 模型以整体几何中心为旋转中心。 +- 3D 预览右下角显示 X/Y/Z 方位坐标和当前旋转角度。 + +## 影响范围 + +- `WebSite/src/components/ProjectLibrary.tsx` + - `ModelPose` 状态更新逻辑。 + - `NativeStlViewer` 中 Three.js group 层级、模型居中、旋转中心、位姿同步。 + - `整体位姿` 控制区按钮和布局。 + - 3D 画布右下角方位提示 overlay。 + +## 风险点 + +- 若旋转中心调整不当,可能导致 STL 构件之间相对位置被破坏。 +- 若快捷按钮步进与滑块范围不一致,可能出现 UI 数值越界。 +- 若右下角方位提示遮挡模型或底部状态栏,可能影响可视化体验。 +- 平移没有“90 度”的物理含义,需要以正负方向固定步进方式解释。 + +## 待确认问题 + +1. “平移新增左右 90 度调整按钮”在空间变换中没有直接物理含义,本方案拟解释为平移 X/Y/Z 的正负方向快捷步进按钮,旋转 X/Y/Z 使用 ±90 度按钮。 +2. `平移缩放位姿` 按钮文案是否需要完整写为 `重置平移缩放位姿`;实现方案中会优先使用完整文案,避免歧义。 + +## 人工审核状态 + +- 需求分析:用户已确认。 +- 确认信息:用户回复“都确认,后续直接搞”。