import { Point3, Point2, fitCircleFrom3Points, calculateOppositeVector } from './mathUtils'; 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, drawCircle: drawCircleSvg, drawPath: drawPathSvg, } = drawing; import { vectorSubtract, vectorAdd, vectorScale, getAngleBetweenLines, degrees, } from './mathUtils'; import { PublicToolProps, ToolProps, EventTypes, SVGDrawingHelper, } from '@cornerstonejs/tools/dist/esm/types'; // 注解数据接口 interface HipNHAAngleMeasurementAnnotation extends Types.Annotation { data: { handles: { points: CoreTypes.Point3[]; // 8个点的世界坐标 activeHandleIndex: number | null; // 当前激活的手柄索引 textBoxLeft?: CoreTypes.Point2; // 左侧角度文本框位置 textBoxRight?: CoreTypes.Point2; // 右侧角度文本框位置 }; cachedStats?: { [targetId: string]: { // 左侧圆信息 centerM1?: CoreTypes.Point3; // 左侧圆心M1 radiusLeft?: number; // 左侧半径 // 右侧圆信息 centerM2?: CoreTypes.Point3; // 右侧圆心M2 radiusRight?: number; // 右侧半径 // 角度信息 angleLeft?: number; // 左侧NHA角(度数) angleRight?: number; // 右侧NHA角(度数) // 辅助向量(用于绘制角度弧线) baselineVector?: CoreTypes.Point3; // M1-M2基准线向量 leftTangentVector?: CoreTypes.Point3; // M1-4切线向量 rightTangentVector?: CoreTypes.Point3; // M2-8切线向量 }; }; }; } // 导出的注解数据接口 export interface ExportedHipNHAAnnotationData { points: CoreTypes.Point3[]; // 8个点的世界坐标 circles: { centerM1: CoreTypes.Point3; // 左侧圆心 radiusLeft: number; // 左侧半径 centerM2: CoreTypes.Point3; // 右侧圆心 radiusRight: number; // 右侧半径 }; angles: { angleLeft: number; // 左侧NHA角 angleRight: number; // 右侧NHA角 }; textBoxPositions?: { textBoxLeft?: CoreTypes.Point2; // 左侧文本框位置 textBoxRight?: CoreTypes.Point2; // 右侧文本框位置 }; metadata: { viewPlaneNormal: CoreTypes.Point3; viewUp: CoreTypes.Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } export default class HipNHAAngleMeasurementTool extends AnnotationTool { static toolName = 'HipNHAAngleMeasurementTool'; editData: { annotation: Types.Annotation; viewportIdsToRender: string[]; handleIndex?: number; newAnnotation?: boolean; hasMoved?: boolean; textBoxBeingMoved?: 'Left' | 'Right'; // 正在拖拽的文本框 textBoxOffset?: CoreTypes.Point2; // 文本框拖拽时的偏移量 movingWholeTool?: boolean; // 是否正在移动整个工具 wholeToolOffset?: CoreTypes.Point2; // 移动整个工具时的偏移量 selected?: boolean; // 工具选中状态 } | null = null; isDrawing: boolean = false; /** * 创建一个预设的注解,带有8个默认点 * @param element HTML元素 * @param viewport Viewport实例 */ static createDefaultAnnotation( element: HTMLDivElement, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport ): HipNHAAngleMeasurementAnnotation { const enabledElement = getEnabledElement(element); if (!enabledElement) { throw new Error('Element is not enabled'); } // 获取viewport的尺寸 const canvas = viewport.canvas; const { width, height } = canvas; const centerX = width / 2; const centerY = height / 2; // 左侧圆:圆心在中心左侧150px,半径60px const leftCircleCenter: CoreTypes.Point2 = [centerX - 150, centerY]; const leftRadius = 60; // 右侧圆:圆心在中心右侧150px,半径60px const rightCircleCenter: CoreTypes.Point2 = [centerX + 150, centerY]; const rightRadius = 60; // 生成左侧圆上的3个点(120度间隔) const leftPoint1: CoreTypes.Point2 = [ leftCircleCenter[0] + leftRadius * Math.cos(0), leftCircleCenter[1] + leftRadius * Math.sin(0) ]; const leftPoint2: CoreTypes.Point2 = [ leftCircleCenter[0] + leftRadius * Math.cos(Math.PI * 2 / 3), leftCircleCenter[1] + leftRadius * Math.sin(Math.PI * 2 / 3) ]; const leftPoint3: CoreTypes.Point2 = [ leftCircleCenter[0] + leftRadius * Math.cos(Math.PI * 4 / 3), leftCircleCenter[1] + leftRadius * Math.sin(Math.PI * 4 / 3) ]; // 左侧髋臼边缘点(在圆心上方) const leftAcetabularPoint: CoreTypes.Point2 = [ leftCircleCenter[0] - 20, leftCircleCenter[1] - 80 ]; // 生成右侧圆上的3个点(120度间隔) const rightPoint1: CoreTypes.Point2 = [ rightCircleCenter[0] + rightRadius * Math.cos(0), rightCircleCenter[1] + rightRadius * Math.sin(0) ]; const rightPoint2: CoreTypes.Point2 = [ rightCircleCenter[0] + rightRadius * Math.cos(Math.PI * 2 / 3), rightCircleCenter[1] + rightRadius * Math.sin(Math.PI * 2 / 3) ]; const rightPoint3: CoreTypes.Point2 = [ rightCircleCenter[0] + rightRadius * Math.cos(Math.PI * 4 / 3), rightCircleCenter[1] + rightRadius * Math.sin(Math.PI * 4 / 3) ]; // 右侧髋臼边缘点(在圆心上方) const rightAcetabularPoint: CoreTypes.Point2 = [ rightCircleCenter[0] + 20, rightCircleCenter[1] - 80 ]; const canvasPoints: CoreTypes.Point2[] = [ leftPoint1, // 点0: 左圆点1 leftPoint2, // 点1: 左圆点2 leftPoint3, // 点2: 左圆点3 leftAcetabularPoint, // 点3: 左侧髋臼边缘点 rightPoint1, // 点4: 右圆点1 rightPoint2, // 点5: 右圆点2 rightPoint3, // 点6: 右圆点3 rightAcetabularPoint, // 点7: 右侧髋臼边缘点 ]; // 转换为world坐标 const worldPoints = canvasPoints.map((canvasPoint) => viewport.canvasToWorld(canvasPoint) ); const camera = viewport.getCamera(); const { viewPlaneNormal, viewUp } = camera; if (viewPlaneNormal === undefined) { throw new Error('viewPlaneNormal is undefined'); } if (viewUp === undefined) { throw new Error('viewUp is undefined'); } const annotationData = { invalidated: true, highlighted: false, metadata: { viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3, viewUp: [...viewUp] as CoreTypes.Point3, FrameOfReferenceUID: viewport.getFrameOfReferenceUID(), referencedImageId: viewport.getCurrentImageId?.() || '', toolName: HipNHAAngleMeasurementTool.toolName, }, data: { label: '', handles: { points: worldPoints, activeHandleIndex: null, }, cachedStats: {}, }, } as HipNHAAngleMeasurementAnnotation; return annotationData; } /** * 导出注解数据 * @param annotation 要导出的注解 * @param viewport Viewport实例 * @returns 导出的数据对象 */ static exportAnnotationData( annotation: HipNHAAngleMeasurementAnnotation, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport ): ExportedHipNHAAnnotationData | null { const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`; const cachedStats = annotation.data.cachedStats?.[targetId]; if (!cachedStats || !cachedStats.centerM1 || !cachedStats.centerM2) { return null; } return { points: [...annotation.data.handles.points], circles: { centerM1: cachedStats.centerM1, radiusLeft: cachedStats.radiusLeft || 0, centerM2: cachedStats.centerM2, radiusRight: cachedStats.radiusRight || 0, }, angles: { angleLeft: cachedStats.angleLeft || 0, angleRight: cachedStats.angleRight || 0, }, textBoxPositions: { textBoxLeft: annotation.data.handles.textBoxLeft, textBoxRight: annotation.data.handles.textBoxRight, }, metadata: { viewPlaneNormal: annotation.metadata?.viewPlaneNormal || [0, 0, 1], viewUp: annotation.metadata?.viewUp || [0, 1, 0], FrameOfReferenceUID: annotation.metadata?.FrameOfReferenceUID || '', referencedImageId: annotation.metadata?.referencedImageId || '', }, }; } /** * 从导出的数据恢复注解 * @param exportedData 导出的数据 * @param element HTML元素 * @param viewport Viewport实例 * @returns 恢复的注解 */ static restoreFromExportedData( exportedData: ExportedHipNHAAnnotationData, element: HTMLDivElement, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport ): HipNHAAngleMeasurementAnnotation { const enabledElement = getEnabledElement(element); if (!enabledElement) { throw new Error('Element is not enabled'); } const annotationData = { invalidated: true, highlighted: false, metadata: { ...exportedData.metadata, toolName: HipNHAAngleMeasurementTool.toolName, }, data: { label: '', handles: { points: [...exportedData.points], activeHandleIndex: null, textBoxLeft: exportedData.textBoxPositions?.textBoxLeft, textBoxRight: exportedData.textBoxPositions?.textBoxRight, }, cachedStats: {}, }, } as HipNHAAngleMeasurementAnnotation; return annotationData; } constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { shadow: true, preventHandleOutsideImage: false, }, } ) { super(toolProps, defaultToolProps); } /** * 设置注解选中状态 * @param annotation 要设置的注解 * @param selected 选中状态 */ private _setAnnotationSelected(annotation: HipNHAAngleMeasurementAnnotation, selected: boolean): void { annotation.isSelected = selected; if (selected) { annotation.highlighted = true; } } /** * 添加新注解 - 禁用此功能 */ addNewAnnotation( evt: EventTypes.InteractionEventType ): HipNHAAngleMeasurementAnnotation { evt.preventDefault(); return {} as HipNHAAngleMeasurementAnnotation; } /** * 计算点到线段的距离 */ private _distanceToSegment( point: CoreTypes.Point2, lineStart: CoreTypes.Point2, lineEnd: CoreTypes.Point2 ): number { const x = point[0]; const y = point[1]; const x1 = lineStart[0]; const y1 = lineStart[1]; const x2 = lineEnd[0]; const y2 = lineEnd[1]; const A = x - x1; const B = y - y1; const C = x2 - x1; const D = y2 - y1; const dot = A * C + B * D; const lenSq = C * C + D * D; let param = -1; if (lenSq !== 0) { param = dot / lenSq; } let xx, yy; if (param < 0) { xx = x1; yy = y1; } else if (param > 1) { xx = x2; yy = y2; } else { xx = x1 + param * C; yy = y1 + param * D; } const dx = x - xx; const dy = y - yy; return Math.sqrt(dx * dx + dy * dy); } /** * 检查点是否靠近工具 */ isPointNearTool( element: HTMLDivElement, annotation: HipNHAAngleMeasurementAnnotation, canvasCoords: CoreTypes.Point2, proximity: number ): boolean { const enabledElement = getEnabledElement(element); if (!enabledElement) { return false; } const { viewport } = enabledElement; const points = annotation.data.handles.points; //检查是否在文本框上 if (this._isPointInTextBox(canvasCoords, annotation.data.handles.textBoxLeft || [0, 0]) || this._isPointInTextBox(canvasCoords, annotation.data.handles.textBoxRight || [0, 0])) { return true; } // 检查是否靠近任意一个手柄点 for (let i = 0; i < points.length; i++) { const point = points[i]; const canvasPoint = viewport.worldToCanvas(point); const distance = Math.sqrt( Math.pow(canvasPoint[0] - canvasCoords[0], 2) + Math.pow(canvasPoint[1] - canvasCoords[1], 2) ); if (distance < proximity) { return true; } } // 检查是否靠近线段 if (points.length >= 8) { const targetId = this.getTargetId(viewport); const cachedStats = targetId ? annotation.data.cachedStats?.[targetId] : undefined; if (cachedStats) { // 检查基准线 M1-M2 if (cachedStats.centerM1 && cachedStats.centerM2) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const dist = this._distanceToSegment(canvasCoords, centerM1Canvas, centerM2Canvas); if (dist < proximity) { return true; } } // 检查左侧切线 M1-point3 if (cachedStats.centerM1 && points.length > 3) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const point3Canvas = viewport.worldToCanvas(points[3]); const dist = this._distanceToSegment(canvasCoords, centerM1Canvas, point3Canvas); if (dist < proximity) { return true; } } // 检查右侧切线 M2-point7 if (cachedStats.centerM2 && points.length > 7) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const point7Canvas = viewport.worldToCanvas(points[7]); const dist = this._distanceToSegment(canvasCoords, centerM2Canvas, point7Canvas); if (dist < proximity) { return true; } } } } return false; } /** * 检查点击是否在文本框内 */ private _isPointInTextBox( canvasPoint: CoreTypes.Point2, textBoxPosition: CoreTypes.Point2, textWidth: number = 120, 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; const x = center[0] + radius * Math.cos(angle); const y = center[1] + radius * Math.sin(angle); points.push([x, y]); } 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 ); element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_MOVE', this._mouseMoveModifyCallback as EventListener ); element.addEventListener( 'keydown', this._keyDownCallback 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 ); element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_MOVE', this._mouseMoveModifyCallback as EventListener ); element.removeEventListener( 'keydown', this._keyDownCallback 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 HipNHAAngleMeasurementAnnotation; const targetId = this.getTargetId(viewport); const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined; if (cachedStats?.centerM1) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const textBoxLeft: CoreTypes.Point2 = customAnn.data.handles.textBoxLeft || [ centerM1Canvas[0] - 60, centerM1Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxLeft)) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); const textBoxOffset: CoreTypes.Point2 = [ canvasCoords[0] - textBoxLeft[0], canvasCoords[1] - textBoxLeft[1], ]; this.editData = { annotation: customAnn, viewportIdsToRender, hasMoved: false, textBoxBeingMoved: 'Left', textBoxOffset: textBoxOffset, }; customAnn.highlighted = true; utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); evt.stopPropagation(); return; } } if (cachedStats?.centerM2) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const textBoxRight: CoreTypes.Point2 = customAnn.data.handles.textBoxRight || [ centerM2Canvas[0] + 20, centerM2Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxRight)) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); const textBoxOffset: CoreTypes.Point2 = [ canvasCoords[0] - textBoxRight[0], canvasCoords[1] - textBoxRight[1], ]; this.editData = { annotation: customAnn, viewportIdsToRender, hasMoved: false, textBoxBeingMoved: 'Right', textBoxOffset: textBoxOffset, }; customAnn.highlighted = true; utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); evt.stopPropagation(); return; } } } // 如果没有点击文本框,再查找最近的手柄 for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; const handle = this.getHandleNearImagePoint( element, customAnn, canvasCoords, 6 ); if (handle) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); // 选中工具 this._setAnnotationSelected(customAnn, true); this.editData = { annotation: customAnn, viewportIdsToRender, handleIndex: customAnn.data.handles.activeHandleIndex || 0, hasMoved: false, }; customAnn.highlighted = true; utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); evt.stopPropagation(); return; } } // 如果没有找到手柄,检查是否点击在线段上(开始移动整个工具) for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; if (this.isPointNearTool(element, customAnn, canvasCoords, 10)) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); // 选中工具 this._setAnnotationSelected(customAnn, true); // 计算鼠标点击位置相对于工具的偏移量 const points = customAnn.data.handles.points; if (points.length >= 8) { const midPoint = vectorAdd(points[0], vectorScale(vectorSubtract(points[1], points[0]), 0.5)); const midCanvas = viewport.worldToCanvas(midPoint); const wholeToolOffset: CoreTypes.Point2 = [ canvasCoords[0] - midCanvas[0], canvasCoords[1] - midCanvas[1], ]; this.editData = { annotation: customAnn, viewportIdsToRender, hasMoved: false, movingWholeTool: true, wholeToolOffset: wholeToolOffset, }; 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, movingWholeTool } = this.editData; const customAnn = ann as HipNHAAngleMeasurementAnnotation; const { data } = customAnn; // 如果正在拖拽文本框 if (textBoxBeingMoved && this.editData.textBoxOffset) { const newTextBoxPosition: CoreTypes.Point2 = [ canvasCoords[0] - this.editData.textBoxOffset[0], canvasCoords[1] - this.editData.textBoxOffset[1], ]; if (textBoxBeingMoved === 'Left') { data.handles.textBoxLeft = newTextBoxPosition; } else if (textBoxBeingMoved === 'Right') { data.handles.textBoxRight = newTextBoxPosition; } this.editData.hasMoved = true; utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); evt.stopPropagation(); return; } // 如果正在移动整个工具 if (movingWholeTool && this.editData.wholeToolOffset) { // 计算新的中点位置 const newMidCanvas: CoreTypes.Point2 = [ canvasCoords[0] - this.editData.wholeToolOffset[0], canvasCoords[1] - this.editData.wholeToolOffset[1], ]; const newMidWorld = enabledElement.viewport.canvasToWorld(newMidCanvas); // 计算所有点的偏移量 const points = data.handles.points; if (points.length >= 8) { const oldMid = vectorAdd(points[0], vectorScale(vectorSubtract(points[1], points[0]), 0.5)); const offset = vectorSubtract(newMidWorld, oldMid); // 移动所有点 for (let i = 0; i < points.length; i++) { points[i] = vectorAdd(points[i], offset); } // 移动文本框位置(使用相同的偏移量) if (data.handles.textBoxLeft) { data.handles.textBoxLeft = [ data.handles.textBoxLeft[0] + offset[0], data.handles.textBoxLeft[1] + offset[1] ]; } if (data.handles.textBoxRight) { data.handles.textBoxRight = [ data.handles.textBoxRight[0] + offset[0], data.handles.textBoxRight[1] + offset[1] ]; } // 重新计算角度 this._updateCachedStats(customAnn, enabledElement); 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 eventDetail = evt.detail; const { element, currentPoints } = eventDetail; const canvasCoords = currentPoints.canvas; const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const { viewport } = enabledElement; const { annotation: ann, hasMoved, movingWholeTool } = this.editData; const customAnn = ann as HipNHAAngleMeasurementAnnotation; customAnn.data.handles.activeHandleIndex = null; // 如果没有拖拽且点击在线段上(movingWholeTool为true),则进入选中状态 if (!hasMoved && movingWholeTool) { // 设置选中状态:使用注解的selected字段 this._setAnnotationSelected(customAnn, true); // 保持高亮以显示选中 customAnn.highlighted = true; } else { customAnn.highlighted = false; } // 如果是简单点击(没有移动),检查是否点击到其他位置以取消选中 if (!hasMoved) { const annotations = annotation.state.getAnnotations(this.getToolName(), element); // 检查是否点击到任何工具部分 let clickedOnTool = false; for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; // 检查点击位置是否在工具范围内 if (this.isPointNearTool(element, customAnn, canvasCoords, 25)) { clickedOnTool = true; break; } // 检查是否在文本框范围内 const targetId = this.getTargetId(viewport); const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined; if (cachedStats?.centerM1) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const textBoxLeft: CoreTypes.Point2 = customAnn.data.handles.textBoxLeft || [ centerM1Canvas[0] - 60, centerM1Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxLeft)) { clickedOnTool = true; break; } } if (cachedStats?.centerM2) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const textBoxRight: CoreTypes.Point2 = customAnn.data.handles.textBoxRight || [ centerM2Canvas[0] + 20, centerM2Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxRight)) { clickedOnTool = true; break; } } } // 如果没有点击到任何工具,清除所有选中状态 if (!clickedOnTool) { for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; this._setAnnotationSelected(customAnn, false); customAnn.highlighted = false; } } } const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); this.editData = null; evt.preventDefault(); evt.stopPropagation(); }; /** * 鼠标移动回调 - 用于修改模式,处理悬停检测 * 实现功能1和2:鼠标悬停在文本上时光标为手型,悬停在线段或手柄上时光标为十字准星 */ _mouseMoveModifyCallback = (evt: EventTypes.InteractionEventType): void => { const eventDetail = evt.detail; const { element, currentPoints } = eventDetail; // 添加空值检查 if (!currentPoints || !currentPoints.canvas) { return; } 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) { // 没有注解时,重置光标 element.style.cursor = 'default'; return; } let isHovering = false; // 检查是否悬停在文本框上 for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; const targetId = this.getTargetId(viewport); const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined; if (cachedStats?.centerM1) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const textBoxLeft: CoreTypes.Point2 = customAnn.data.handles.textBoxLeft || [ centerM1Canvas[0] - 60, centerM1Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxLeft)) { element.style.cursor = 'pointer'; // 手型光标 customAnn.highlighted = true; isHovering = true; break; } } if (cachedStats?.centerM2) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const textBoxRight: CoreTypes.Point2 = customAnn.data.handles.textBoxRight || [ centerM2Canvas[0] + 20, centerM2Canvas[1] - 50, ]; if (this._isPointInTextBox(canvasCoords, textBoxRight)) { element.style.cursor = 'pointer'; // 手型光标 customAnn.highlighted = true; isHovering = true; break; } } } // 如果没有悬停在文本框上,检查是否悬停在手柄或线段上 if (!isHovering) { for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; // 检查是否靠近手柄 const handle = this.getHandleNearImagePoint(element, customAnn, canvasCoords, 6); if (handle) { element.style.cursor = 'crosshair'; // 十字准星光标 customAnn.highlighted = true; isHovering = true; break; } // 检查是否靠近线段(圆弧路径) if (this.isPointNearTool(element, customAnn, canvasCoords, 10)) { element.style.cursor = 'crosshair'; // 十字准星光标 customAnn.highlighted = true; isHovering = true; break; } } } // 如果没有悬停在任何地方,重置高亮(但保留选中状态) if (!isHovering) { for (const ann of annotations) { const customAnn = ann as HipNHAAngleMeasurementAnnotation; // 只有未选中的工具才重置 highlighted // 已选中的工具保持 highlighted = true if (!customAnn.isSelected) { customAnn.highlighted = false; } } element.style.cursor = 'default'; } // 触发渲染以更新高亮状态 const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; /** * 键盘按下回调 - 处理Delete键删除选中注解 */ _keyDownCallback = (evt: KeyboardEvent): void => { const element = evt.currentTarget as HTMLDivElement; // 检查是否按下 Delete 或 Backspace 键 if (evt.key === 'Delete' || evt.key === 'Backspace') { const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const annotations = annotation.state.getAnnotations(this.getToolName(), element); if (!annotations || annotations.length === 0) { return; } // 查找被选中的注解 const selectedAnnotations = annotations.filter( (ann) => (ann as HipNHAAngleMeasurementAnnotation).isSelected ); if (selectedAnnotations.length === 0) { return; } // 删除所有选中的注解 for (const ann of selectedAnnotations) { annotation.state.removeAnnotation(ann.annotationUID!); } // 触发渲染更新 const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); evt.stopPropagation(); } }; /** * 处理选中回调 */ handleSelectedCallback( evt: EventTypes.InteractionEventType, annotation: HipNHAAngleMeasurementAnnotation ): void { const eventDetail = evt.detail; const { element } = eventDetail; annotation.highlighted = true; const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); } /** * 工具选中回调 */ toolSelectedCallback( evt: EventTypes.InteractionEventType, annotation: HipNHAAngleMeasurementAnnotation ): void { // 实现工具选中逻辑 } /** * 处理鼠标拖拽 */ _dragCallback = (evt: EventTypes.InteractionEventType): void => { this.isDrawing = true; const eventDetail = evt.detail; const { element } = eventDetail; const enabledElement = getEnabledElement(element); if (!enabledElement || !this.editData) { return; } const { annotation, viewportIdsToRender } = this.editData; const { data } = annotation as HipNHAAngleMeasurementAnnotation; if (annotation) { const { currentPoints } = eventDetail; const worldPos = 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 HipNHAAngleMeasurementAnnotation, enabledElement ); this.editData!.hasMoved = true; } else if (this.editData!.newAnnotation) { const lastIndex = points.length - 1; if (lastIndex >= 0) { points[lastIndex] = worldPos; } } utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); } }; /** * 处理鼠标抬起/点击 */ _endCallback = (evt: EventTypes.InteractionEventType): void => { const eventDetail = evt.detail; const { element } = eventDetail; if (!this.editData) { return; } const { annotation, hasMoved } = this.editData; const { data } = annotation as HipNHAAngleMeasurementAnnotation; if (hasMoved) { this.editData.hasMoved = false; } data.handles.activeHandleIndex = null; }; /** * 获取靠近图像点的手柄 */ getHandleNearImagePoint( element: HTMLDivElement, annotation: HipNHAAngleMeasurementAnnotation, 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 point = points[i]; const canvasPoint = viewport.worldToCanvas(point); 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: point, } as Types.ToolHandle; } } annotation.data.handles.activeHandleIndex = null; return undefined; } /** * 更新缓存的统计数据 */ _updateCachedStats( annotation: HipNHAAngleMeasurementAnnotation, 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] = {}; } const stats = data.cachedStats[targetId]; if (points.length >= 8) { // 拟合左侧圆(点0, 1, 2) const leftCircleResult = fitCircleFrom3Points(points[0], points[1], points[2]); if (leftCircleResult) { stats.centerM1 = leftCircleResult.center; stats.radiusLeft = leftCircleResult.radius; } // 拟合右侧圆(点4, 5, 6) const rightCircleResult = fitCircleFrom3Points(points[4], points[5], points[6]); if (rightCircleResult) { stats.centerM2 = rightCircleResult.center; stats.radiusRight = rightCircleResult.radius; } // 如果两个圆都拟合成功,计算角度 if (stats.centerM1 && stats.centerM2) { // 基准线向量 M1 -> M2 stats.baselineVector = vectorSubtract(stats.centerM2, stats.centerM1); // 左侧切线向量 M1 -> point3 stats.leftTangentVector = vectorSubtract(points[3], stats.centerM1); // 右侧切线向量 M2 -> point7 stats.rightTangentVector = vectorSubtract(points[7], stats.centerM2); // 计算左侧NHA角 const leftAngleRad = getAngleBetweenLines( stats.baselineVector, stats.leftTangentVector ); stats.angleLeft = Math.round(degrees(leftAngleRad)); // 计算右侧NHA角 const rightAngleRad = getAngleBetweenLines( stats.baselineVector, calculateOppositeVector(stats.rightTangentVector) ); stats.angleRight = Math.round(degrees(rightAngleRad)); } } } /** * 渲染注解 */ 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 ann = annotations[i] as HipNHAAngleMeasurementAnnotation; const { annotationUID, data } = ann; const points = data.handles.points; if (points.length < 1) { continue; } const targetId = this.getTargetId(viewport); const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined; // 转换所有点为 canvas 坐标 const canvasPoints = points.map((p) => viewport.worldToCanvas(p)); // 确定颜色(选中时蓝色,未选中时白色) const mainColor = ann.highlighted ? 'rgb(0, 120, 255)' : 'rgb(255, 255, 255)'; // 根据选中或悬停状态调整手柄半径:选中或悬停时为12,否则为8 const isHighlighted = ann.isSelected || ann.highlighted; const handleRadius = isHighlighted ? 12 : 8; // 1. 绘制8个手柄点 if (annotationUID) { const handleGroupUID = '0'; drawHandles( svgDrawingHelper, annotationUID, handleGroupUID, canvasPoints, { color: 'rgb(255, 255, 255)', handleRadius: handleRadius, } ); } // 如果缓存统计数据存在,绘制圆和线 if (cachedStats && annotationUID) { // 2. 绘制左侧股骨头圆 if (cachedStats.centerM1 && cachedStats.radiusLeft) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); // 计算像素半径 const pointOnCircle: CoreTypes.Point3 = [ cachedStats.centerM1[0] + cachedStats.radiusLeft, cachedStats.centerM1[1], cachedStats.centerM1[2] ]; const pointOnCircleCanvas = viewport.worldToCanvas(pointOnCircle); const radiusPixels = Math.abs(pointOnCircleCanvas[0] - centerM1Canvas[0]); drawCircleSvg( svgDrawingHelper, annotationUID, 'leftCircle', centerM1Canvas, radiusPixels, { color: mainColor, width: 2, } ); } // 3. 绘制右侧股骨头圆 if (cachedStats.centerM2 && cachedStats.radiusRight) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); // 计算像素半径 const pointOnCircle: CoreTypes.Point3 = [ cachedStats.centerM2[0] + cachedStats.radiusRight, cachedStats.centerM2[1], cachedStats.centerM2[2] ]; const pointOnCircleCanvas = viewport.worldToCanvas(pointOnCircle); const radiusPixels = Math.abs(pointOnCircleCanvas[0] - centerM2Canvas[0]); drawCircleSvg( svgDrawingHelper, annotationUID, 'rightCircle', centerM2Canvas, radiusPixels, { color: mainColor, width: 2, } ); } // 4. 绘制基准线 M1-M2 if (cachedStats.centerM1 && cachedStats.centerM2) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); drawLineSvg( svgDrawingHelper, annotationUID, 'baseline', centerM1Canvas, centerM2Canvas, { color: mainColor, width: 2, } ); } // 5. 绘制左侧切线 M1-point3 if (cachedStats.centerM1 && points.length > 3) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); const point3Canvas = viewport.worldToCanvas(points[3]); drawLineSvg( svgDrawingHelper, annotationUID, 'leftTangent', centerM1Canvas, point3Canvas, { color: mainColor, width: 2, } ); } // 6. 绘制右侧切线 M2-point7 if (cachedStats.centerM2 && points.length > 7) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); const point7Canvas = viewport.worldToCanvas(points[7]); drawLineSvg( svgDrawingHelper, annotationUID, 'rightTangent', centerM2Canvas, point7Canvas, { color: mainColor, width: 2, } ); } // 7. 绘制左侧角度弧线和文本 if ( cachedStats.centerM1 && cachedStats.baselineVector && cachedStats.leftTangentVector && cachedStats.angleLeft !== undefined ) { const centerM1Canvas = viewport.worldToCanvas(cachedStats.centerM1); // 绘制角度弧线 const leftArcPoints = this._createAngleArcPoints( centerM1Canvas, [cachedStats.baselineVector[0], cachedStats.baselineVector[1]], [cachedStats.leftTangentVector[0], cachedStats.leftTangentVector[1]], 40 ); drawPathSvg( svgDrawingHelper, annotationUID, 'leftArc', leftArcPoints, { color: mainColor, width: 1, lineDash: '4,4', } ); // 绘制左侧角度文本框 const textBoxLeft: CoreTypes.Point2 = data.handles.textBoxLeft || [ centerM1Canvas[0] - 60, centerM1Canvas[1] - 50, ]; drawLinkedTextBox( svgDrawingHelper, annotationUID, 'leftAngleText', [`左侧NHA: ${cachedStats.angleLeft}°`], textBoxLeft, [centerM1Canvas], {}, { color: mainColor, } ); } // 8. 绘制右侧角度弧线和文本 if ( cachedStats.centerM2 && cachedStats.baselineVector && cachedStats.rightTangentVector && cachedStats.angleRight !== undefined ) { const centerM2Canvas = viewport.worldToCanvas(cachedStats.centerM2); // 绘制角度弧线 const rightArcPoints = this._createAngleArcPoints( centerM2Canvas, [-cachedStats.baselineVector[0], -cachedStats.baselineVector[1]], [cachedStats.rightTangentVector[0], cachedStats.rightTangentVector[1]], 40 ); drawPathSvg( svgDrawingHelper, annotationUID, 'rightArc', rightArcPoints, { color: mainColor, width: 1, lineDash: '4,4', } ); // 绘制右侧角度文本框 const textBoxRight: CoreTypes.Point2 = data.handles.textBoxRight || [ centerM2Canvas[0] + 20, centerM2Canvas[1] - 50, ]; drawLinkedTextBox( svgDrawingHelper, annotationUID, 'rightAngleText', [`右侧NHA: ${cachedStats.angleRight}°`], textBoxRight, [centerM2Canvas], {}, { color: mainColor, } ); } } renderStatus = true; } return renderStatus; }; }