浏览代码

添加动物专用测量工具--胫骨平台夹角 TPA

sw 3 天之前
父节点
当前提交
726375875e

+ 947 - 0
docs/实现/添加测量工具完整指南.md

@@ -0,0 +1,947 @@
+# 添加新测量工具完整指南 - 以TPA为例
+
+## 📋 概述
+
+本文档详细说明如何在系统中添加一个新的Cornerstone3D测量工具。以**胫骨平台夹角(TPA)测量工具**为例,展示从工具类实现到UI集成的完整流程。
+
+---
+
+## 🎯 实现步骤总览
+
+添加一个新的测量工具需要修改以下7个文件:
+
+1. ✅ **创建工具类** - `src/components/measures/YourTool.ts`
+2. ✅ **扩展Redux状态** - `src/states/view/measurementPanelSlice.ts`
+3. ✅ **全局注册工具** - `src/utils/cornerstoneToolsSetup.ts`
+4. ✅ **扩展工具管理器** - `src/utils/measurementToolManager.ts`
+5. ✅ **集成到Viewer** - `src/pages/view/components/viewers/stack.image.viewer.tsx`
+6. ✅ **集成到容器** - `src/pages/view/components/ViewerContainer.tsx`
+7. ✅ **UI按钮配置** - `src/pages/view/components/MeasurementPanel.tsx`(通常已存在)
+
+---
+
+## 📝 详细步骤
+
+### 步骤1: 创建工具类
+
+**文件**: `src/components/measures/TibialPlateauAngleTool.ts`
+
+这是最核心的一步,需要创建继承自`AnnotationTool`的工具类。
+
+#### 关键要点:
+
+```typescript
+import { AnnotationTool, Types } from '@cornerstonejs/tools';
+
+export default class TibialPlateauAngleTool extends AnnotationTool {
+  // 1. 定义工具名称(必须唯一)
+  static toolName = 'TibialPlateauAngleTool';
+
+  // 2. 创建默认标注
+  static createDefaultAnnotation(
+    element: HTMLDivElement,
+    viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
+    config?: AnnotationInitialConfig
+  ): TibialPlateauAngleAnnotation {
+    // 在这里定义标注的初始位置和数据结构
+    // 返回包含控制点、元数据的标注对象
+  }
+
+  // 3. 渲染标注
+  renderAnnotation = (
+    enabledElement: CoreTypes.IEnabledElement,
+    svgDrawingHelper: SVGDrawingHelper
+  ): boolean => {
+    // 使用Cornerstone3D的drawing工具绘制标注
+    // drawLine, drawHandles, drawLinkedTextBox等
+  }
+
+  // 4. 处理鼠标事件
+  _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    // 处理鼠标按下:选中控制点或文本框
+  }
+
+  _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    // 处理拖动:更新控制点或文本框位置
+  }
+
+  _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    // 处理鼠标释放:完成编辑
+  }
+
+  // 5. 计算测量值
+  _updateCachedStats(
+    annotation: TibialPlateauAngleAnnotation,
+    enabledElement: CoreTypes.IEnabledElement
+  ): void {
+    // 根据控制点计算测量结果(如角度、长度等)
+    // 存储到annotation.data.cachedStats中
+  }
+
+  // 6. 可选:导出/导入数据
+  static exportAnnotationData(...): ExportedAnnotationData { }
+  static restoreFromExportedData(...): Annotation { }
+}
+```
+
+#### 完整代码结构:
+
+```typescript
+// 导入依赖
+import { AnnotationTool } from '@cornerstonejs/tools';
+import { Types as CoreTypes } from '@cornerstonejs/core';
+
+// 定义数据接口
+interface YourToolAnnotation extends Types.Annotation {
+  data: {
+    handles: {
+      points: CoreTypes.Point3[];
+      activeHandleIndex: number | null;
+      textBox?: CoreTypes.Point2;
+    };
+    cachedStats?: {
+      [targetId: string]: {
+        measurementValue: number;
+        // 其他计算结果
+      };
+    };
+  };
+}
+
+// 实现工具类
+export default class YourTool extends AnnotationTool {
+  static toolName = 'YourTool';
+  
+  editData: EditData | null = null;
+  isDrawing: boolean = false;
+
+  // ... 实现所有必需方法
+}
+```
+
+---
+
+### 步骤2: 扩展Redux状态
+
+**文件**: `src/states/view/measurementPanelSlice.ts`
+
+**目的**: 让Redux知道新的测量操作类型
+
+#### 修改内容:
+
+```typescript
+// 找到 MeasurementAction 类型定义
+export type MeasurementAction =
+  | '线段测量'
+  | '角度测量'
+  | '清除测量'
+  | '测量校正'
+  | '停用线段测量'
+  | '胫骨平台夹角'  // ⭐ 添加这一行
+  | null;
+```
+
+#### 位置定位:
+- 在文件顶部,导出的`MeasurementAction`类型定义中
+- 添加新的字符串字面量类型
+
+---
+
+### 步骤3: 全局注册工具
+
+**文件**: `src/utils/cornerstoneToolsSetup.ts`
+
+**目的**: 在Cornerstone3D中全局注册工具,使其可被使用
+
+#### 修改内容:
+
+```typescript
+// 1. 导入工具类
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
+
+// 2. 在registerGlobalTools函数中注册
+export function registerGlobalTools(): void {
+  if (toolsRegistered) {
+    console.log('[cornerstoneToolsSetup] Tools already registered, skipping...');
+    return;
+  }
+
+  console.log('[cornerstoneToolsSetup] Registering global tools...');
+
+  try {
+    // ... 其他工具注册
+    cornerstoneTools.addTool(LengthTool);
+    cornerstoneTools.addTool(cornerstoneTools.AngleTool);
+    cornerstoneTools.addTool(TibialPlateauAngleTool); // ⭐ 添加这一行
+
+    toolsRegistered = true;
+    console.log('[cornerstoneToolsSetup] All tools registered successfully');
+  } catch (error) {
+    console.error('[cornerstoneToolsSetup] Error registering tools:', error);
+    throw error;
+  }
+}
+```
+
+---
+
+### 步骤4: 扩展工具管理器
+
+**文件**: `src/utils/measurementToolManager.ts`
+
+**目的**: 提供统一的API来激活、停用、清除工具
+
+#### 修改内容:
+
+```typescript
+// 1. 导入工具类
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
+
+// 2. 添加工具管理方法(在类中添加)
+export class MeasurementToolManager {
+  // ... 现有方法
+
+  // ==================== 胫骨平台夹角测量工具 ====================
+
+  /**
+   * 激活工具
+   */
+  static activateTibialPlateauAngleTool(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      // 停用其他可能冲突的工具
+      toolGroup.setToolPassive(WindowLevelTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(MagnifyTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(LengthTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(AngleTool.toolName, {
+        removeAllBindings: true,
+      });
+
+      // 激活工具
+      toolGroup.setToolActive(TibialPlateauAngleTool.toolName, {
+        bindings: [{ mouseButton: MouseBindings.Primary }],
+      });
+
+      console.log(
+        `[MeasurementToolManager] TibialPlateauAngle tool activated for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error activating TibialPlateauAngle tool:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 停用工具
+   */
+  static deactivateTibialPlateauAngleTool(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      toolGroup.setToolPassive(TibialPlateauAngleTool.toolName, {
+        removeAllBindings: true,
+      });
+      console.log(
+        `[MeasurementToolManager] TibialPlateauAngle tool deactivated for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error deactivating TibialPlateauAngle tool:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 检查工具是否激活
+   */
+  static isTibialPlateauAngleToolActive(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      const activeTool = toolGroup.getActivePrimaryMouseButtonTool();
+      return activeTool === TibialPlateauAngleTool.toolName;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error checking TibialPlateauAngle tool state:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 切换工具状态
+   */
+  static toggleTibialPlateauAngleTool(viewportId: string): boolean {
+    const isActive = this.isTibialPlateauAngleToolActive(viewportId);
+    if (isActive) {
+      return this.deactivateTibialPlateauAngleTool(viewportId);
+    } else {
+      return this.activateTibialPlateauAngleTool(viewportId);
+    }
+  }
+
+  /**
+   * 清除标注
+   */
+  static clearTibialPlateauAngleMeasurements(viewportId: string): boolean {
+    try {
+      const viewport =
+        cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+      if (!viewport) {
+        console.warn(
+          `[MeasurementToolManager] Viewport not found: ${viewportId}`
+        );
+        return false;
+      }
+
+      const annotations = cornerstoneTools.annotation.state.getAnnotations(
+        TibialPlateauAngleTool.toolName,
+        viewport.element
+      );
+
+      let removedCount = 0;
+      annotations.forEach((annotation) => {
+        if (annotation.annotationUID) {
+          cornerstoneTools.annotation.state.removeAnnotation(
+            annotation.annotationUID
+          );
+          removedCount++;
+        }
+      });
+
+      viewport.render();
+      console.log(
+        `[MeasurementToolManager] Cleared ${removedCount} TibialPlateauAngle measurements for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error clearing TibialPlateauAngle measurements:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 获取测量结果
+   */
+  static getTibialPlateauAngleMeasurements(viewportId: string): any[] {
+    try {
+      const viewport =
+        cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+      if (!viewport) {
+        console.warn(
+          `[MeasurementToolManager] Viewport not found: ${viewportId}`
+        );
+        return [];
+      }
+
+      const annotations = cornerstoneTools.annotation.state.getAnnotations(
+        TibialPlateauAngleTool.toolName,
+        viewport.element
+      );
+
+      return annotations.map((annotation) => ({
+        annotationUID: annotation.annotationUID,
+        TPA: annotation.data?.cachedStats?.TPA || 0,
+        unit: 'degrees',
+        points: annotation.data?.handles?.points || [],
+      }));
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error getting TibialPlateauAngle measurements:`,
+        error
+      );
+      return [];
+    }
+  }
+
+  /**
+   * 批量操作方法
+   */
+  static activateTibialPlateauAngleToolForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.activateTibialPlateauAngleTool(viewportId)
+    );
+  }
+
+  static deactivateTibialPlateauAngleToolForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.deactivateTibialPlateauAngleTool(viewportId)
+    );
+  }
+
+  static clearTibialPlateauAngleMeasurementsForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.clearTibialPlateauAngleMeasurements(viewportId)
+    );
+  }
+}
+```
+
+#### 方法命名规范:
+- `activate{ToolName}Tool` - 激活工具
+- `deactivate{ToolName}Tool` - 停用工具
+- `is{ToolName}ToolActive` - 检查状态
+- `toggle{ToolName}Tool` - 切换状态
+- `clear{ToolName}Measurements` - 清除标注
+- `get{ToolName}Measurements` - 获取结果
+- `{action}{ToolName}ToolForViewports` - 批量操作
+
+---
+
+### 步骤5: 集成到StackViewer
+
+**文件**: `src/pages/view/components/viewers/stack.image.viewer.tsx`
+
+**目的**: 将工具添加到每个viewport的工具组,并导出便捷函数
+
+#### 修改内容:
+
+```typescript
+// 1. 导入工具类
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
+
+// 2. 在registerTools函数中添加工具到工具组
+function registerTools(viewportId, renderingEngineId) {
+  // ... 现有代码
+  
+  // 添加工具到工具组
+  toolGroup.addTool(MagnifyTool.toolName);
+  toolGroup.addTool(PanTool.toolName);
+  // ... 其他工具
+  toolGroup.addTool(LengthTool.toolName);
+  toolGroup.addTool(AngleTool.toolName);
+  toolGroup.addTool(TibialPlateauAngleTool.toolName); // ⭐ 添加这一行
+
+  // 设置默认工具状态
+  setupDefaultToolStates(toolGroup);
+  // ...
+}
+
+// 3. 在setupDefaultToolStates中设置为被动状态
+function setupDefaultToolStates(toolGroup: cornerstoneTools.Types.IToolGroup) {
+  // ... 其他工具状态设置
+  
+  toolGroup.setToolPassive(LengthTool.toolName);
+  toolGroup.setToolPassive(AngleTool.toolName);
+  toolGroup.setToolPassive(TibialPlateauAngleTool.toolName); // ⭐ 添加这一行
+}
+
+// 4. 导出便捷函数(在文件末尾,StackViewer组件定义之前)
+// ==================== 胫骨平台夹角测量相关函数 ====================
+
+/**
+ * 激活胫骨平台夹角测量工具
+ */
+export function activateTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[activateTibialPlateauAngleMeasurement] Activating TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.activateTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 停用胫骨平台夹角测量工具
+ */
+export function deactivateTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[deactivateTibialPlateauAngleMeasurement] Deactivating TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.deactivateTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 切换胫骨平台夹角测量工具状态
+ */
+export function toggleTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[toggleTibialPlateauAngleMeasurement] Toggling TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.toggleTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 清除胫骨平台夹角测量标注
+ */
+export function clearTibialPlateauAngleMeasurements(viewportId: string): boolean {
+  console.log(
+    `[clearTibialPlateauAngleMeasurements] Clearing TPA measurements for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.clearTibialPlateauAngleMeasurements(viewportId);
+}
+
+/**
+ * 获取胫骨平台夹角测量结果
+ */
+export function getTibialPlateauAngleMeasurements(viewportId: string): any[] {
+  console.log(
+    `[getTibialPlateauAngleMeasurements] Getting TPA measurements for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.getTibialPlateauAngleMeasurements(viewportId);
+}
+
+/**
+ * 检查胫骨平台夹角测量工具是否激活
+ */
+export function isTibialPlateauAngleMeasurementActive(viewportId: string): boolean {
+  return MeasurementToolManager.isTibialPlateauAngleToolActive(viewportId);
+}
+```
+
+---
+
+### 步骤6: 集成到ViewerContainer
+
+**文件**: `src/pages/view/components/ViewerContainer.tsx`
+
+**目的**: 响应Redux action,协调工具的激活和状态更新
+
+#### 修改内容:
+
+```typescript
+// 1. 导入函数
+import StackViewer, {
+  // ... 其他导入
+  activateLengthMeasurement,
+  deactivateLengthMeasurement,
+  clearLengthMeasurements,
+  activateAngleMeasurement,
+  deactivateAngleMeasurement,
+  clearAngleMeasurements,
+  activateTibialPlateauAngleMeasurement,     // ⭐ 添加
+  deactivateTibialPlateauAngleMeasurement,   // ⭐ 添加
+  clearTibialPlateauAngleMeasurements,       // ⭐ 添加
+} from './viewers/stack.image.viewer';
+
+// 2. 在测量面板Action处理的useEffect中添加case
+useEffect(() => {
+  if (measurementAction) {
+    console.log(`[ViewerContainer] 处理测量操作: ${measurementAction}`);
+
+    const selectedViewportIds = selectedViewerUrls
+      .map(getViewportIdByUrl)
+      .filter((id): id is string => id !== null);
+
+    switch (measurementAction) {
+      // ... 其他case
+      
+      case '胫骨平台夹角':  // ⭐ 添加这个case
+        console.log(`开始胫骨平台夹角测量`);
+        if (selectedViewportIds.length > 0) {
+          selectedViewportIds.forEach((viewportId) => {
+            const success = activateTibialPlateauAngleMeasurement(viewportId);
+            if (success) {
+              console.log(`激活胫骨平台夹角测量工具成功`);
+              dispatch(
+                setToolActive({
+                  toolName: 'TibialPlateauAngleTool',
+                  viewportId: viewportId,
+                })
+              );
+            }
+          });
+        } else {
+          // 如果没有选中的 viewport,为所有可见的 viewport 激活
+          const visibleViewportCount = getVisibleViewportCount();
+          for (let i = 0; i < visibleViewportCount; i++) {
+            const viewportId = `viewport-${i}`;
+            const success = activateTibialPlateauAngleMeasurement(viewportId);
+            if (success) {
+              dispatch(
+                setToolActive({
+                  toolName: 'TibialPlateauAngleTool',
+                  viewportId: viewportId,
+                })
+              );
+            }
+          }
+        }
+        console.log('Activating TibialPlateauAngle Measurement from MeasurementPanel');
+        break;
+
+      // ... 其他case
+    }
+
+    dispatch(clearMeasurementAction());
+  }
+}, [measurementAction, selectedViewerUrls, gridLayout, dispatch]);
+
+// 3. 更新清除测量的逻辑
+case '清除测量':
+  if (selectedViewportIds.length > 0) {
+    MeasurementToolManager.clearLengthMeasurementsForViewports(selectedViewportIds);
+    MeasurementToolManager.clearAngleMeasurementsForViewports(selectedViewportIds);
+    MeasurementToolManager.clearTibialPlateauAngleMeasurementsForViewports(selectedViewportIds); // ⭐ 添加
+  }
+  console.log('Clearing All Measurements from MeasurementPanel');
+  break;
+```
+
+---
+
+### 步骤7: UI按钮配置
+
+**文件**: `src/pages/view/components/MeasurementPanel.tsx`
+
+**说明**: 这一步通常已经完成,按钮配置如下:
+
+```typescript
+<FunctionButton
+  title="胫骨平台夹角"
+  action="胫骨平台夹角"  // ⭐ 这个值必须与Redux中的MeasurementAction类型匹配
+  iconName="btn_TPA"
+  productId="animal"
+/>
+```
+
+`FunctionButton`组件的`handleMeasurementAction`函数会自动分发Redux action:
+
+```typescript
+const handleMeasurementAction = (action: string) => {
+  console.log(`执行测量操作: ${action}`);
+  dispatch(setMeasurementAction(action as MeasurementAction));
+};
+```
+
+---
+
+## 🔄 数据流图
+
+```
+用户点击按钮
+    ↓
+FunctionButton.handleMeasurementAction()
+    ↓
+dispatch(setMeasurementAction('胫骨平台夹角'))
+    ↓
+Redux State更新: measurementAction = '胫骨平台夹角'
+    ↓
+ViewerContainer监听到measurementAction变化
+    ↓
+执行switch case: '胫骨平台夹角'
+    ↓
+调用activateTibialPlateauAngleMeasurement(viewportId)
+    ↓
+MeasurementToolManager.activateTibialPlateauAngleTool(viewportId)
+    ↓
+ToolGroup.setToolActive(TibialPlateauAngleTool)
+    ↓
+工具激活,等待用户在图像上操作
+    ↓
+用户拖动控制点 → _updateCachedStats() → renderAnnotation()
+    ↓
+显示测量结果
+```
+
+---
+
+## 📋 快速检查清单
+
+添加新工具时,按以下清单逐一检查:
+
+- [ ] **步骤1**: 创建工具类 `src/components/measures/{ToolName}.ts`
+  - [ ] 继承 `AnnotationTool`
+  - [ ] 定义 `static toolName`
+  - [ ] 实现 `createDefaultAnnotation`
+  - [ ] 实现 `renderAnnotation`
+  - [ ] 实现鼠标事件处理
+  - [ ] 实现 `_updateCachedStats`
+
+- [ ] **步骤2**: 扩展Redux类型 `src/states/view/measurementPanelSlice.ts`
+  - [ ] 在 `MeasurementAction` 类型中添加新的操作名
+
+- [ ] **步骤3**: 全局注册 `src/utils/cornerstoneToolsSetup.ts`
+  - [ ] 导入工具类
+  - [ ] 在 `registerGlobalTools` 中调用 `cornerstoneTools.addTool`
+
+- [ ] **步骤4**: 扩展工具管理器 `src/utils/measurementToolManager.ts`
+  - [ ] 导入工具类
+  - [ ] 添加 `activate{ToolName}Tool`
+  - [ ] 添加 `deactivate{ToolName}Tool`
+  - [ ] 添加 `is{ToolName}ToolActive`
+  - [ ] 添加 `toggle{ToolName}Tool`
+  - [ ] 添加 `clear{ToolName}Measurements`
+  - [ ] 添加 `get{ToolName}Measurements`
+  - [ ] 添加批量操作方法
+
+- [ ] **步骤5**: 集成到Viewer `src/pages/view/components/viewers/stack.image.viewer.tsx`
+  - [ ] 导入工具类
+  - [ ] 在 `registerTools` 中添加工具到工具组
+  - [ ] 在 `setupDefaultToolStates` 中设置为被动状态
+  - [ ] 导出6个便捷函数
+
+- [ ] **步骤6**: 集成到容器 `src/pages/view/components/ViewerContainer.tsx`
+  - [ ] 导入便捷函数
+  - [ ] 在测量action处理中添加case
+  - [ ] 在清除测量中添加清除逻辑
+
+- [ ] **步骤7**: 确认UI按钮 `src/pages/view/components/MeasurementPanel.tsx`
+  - [ ] 确认 `FunctionButton` 的 `action` 属性与Redux类型匹配
+
+---
+
+## 🎯 模板代码
+
+### 工具管理器方法模板
+
+复制此模板,替换`{ToolName}`为你的工具名:
+
+```typescript
+// ==================== {工具中文名}测量工具 ====================
+
+/**
+ * 激活{工具中文名}测量工具
+ */
+static activate{ToolName}Tool(viewportId: string): boolean {
+  const toolGroup = this.getToolGroup(viewportId);
+  if (!toolGroup) return false;
+
+  try {
+    // 停用其他可能冲突的工具
+    toolGroup.setToolPassive(WindowLevelTool.toolName, { removeAllBindings: true });
+    toolGroup.setToolPassive(MagnifyTool.toolName, { removeAllBindings: true });
+    toolGroup.setToolPassive(LengthTool.toolName, { removeAllBindings: true });
+    toolGroup.setToolPassive(AngleTool.toolName, { removeAllBindings: true });
+
+    // 激活工具
+    toolGroup.setToolActive({ToolName}Tool.toolName, {
+      bindings: [{ mouseButton: MouseBindings.Primary }],
+    });
+
+    console.log(`[MeasurementToolManager] {ToolName} tool activated for viewport: ${viewportId}`);
+    return true;
+  } catch (error) {
+    console.error(`[MeasurementToolManager] Error activating {ToolName} tool:`, error);
+    return false;
+  }
+}
+
+/**
+ * 停用{工具中文名}测量工具
+ */
+static deactivate{ToolName}Tool(viewportId: string): boolean {
+  const toolGroup = this.getToolGroup(viewportId);
+  if (!toolGroup) return false;
+
+  try {
+    toolGroup.setToolPassive({ToolName}Tool.toolName, { removeAllBindings: true });
+    console.log(`[MeasurementToolManager] {ToolName} tool deactivated for viewport: ${viewportId}`);
+    return true;
+  } catch (error) {
+    console.error(`[MeasurementToolManager] Error deactivating {ToolName} tool:`, error);
+    return false;
+  }
+}
+
+/**
+ * 检查{工具中文名}测量工具是否激活
+ */
+static is{ToolName}ToolActive(viewportId: string): boolean {
+  const toolGroup = this.getToolGroup(viewportId);
+  if (!toolGroup) return false;
+
+  try {
+    const activeTool = toolGroup.getActivePrimaryMouseButtonTool();
+    return activeTool === {ToolName}Tool.toolName;
+  } catch (error) {
+    console.error(`[MeasurementToolManager] Error checking {ToolName} tool state:`, error);
+    return false;
+  }
+}
+
+/**
+ * 切换{工具中文名}测量工具状态
+ */
+static toggle{ToolName}Tool(viewportId: string): boolean {
+  const isActive = this.is{ToolName}ToolActive(viewportId);
+  if (isActive) {
+    return this.deactivate{ToolName}Tool(viewportId);
+  } else {
+    return this.activate{ToolName}Tool(viewportId);
+  }
+}
+
+/**
+ * 清除{工具中文名}测量标注
+ */
+static clear{ToolName}Measurements(viewportId: string): boolean {
+  try {
+    const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+    if (!viewport) {
+      console.warn(`[MeasurementToolManager] Viewport not found: ${viewportId}`);
+      return false;
+    }
+
+    const annotations = cornerstoneTools.annotation.state.getAnnotations(
+      {ToolName}Tool.toolName,
+      viewport.element
+    );
+
+    let removedCount = 0;
+    annotations.forEach((annotation) => {
+      if (annotation.annotationUID) {
+        cornerstoneTools.annotation.state.removeAnnotation(annotation.annotationUID);
+        removedCount++;
+      }
+    });
+
+    viewport.render();
+    console.log(`[MeasurementToolManager] Cleared ${removedCount} {ToolName} measurements for viewport: ${viewportId}`);
+    return true;
+  } catch (error) {
+    console.error(`[MeasurementToolManager] Error clearing {ToolName} measurements:`, error);
+    return false;
+  }
+}
+
+/**
+ * 获取{工具中文名}测量结果
+ */
+static get{ToolName}Measurements(viewportId: string): any[] {
+  try {
+    const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+    if (!viewport) {
+      console.warn(`[MeasurementToolManager] Viewport not found: ${viewportId}`);
+      return [];
+    }
+
+    const annotations = cornerstoneTools.annotation.state.getAnnotations(
+      {ToolName}Tool.toolName,
+      viewport.element
+    );
+
+    return annotations.map((annotation) => ({
+      annotationUID: annotation.annotationUID,
+      // 根据你的工具返回相应的测量值
+      value: annotation.data?.cachedStats?.value || 0,
+      unit: 'unit',
+      points: annotation.data?.handles?.points || [],
+    }));
+  } catch (error) {
+    console.error(`[MeasurementToolManager] Error getting {ToolName} measurements:`, error);
+    return [];
+  }
+}
+
+/**
+ * 批量操作方法
+ */
+static activate{ToolName}ToolForViewports(viewportIds: string[]): boolean[] {
+  return viewportIds.map((viewportId) => this.activate{ToolName}Tool(viewportId));
+}
+
+static deactivate{ToolName}ToolForViewports(viewportIds: string[]): boolean[] {
+  return viewportIds.map((viewportId) => this.deactivate{ToolName}Tool(viewportId));
+}
+
+static clear{ToolName}MeasurementsForViewports(viewportIds: string[]): boolean[] {
+  return viewportIds.map((viewportId) => this.clear{ToolName}Measurements(viewportId));
+}
+```
+
+---
+
+## 💡 常见问题
+
+### Q1: 工具激活后没有反应?
+**A**: 检查以下几点:
+1. 工具是否正确注册到全局(步骤3)
+2. 工具是否添加到ToolGroup(步骤5)
+3. 控制台是否有错误信息
+4. toolName是否在所有地方保持一致
+
+### Q2: 如何调试工具?
+**A**: 
+1. 在浏览器控制台查看日志
+2. 检查Redux DevTools中的状态
+3. 在工具类的关键方法中添加console.log
+4. 使用Chrome DevTools断点调试
+
+### Q3: 标注渲染不出来?
+**A**: 
+1. 检查`renderAnnotation`方法返回值是否为true
+2. 确认annotationUID存在
+3. 检查SVG元素是否正确创建
+4. 验证坐标转换是否正确(world ↔ canvas)
+
+### Q4: 多个viewport的标注混乱?
+**A**: 
+1. 确保使用正确的targetId区分不同viewport的数据
+2. 清除标注时使用viewport.element作为参数
+3. 检查工具状态是否正确绑定到各自的ToolGroup
+
+### Q5: 如何实现标注的持久化?
+**A**: 
+1. 实现`exportAnnotationData`方法导出数据
+2. 实现`restoreFromExportedData`方法恢复数据
+3. 在适当时机保存到localStorage或服务器
+4. 页面加载时恢复标注
+
+---
+
+## 📚 参考资源
+
+### Cornerstone3D文档
+- [官方文档](https://www.cornerstonejs.org/)
+- [工具开发指南](https://www.cornerstonejs.org/docs/concepts/cornerstone-tools/tools)
+- [注解系统](https://www.cornerstonejs.org/docs/concepts/cornerstone-tools/annotation)
+
+### 项目内参考
+- **LengthTool**: 线段测量工具,参考基础测量实现
+- **AngleTool**: 角度测量工具,参考角度计算
+- **TibialPlateauAngleTool**: 完整的自定义工具实现示例
+
+---
+
+## 🎉 总结
+
+通过以上7个步骤,你已经成功添加了一个新的测量工具!
+
+**关键要点回顾**:
+1. ✅ 工具类继承`AnnotationTool`并实现所有必要方法
+2. ✅ Redux状态类型必须包含新的操作名
+3. ✅ 全局注册工具才能使用
+4. ✅ 工具管理器提供统一的API
+5. ✅ 每个viewport需要独立的ToolGroup
+6. ✅ ViewerContainer协调Redux和工具系统
+7. ✅ UI按钮通过Redux action触发整个流程
+
+**下次添加新工具时**,只需按照本文档的7个步骤,复制模板代码并替换工具名即可!
+
+---
+
+## 📞 需要帮助?
+
+如果在实现过程中遇到问题:
+1. 检查控制台错误信息
+2. 对照本文档的检查清单
+3. 参考TibialPlateauAngleTool的完整实现
+4. 查看Cornerstone3D官方文档
+
+祝你成功添加新的测量工具!🚀

+ 894 - 0
src/components/measures/TibialPlateauAngleTool.ts

@@ -0,0 +1,894 @@
+import {
+  utilities as csUtils,
+  Types as CoreTypes,
+  getEnabledElement,
+} from '@cornerstonejs/core';
+
+import {
+  AnnotationTool,
+  utilities,
+  Types,
+  annotation,
+  drawing,
+} from '@cornerstonejs/tools';
+
+const {
+  drawHandles,
+  drawLinkedTextBox,
+  drawLine: drawLineSvg,
+  drawPath: drawPathSvg,
+} = drawing;
+
+import {
+  calculateMidpoint,
+  vectorSubtract,
+  vectorAdd,
+  vectorScale,
+  getAngleBetweenLines,
+  degrees,
+} from './mathUtils';
+import {
+  PublicToolProps,
+  ToolProps,
+  EventTypes,
+  SVGDrawingHelper,
+} from '@cornerstonejs/tools/dist/esm/types';
+
+// 注解初始配置接口
+interface AnnotationInitialConfig {
+  axisLineLength?: number;
+  plateauLineLength?: number;
+  axisLineCenterX?: number;
+  axisLineCenterY?: number;
+  plateauLineCenterX?: number;
+  plateauLineCenterY?: number;
+}
+
+// 注解数据接口
+interface TibialPlateauAngleAnnotation extends Types.Annotation {
+  data: {
+    handles: {
+      points: CoreTypes.Point3[];
+      activeHandleIndex: number | null;
+      textBoxTPA?: CoreTypes.Point2;
+    };
+    cachedStats?: {
+      [targetId: string]: {
+        TPA: number;
+        functionalAxisVector: CoreTypes.Point3;
+        plateauLineVector: CoreTypes.Point3;
+        perpendicularVector: CoreTypes.Point3;
+        plateauMidpoint: CoreTypes.Point3;
+        intersectionPoint?: CoreTypes.Point3;  // 功能轴线与中垂线的交点
+      };
+    };
+  };
+}
+
+// 导出的注解数据接口
+export interface ExportedAnnotationData {
+  points: CoreTypes.Point3[];
+  TPA: number;
+  textBoxPosition?: CoreTypes.Point2;
+  metadata: {
+    viewPlaneNormal: CoreTypes.Point3;
+    viewUp: CoreTypes.Point3;
+    FrameOfReferenceUID: string;
+    referencedImageId: string;
+  };
+}
+
+export default class TibialPlateauAngleTool extends AnnotationTool {
+  static toolName = 'TibialPlateauAngleTool';
+
+  editData: {
+    annotation: Types.Annotation;
+    viewportIdsToRender: string[];
+    handleIndex?: number;
+    newAnnotation?: boolean;
+    hasMoved?: boolean;
+    textBoxBeingMoved?: boolean;
+    textBoxOffset?: CoreTypes.Point2;
+  } | null = null;
+
+  isDrawing: boolean = false;
+
+  static createDefaultAnnotation(
+    element: HTMLDivElement,
+    viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
+    config?: AnnotationInitialConfig
+  ): TibialPlateauAngleAnnotation {
+    const enabledElement = getEnabledElement(element);
+    if (!enabledElement) {
+      throw new Error('Element is not enabled');
+    }
+
+    const canvas = viewport.canvas;
+    const { width, height } = canvas;
+    const centerX = width / 2;
+    const centerY = height / 2;
+
+    const axisLineLength = config?.axisLineLength ?? 200;
+    const plateauLineLength = config?.plateauLineLength ?? 150;
+    const axisLineCenterX = config?.axisLineCenterX ?? centerX - 100;
+    const axisLineCenterY = config?.axisLineCenterY ?? centerY;
+    const plateauLineCenterX = config?.plateauLineCenterX ?? centerX + 100;
+    const plateauLineCenterY = config?.plateauLineCenterY ?? centerY;
+
+    // 点1、2: 胫骨内侧平台线 (水平线)
+    const plateauHalfLength = plateauLineLength / 2;
+    const point1Canvas: CoreTypes.Point2 = [
+      plateauLineCenterX - plateauHalfLength,
+      plateauLineCenterY
+    ];
+    const point2Canvas: CoreTypes.Point2 = [
+      plateauLineCenterX + plateauHalfLength,
+      plateauLineCenterY
+    ];
+
+    // 点3、4: 胫骨功能轴线 (垂直线)
+    const axisHalfLength = axisLineLength / 2;
+    const point3Canvas: CoreTypes.Point2 = [
+      axisLineCenterX,
+      axisLineCenterY - axisHalfLength
+    ];
+    const point4Canvas: CoreTypes.Point2 = [
+      axisLineCenterX,
+      axisLineCenterY + axisHalfLength
+    ];
+
+    const canvasPoints: CoreTypes.Point2[] = [
+      point1Canvas,
+      point2Canvas,
+      point3Canvas,
+      point4Canvas,
+    ];
+
+    const worldPoints = canvasPoints.map((canvasPoint) =>
+      viewport.canvasToWorld(canvasPoint)
+    );
+
+    const camera = viewport.getCamera();
+    const { viewPlaneNormal, viewUp } = camera;
+    if (viewPlaneNormal === undefined || viewUp === undefined) {
+      throw new Error('Camera parameters undefined');
+    }
+
+    return {
+      invalidated: true,
+      highlighted: false,
+      metadata: {
+        viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3,
+        viewUp: [...viewUp] as CoreTypes.Point3,
+        FrameOfReferenceUID: viewport.getFrameOfReferenceUID(),
+        referencedImageId: viewport.getCurrentImageId?.() || '',
+        toolName: TibialPlateauAngleTool.toolName,
+      },
+      data: {
+        label: '',
+        handles: {
+          points: worldPoints,
+          activeHandleIndex: null,
+        },
+        cachedStats: {},
+      },
+    } as TibialPlateauAngleAnnotation;
+  }
+
+  static exportAnnotationData(
+    annotation: TibialPlateauAngleAnnotation,
+    viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
+  ): ExportedAnnotationData | null {
+    const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`;
+    const cachedStats = annotation.data.cachedStats?.[targetId];
+
+    if (!cachedStats || cachedStats.TPA === undefined) {
+      return null;
+    }
+
+    return {
+      points: [...annotation.data.handles.points],
+      TPA: cachedStats.TPA,
+      textBoxPosition: annotation.data.handles.textBoxTPA,
+      metadata: {
+        viewPlaneNormal: annotation?.metadata?.viewPlaneNormal || [0, 0, 1],
+        viewUp: annotation?.metadata?.viewUp || [0, 1, 0],
+        FrameOfReferenceUID: annotation?.metadata?.FrameOfReferenceUID || '',
+        referencedImageId: annotation.metadata?.referencedImageId || '',
+      },
+    };
+  }
+
+  static restoreFromExportedData(
+    exportedData: ExportedAnnotationData,
+    element: HTMLDivElement,
+    viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
+  ): TibialPlateauAngleAnnotation {
+    const enabledElement = getEnabledElement(element);
+    if (!enabledElement) {
+      throw new Error('Element is not enabled');
+    }
+
+    return {
+      invalidated: true,
+      highlighted: false,
+      metadata: {
+        ...exportedData.metadata,
+        toolName: TibialPlateauAngleTool.toolName,
+      },
+      data: {
+        label: '',
+        handles: {
+          points: [...exportedData.points],
+          activeHandleIndex: null,
+          textBoxTPA: exportedData.textBoxPosition,
+        },
+        cachedStats: {},
+      },
+    } as TibialPlateauAngleAnnotation;
+  }
+
+  constructor(
+    toolProps: PublicToolProps = {},
+    defaultToolProps: ToolProps = {
+      supportedInteractionTypes: ['Mouse', 'Touch'],
+      configuration: {
+        shadow: true,
+        preventHandleOutsideImage: false,
+      },
+    }
+  ) {
+    super(toolProps, defaultToolProps);
+  }
+
+  addNewAnnotation(evt: EventTypes.InteractionEventType): TibialPlateauAngleAnnotation {
+    evt.preventDefault();
+    return {} as TibialPlateauAngleAnnotation;
+  }
+
+  isPointNearTool(
+    element: HTMLDivElement,
+    annotation: TibialPlateauAngleAnnotation,
+    canvasCoords: CoreTypes.Point2,
+    proximity: number
+  ): boolean {
+    const enabledElement = getEnabledElement(element);
+    if (!enabledElement) return false;
+    
+    const { viewport } = enabledElement;
+    const points = annotation.data.handles.points;
+
+    for (let i = 0; i < points.length; i++) {
+      const canvasPoint = viewport.worldToCanvas(points[i]);
+      const distance = Math.sqrt(
+        Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
+        Math.pow(canvasPoint[1] - canvasCoords[1], 2)
+      );
+      if (distance < proximity) return true;
+    }
+    return false;
+  }
+
+  private _isPointInTextBox(
+    canvasPoint: CoreTypes.Point2,
+    textBoxPosition: CoreTypes.Point2,
+    textWidth: number = 100,
+    textHeight: number = 40
+  ): boolean {
+    const [x, y] = canvasPoint;
+    const [tx, ty] = textBoxPosition;
+    const margin = 10;
+    return (
+      x >= tx - margin &&
+      x <= tx + textWidth + margin &&
+      y >= ty - margin &&
+      y <= ty + textHeight + margin
+    );
+  }
+
+  private _createAngleArcPoints(
+    center: CoreTypes.Point2,
+    vector1: CoreTypes.Point2,
+    vector2: CoreTypes.Point2,
+    radius: number
+  ): CoreTypes.Point2[] {
+    const angle1 = Math.atan2(vector1[1], vector1[0]);
+    const angle2 = Math.atan2(vector2[1], vector2[0]);
+    
+    let startAngle = angle1;
+    let endAngle = angle2;
+    
+    let angleDiff = endAngle - startAngle;
+    if (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
+    else if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
+    
+    if (angleDiff < 0) {
+      [startAngle, endAngle] = [endAngle, startAngle];
+      angleDiff = -angleDiff;
+    }
+    
+    const numPoints = Math.max(10, Math.ceil(Math.abs(angleDiff) / (Math.PI / 18)));
+    const points: CoreTypes.Point2[] = [];
+    
+    for (let i = 0; i <= numPoints; i++) {
+      const t = i / numPoints;
+      const angle = startAngle + angleDiff * t;
+      points.push([
+        center[0] + radius * Math.cos(angle),
+        center[1] + radius * Math.sin(angle)
+      ]);
+    }
+    return points;
+  }
+
+  cancel(element: HTMLDivElement): string {
+    if (this.isDrawing) {
+      this.isDrawing = false;
+      this._deactivateDraw(element);
+      this._deactivateModify(element);
+
+      const enabledElement = getEnabledElement(element);
+      if (enabledElement) {
+        const viewportIdsToRender =
+          utilities.viewportFilters.getViewportIdsWithToolToRender(
+            element,
+            this.getToolName()
+          );
+        utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+      }
+
+      this.editData = null;
+      return this.getToolName();
+    }
+    return '';
+  }
+
+  _activateDraw(element: HTMLDivElement): void {
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener);
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener);
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener);
+  }
+
+  _deactivateDraw(element: HTMLDivElement): void {
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener);
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener);
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener);
+  }
+
+  _activateModify(element: HTMLDivElement): void {
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener);
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener);
+    element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener);
+  }
+
+  _deactivateModify(element: HTMLDivElement): void {
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener);
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener);
+    element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener);
+  }
+
+  _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    const eventDetail = evt.detail;
+    const { element, currentPoints } = eventDetail;
+    const canvasCoords = currentPoints.canvas;
+
+    const enabledElement = getEnabledElement(element);
+    if (!enabledElement) return;
+    
+    const { viewport } = enabledElement;
+    const annotations = annotation.state.getAnnotations(this.getToolName(), element);
+    if (!annotations || annotations.length === 0) return;
+
+    // 先检查是否点击在文本框上
+    for (const ann of annotations) {
+      const customAnn = ann as TibialPlateauAngleAnnotation;
+      const targetId = this.getTargetId(viewport);
+      const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
+
+      if (cachedStats?.intersectionPoint) {
+        const intersectionCanvas = viewport.worldToCanvas(cachedStats.intersectionPoint);
+        // 获取文本框位置,如果不存在则使用默认位置
+        const textBoxTPA: CoreTypes.Point2 = customAnn.data.handles.textBoxTPA || [
+          intersectionCanvas[0] + 40,
+          intersectionCanvas[1] - 10,
+        ];
+
+        if (this._isPointInTextBox(canvasCoords, textBoxTPA)) {
+          const viewportIdsToRender =
+            utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
+
+          // 计算鼠标点击位置相对于文本框左上角的偏移量
+          const textBoxOffset: CoreTypes.Point2 = [
+            canvasCoords[0] - textBoxTPA[0],
+            canvasCoords[1] - textBoxTPA[1],
+          ];
+
+          this.editData = {
+            annotation: customAnn,
+            viewportIdsToRender,
+            hasMoved: false,
+            textBoxBeingMoved: true,
+            textBoxOffset: textBoxOffset,
+          };
+
+          customAnn.highlighted = true;
+          utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+          evt.preventDefault();
+          evt.stopPropagation();
+          return;
+        }
+      }
+    }
+
+    // 如果没有点击文本框,再检查是否点击在手柄上
+    for (const ann of annotations) {
+      const customAnn = ann as TibialPlateauAngleAnnotation;
+      const handle = this.getHandleNearImagePoint(element, customAnn, canvasCoords, 6);
+
+      if (handle) {
+        const viewportIdsToRender =
+          utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
+
+        this.editData = {
+          annotation: customAnn,
+          viewportIdsToRender,
+          handleIndex: customAnn.data.handles.activeHandleIndex || 0,
+          hasMoved: false,
+        };
+
+        customAnn.highlighted = true;
+        utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+        evt.preventDefault();
+        evt.stopPropagation();
+        return;
+      }
+    }
+  };
+
+  _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    if (!this.editData) return;
+
+    const eventDetail = evt.detail;
+    const { currentPoints } = eventDetail;
+    const canvasCoords = currentPoints.canvas;
+
+    const enabledElement = getEnabledElement(eventDetail.element);
+    if (!enabledElement) return;
+
+    const { annotation: ann, viewportIdsToRender, textBoxBeingMoved } = this.editData;
+    const customAnn = ann as TibialPlateauAngleAnnotation;
+    const { data } = customAnn;
+
+    if (textBoxBeingMoved && this.editData.textBoxOffset) {
+      data.handles.textBoxTPA = [
+        canvasCoords[0] - this.editData.textBoxOffset[0],
+        canvasCoords[1] - this.editData.textBoxOffset[1],
+      ];
+      this.editData.hasMoved = true;
+      utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+      evt.preventDefault();
+      evt.stopPropagation();
+      return;
+    }
+
+    const worldPos = currentPoints.world;
+    const activeHandleIndex = data.handles.activeHandleIndex;
+
+    if (activeHandleIndex !== null && activeHandleIndex >= 0 && activeHandleIndex < data.handles.points.length) {
+      data.handles.points[activeHandleIndex] = worldPos;
+      this._updateCachedStats(customAnn, enabledElement);
+      this.editData.hasMoved = true;
+      utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+      evt.preventDefault();
+      evt.stopPropagation();
+    }
+  };
+
+  _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => {
+    if (!this.editData) return;
+
+    const { annotation: ann } = this.editData;
+    const customAnn = ann as TibialPlateauAngleAnnotation;
+
+    customAnn.data.handles.activeHandleIndex = null;
+    customAnn.highlighted = false;
+
+    const viewportIdsToRender =
+      utilities.viewportFilters.getViewportIdsWithToolToRender(
+        evt.detail.element,
+        this.getToolName()
+      );
+
+    utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+    this.editData = null;
+    evt.preventDefault();
+    evt.stopPropagation();
+  };
+
+  handleSelectedCallback(
+    evt: EventTypes.InteractionEventType,
+    annotation: TibialPlateauAngleAnnotation
+  ): void {
+    annotation.highlighted = true;
+    const viewportIdsToRender =
+      utilities.viewportFilters.getViewportIdsWithToolToRender(
+        evt.detail.element,
+        this.getToolName()
+      );
+    utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+    evt.preventDefault();
+  }
+
+  toolSelectedCallback(
+    evt: EventTypes.InteractionEventType,
+    annotation: TibialPlateauAngleAnnotation
+  ): void {
+    // 实现工具选中逻辑
+  }
+
+  _dragCallback = (evt: EventTypes.InteractionEventType): void => {
+    this.isDrawing = true;
+    const enabledElement = getEnabledElement(evt.detail.element);
+    if (!enabledElement || !this.editData) return;
+
+    const { annotation, viewportIdsToRender } = this.editData;
+    const { data } = annotation as TibialPlateauAngleAnnotation;
+
+    if (annotation) {
+      const worldPos = evt.detail.currentPoints.world;
+      const points = data.handles.points;
+      const activeHandleIndex = data.handles.activeHandleIndex;
+
+      if (activeHandleIndex !== null && activeHandleIndex < points.length) {
+        points[activeHandleIndex] = worldPos;
+        this._updateCachedStats(annotation as TibialPlateauAngleAnnotation, enabledElement);
+        this.editData!.hasMoved = true;
+      }
+
+      utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
+    }
+  };
+
+  _endCallback = (evt: EventTypes.InteractionEventType): void => {
+    if (!this.editData) return;
+    if (this.editData.hasMoved) {
+      this.editData.hasMoved = false;
+    }
+  };
+
+  getHandleNearImagePoint(
+    element: HTMLDivElement,
+    annotation: TibialPlateauAngleAnnotation,
+    canvasCoords: CoreTypes.Point2,
+    proximity: number
+  ): Types.ToolHandle | undefined {
+    const enabledElement = getEnabledElement(element);
+    if (!enabledElement) return undefined;
+    
+    const { viewport } = enabledElement;
+    const points = annotation.data.handles.points;
+    const handleProximity = Math.max(proximity, 15);
+
+    for (let i = 0; i < points.length; i++) {
+      const canvasPoint = viewport.worldToCanvas(points[i]);
+      const distance = Math.sqrt(
+        Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
+        Math.pow(canvasPoint[1] - canvasCoords[1], 2)
+      );
+
+      if (distance < handleProximity) {
+        annotation.data.handles.activeHandleIndex = i;
+        return { worldPosition: points[i] } as Types.ToolHandle;
+      }
+    }
+    
+    annotation.data.handles.activeHandleIndex = null;
+    return undefined;
+  }
+
+  _updateCachedStats(
+    annotation: TibialPlateauAngleAnnotation,
+    enabledElement: CoreTypes.IEnabledElement
+  ): void {
+    const { viewport } = enabledElement;
+    const { data } = annotation;
+    const points = data.handles.points;
+
+    const targetId = this.getTargetId(viewport);
+    if (!targetId) return;
+
+    if (!data.cachedStats) data.cachedStats = {};
+    if (!data.cachedStats[targetId]) {
+      data.cachedStats[targetId] = {
+        TPA: 0,
+        functionalAxisVector: [0, 0, 0],
+        plateauLineVector: [0, 0, 0],
+        perpendicularVector: [0, 0, 0],
+        plateauMidpoint: [0, 0, 0],
+      };
+    }
+
+    const stats = data.cachedStats[targetId];
+
+    if (points.length >= 4) {
+      const point1 = points[0];  // 平台线起点
+      const point2 = points[1];  // 平台线终点
+      const point3 = points[2];  // 功能轴线起点
+      const point4 = points[3];  // 功能轴线终点
+
+      // 计算胫骨内侧平台线向量 (点1→点2)
+      const plateauLineVector = vectorSubtract(point2, point1);
+      stats.plateauLineVector = plateauLineVector;
+
+      // 计算胫骨功能轴线向量 (点3→点4)
+      const functionalAxisVector = vectorSubtract(point4, point3);
+      stats.functionalAxisVector = functionalAxisVector;
+
+      // 计算胫骨内侧平台线中点(用于显示参考)
+      const plateauMidpoint = calculateMidpoint(point1, point2);
+      stats.plateauMidpoint = plateauMidpoint;
+
+      // 计算平台线与功能轴线的交点(作为中垂线的起点)
+      const intersection = this._getLineIntersection(
+        point1,                  // 平台线起点
+        plateauLineVector,       // 平台线方向
+        point3,                  // 功能轴线起点
+        functionalAxisVector     // 功能轴线方向
+      );
+      
+      if (intersection) {
+        stats.intersectionPoint = intersection;
+      }
+
+      // 计算平台线的中垂线向量(2D平面上,向量(x,y)的逆时针垂直向量为(-y,x))
+      // 这条垂线从交点出发,垂直于平台线
+      const perpendicularVector: CoreTypes.Point3 = [
+        -plateauLineVector[1],
+        plateauLineVector[0],
+        0
+      ];
+      stats.perpendicularVector = perpendicularVector;
+
+      // 计算TPA角度:功能轴线与平台线中垂线的夹角
+      const angleRad = getAngleBetweenLines(functionalAxisVector, perpendicularVector);
+      stats.TPA = Math.round(degrees(angleRad));
+    }
+  }
+
+  private _normalizeVector(v: CoreTypes.Point3): CoreTypes.Point3 {
+    const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+    if (length === 0) return [0, 0, 0];
+    return [v[0] / length, v[1] / length, v[2] / length];
+  }
+
+  /**
+   * 计算两条直线的交点
+   */
+  private _getLineIntersection(
+    line1Start: CoreTypes.Point3,
+    line1Dir: CoreTypes.Point3,
+    line2Start: CoreTypes.Point3,
+    line2Dir: CoreTypes.Point3
+  ): CoreTypes.Point3 | null {
+    const dx = line2Start[0] - line1Start[0];
+    const dy = line2Start[1] - line1Start[1];
+    
+    const det = line1Dir[0] * line2Dir[1] - line1Dir[1] * line2Dir[0];
+    
+    if (Math.abs(det) < 1e-10) {
+      return null; // 平行线
+    }
+    
+    const t = (dx * line2Dir[1] - dy * line2Dir[0]) / det;
+    
+    return [
+      line1Start[0] + t * line1Dir[0],
+      line1Start[1] + t * line1Dir[1],
+      line1Start[2] + t * line1Dir[2]
+    ];
+  }
+
+  renderAnnotation = (
+    enabledElement: CoreTypes.IEnabledElement,
+    svgDrawingHelper: SVGDrawingHelper
+  ): boolean => {
+    let renderStatus = false;
+    const { viewport } = enabledElement;
+    const { element } = viewport;
+
+    let annotations = annotation.state.getAnnotations(this.getToolName(), element);
+    if (!annotations?.length) return renderStatus;
+
+    for (let i = 0; i < annotations.length; i++) {
+      const annotation = annotations[i] as TibialPlateauAngleAnnotation;
+      const { annotationUID, data } = annotation;
+      const points = data.handles.points;
+
+      if (points.length < 4) continue;
+
+      const targetId = this.getTargetId(viewport);
+      const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined;
+      const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
+      const lineColor = annotation.highlighted ? 'rgb(0, 150, 255)' : 'rgb(255, 255, 255)';
+
+      // 绘制胫骨内侧平台线(点1→点2)
+      if (annotationUID) {
+        drawLineSvg(
+          svgDrawingHelper,
+          annotationUID,
+          `${annotationUID}-plateauLine`,
+          canvasPoints[0],
+          canvasPoints[1],
+          { color: lineColor, width: 2 }
+        );
+
+        // 绘制胫骨功能轴线(点3→点4)
+        drawLineSvg(
+          svgDrawingHelper,
+          annotationUID,
+          `${annotationUID}-functionalAxis`,
+          canvasPoints[2],
+          canvasPoints[3],
+          { color: lineColor, width: 2 }
+        );
+
+        // 绘制胫骨内侧平台线的延长线(虚线):从最近的端点延伸到交点
+        if (cachedStats && cachedStats.intersectionPoint && points.length >= 2) {
+          const point1 = points[0];  // 平台线起点
+          const point2 = points[1];  // 平台线终点
+          const intersection = cachedStats.intersectionPoint;
+          
+          // 计算交点到两个端点的距离,选择较近的端点
+          const dist1 = Math.sqrt(
+            Math.pow(intersection[0] - point1[0], 2) +
+            Math.pow(intersection[1] - point1[1], 2)
+          );
+          const dist2 = Math.sqrt(
+            Math.pow(intersection[0] - point2[0], 2) +
+            Math.pow(intersection[1] - point2[1], 2)
+          );
+          
+          // 选择离交点较近的端点作为延长线起点
+          const extensionStart = dist1 < dist2 ? point1 : point2;
+          
+          const extensionStartCanvas = viewport.worldToCanvas(extensionStart);
+          const intersectionCanvas = viewport.worldToCanvas(intersection);
+          
+          drawLineSvg(
+            svgDrawingHelper,
+            annotationUID,
+            `${annotationUID}-plateauExtension`,
+            extensionStartCanvas,
+            intersectionCanvas,
+            { color: 'rgb(200, 200, 200)', width: 1, lineDash: '4,4' }
+          );
+        }
+
+        // 绘制功能轴线的延长线(虚线):从最近的端点延伸到交点
+        if (cachedStats && cachedStats.intersectionPoint && points.length >= 4) {
+          const point3 = points[2];  // 功能轴线起点
+          const point4 = points[3];  // 功能轴线终点
+          const intersection = cachedStats.intersectionPoint;
+          
+          // 计算交点到两个端点的距离,选择较近的端点
+          const dist3 = Math.sqrt(
+            Math.pow(intersection[0] - point3[0], 2) +
+            Math.pow(intersection[1] - point3[1], 2)
+          );
+          const dist4 = Math.sqrt(
+            Math.pow(intersection[0] - point4[0], 2) +
+            Math.pow(intersection[1] - point4[1], 2)
+          );
+          
+          // 选择离交点较近的端点作为延长线起点
+          const extensionStart = dist3 < dist4 ? point3 : point4;
+          
+          const extensionStartCanvas = viewport.worldToCanvas(extensionStart);
+          const intersectionCanvas = viewport.worldToCanvas(intersection);
+          
+          drawLineSvg(
+            svgDrawingHelper,
+            annotationUID,
+            `${annotationUID}-axisExtension`,
+            extensionStartCanvas,
+            intersectionCanvas,
+            { color: 'rgb(200, 200, 200)', width: 1, lineDash: '4,4' }
+          );
+        }
+
+        // 绘制平台线的中垂线(从交点向两端延伸)
+        if (cachedStats && cachedStats.perpendicularVector && cachedStats.intersectionPoint) {
+          // 归一化中垂线向量
+          const normalizedPerp = this._normalizeVector(cachedStats.perpendicularVector);
+          
+          // 延长长度:从交点向两端各延伸150像素
+          const extendLength = 150;
+
+          // 中垂线起点:从交点向一个方向延伸
+          const perpStart = vectorAdd(
+            cachedStats.intersectionPoint,
+            vectorScale(normalizedPerp, -extendLength)
+          );
+
+          // 中垂线终点:从交点向另一个方向延伸
+          const perpEnd = vectorAdd(
+            cachedStats.intersectionPoint,
+            vectorScale(normalizedPerp, extendLength)
+          );
+
+          const perpStartCanvas = viewport.worldToCanvas(perpStart);
+          const perpEndCanvas = viewport.worldToCanvas(perpEnd);
+
+          drawLineSvg(
+            svgDrawingHelper,
+            annotationUID,
+            `${annotationUID}-perpendicular`,
+            perpStartCanvas,
+            perpEndCanvas,
+            { color: 'rgb(0, 255, 0)', width: 2, lineDash: '4,4' }
+          );
+        }
+
+        // 绘制角度弧线和文本(在交点位置)
+        if (cachedStats && cachedStats.functionalAxisVector && cachedStats.perpendicularVector && cachedStats.intersectionPoint) {
+          // 使用交点作为角度弧线的中心
+          const intersectionCanvas = viewport.worldToCanvas(cachedStats.intersectionPoint);
+
+          const axisVector2D: CoreTypes.Point2 = [
+            cachedStats.functionalAxisVector[0],
+            cachedStats.functionalAxisVector[1]
+          ];
+          const perpVector2D: CoreTypes.Point2 = [
+            cachedStats.perpendicularVector[0],
+            cachedStats.perpendicularVector[1]
+          ];
+
+          const arcPoints = this._createAngleArcPoints(intersectionCanvas, axisVector2D, perpVector2D, 30);
+
+          drawPathSvg(
+            svgDrawingHelper,
+            annotationUID,
+            `${annotationUID}-arc`,
+            arcPoints,
+            { color: 'rgb(255, 255, 0)', width: 2 }
+          );
+
+          // 计算弧线中间位置:取弧线点数组的中间点
+          const arcMidIndex = Math.floor(arcPoints.length / 2);
+          const arcMidPoint: CoreTypes.Point2 = arcPoints[arcMidIndex];
+
+          const textLines = [`TPA: ${cachedStats.TPA}°`];
+          const textBoxPosition: CoreTypes.Point2 = data.handles.textBoxTPA || [
+            intersectionCanvas[0] + 40,
+            intersectionCanvas[1] - 10,
+          ];
+
+          // 文本虚线连接到弧线中间位置
+          drawLinkedTextBox(
+            svgDrawingHelper,
+            annotationUID,
+            `${annotationUID}-text`,
+            textLines,
+            textBoxPosition,
+            [arcMidPoint],
+            {},
+            { color: 'rgb(255, 255, 0)' }
+          );
+        }
+
+        // 绘制控制点
+        drawHandles(
+          svgDrawingHelper,
+          annotationUID,
+          '0',
+          canvasPoints,
+          { color: 'rgb(255, 255, 255)', handleRadius: 6 }
+        );
+      }
+
+      renderStatus = true;
+    }
+
+    return renderStatus;
+  };
+}

+ 316 - 0
src/components/measures/mathUtils.ts

@@ -0,0 +1,316 @@
+// 数学计算工具函数
+import type { Types } from '@cornerstonejs/core';
+type Point3 = Types.Point3
+type Point2 = Types.Point2;
+export {Point2};
+
+
+// 计算中点
+export function calculateMidpoint(point1: Point3, point2: Point3): Point3 {
+  return [
+    (point1[0] + point2[0]) / 2,
+    (point1[1] + point2[1]) / 2,
+    (point1[2] + point2[2]) / 2
+  ];
+}
+
+// 向量减法
+export function vectorSubtract(v1: Point3, v2: Point3): Point3 {
+  return [
+    v1[0] - v2[0],
+    v1[1] - v2[1],
+    v1[2] - v2[2]
+  ];
+}
+
+// 向量加法
+export function vectorAdd(v1: Point3, v2: Point3): Point3 {
+  return [
+    v1[0] + v2[0],
+    v1[1] + v2[1],
+    v1[2] + v2[2]
+  ];
+}
+
+// 向量缩放
+export function vectorScale(v: Point3, scale: number): Point3 {
+  return [
+    v[0] * scale,
+    v[1] * scale,
+    v[2] * scale
+  ];
+}
+
+/**
+ * 计算向量的反向向量
+ * @param vector 输入向量 [x, y, z]
+ * @returns 反向向量 [-x, -y, -z]
+ */
+export function calculateOppositeVector(vector: Point3): Point3 {
+  const [x, y, z] = vector;
+  return [-x, -y, -z];
+}
+
+/**
+ * 将向量方向的指定长度分解到坐标轴上(2D 平面,忽略 z 轴)
+ * @param vector 输入向量 [x, y, z],但只使用 [x, y]
+ * @param length 目标长度
+ * @returns 分解后的向量 [px, py, 0],其长度为 length
+ */
+export function decomposeVectorOnAxes(vector: Point3, length: number): Point3 {
+  const vx = vector[0];
+  const vy = vector[1];
+  // 计算模长(忽略 z)
+  const norm = Math.sqrt(vx * vx + vy * vy);
+  
+  if (norm === 0) {
+    // 输入为零向量,返回零向量
+    return [0, 0, 0];
+  }
+  
+  // 分解:单位向量 * length
+  const px = length * (vx / norm);
+  const py = length * (vy / norm);
+  
+  return [px, py, 0];
+}
+
+// 向量点积
+export function dotProduct(v1: Point3, v2: Point3): number {
+  return v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2];
+}
+
+// 计算两点之间的欧几里得距离
+export function calculateDistance(p1: Point3, p2: Point3): number {
+  const dx = p2[0] - p1[0];
+  const dy = p2[1] - p1[1];
+  const dz = p2[2] - p1[2];
+  return Math.sqrt(dx * dx + dy * dy + dz * dz);
+}
+
+// 计算向量的角度(弧度)
+export function getAngle(vector: Point3): number {
+  return Math.atan2(vector[1], vector[0]);
+}
+
+// 计算两条线之间的夹角(弧度)
+export function getAngleBetweenLines(line1: Point3, line2: Point3): number {
+  const dot = line1[0] * line2[0] + line1[1] * line2[1];
+  const mag1 = Math.sqrt(line1[0] * line1[0] + line1[1] * line1[1]);
+  const mag2 = Math.sqrt(line2[0] * line2[0] + line2[1] * line2[1]);
+  
+  if (mag1 === 0 || mag2 === 0) return 0;
+  
+  const cosAngle = dot / (mag1 * mag2);
+  return Math.acos(Math.max(-1, Math.min(1, cosAngle)));
+}
+
+// 将弧度转换为角度
+export function degrees(radians: number): number {
+  return radians * (180 / Math.PI);
+}
+
+// 计算两条线段的交点
+export function getLineIntersection(
+  line1Start: Point3,
+  line1Dir: Point3,
+  line2Start: Point3,
+  line2Dir: Point3
+): Point3 | null {
+  // 使用参数方程求解交点
+  // line1: P1 + t * D1
+  // line2: P2 + s * D2
+  
+  const dx = line2Start[0] - line1Start[0];
+  const dy = line2Start[1] - line1Start[1];
+  
+  const det = line1Dir[0] * line2Dir[1] - line1Dir[1] * line2Dir[0];
+  
+  if (Math.abs(det) < 1e-10) {
+    // 平行线,无交点
+    return null;
+  }
+  
+  const t = (dx * line2Dir[1] - dy * line2Dir[0]) / det;
+  const s = (dx * line1Dir[1] - dy * line1Dir[0]) / det;
+  
+  // 检查交点是否在线段延长线上
+  if (t >= 0 && s >= 0) {
+    return [
+      line1Start[0] + t * line1Dir[0],
+      line1Start[1] + t * line1Dir[1],
+      0
+    ];
+  }
+  
+  return null;
+}
+
+export function fromPoint3ToPoint2(p3:Point3) :Point2{
+  return [p3[0],p3[1]];
+}
+export function fromPoint2ToPoint3(p2:Point2) :Point3{
+  return [p2[0],p2[1],0];
+}
+
+/**
+ * 求解3x3线性方程组 A*x = B
+ * 使用克拉默法则
+ * @param A 3x3系数矩阵
+ * @param B 3x1常数向量
+ * @returns 解向量 [x, y, z]
+ */
+export function solveLinearSystem3x3(A: number[][], B: number[]): number[] {
+  // 计算3x3矩阵的行列式
+  const det = (m: number[][]): number => 
+    m[0][0] * (m[1][1]*m[2][2] - m[1][2]*m[2][1]) -
+    m[0][1] * (m[1][0]*m[2][2] - m[1][2]*m[2][0]) +
+    m[0][2] * (m[1][0]*m[2][1] - m[1][1]*m[2][0]);
+  
+  const detA = det(A);
+  
+  // 检查行列式是否接近0(矩阵奇异)
+  if (Math.abs(detA) < 1e-10) {
+    throw new Error('矩阵奇异,无法求解线性方程组');
+  }
+  
+  // 使用克拉默法则求解
+  // x = det(A1) / det(A), 其中A1是将A的第一列替换为B
+  const A1 = [
+    [B[0], A[0][1], A[0][2]], 
+    [B[1], A[1][1], A[1][2]], 
+    [B[2], A[2][1], A[2][2]]
+  ];
+  
+  const A2 = [
+    [A[0][0], B[0], A[0][2]], 
+    [A[1][0], B[1], A[1][2]], 
+    [A[2][0], B[2], A[2][2]]
+  ];
+  
+  const A3 = [
+    [A[0][0], A[0][1], B[0]], 
+    [A[1][0], A[1][1], B[1]], 
+    [A[2][0], A[2][1], B[2]]
+  ];
+  
+  return [det(A1)/detA, det(A2)/detA, det(A3)/detA];
+}
+
+/**
+ * 通过3个点拟合圆
+ * 圆的一般方程: x² + y² + Dx + Ey + F = 0
+ * 圆心坐标: (-D/2, -E/2)
+ * 半径: r = √((D² + E²)/4 - F)
+ * @param p1 第一个点
+ * @param p2 第二个点
+ * @param p3 第三个点
+ * @returns 圆心和半径 {center: Point3, radius: number}
+ */
+export function fitCircleFrom3Points(
+  p1: Point3, 
+  p2: Point3, 
+  p3: Point3
+): { center: Point3; radius: number } | null {
+  // 提取2D坐标(x, y)
+  const x1 = p1[0], y1 = p1[1];
+  const x2 = p2[0], y2 = p2[1];
+  const x3 = p3[0], y3 = p3[1];
+  
+  // 构建线性方程组 A * [D, E, F]ᵀ = B
+  // 对于圆上的每个点(xi, yi): xi² + yi² + D*xi + E*yi + F = 0
+  const A = [
+    [x1, y1, 1],
+    [x2, y2, 1],
+    [x3, y3, 1]
+  ];
+  
+  const B = [
+    -(x1*x1 + y1*y1),
+    -(x2*x2 + y2*y2),
+    -(x3*x3 + y3*y3)
+  ];
+  
+  try {
+    // 求解方程组得到 D, E, F
+    const [D, E, F] = solveLinearSystem3x3(A, B);
+    
+    // 计算圆心坐标
+    const centerX = -D / 2;
+    const centerY = -E / 2;
+    const centerZ = p1[2];  // 保持Z坐标不变
+    
+    // 计算半径
+    const radiusSquared = (D*D + E*E) / 4 - F;
+    
+    // 检查半径是否有效
+    if (radiusSquared < 0) {
+      console.warn('计算出的半径平方为负值,三点可能共线或数据有误');
+      return null;
+    }
+    
+    const radius = Math.sqrt(radiusSquared);
+    
+    return {
+      center: [centerX, centerY, centerZ],
+      radius
+    };
+  } catch (error) {
+    console.warn('圆拟合失败,三点可能共线:', error);
+    return null;
+  }
+}
+
+/**
+ * 计算点到直线的垂直距离
+ * @param point 目标点
+ * @param lineStart 直线上的一点(起点)
+ * @param lineDirection 直线的方向向量
+ * @returns 点到直线的垂直距离
+ */
+export function pointToLineDistance(
+  point: Point3,
+  lineStart: Point3,
+  lineDirection: Point3
+): number {
+  // 向量AP = point - lineStart
+  const AP = vectorSubtract(point, lineStart);
+  
+  // 2D平面上的叉积的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]
+  );
+  
+  // 避免除以0
+  if (directionMagnitude === 0) {
+    return calculateDistance(point, lineStart);
+  }
+  
+  // 距离 = |叉积| / |方向向量|
+  return Math.abs(crossProduct) / directionMagnitude;
+}
+
+/**
+ * 向量归一化(转换为单位向量)
+ * @param v 输入向量
+ * @returns 归一化后的向量
+ */
+export function normalizeVector(v: Point3): Point3 {
+  const magnitude = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
+  
+  if (magnitude === 0) {
+    return [0, 0, 0];
+  }
+  
+  return [
+    v[0] / magnitude,
+    v[1] / magnitude,
+    v[2] / magnitude
+  ];
+}
+
+export type {Point3};

+ 39 - 1
src/pages/view/components/ViewerContainer.tsx

@@ -24,6 +24,9 @@ import StackViewer, {
   activateAngleMeasurement,
   deactivateAngleMeasurement,
   clearAngleMeasurements,
+  activateTibialPlateauAngleMeasurement,
+  deactivateTibialPlateauAngleMeasurement,
+  clearTibialPlateauAngleMeasurements,
 } from './viewers/stack.image.viewer';
 import { useSelector, useDispatch } from 'react-redux';
 import store, { RootState } from '@/states/store';
@@ -401,8 +404,9 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           if (selectedViewportIds.length > 0) {
             MeasurementToolManager.clearLengthMeasurementsForViewports(selectedViewportIds);
             MeasurementToolManager.clearAngleMeasurementsForViewports(selectedViewportIds);
+            MeasurementToolManager.clearTibialPlateauAngleMeasurementsForViewports(selectedViewportIds);
           }
-          console.log('Clearing Length Measurements from MeasurementPanel');
+          console.log('Clearing All Measurements from MeasurementPanel');
           break;
 
         case '停用线段测量':
@@ -441,6 +445,40 @@ const ViewerContainer: React.FC<ViewerContainerProps> = ({ imageUrls }) => {
           console.log('Activating Angle Measurement from MeasurementPanel');
           break;
 
+        case '胫骨平台夹角':
+          console.log(`开始胫骨平台夹角测量`);
+          if (selectedViewportIds.length > 0) {
+            selectedViewportIds.forEach((viewportId) => {
+              const success = activateTibialPlateauAngleMeasurement(viewportId);
+              if (success) {
+                console.log(`激活胫骨平台夹角测量工具成功`);
+                dispatch(
+                  setToolActive({
+                    toolName: 'TibialPlateauAngleTool',
+                    viewportId: viewportId,
+                  })
+                );
+              }
+            });
+          } else {
+            // 如果没有选中的 viewport,为所有可见的 viewport 激活
+            const visibleViewportCount = getVisibleViewportCount();
+            for (let i = 0; i < visibleViewportCount; i++) {
+              const viewportId = `viewport-${i}`;
+              const success = activateTibialPlateauAngleMeasurement(viewportId);
+              if (success) {
+                dispatch(
+                  setToolActive({
+                    toolName: 'TibialPlateauAngleTool',
+                    viewportId: viewportId,
+                  })
+                );
+              }
+            }
+          }
+          console.log('Activating TibialPlateauAngle Measurement from MeasurementPanel');
+          break;
+
         case '测量校正':
           console.log('Measurement Calibration - 功能待实现');
           break;

+ 63 - 0
src/pages/view/components/viewers/stack.image.viewer.tsx

@@ -6,6 +6,7 @@ import { annotation, SplineROITool } from '@cornerstonejs/tools';
 import { eventTarget } from '@cornerstonejs/core';
 import { registerGlobalTools } from '@/utils/cornerstoneToolsSetup';
 import { MeasurementToolManager } from '@/utils/measurementToolManager';
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
 import { boolean } from 'zod';
 
 const {
@@ -117,6 +118,7 @@ function registerTools(viewportId, renderingEngineId) {
   toolGroup.addTool(PlanarRotateTool.toolName);
   toolGroup.addTool(LengthTool.toolName); // 添加线段测量工具
   toolGroup.addTool(AngleTool.toolName); // 添加角度测量工具
+  toolGroup.addTool(TibialPlateauAngleTool.toolName); // 添加胫骨平台夹角测量工具
 
   // 设置默认工具状态
   setupDefaultToolStates(toolGroup);
@@ -165,6 +167,7 @@ function setupDefaultToolStates(toolGroup: cornerstoneTools.Types.IToolGroup) {
   toolGroup.setToolPassive(PlanarRotateTool.toolName);
   toolGroup.setToolPassive(LengthTool.toolName);
   toolGroup.setToolPassive(AngleTool.toolName);
+  toolGroup.setToolPassive(TibialPlateauAngleTool.toolName);
 }
 export function addLMark(currentViewportId: string): void {
   // Implement the logic to add an L mark
@@ -611,6 +614,66 @@ export function isAngleMeasurementActive(viewportId: string): boolean {
   return MeasurementToolManager.isAngleToolActive(viewportId);
 }
 
+// ==================== 胫骨平台夹角测量相关函数 ====================
+
+/**
+ * 激活胫骨平台夹角测量工具
+ */
+export function activateTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[activateTibialPlateauAngleMeasurement] Activating TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.activateTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 停用胫骨平台夹角测量工具
+ */
+export function deactivateTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[deactivateTibialPlateauAngleMeasurement] Deactivating TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.deactivateTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 切换胫骨平台夹角测量工具状态
+ */
+export function toggleTibialPlateauAngleMeasurement(viewportId: string): boolean {
+  console.log(
+    `[toggleTibialPlateauAngleMeasurement] Toggling TPA measurement for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.toggleTibialPlateauAngleTool(viewportId);
+}
+
+/**
+ * 清除胫骨平台夹角测量标注
+ */
+export function clearTibialPlateauAngleMeasurements(viewportId: string): boolean {
+  console.log(
+    `[clearTibialPlateauAngleMeasurements] Clearing TPA measurements for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.clearTibialPlateauAngleMeasurements(viewportId);
+}
+
+/**
+ * 获取胫骨平台夹角测量结果
+ */
+// eslint-disable-next-line
+export function getTibialPlateauAngleMeasurements(viewportId: string): any[] {
+  console.log(
+    `[getTibialPlateauAngleMeasurements] Getting TPA measurements for viewport: ${viewportId}`
+  );
+  return MeasurementToolManager.getTibialPlateauAngleMeasurements(viewportId);
+}
+
+/**
+ * 检查胫骨平台夹角测量工具是否激活
+ */
+export function isTibialPlateauAngleMeasurementActive(viewportId: string): boolean {
+  return MeasurementToolManager.isTibialPlateauAngleToolActive(viewportId);
+}
+
 const StackViewer = ({
   imageIndex = 0,
   imageUrls = [],

+ 1 - 0
src/states/view/measurementPanelSlice.ts

@@ -7,6 +7,7 @@ export type MeasurementAction =
   | '清除测量'
   | '测量校正'
   | '停用线段测量'
+  | '胫骨平台夹角'
   | null;
 
 // 测量结果类型

+ 2 - 0
src/utils/cornerstoneToolsSetup.ts

@@ -1,4 +1,5 @@
 import * as cornerstoneTools from '@cornerstonejs/tools';
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
 
 const {
   MagnifyTool,
@@ -38,6 +39,7 @@ export function registerGlobalTools(): void {
     cornerstoneTools.addTool(PlanarRotateTool);
     cornerstoneTools.addTool(LengthTool); // 添加线段测量工具
     cornerstoneTools.addTool(cornerstoneTools.AngleTool); // 添加角度测量工具
+    cornerstoneTools.addTool(TibialPlateauAngleTool); // 添加胫骨平台夹角测量工具
 
     toolsRegistered = true;
     console.log('[cornerstoneToolsSetup] All tools registered successfully');

+ 266 - 0
src/utils/measurementToolManager.ts

@@ -1,5 +1,6 @@
 import * as cornerstone from '@cornerstonejs/core';
 import * as cornerstoneTools from '@cornerstonejs/tools';
+import TibialPlateauAngleTool from '@/components/measures/TibialPlateauAngleTool';
 
 const {
   ToolGroupManager,
@@ -423,4 +424,269 @@ export class MeasurementToolManager {
       this.clearAngleMeasurements(viewportId)
     );
   }
+
+  // ==================== 胫骨平台夹角测量工具 ====================
+
+  /**
+   * 激活胫骨平台夹角测量工具
+   */
+  static activateTibialPlateauAngleTool(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      // 停用其他可能冲突的工具
+      toolGroup.setToolPassive(WindowLevelTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(MagnifyTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(LengthTool.toolName, {
+        removeAllBindings: true,
+      });
+      toolGroup.setToolPassive(AngleTool.toolName, {
+        removeAllBindings: true,
+      });
+
+      // 激活胫骨平台夹角测量工具
+      toolGroup.setToolActive(TibialPlateauAngleTool.toolName, {
+        bindings: [{ mouseButton: MouseBindings.Primary }],
+      });
+      // 获取工具实例并激活修改模式
+      const toolInstance = toolGroup.getToolInstance(
+        TibialPlateauAngleTool.toolName
+      ) as TibialPlateauAngleTool;
+      // 使用参数中的 viewportId,而不是硬编码
+      const viewport = cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+      if (toolInstance && viewport.element) {
+        toolInstance._activateModify(viewport.element);
+      }
+
+      // 自动创建一个预设的注解
+      try {
+
+
+        if (viewport && viewport.element) {
+          // 创建预设注解
+          const defaultAnnotation = TibialPlateauAngleTool.createDefaultAnnotation(
+            viewport.element as HTMLDivElement,
+            viewport as cornerstone.Types.IStackViewport
+          );
+
+          // 添加注解到状态管理
+          cornerstoneTools.annotation.state.addAnnotation(
+            defaultAnnotation,
+            viewport.element
+          );
+
+          // 获取工具实例并更新缓存统计数据
+          const enabledElement = cornerstone.getEnabledElement(viewport.element);
+          if (enabledElement) {
+            const toolInstance = toolGroup.getToolInstance(
+              TibialPlateauAngleTool.toolName
+            ) as TibialPlateauAngleTool;
+
+            if (toolInstance && '_updateCachedStats' in toolInstance) {
+              (toolInstance as any)._updateCachedStats(defaultAnnotation, enabledElement);
+            }
+          }
+
+          // 触发渲染更新
+          // 触发渲染更新
+          const viewportIdsToRender = cornerstoneTools.utilities.viewportFilters.getViewportIdsWithToolToRender(
+            viewport.element,
+            TibialPlateauAngleTool.toolName
+          );
+
+          cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
+            viewportIdsToRender
+          );
+
+          console.log('[MeasurementToolManager] Default TPA annotation created successfully');
+        }
+      } catch (error) {
+        console.error('[MeasurementToolManager] Failed to create default annotation:', error);
+        // 注解创建失败不影响工具激活
+      }
+
+      console.log(
+        `[MeasurementToolManager] TibialPlateauAngle tool activated for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error activating TibialPlateauAngle tool:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 停用胫骨平台夹角测量工具
+   */
+  static deactivateTibialPlateauAngleTool(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      toolGroup.setToolPassive(TibialPlateauAngleTool.toolName, {
+        removeAllBindings: true,
+      });
+      console.log(
+        `[MeasurementToolManager] TibialPlateauAngle tool deactivated for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error deactivating TibialPlateauAngle tool:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 检查胫骨平台夹角测量工具是否处于激活状态
+   */
+  static isTibialPlateauAngleToolActive(viewportId: string): boolean {
+    const toolGroup = this.getToolGroup(viewportId);
+    if (!toolGroup) return false;
+
+    try {
+      const activeTool = toolGroup.getActivePrimaryMouseButtonTool();
+      return activeTool === TibialPlateauAngleTool.toolName;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error checking TibialPlateauAngle tool state:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 切换胫骨平台夹角测量工具状态
+   */
+  static toggleTibialPlateauAngleTool(viewportId: string): boolean {
+    const isActive = this.isTibialPlateauAngleToolActive(viewportId);
+
+    if (isActive) {
+      return this.deactivateTibialPlateauAngleTool(viewportId);
+    } else {
+      return this.activateTibialPlateauAngleTool(viewportId);
+    }
+  }
+
+  /**
+   * 清除指定 viewport 的所有胫骨平台夹角测量标注
+   */
+  static clearTibialPlateauAngleMeasurements(viewportId: string): boolean {
+    try {
+      const viewport =
+        cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+      if (!viewport) {
+        console.warn(
+          `[MeasurementToolManager] Viewport not found: ${viewportId}`
+        );
+        return false;
+      }
+
+      const annotations = cornerstoneTools.annotation.state.getAnnotations(
+        TibialPlateauAngleTool.toolName,
+        viewport.element
+      );
+
+      let removedCount = 0;
+      annotations.forEach((annotation) => {
+        if (annotation.annotationUID) {
+          cornerstoneTools.annotation.state.removeAnnotation(
+            annotation.annotationUID
+          );
+          removedCount++;
+        }
+      });
+
+      viewport.render();
+      console.log(
+        `[MeasurementToolManager] Cleared ${removedCount} TibialPlateauAngle measurements for viewport: ${viewportId}`
+      );
+      return true;
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error clearing TibialPlateauAngle measurements:`,
+        error
+      );
+      return false;
+    }
+  }
+
+  /**
+   * 获取指定 viewport 的所有胫骨平台夹角测量结果
+   */
+  // eslint-disable-next-line
+  static getTibialPlateauAngleMeasurements(viewportId: string): any[] {
+    try {
+      const viewport =
+        cornerstone.getEnabledElementByViewportId(viewportId)?.viewport;
+      if (!viewport) {
+        console.warn(
+          `[MeasurementToolManager] Viewport not found: ${viewportId}`
+        );
+        return [];
+      }
+
+      const annotations = cornerstoneTools.annotation.state.getAnnotations(
+        TibialPlateauAngleTool.toolName,
+        viewport.element
+      );
+
+      return annotations.map((annotation) => ({
+        annotationUID: annotation.annotationUID,
+        TPA: annotation.data?.cachedStats?.TPA || 0,
+        unit: 'degrees',
+        points: annotation.data?.handles?.points || [],
+      }));
+    } catch (error) {
+      console.error(
+        `[MeasurementToolManager] Error getting TibialPlateauAngle measurements:`,
+        error
+      );
+      return [];
+    }
+  }
+
+  /**
+   * 为多个 viewport 批量激活胫骨平台夹角测量工具
+   */
+  static activateTibialPlateauAngleToolForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.activateTibialPlateauAngleTool(viewportId)
+    );
+  }
+
+  /**
+   * 为多个 viewport 批量停用胫骨平台夹角测量工具
+   */
+  static deactivateTibialPlateauAngleToolForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.deactivateTibialPlateauAngleTool(viewportId)
+    );
+  }
+
+  /**
+   * 为多个 viewport 批量清除胫骨平台夹角测量
+   */
+  static clearTibialPlateauAngleMeasurementsForViewports(
+    viewportIds: string[]
+  ): boolean[] {
+    return viewportIds.map((viewportId) =>
+      this.clearTibialPlateauAngleMeasurements(viewportId)
+    );
+  }
 }