import { Point3, Point2 } 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, 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 directionVector: CoreTypes.Point3 = [ intersection[0] - extensionStart[0], intersection[1] - extensionStart[1], intersection[2] - extensionStart[2] ]; // 归一化方向向量 const normalizedDir = this._normalizeVector(directionVector); // 在交点后继续延长的长度(像素) const extraExtensionLength = 150; // 计算延长线的最终终点:交点 + 延长距离 const extensionEnd = vectorAdd( intersection, vectorScale(normalizedDir, extraExtensionLength) ); const extensionStartCanvas = viewport.worldToCanvas(extensionStart); const extensionEndCanvas = viewport.worldToCanvas(extensionEnd); // 绘制完整的延长线:从端点经过交点到延伸终点 drawLineSvg( svgDrawingHelper, annotationUID, `${annotationUID}-plateauExtension`, extensionStartCanvas, extensionEndCanvas, { 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 directionVector: CoreTypes.Point3 = [ intersection[0] - extensionStart[0], intersection[1] - extensionStart[1], intersection[2] - extensionStart[2] ]; // 归一化方向向量 const normalizedDir = this._normalizeVector(directionVector); // 在交点后继续延长的长度(像素) const extraExtensionLength = 150; // 计算延长线的最终终点:交点 + 延长距离 const extensionEnd = vectorAdd( intersection, vectorScale(normalizedDir, extraExtensionLength) ); const extensionStartCanvas = viewport.worldToCanvas(extensionStart); const extensionEndCanvas = viewport.worldToCanvas(extensionEnd); // 绘制完整的延长线:从端点经过交点到延伸终点 drawLineSvg( svgDrawingHelper, annotationUID, `${annotationUID}-axisExtension`, extensionStartCanvas, extensionEndCanvas, { 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; }; }