本文档详细说明如何在系统中添加一个新的Cornerstone3D测量工具。以胫骨平台夹角(TPA)测量工具为例,展示从工具类实现到UI集成的完整流程。
添加一个新的测量工具需要修改以下7个文件:
src/components/measures/YourTool.ts
src/states/view/measurementPanelSlice.ts
src/utils/cornerstoneToolsSetup.ts
src/utils/measurementToolManager.ts
src/pages/view/components/viewers/stack.image.viewer.tsx
src/pages/view/components/ViewerContainer.tsx
src/pages/view/components/MeasurementPanel.tsx
(通常已存在)文件: src/components/measures/TibialPlateauAngleTool.ts
这是最核心的一步,需要创建继承自AnnotationTool
的工具类。
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 { }
}
// 导入依赖
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;
// ... 实现所有必需方法
}
文件: src/states/view/measurementPanelSlice.ts
目的: 让Redux知道新的测量操作类型
// 找到 MeasurementAction 类型定义
export type MeasurementAction =
| '线段测量'
| '角度测量'
| '清除测量'
| '测量校正'
| '停用线段测量'
| '胫骨平台夹角' // ⭐ 添加这一行
| null;
MeasurementAction
类型定义中文件: src/utils/cornerstoneToolsSetup.ts
目的: 在Cornerstone3D中全局注册工具,使其可被使用
// 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;
}
}
文件: src/utils/measurementToolManager.ts
目的: 提供统一的API来激活、停用、清除工具
// 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
- 批量操作文件: src/pages/view/components/viewers/stack.image.viewer.tsx
目的: 将工具添加到每个viewport的工具组,并导出便捷函数
// 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);
}
文件: src/pages/view/components/ViewerContainer.tsx
目的: 响应Redux action,协调工具的激活和状态更新
// 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;
文件: src/pages/view/components/MeasurementPanel.tsx
说明: 这一步通常已经完成,按钮配置如下:
<FunctionButton
title="胫骨平台夹角"
action="胫骨平台夹角" // ⭐ 这个值必须与Redux中的MeasurementAction类型匹配
iconName="btn_TPA"
productId="animal"
/>
FunctionButton
组件的handleMeasurementAction
函数会自动分发Redux action:
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: 集成到容器 src/pages/view/components/ViewerContainer.tsx
[ ] 步骤7: 确认UI按钮 src/pages/view/components/MeasurementPanel.tsx
FunctionButton
的 action
属性与Redux类型匹配复制此模板,替换{ToolName}
为你的工具名:
// ==================== {工具中文名}测量工具 ====================
/**
* 激活{工具中文名}测量工具
*/
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));
}
A: 检查以下几点:
A:
A:
renderAnnotation
方法返回值是否为trueA:
A:
exportAnnotationData
方法导出数据restoreFromExportedData
方法恢复数据通过以上7个步骤,你已经成功添加了一个新的测量工具!
关键要点回顾:
AnnotationTool
并实现所有必要方法下次添加新工具时,只需按照本文档的7个步骤,复制模板代码并替换工具名即可!
如果在实现过程中遇到问题:
祝你成功添加新的测量工具!🚀