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