# TTAMeasurementTool 胫骨结节前移术测量工具文档 ## 一、业务需求理解 ### 1.1 测量目的 在术前确定胫骨结节应前移的距离(TTA Distance),以优化膝关节稳定性并指导手术规划。 ### 1.2 点定义 | 点编号 | 作用 | 分组 | |--------|------|------| | 1, 2, 3 | 确定胫骨平台圆心 | 胫骨平台圆 | | 4, 5, 6 | 确定股骨头一侧髁骨圆心 | 髁骨圆1 | | 7, 8, 9 | 确定股骨头另一侧髁骨圆心 | 髁骨圆2 | | 10 | 标记膝盖骨外侧与韧带的交叉点 | 参考点 | | 11 | 标记胫骨粗隆的冠状边缘 | 测量终点 | ### 1.3 TTA距离计算步骤 1. **拟合圆1**: 通过点(1, 2, 3)拟合胫骨平台圆,得到圆心M1 2. **拟合圆2**: 通过点(4, 5, 6)拟合一侧髁骨圆,得到圆心M2 3. **拟合圆3**: 通过点(7, 8, 9)拟合另一侧髁骨圆,得到圆心M3 4. **计算中点M**: 连接M2M3,得到其中点M 5. **建立参考线**: 连接MM1,形成参考线方向 6. **作平行线L**: 在点10处作一条与MM1平行的直线L 7. **计算TTA**: TTA距离 = 点11到直线L的垂直距离 --- ## 二、技术实现方案 ### 2.1 核心技术元素映射 #### **2.1.1 点的抽象** - **总共11个点**: 存储在`handles.points`数组中 - **点的分组**: - 索引0-2: 胫骨平台圆的3个点 - 索引3-5: 髁骨圆1的3个点 - 索引6-8: 髁骨圆2的3个点 - 索引9: 参考点(点10) - 索引10: 测量终点(点11) #### **2.1.2 圆的抽象** - **圆的表示**: `{center: Point3, radius: number}` - **圆的拟合**: 使用`fitCircleFrom3Points(p1, p2, p3)`函数 - **需要拟合的圆**: - 胫骨平台圆: fitCircleFrom3Points(point[0], point[1], point[2]) - 髁骨圆1: fitCircleFrom3Points(point[3], point[4], point[5]) - 髁骨圆2: fitCircleFrom3Points(point[6], point[7], point[8]) #### **2.1.3 线的抽象** - **线段的表示**: 起点 + 方向向量 - **M1M2线段**: - 起点: M1(胫骨平台圆圆心) - 终点: M2(髁骨圆1圆心) - **MM1线段**(参考线): - M = M1和M2的中点 - 方向向量 = M1 - M - **平行线L**: - 起点: 点10 - 方向向量: 与MM1相同 #### **2.1.4 距离计算** - **点到直线的距离公式**: ``` 设直线L: 起点P0, 方向向量D (单位向量) 设点P 向量 AP = P - P0 距离 = |AP × D| / |D| 对于2D情况(z=0): 距离 = |AP_x * D_y - AP_y * D_x| / sqrt(D_x² + D_y²) ``` ### 2.2 需要的数学函数 #### **2.2.1 已有函数(mathUtils.ts)** - ✅ `fitCircleFrom3Points(p1, p2, p3)` - 圆拟合 - ✅ `calculateMidpoint(p1, p2)` - 中点计算 - ✅ `vectorSubtract(v1, v2)` - 向量减法 - ✅ `calculateDistance(p1, p2)` - 欧几里得距离 #### **2.2.2 需要新增函数** - ❌ `pointToLineDistance(point, lineStart, lineDirection)` - 点到直线距离 - ❌ `normalizeVector(v)` - 向量归一化 --- ## 三、Cornerstone3D API使用 ### 3.1 核心API映射 | 功能 | Cornerstone3D API | 用途 | |------|-------------------|------| | 坐标转换 | `viewport.worldToCanvas(point)` | 世界坐标→画布坐标(渲染用) | | 坐标转换 | `viewport.canvasToWorld(point)` | 画布坐标→世界坐标(存储用) | | 圆的绘制 | `drawCircle(svgHelper, uid, center, radius, options)` | 绘制拟合的圆 | | 线段绘制 | `drawLine(svgHelper, uid, lineId, start, end, options)` | 绘制M1M2、MM1、平行线L | | 点的绘制 | `drawHandles(svgHelper, uid, groupId, points, options)` | 绘制11个可拖拽手柄 | | 文本绘制 | `drawLinkedTextBox(...)` | 显示TTA距离值 | | 实线绘制 | 默认样式 | 平行线L和垂线都使用实线 | ### 3.2 数据结构设计 ```typescript interface TTAMeasurementAnnotation extends Types.Annotation { data: { handles: { points: Point3[]; // 11个点 activeHandleIndex: number | null; textBox?: Point2; // TTA距离文本框位置 }; cachedStats?: { [targetId: string]: { // 三个圆的信息 tibialCircle?: { center: Point3; radius: number }; // M1 condyleCircle1?: { center: Point3; radius: number }; // M2 condyleCircle2?: { center: Point3; radius: number }; // M3 // 关键点 midPoint?: Point3; // M (M1M2的中点) // 参考线方向 referenceLine?: { start: Point3; // MM1的起点(M) direction: Point3; // MM1的方向向量 }; // 平行线L parallelLine?: { start: Point3; // 点10 direction: Point3; // 与MM1相同的方向 }; // TTA距离 ttaDistance?: number; // 点11到平行线L的距离 }; }; }; } ``` --- ## 四、可视化设计 ### 4.1 颜色方案 | 元素 | 颜色 | 样式 | 说明 | |------|------|------|------| | 点1-3 | 红色 | 实心圆点 | 胫骨平台圆的点 | | 点4-6 | 蓝色 | 实心圆点 | 髁骨圆1的点 | | 点7-9 | 绿色 | 实心圆点 | 髁骨圆2的点 | | 点10 | 黄色 | 实心圆点 | 参考点 | | 点11 | 紫色 | 实心圆点 | 测量终点 | | 胫骨平台圆 | 红色 | 虚线 | 拟合圆1 | | 髁骨圆1 | 蓝色 | 虚线 | 拟合圆2 | | 髁骨圆2 | 绿色 | 虚线 | 拟合圆3 | | M1M2线段 | 橙色 | 实线 | 连接两个圆心 | | MM1线段 | 橙色 | 实线 | 参考线 | | 平行线L | 黄色 | 实线 | 通过点10的平行线 | | 垂直线 | 紫色 | 实线 | 点11到平行线L的垂线 | | 圆心标记 | 白色 | 小十字 | M1, M2, M3 | | TTA距离文本 | 白色 | 带背景 | 显示数值 | ### 4.2 渲染顺序 1. **圆的渲染**: 绘制3个拟合圆(虚线) 2. **圆心标记**: 绘制M1, M2, M3的十字标记 3. **线段渲染**: - M1M2线段(橙色实线) - 中点M的标记 - MM1线段(橙色实线) 4. **平行线L**: 通过点10的平行线(黄色实线) 5. **测量线段**: 点10到垂足的实线(黄色,突出显示测量线段) 6. **垂直线**: 点11到垂足的垂线(紫色实线) 7. **手柄点**: 11个可拖拽的点 8. **文本**: TTA距离值 ### 4.3 TTA测量几何关系绘制思路 #### **绘制逻辑分析** 在TTA测量中,核心是计算点11到平行线L的垂直距离。为了更清楚地展示测量几何关系,我们需要绘制: 1. **平行线L**: 通过点10,与MM1方向平行的参考线 2. **垂足计算**: 点11到平行线L的垂线与平行线L的交点 3. **测量线段**: 从点10到垂足的实线,表示测量基准线段 4. **垂直线**: 从点11到垂足的实线,表示垂直距离 #### **具体实现步骤** ```typescript // 1. 计算垂足(交点) const AP = vectorSubtract(points[10], point10); // 向量 P10->P11 const dotProduct = AP[0] * lineDirection[0] + AP[1] * lineDirection[1]; const directionMagSq = lineDirection[0]**2 + lineDirection[1]**2; const t = dotProduct / directionMagSq; // 垂足坐标 = 点10 + t * 方向向量 const footPoint = [ point10[0] + t * lineDirection[0], point10[1] + t * lineDirection[1], point10[2] ]; // 2. 绘制测量线段(点10到垂足) drawLineSvg(svgDrawingHelper, annotationUID, 'measurementLine', point10Canvas, footPointCanvas, { color: colors.point10, width: 3 } // 黄色实线,较粗 ); // 3. 绘制垂直线(点11到垂足) drawLineSvg(svgDrawingHelper, annotationUID, 'perpLine', point11Canvas, footPointCanvas, { color: colors.perpLine, width: 2 } // 紫色实线 ); ``` #### **视觉效果说明** - **平行线L**: 黄色实线,作为测量基准线 - **测量线段**: 黄色实线,线宽3,突出显示从点10到垂足的距离 - **垂直线**: 紫色实线,线宽2,显示点11到垂足的垂直距离 这样的绘制方式清晰地展示了: 1. 平行线L是测量基准 2. 垂足是垂直线的交点 3. 点10到垂足的线段是测量线段 4. 点11到垂足的线段是垂直距离 #### **交互意义** - 拖拽点10:改变平行线L的位置 - 拖拽点11:改变垂足位置,实时更新TTA距离 - 垂线始终垂直于平行线L - 测量线段实时反映当前的测量基准 #### **平行线L几何关系修正** **问题**: 最初的实现中,平行线L被绘制为无限长的直线,但根据TTA测量几何关系,平行线L应该是从点10到垂足的线段。 **几何关系**: 平行线L是通过点10,与MM1方向平行的直线。垂足是点11到平行线L的垂线与平行线L的交点。因此平行线L应该只绘制从点10到垂足的部分。 **修复**: 修改绘制逻辑,让平行线L只绘制从点10到垂足的线段。 ```typescript // 修正后的代码:平行线L从点10到垂足 if (cachedStats?.parallelLine && points.length >= 11 && annotationUID) { const point10Canvas = canvasPoints[9]; const point10 = points[9]; const lineDirection = cachedStats.parallelLine.direction; // 计算垂足 const AP = vectorSubtract(points[10], point10); const dotProduct = AP[0] * lineDirection[0] + AP[1] * lineDirection[1]; const directionMagSq = lineDirection[0] * lineDirection[0] + lineDirection[1] * lineDirection[1]; const t = dotProduct / directionMagSq; const footPoint = [ point10[0] + t * lineDirection[0], point10[1] + t * lineDirection[1], point10[2] ] as CoreTypes.Point3; const footPointCanvas = viewport.worldToCanvas(footPoint); // 绘制从点10到垂足的平行线L drawLineSvg(svgDrawingHelper, annotationUID, 'parallelLine', point10Canvas, footPointCanvas, { color: colors.parallelLine, width: 2 } ); } ``` --- ## 五、计算流程详解 ### 5.1 _updateCachedStats 核心逻辑 ```typescript _updateCachedStats(annotation, enabledElement) { const points = annotation.data.handles.points; // 步骤1: 拟合三个圆 const tibialCircle = fitCircleFrom3Points(points[0], points[1], points[2]); // M1 const condyleCircle1 = fitCircleFrom3Points(points[3], points[4], points[5]); // M2 const condyleCircle2 = fitCircleFrom3Points(points[6], points[7], points[8]); // M3 if (!tibialCircle || !condyleCircle1 || !condyleCircle2) { // 圆拟合失败,退出 return; } const M1 = tibialCircle.center; const M2 = condyleCircle1.center; const M3 = condyleCircle2.center; // 步骤2: 计算M1M2的中点M const M = calculateMidpoint(M1, M2); // 步骤3: 计算MM1的方向向量 const MM1_direction = vectorSubtract(M1, M); // 步骤4: 构建平行线L (通过点10, 方向与MM1相同) const point10 = points[9]; const parallelLine = { start: point10, direction: MM1_direction // 与MM1平行 }; // 步骤5: 计算点11到平行线L的距离 (TTA) const point11 = points[10]; const ttaDistance = pointToLineDistance(point11, point10, MM1_direction); // 保存到cachedStats stats.tibialCircle = tibialCircle; stats.condyleCircle1 = condyleCircle1; stats.condyleCircle2 = condyleCircle2; stats.midPoint = M; stats.referenceLine = { start: M, direction: MM1_direction }; stats.parallelLine = parallelLine; stats.ttaDistance = ttaDistance; } ``` ### 5.2 点到直线距离计算 ```typescript // 需要新增到mathUtils.ts function pointToLineDistance( point: Point3, lineStart: Point3, lineDirection: Point3 ): number { // 向量AP = point - lineStart const AP = vectorSubtract(point, lineStart); // 叉积的z分量 = AP_x * D_y - AP_y * D_x const crossProduct = AP[0] * lineDirection[1] - AP[1] * lineDirection[0]; // 方向向量的模 const directionMagnitude = Math.sqrt( lineDirection[0] * lineDirection[0] + lineDirection[1] * lineDirection[1] ); // 距离 = |叉积| / |方向向量| return Math.abs(crossProduct) / directionMagnitude; } ``` --- ## 六、初始化配置 ### 6.1 默认布局配置 ```typescript interface TTAAnnotationInitialConfig { // 三个圆的布局 tibialCircleConfig?: { centerX: number; // 胫骨平台圆中心X centerY: number; // 胫骨平台圆中心Y radius: number; // 半径 }; condyleCircle1Config?: { centerX: number; // 髁骨圆1中心X centerY: number; // 髁骨圆1中心Y radius: number; // 半径 }; condyleCircle2Config?: { centerX: number; // 髁骨圆2中心X centerY: number; // 髁骨圆2中心Y radius: number; // 半径 }; // 点10和点11的位置 point10Offset?: { x: number; y: number }; // 相对于viewport中心 point11Offset?: { x: number; y: number }; // 相对于viewport中心 } ``` ### 6.2 默认值 ```typescript const defaultConfig = { tibialCircleConfig: { centerX: centerX - 100, // 左侧 centerY: centerY, radius: 80 }, condyleCircle1Config: { centerX: centerX + 50, // 右上 centerY: centerY - 80, radius: 60 }, condyleCircle2Config: { centerX: centerX + 50, // 右下 centerY: centerY + 80, radius: 60 }, point10Offset: { x: -50, y: 100 }, // 点10在左下 point11Offset: { x: -50, y: 150 } // 点11在点10下方 }; ``` ### 6.3 createDefaultAnnotation 实现 ```typescript static createDefaultAnnotation(element, viewport, config?) { const canvas = viewport.canvas; const { width, height } = canvas; const centerX = width / 2; const centerY = height / 2; // 应用默认值 const tibialConfig = config?.tibialCircleConfig ?? defaultConfig.tibialCircleConfig; const condyle1Config = config?.condyleCircle1Config ?? defaultConfig.condyleCircle1Config; const condyle2Config = config?.condyleCircle2Config ?? defaultConfig.condyleCircle2Config; // 生成圆上的3个点(每个圆120度间隔) const generateCirclePoints = (centerX, centerY, radius) => { const angles = [0, 120, 240]; // 度数 return angles.map(deg => { const rad = (deg * Math.PI) / 180; return [ centerX + radius * Math.cos(rad), centerY + radius * Math.sin(rad) ]; }); }; // 生成胫骨平台圆的3个点 (点1,2,3) const tibialPoints = generateCirclePoints( tibialConfig.centerX, tibialConfig.centerY, tibialConfig.radius ); // 生成髁骨圆1的3个点 (点4,5,6) const condyle1Points = generateCirclePoints( condyle1Config.centerX, condyle1Config.centerY, condyle1Config.radius ); // 生成髁骨圆2的3个点 (点7,8,9) const condyle2Points = generateCirclePoints( condyle2Config.centerX, condyle2Config.centerY, condyle2Config.radius ); // 点10和点11 const point10 = [ centerX + (config?.point10Offset?.x ?? -50), centerY + (config?.point10Offset?.y ?? 100) ]; const point11 = [ centerX + (config?.point11Offset?.x ?? -50), centerY + (config?.point11Offset?.y ?? 150) ]; // 合并所有canvas坐标点 const canvasPoints = [ ...tibialPoints, // 0-2 ...condyle1Points, // 3-5 ...condyle2Points, // 6-8 point10, // 9 point11 // 10 ]; // 转换为世界坐标 const worldPoints = canvasPoints.map(p => viewport.canvasToWorld(p)); // 创建注解数据... return annotationData; } ``` --- ## 七、交互功能 ### 7.1 手柄拖拽 - **11个手柄点**都可以独立拖拽 - 拖拽任意点后,实时重新计算: 1. 重新拟合相关的圆 2. 重新计算M点 3. 重新计算参考线和平行线 4. 重新计算TTA距离 ### 7.2 文本框拖拽 - TTA距离文本框可以拖拽到任意位置 - 使用`textBoxOffset`防止拖拽跳跃 --- ## 八、导出数据结构 ```typescript export interface ExportedTTAData { points: Point3[]; // 11个点的世界坐标 circles: { tibialCircle: { // 胫骨平台圆 center: Point3; radius: number; }; condyleCircle1: { // 髁骨圆1 center: Point3; radius: number; }; condyleCircle2: { // 髁骨圆2 center: Point3; radius: number; }; }; measurements: { midPoint: Point3; // M点 ttaDistance: number; // TTA距离值 }; textBoxPosition?: Point2; // 文本框位置 metadata: { viewPlaneNormal: Point3; viewUp: Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } ``` --- ## 九、开发清单 ### 9.1 需要在mathUtils.ts新增的函数 - [ ] `pointToLineDistance(point, lineStart, lineDirection)` - 点到直线距离 - [ ] `normalizeVector(v)` - 向量归一化(可选,用于方向向量标准化) ### 9.2 TTAMeasurementTool类需要实现的方法 - [ ] `createDefaultAnnotation()` - 创建预设的11点注解 - [ ] `_updateCachedStats()` - 计算圆心、TTA距离 - [ ] `renderAnnotation()` - 渲染圆、线、点、文本 - [ ] `_activateModify()` / `_deactivateModify()` - 编辑模式 - [ ] `exportAnnotationData()` - 导出数据 - [ ] `restoreFromExportedData()` - 恢复数据 - [ ] 鼠标事件处理(拖拽手柄、文本框) ### 9.3 页面集成 - [ ] 在stack.image.viewer.tsx注册工具 - [ ] 添加"激活TTA测量工具"按钮 - [ ] 实现工具激活/停用逻辑 --- ## 十、关键实现要点 1. **圆拟合验证**: 如果三点共线,`fitCircleFrom3Points`会返回null,需要处理这种情况 2. **方向向量处理**: MM1方向向量不需要归一化,因为点到直线距离公式会自动处理 3. **坐标系统**: 所有计算使用世界坐标,渲染时转换为canvas坐标 4. **实时更新**: 任意点拖拽都会触发`_updateCachedStats`,重新计算所有几何元素 5. **可视化反馈**: 用不同颜色区分不同元素,便于医生理解测量结果 --- ## 十一、使用示例 ```typescript // 1. 激活工具并创建预设注解 const annotation = TTAMeasurementTool.createDefaultAnnotation( element, viewport ); // 2. 添加到状态 cornerstoneTools.annotation.state.addAnnotation(annotation, element); // 3. 计算统计数据 toolInstance._updateCachedStats(annotation, enabledElement); // 4. 触发渲染 cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(viewportIds); // 5. 导出数据 const exportedData = TTAMeasurementTool.exportAnnotationData(annotation, viewport); console.log('TTA距离:', exportedData.measurements.ttaDistance, 'mm');