|
@@ -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;
|
|
|
+ };
|
|
|
+}
|