|
|
@@ -0,0 +1,1332 @@
|
|
|
+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';
|
|
|
+
|
|
|
+// Cobb角测量注解接口
|
|
|
+interface CobbAngleAnnotation extends Types.Annotation {
|
|
|
+ data: {
|
|
|
+ handles: {
|
|
|
+ points: CoreTypes.Point3[];
|
|
|
+ activeHandleIndex: number | null;
|
|
|
+ textBoxCobb?: CoreTypes.Point2;
|
|
|
+ };
|
|
|
+ cachedStats?: {
|
|
|
+ [targetId: string]: {
|
|
|
+ cobbAngle: number;
|
|
|
+ upperVertebraLine: CoreTypes.Point3;
|
|
|
+ lowerVertebraLine: CoreTypes.Point3;
|
|
|
+ angleCenter: CoreTypes.Point3;
|
|
|
+ };
|
|
|
+ };
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 导出的注解数据接口
|
|
|
+export interface ExportedCobbAnnotationData {
|
|
|
+ points: CoreTypes.Point3[];
|
|
|
+ cobbAngle: number;
|
|
|
+ textBoxPosition?: CoreTypes.Point2;
|
|
|
+ metadata: {
|
|
|
+ viewPlaneNormal: CoreTypes.Point3;
|
|
|
+ viewUp: CoreTypes.Point3;
|
|
|
+ FrameOfReferenceUID: string;
|
|
|
+ referencedImageId: string;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default class CobbAngleMeasurementTool extends AnnotationTool {
|
|
|
+ static toolName = 'CobbAngleMeasurementTool';
|
|
|
+
|
|
|
+ editData: {
|
|
|
+ annotation: Types.Annotation;
|
|
|
+ viewportIdsToRender: string[];
|
|
|
+ handleIndex?: number;
|
|
|
+ newAnnotation?: boolean;
|
|
|
+ hasMoved?: boolean;
|
|
|
+ textBoxBeingMoved?: boolean;
|
|
|
+ textBoxOffset?: CoreTypes.Point2;
|
|
|
+ movingWholeTool?: boolean;
|
|
|
+ wholeToolOffset?: CoreTypes.Point2;
|
|
|
+ } | null = null;
|
|
|
+
|
|
|
+ isDrawing: boolean = false;
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ toolProps: PublicToolProps = {},
|
|
|
+ defaultToolProps: ToolProps = {
|
|
|
+ supportedInteractionTypes: ['Mouse', 'Touch'],
|
|
|
+ configuration: {
|
|
|
+ shadow: true,
|
|
|
+ preventHandleOutsideImage: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ) {
|
|
|
+ super(toolProps, defaultToolProps);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建默认的Cobb角测量注解
|
|
|
+ * 需要8个点:上椎体端板线(4点)+ 下椎体端板线(4点)
|
|
|
+ */
|
|
|
+ static createDefaultAnnotation(
|
|
|
+ element: HTMLDivElement,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
|
|
|
+ config?: {
|
|
|
+ upperVertebraCenterX?: number;
|
|
|
+ upperVertebraCenterY?: number;
|
|
|
+ lowerVertebraCenterX?: number;
|
|
|
+ lowerVertebraCenterY?: number;
|
|
|
+ lineLength?: number;
|
|
|
+ }
|
|
|
+ ): CobbAngleAnnotation {
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ throw new Error('Element is not enabled');
|
|
|
+ }
|
|
|
+
|
|
|
+ const canvas = viewport.canvas;
|
|
|
+ const { width, height } = canvas;
|
|
|
+
|
|
|
+ // 默认配置
|
|
|
+ const upperCenterX = config?.upperVertebraCenterX ?? width / 2 - 50;
|
|
|
+ const upperCenterY = config?.upperVertebraCenterY ?? height / 2 - 100;
|
|
|
+ const lowerCenterX = config?.lowerVertebraCenterX ?? width / 2 + 50;
|
|
|
+ const lowerCenterY = config?.lowerVertebraCenterY ?? height / 2 + 100;
|
|
|
+ const lineLength = config?.lineLength ?? 80;
|
|
|
+
|
|
|
+ // 上椎体端板线(水平线,模拟端板方向)
|
|
|
+ const upperHalfLength = lineLength / 2;
|
|
|
+ const upperPoint1: CoreTypes.Point2 = [
|
|
|
+ upperCenterX - upperHalfLength,
|
|
|
+ upperCenterY
|
|
|
+ ];
|
|
|
+ const upperPoint2: CoreTypes.Point2 = [
|
|
|
+ upperCenterX + upperHalfLength,
|
|
|
+ upperCenterY
|
|
|
+ ];
|
|
|
+ // 上椎体端板线的延长点(用于方向计算)
|
|
|
+ const upperPoint3: CoreTypes.Point2 = [
|
|
|
+ upperCenterX - upperHalfLength - 20,
|
|
|
+ upperCenterY
|
|
|
+ ];
|
|
|
+ const upperPoint4: CoreTypes.Point2 = [
|
|
|
+ upperCenterX + upperHalfLength + 20,
|
|
|
+ upperCenterY
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 下椎体端板线(有一定倾斜角度)
|
|
|
+ const lowerHalfLength = lineLength / 2;
|
|
|
+ const lowerPoint1: CoreTypes.Point2 = [
|
|
|
+ lowerCenterX - lowerHalfLength,
|
|
|
+ lowerCenterY
|
|
|
+ ];
|
|
|
+ const lowerPoint2: CoreTypes.Point2 = [
|
|
|
+ lowerCenterX + lowerHalfLength,
|
|
|
+ lowerCenterY + 30 // 模拟侧弯
|
|
|
+ ];
|
|
|
+ // 下椎体端板线的延长点
|
|
|
+ const lowerPoint3: CoreTypes.Point2 = [
|
|
|
+ lowerCenterX - lowerHalfLength - 20,
|
|
|
+ lowerCenterY - 10
|
|
|
+ ];
|
|
|
+ const lowerPoint4: CoreTypes.Point2 = [
|
|
|
+ lowerCenterX + lowerHalfLength + 20,
|
|
|
+ lowerCenterY + 40
|
|
|
+ ];
|
|
|
+
|
|
|
+ const canvasPoints: CoreTypes.Point2[] = [
|
|
|
+ upperPoint1, upperPoint2, upperPoint3, upperPoint4, // 上椎体端板线
|
|
|
+ lowerPoint1, lowerPoint2, lowerPoint3, lowerPoint4, // 下椎体端板线
|
|
|
+ ];
|
|
|
+
|
|
|
+ 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: CobbAngleMeasurementTool.toolName,
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ label: '',
|
|
|
+ handles: {
|
|
|
+ points: worldPoints,
|
|
|
+ activeHandleIndex: null,
|
|
|
+ },
|
|
|
+ cachedStats: {},
|
|
|
+ },
|
|
|
+ } as CobbAngleAnnotation;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导出Cobb角测量数据
|
|
|
+ */
|
|
|
+ static exportAnnotationData(
|
|
|
+ annotation: CobbAngleAnnotation,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
|
|
|
+ ): ExportedCobbAnnotationData | null {
|
|
|
+ const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`;
|
|
|
+ const cachedStats = annotation.data.cachedStats?.[targetId];
|
|
|
+
|
|
|
+ if (!cachedStats || cachedStats.cobbAngle === undefined) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ points: [...annotation.data.handles.points],
|
|
|
+ cobbAngle: cachedStats.cobbAngle,
|
|
|
+ textBoxPosition: annotation.data.handles.textBoxCobb,
|
|
|
+ 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: ExportedCobbAnnotationData,
|
|
|
+ element: HTMLDivElement,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
|
|
|
+ ): CobbAngleAnnotation {
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ throw new Error('Element is not enabled');
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ invalidated: true,
|
|
|
+ highlighted: false,
|
|
|
+ metadata: {
|
|
|
+ ...exportedData.metadata,
|
|
|
+ toolName: CobbAngleMeasurementTool.toolName,
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ label: '',
|
|
|
+ handles: {
|
|
|
+ points: [...exportedData.points],
|
|
|
+ activeHandleIndex: null,
|
|
|
+ textBoxCobb: exportedData.textBoxPosition,
|
|
|
+ },
|
|
|
+ cachedStats: {},
|
|
|
+ },
|
|
|
+ } as CobbAngleAnnotation;
|
|
|
+ }
|
|
|
+
|
|
|
+ addNewAnnotation(evt: EventTypes.InteractionEventType): CobbAngleAnnotation {
|
|
|
+ evt.preventDefault();
|
|
|
+ return {} as CobbAngleAnnotation;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查点是否在工具附近
|
|
|
+ */
|
|
|
+ isPointNearTool(
|
|
|
+ element: HTMLDivElement,
|
|
|
+ annotation: CobbAngleAnnotation,
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否靠近测量线
|
|
|
+ if (points.length >= 8) {
|
|
|
+ const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
|
|
|
+
|
|
|
+ // 检查上椎体端板线
|
|
|
+ const distToUpperLine = this._distanceToSegment(canvasCoords, canvasPoints[0], canvasPoints[1]);
|
|
|
+ if (distToUpperLine < proximity) return true;
|
|
|
+
|
|
|
+ // 检查下椎体端板线
|
|
|
+ const distToLowerLine = this._distanceToSegment(canvasCoords, canvasPoints[4], canvasPoints[5]);
|
|
|
+ if (distToLowerLine < proximity) return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算点到线段的距离
|
|
|
+ */
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建角度弧线点
|
|
|
+ */
|
|
|
+ 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 angleDiff = angle2 - angle1;
|
|
|
+
|
|
|
+ // 归一化角度差到 [-π, π] 范围
|
|
|
+ while (angleDiff > Math.PI) {
|
|
|
+ angleDiff -= 2 * Math.PI;
|
|
|
+ }
|
|
|
+ while (angleDiff < -Math.PI) {
|
|
|
+ angleDiff += 2 * Math.PI;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算两个向量之间的夹角(0-π)
|
|
|
+ const crossProduct = vector1[0] * vector2[1] - vector1[1] * vector2[0];
|
|
|
+ const isClockwise = crossProduct < 0; // 叉积为负表示顺时针
|
|
|
+
|
|
|
+ // 确保使用锐角(最小角度),但保持正确的方向
|
|
|
+ let startAngle: number;
|
|
|
+ let endAngle: number;
|
|
|
+
|
|
|
+ if (Math.abs(angleDiff) <= Math.PI / 2) {
|
|
|
+ // 锐角情况:直接使用当前角度
|
|
|
+ startAngle = angle1;
|
|
|
+ endAngle = angle2;
|
|
|
+ } else {
|
|
|
+ // 钝角情况:使用补角,但要选择正确的内侧
|
|
|
+ if (angleDiff > 0) {
|
|
|
+ // angle2 在 angle1 的顺时针方向
|
|
|
+ if (isClockwise) {
|
|
|
+ // 顺时针方向,使用补角的逆时针弧
|
|
|
+ startAngle = angle1;
|
|
|
+ endAngle = angle1 + (angleDiff - Math.PI);
|
|
|
+ } else {
|
|
|
+ // 逆时针方向,使用补角的顺时针弧
|
|
|
+ startAngle = angle2;
|
|
|
+ endAngle = angle2 - (angleDiff - Math.PI);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // angle2 在 angle1 的逆时针方向
|
|
|
+ if (isClockwise) {
|
|
|
+ // 顺时针方向,使用补角的逆时针弧
|
|
|
+ startAngle = angle2;
|
|
|
+ endAngle = angle2 + (Math.abs(angleDiff) - Math.PI);
|
|
|
+ } else {
|
|
|
+ // 逆时针方向,使用补角的顺时针弧
|
|
|
+ startAngle = angle1;
|
|
|
+ endAngle = angle1 - (Math.abs(angleDiff) - Math.PI);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 归一化角度到 [0, 2π] 范围
|
|
|
+ const normalizeAngle = (angle: number): number => {
|
|
|
+ while (angle < 0) angle += 2 * Math.PI;
|
|
|
+ while (angle >= 2 * Math.PI) angle -= 2 * Math.PI;
|
|
|
+ return angle;
|
|
|
+ };
|
|
|
+
|
|
|
+ startAngle = normalizeAngle(startAngle);
|
|
|
+ endAngle = normalizeAngle(endAngle);
|
|
|
+
|
|
|
+ // 计算弧线长度
|
|
|
+ let arcLength = endAngle - startAngle;
|
|
|
+ if (arcLength < 0) arcLength += 2 * Math.PI;
|
|
|
+
|
|
|
+ // 确保弧线长度不超过180度(锐角)
|
|
|
+ if (arcLength > Math.PI) {
|
|
|
+ [startAngle, endAngle] = [endAngle, startAngle];
|
|
|
+ arcLength = 2 * Math.PI - arcLength;
|
|
|
+ }
|
|
|
+
|
|
|
+ const numPoints = Math.max(10, Math.ceil(arcLength / (Math.PI / 18)));
|
|
|
+ const points: CoreTypes.Point2[] = [];
|
|
|
+
|
|
|
+ for (let i = 0; i <= numPoints; i++) {
|
|
|
+ const t = i / numPoints;
|
|
|
+ const angle = startAngle + arcLength * 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 CobbAngleAnnotation;
|
|
|
+ const targetId = this.getTargetId(viewport);
|
|
|
+ const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
|
|
|
+
|
|
|
+ if (cachedStats?.angleCenter) {
|
|
|
+ // 计算默认文本框位置(与renderAnnotation中的逻辑一致)
|
|
|
+ let defaultTextBoxPosition: CoreTypes.Point2;
|
|
|
+
|
|
|
+ if (customAnn.data.handles.points.length >= 8) {
|
|
|
+ const canvasPoints = customAnn.data.handles.points.map(p => viewport.worldToCanvas(p));
|
|
|
+
|
|
|
+ // 计算两条线段的中点
|
|
|
+ const upperMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[0][0] + canvasPoints[1][0]) / 2,
|
|
|
+ (canvasPoints[0][1] + canvasPoints[1][1]) / 2
|
|
|
+ ];
|
|
|
+ const lowerMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[4][0] + canvasPoints[5][0]) / 2,
|
|
|
+ (canvasPoints[4][1] + canvasPoints[5][1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算连接线的中点
|
|
|
+ const midlineCenter: CoreTypes.Point2 = [
|
|
|
+ (upperMidCanvas[0] + lowerMidCanvas[0]) / 2,
|
|
|
+ (upperMidCanvas[1] + lowerMidCanvas[1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算连接线的方向向量
|
|
|
+ const midlineVector: CoreTypes.Point2 = [
|
|
|
+ lowerMidCanvas[0] - upperMidCanvas[0],
|
|
|
+ lowerMidCanvas[1] - upperMidCanvas[1]
|
|
|
+ ];
|
|
|
+ const midlineLength = Math.sqrt(
|
|
|
+ midlineVector[0] * midlineVector[0] + midlineVector[1] * midlineVector[1]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 计算垂直方向
|
|
|
+ const perpVector: CoreTypes.Point2 = [
|
|
|
+ -midlineVector[1] / midlineLength,
|
|
|
+ midlineVector[0] / midlineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 角度标识线的末端
|
|
|
+ const angleLineLength = 50;
|
|
|
+ const angleLineEnd: CoreTypes.Point2 = [
|
|
|
+ midlineCenter[0] + perpVector[0] * angleLineLength,
|
|
|
+ midlineCenter[1] + perpVector[1] * angleLineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ defaultTextBoxPosition = [
|
|
|
+ angleLineEnd[0] + 10,
|
|
|
+ angleLineEnd[1] - 10,
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ const angleCenterCanvas = viewport.worldToCanvas(cachedStats.angleCenter);
|
|
|
+ defaultTextBoxPosition = [
|
|
|
+ angleCenterCanvas[0] + 40,
|
|
|
+ angleCenterCanvas[1] - 10,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取文本框位置,如果不存在则使用默认位置
|
|
|
+ const textBoxCobb: CoreTypes.Point2 = customAnn.data.handles.textBoxCobb || defaultTextBoxPosition;
|
|
|
+
|
|
|
+ if (this._isPointInTextBox(canvasCoords, textBoxCobb)) {
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
|
|
|
+
|
|
|
+ // 计算鼠标点击位置相对于文本框左上角的偏移量
|
|
|
+ const textBoxOffset: CoreTypes.Point2 = [
|
|
|
+ canvasCoords[0] - textBoxCobb[0],
|
|
|
+ canvasCoords[1] - textBoxCobb[1],
|
|
|
+ ];
|
|
|
+
|
|
|
+ this.editData = {
|
|
|
+ annotation: customAnn,
|
|
|
+ viewportIdsToRender,
|
|
|
+ hasMoved: false,
|
|
|
+ textBoxBeingMoved: true,
|
|
|
+ textBoxOffset: textBoxOffset,
|
|
|
+ };
|
|
|
+
|
|
|
+ customAnn.highlighted = true;
|
|
|
+ customAnn.isSelected = true; // 点击文本时设置选中状态
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有点击文本框,再检查是否点击在手柄上
|
|
|
+ for (const ann of annotations) {
|
|
|
+ const customAnn = ann as CobbAngleAnnotation;
|
|
|
+ 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;
|
|
|
+ customAnn.isSelected = true; // 点击手柄时设置选中状态
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有点击手柄,再检查是否点击在线段上(整体移动)
|
|
|
+ for (const ann of annotations) {
|
|
|
+ const customAnn = ann as CobbAngleAnnotation;
|
|
|
+ if (this.isPointNearTool(element, customAnn, canvasCoords, 10)) {
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
|
|
|
+
|
|
|
+ // 使用两条测量线的中心作为拖拽基准点
|
|
|
+ const upperMid = calculateMidpoint(customAnn.data.handles.points[0], customAnn.data.handles.points[1]);
|
|
|
+ const lowerMid = calculateMidpoint(customAnn.data.handles.points[4], customAnn.data.handles.points[5]);
|
|
|
+ const toolCenter = calculateMidpoint(upperMid, lowerMid);
|
|
|
+ const toolCenterCanvas = viewport.worldToCanvas(toolCenter);
|
|
|
+
|
|
|
+ // 计算鼠标点击位置相对工具中心的偏移
|
|
|
+ const wholeToolOffset: CoreTypes.Point2 = [
|
|
|
+ canvasCoords[0] - toolCenterCanvas[0],
|
|
|
+ canvasCoords[1] - toolCenterCanvas[1],
|
|
|
+ ];
|
|
|
+
|
|
|
+ this.editData = {
|
|
|
+ annotation: customAnn,
|
|
|
+ viewportIdsToRender,
|
|
|
+ hasMoved: false,
|
|
|
+ movingWholeTool: true,
|
|
|
+ wholeToolOffset: wholeToolOffset,
|
|
|
+ };
|
|
|
+
|
|
|
+ customAnn.isSelected = true; // 点击线段时设置选中状态
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有点击在工具上,取消所有工具的选中状态
|
|
|
+ for (const ann of annotations) {
|
|
|
+ const customAnn = ann as CobbAngleAnnotation;
|
|
|
+ customAnn.isSelected = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 触发渲染更新
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ };
|
|
|
+
|
|
|
+ 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
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ _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 CobbAngleAnnotation;
|
|
|
+ const { data } = customAnn;
|
|
|
+
|
|
|
+ if (textBoxBeingMoved && this.editData.textBoxOffset) {
|
|
|
+ data.handles.textBoxCobb = [
|
|
|
+ canvasCoords[0] - this.editData.textBoxOffset[0],
|
|
|
+ canvasCoords[1] - this.editData.textBoxOffset[1],
|
|
|
+ ];
|
|
|
+ this.editData.hasMoved = true;
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果正在移动整个工具
|
|
|
+ if (this.editData.movingWholeTool && this.editData.wholeToolOffset) {
|
|
|
+ const { viewport } = enabledElement;
|
|
|
+
|
|
|
+ // 计算新的工具中心位置
|
|
|
+ const newCenterCanvas: CoreTypes.Point2 = [
|
|
|
+ canvasCoords[0] - this.editData.wholeToolOffset[0],
|
|
|
+ canvasCoords[1] - this.editData.wholeToolOffset[1],
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 获取当前工具中心位置
|
|
|
+ const upperMid = calculateMidpoint(customAnn.data.handles.points[0], customAnn.data.handles.points[1]);
|
|
|
+ const lowerMid = calculateMidpoint(customAnn.data.handles.points[4], customAnn.data.handles.points[5]);
|
|
|
+ const currentCenter = calculateMidpoint(upperMid, lowerMid);
|
|
|
+ const currentCenterCanvas = viewport.worldToCanvas(currentCenter);
|
|
|
+
|
|
|
+ // 计算偏移量(canvas坐标)
|
|
|
+ const offsetCanvas: CoreTypes.Point2 = [
|
|
|
+ newCenterCanvas[0] - currentCenterCanvas[0],
|
|
|
+ newCenterCanvas[1] - currentCenterCanvas[1],
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 将canvas偏移转换为world偏移
|
|
|
+ const offsetWorld = [
|
|
|
+ viewport.canvasToWorld(offsetCanvas)[0] - viewport.canvasToWorld([0, 0])[0],
|
|
|
+ viewport.canvasToWorld(offsetCanvas)[1] - viewport.canvasToWorld([0, 0])[1],
|
|
|
+ 0, // z轴偏移为0,保持在2D平面上
|
|
|
+ ] as CoreTypes.Point3;
|
|
|
+
|
|
|
+ // 应用相同的偏移量给所有控制点
|
|
|
+ for (let i = 0; i < data.handles.points.length; i++) {
|
|
|
+ data.handles.points[i] = [
|
|
|
+ data.handles.points[i][0] + offsetWorld[0],
|
|
|
+ data.handles.points[i][1] + offsetWorld[1],
|
|
|
+ data.handles.points[i][2],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新文本框位置
|
|
|
+ if (data.handles.textBoxCobb) {
|
|
|
+ data.handles.textBoxCobb[0] += offsetCanvas[0];
|
|
|
+ data.handles.textBoxCobb[1] += offsetCanvas[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 { annotation: ann } = this.editData;
|
|
|
+ const customAnn = ann as CobbAngleAnnotation;
|
|
|
+
|
|
|
+ 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();
|
|
|
+ };
|
|
|
+
|
|
|
+ _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) {
|
|
|
+ element.style.cursor = 'default';
|
|
|
+ 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 CobbAngleAnnotation;
|
|
|
+ const targetId = this.getTargetId(viewport);
|
|
|
+ const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
|
|
|
+
|
|
|
+ if (cachedStats?.angleCenter) {
|
|
|
+ // 计算默认文本框位置(与renderAnnotation中的逻辑一致)
|
|
|
+ let defaultTextBoxPosition: CoreTypes.Point2;
|
|
|
+
|
|
|
+ if (customAnn.data.handles.points.length >= 8) {
|
|
|
+ const canvasPoints = customAnn.data.handles.points.map(p => viewport.worldToCanvas(p));
|
|
|
+
|
|
|
+ // 计算两条线段的中点
|
|
|
+ const upperMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[0][0] + canvasPoints[1][0]) / 2,
|
|
|
+ (canvasPoints[0][1] + canvasPoints[1][1]) / 2
|
|
|
+ ];
|
|
|
+ const lowerMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[4][0] + canvasPoints[5][0]) / 2,
|
|
|
+ (canvasPoints[4][1] + canvasPoints[5][1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算连接线的中点
|
|
|
+ const midlineCenter: CoreTypes.Point2 = [
|
|
|
+ (upperMidCanvas[0] + lowerMidCanvas[0]) / 2,
|
|
|
+ (upperMidCanvas[1] + lowerMidCanvas[1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算连接线的方向向量
|
|
|
+ const midlineVector: CoreTypes.Point2 = [
|
|
|
+ lowerMidCanvas[0] - upperMidCanvas[0],
|
|
|
+ lowerMidCanvas[1] - upperMidCanvas[1]
|
|
|
+ ];
|
|
|
+ const midlineLength = Math.sqrt(
|
|
|
+ midlineVector[0] * midlineVector[0] + midlineVector[1] * midlineVector[1]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 计算垂直方向
|
|
|
+ const perpVector: CoreTypes.Point2 = [
|
|
|
+ -midlineVector[1] / midlineLength,
|
|
|
+ midlineVector[0] / midlineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 角度标识线的末端
|
|
|
+ const angleLineLength = 50;
|
|
|
+ const angleLineEnd: CoreTypes.Point2 = [
|
|
|
+ midlineCenter[0] + perpVector[0] * angleLineLength,
|
|
|
+ midlineCenter[1] + perpVector[1] * angleLineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ defaultTextBoxPosition = [
|
|
|
+ angleLineEnd[0] + 10,
|
|
|
+ angleLineEnd[1] - 10,
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ const angleCenterCanvas = viewport.worldToCanvas(cachedStats.angleCenter);
|
|
|
+ defaultTextBoxPosition = [
|
|
|
+ angleCenterCanvas[0] + 40,
|
|
|
+ angleCenterCanvas[1] - 10,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ const textBoxCobb: CoreTypes.Point2 = customAnn.data.handles.textBoxCobb || defaultTextBoxPosition;
|
|
|
+
|
|
|
+ if (this._isPointInTextBox(canvasCoords, textBoxCobb)) {
|
|
|
+ element.style.cursor = 'pointer'; // 手型光标
|
|
|
+ customAnn.highlighted = true;
|
|
|
+ isHovering = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果没有悬停在文本框上,检查是否悬停在手柄、线段上
|
|
|
+ if (!isHovering) {
|
|
|
+ for (const ann of annotations) {
|
|
|
+ const customAnn = ann as CobbAngleAnnotation;
|
|
|
+ const targetId = this.getTargetId(viewport);
|
|
|
+ const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
|
|
|
+
|
|
|
+ // 检查是否靠近手柄
|
|
|
+ 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 CobbAngleAnnotation;
|
|
|
+ customAnn.highlighted = false;
|
|
|
+ }
|
|
|
+ element.style.cursor = 'default';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 触发渲染以更新高亮状态
|
|
|
+ const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ };
|
|
|
+
|
|
|
+ _keyDownCallback = (evt: KeyboardEvent): void => {
|
|
|
+ if (evt.key === 'Delete') {
|
|
|
+ const element = document.activeElement as HTMLDivElement;
|
|
|
+ if (!element) return;
|
|
|
+
|
|
|
+ const annotations = annotation.state.getAnnotations(this.getToolName(), element);
|
|
|
+
|
|
|
+ // 查找选中的注解
|
|
|
+ const selectedAnnotation = annotations.find(ann => ann.isSelected);
|
|
|
+ if (selectedAnnotation) {
|
|
|
+ // 删除选中的注解
|
|
|
+ annotation.state.removeAnnotation(selectedAnnotation.annotationUID!);
|
|
|
+
|
|
|
+ // 触发视图更新
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ handleSelectedCallback(
|
|
|
+ evt: EventTypes.InteractionEventType,
|
|
|
+ annotation: CobbAngleAnnotation
|
|
|
+ ): void {
|
|
|
+ annotation.highlighted = true;
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ evt.detail.element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
|
|
|
+ evt.preventDefault();
|
|
|
+ }
|
|
|
+
|
|
|
+ toolSelectedCallback(
|
|
|
+ evt: EventTypes.InteractionEventType,
|
|
|
+ annotation: CobbAngleAnnotation
|
|
|
+ ): 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 CobbAngleAnnotation;
|
|
|
+
|
|
|
+ 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 CobbAngleAnnotation, 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: CobbAngleAnnotation,
|
|
|
+ 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: CobbAngleAnnotation,
|
|
|
+ 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] = {
|
|
|
+ cobbAngle: 0,
|
|
|
+ upperVertebraLine: [0, 0, 0] as CoreTypes.Point3,
|
|
|
+ lowerVertebraLine: [0, 0, 0] as CoreTypes.Point3,
|
|
|
+ angleCenter: [0, 0, 0] as CoreTypes.Point3,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const stats = data.cachedStats[targetId];
|
|
|
+
|
|
|
+ if (points.length >= 8) {
|
|
|
+ // 上椎体端板线:points[0] -> points[1]
|
|
|
+ const upperPoint1 = points[0];
|
|
|
+ const upperPoint2 = points[1];
|
|
|
+ const upperVertebraVector = vectorSubtract(upperPoint2, upperPoint1);
|
|
|
+ stats.upperVertebraLine = upperVertebraVector;
|
|
|
+
|
|
|
+ // 下椎体端板线:points[4] -> points[5]
|
|
|
+ const lowerPoint1 = points[4];
|
|
|
+ const lowerPoint2 = points[5];
|
|
|
+ const lowerVertebraVector = vectorSubtract(lowerPoint2, lowerPoint1);
|
|
|
+ stats.lowerVertebraLine = lowerVertebraVector;
|
|
|
+
|
|
|
+ // 计算两条平行线的夹角
|
|
|
+ const angleRad = getAngleBetweenLines(upperVertebraVector, lowerVertebraVector);
|
|
|
+ // Cobb角通常取锐角
|
|
|
+ const acuteAngleRad = Math.min(angleRad, Math.PI - angleRad);
|
|
|
+ stats.cobbAngle = Math.round(degrees(acuteAngleRad));
|
|
|
+
|
|
|
+ // 计算两条延长线的交汇点作为角度中心点
|
|
|
+ const intersectionPoint = this._calculateExtensionLinesIntersection(
|
|
|
+ upperPoint1, upperPoint2, lowerPoint1, lowerPoint2
|
|
|
+ );
|
|
|
+ stats.angleCenter = intersectionPoint;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算两条延长线的交汇点
|
|
|
+ */
|
|
|
+ private _calculateExtensionLinesIntersection(
|
|
|
+ upperPoint1: CoreTypes.Point3,
|
|
|
+ upperPoint2: CoreTypes.Point3,
|
|
|
+ lowerPoint1: CoreTypes.Point3,
|
|
|
+ lowerPoint2: CoreTypes.Point3
|
|
|
+ ): CoreTypes.Point3 {
|
|
|
+ // 将3D点转换为2D点(忽略z坐标)
|
|
|
+ const upperP1: CoreTypes.Point2 = [upperPoint1[0], upperPoint1[1]];
|
|
|
+ const upperP2: CoreTypes.Point2 = [upperPoint2[0], upperPoint2[1]];
|
|
|
+ const lowerP1: CoreTypes.Point2 = [lowerPoint1[0], lowerPoint1[1]];
|
|
|
+ const lowerP2: CoreTypes.Point2 = [lowerPoint2[0], lowerPoint2[1]];
|
|
|
+
|
|
|
+ // 计算两条线的方向向量
|
|
|
+ const upperVector: CoreTypes.Point2 = [
|
|
|
+ upperP2[0] - upperP1[0],
|
|
|
+ upperP2[1] - upperP1[1]
|
|
|
+ ];
|
|
|
+ const lowerVector: CoreTypes.Point2 = [
|
|
|
+ lowerP2[0] - lowerP1[0],
|
|
|
+ lowerP2[1] - lowerP1[1]
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算两条线的交汇点
|
|
|
+ const intersection = this._calculateLineIntersection(upperP1, upperVector, lowerP1, lowerVector);
|
|
|
+
|
|
|
+ // 如果两条线平行或几乎平行,返回两条线的中点
|
|
|
+ if (!intersection) {
|
|
|
+ const upperMid = calculateMidpoint(upperPoint1, upperPoint2);
|
|
|
+ const lowerMid = calculateMidpoint(lowerPoint1, lowerPoint2);
|
|
|
+ return calculateMidpoint(upperMid, lowerMid);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 返回交汇点,保持z坐标为0
|
|
|
+ return [intersection[0], intersection[1], 0];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算两条线的交汇点
|
|
|
+ */
|
|
|
+ private _calculateLineIntersection(
|
|
|
+ point1: CoreTypes.Point2,
|
|
|
+ vector1: CoreTypes.Point2,
|
|
|
+ point2: CoreTypes.Point2,
|
|
|
+ vector2: CoreTypes.Point2
|
|
|
+ ): CoreTypes.Point2 | null {
|
|
|
+ const x1 = point1[0], y1 = point1[1];
|
|
|
+ const dx1 = vector1[0], dy1 = vector1[1];
|
|
|
+ const x2 = point2[0], y2 = point2[1];
|
|
|
+ const dx2 = vector2[0], dy2 = vector2[1];
|
|
|
+
|
|
|
+ // 计算行列式
|
|
|
+ const denominator = dx1 * dy2 - dy1 * dx2;
|
|
|
+
|
|
|
+ // 如果行列式接近0,两条线平行或重合
|
|
|
+ if (Math.abs(denominator) < 1e-10) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算参数t
|
|
|
+ const t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / denominator;
|
|
|
+
|
|
|
+ // 计算交汇点
|
|
|
+ const intersectionX = x1 + t * dx1;
|
|
|
+ const intersectionY = y1 + t * dy1;
|
|
|
+
|
|
|
+ return [intersectionX, intersectionY];
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 CobbAngleAnnotation;
|
|
|
+ const { annotationUID, data } = annotation;
|
|
|
+ const points = data.handles.points;
|
|
|
+
|
|
|
+ if (points.length < 8) 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)';
|
|
|
+
|
|
|
+ // 绘制上椎体端板线
|
|
|
+ if (annotationUID) {
|
|
|
+ drawLineSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ `${annotationUID}-upperVertebra`,
|
|
|
+ canvasPoints[0],
|
|
|
+ canvasPoints[1],
|
|
|
+ { color: lineColor, width: 3 }
|
|
|
+ );
|
|
|
+
|
|
|
+ // 绘制下椎体端板线
|
|
|
+ drawLineSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ `${annotationUID}-lowerVertebra`,
|
|
|
+ canvasPoints[4],
|
|
|
+ canvasPoints[5],
|
|
|
+ { color: lineColor, width: 3 }
|
|
|
+ );
|
|
|
+
|
|
|
+ // 绘制角度标识线和文本
|
|
|
+ if (cachedStats && cachedStats.angleCenter) {
|
|
|
+ // 计算两条线段的中点
|
|
|
+ const upperMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[0][0] + canvasPoints[1][0]) / 2,
|
|
|
+ (canvasPoints[0][1] + canvasPoints[1][1]) / 2
|
|
|
+ ];
|
|
|
+ const lowerMidCanvas: CoreTypes.Point2 = [
|
|
|
+ (canvasPoints[4][0] + canvasPoints[5][0]) / 2,
|
|
|
+ (canvasPoints[4][1] + canvasPoints[5][1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 绘制连接两个中点的虚线
|
|
|
+ drawLineSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ `${annotationUID}-midline`,
|
|
|
+ upperMidCanvas,
|
|
|
+ lowerMidCanvas,
|
|
|
+ { color: 'rgb(255, 200, 0)', width: 1, lineDash: '4,4' }
|
|
|
+ );
|
|
|
+
|
|
|
+ // 计算连接线的中点
|
|
|
+ const midlineCenter: CoreTypes.Point2 = [
|
|
|
+ (upperMidCanvas[0] + lowerMidCanvas[0]) / 2,
|
|
|
+ (upperMidCanvas[1] + lowerMidCanvas[1]) / 2
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 计算连接线的方向向量(归一化)
|
|
|
+ const midlineVector: CoreTypes.Point2 = [
|
|
|
+ lowerMidCanvas[0] - upperMidCanvas[0],
|
|
|
+ lowerMidCanvas[1] - upperMidCanvas[1]
|
|
|
+ ];
|
|
|
+ const midlineLength = Math.sqrt(
|
|
|
+ midlineVector[0] * midlineVector[0] + midlineVector[1] * midlineVector[1]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 计算垂直于连接线的方向(向右侧延伸)
|
|
|
+ const perpVector: CoreTypes.Point2 = [
|
|
|
+ -midlineVector[1] / midlineLength,
|
|
|
+ midlineVector[0] / midlineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 角度标识线的长度
|
|
|
+ const angleLineLength = 50;
|
|
|
+ const angleLineEnd: CoreTypes.Point2 = [
|
|
|
+ midlineCenter[0] + perpVector[0] * angleLineLength,
|
|
|
+ midlineCenter[1] + perpVector[1] * angleLineLength
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 绘制角度标识线(从连接线中点伸出的虚线)
|
|
|
+ drawLineSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ `${annotationUID}-angleline`,
|
|
|
+ midlineCenter,
|
|
|
+ angleLineEnd,
|
|
|
+ { color: 'rgb(255, 0, 0)', width: 2, lineDash: '4,4' }
|
|
|
+ );
|
|
|
+
|
|
|
+ // 文本位置:在角度标识线的末端
|
|
|
+ const textLines = [`Cobb角: ${cachedStats.cobbAngle}°`];
|
|
|
+ const textBoxPosition: CoreTypes.Point2 = data.handles.textBoxCobb || [
|
|
|
+ angleLineEnd[0] + 10,
|
|
|
+ angleLineEnd[1] - 10,
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 文本连接到角度标识线末端
|
|
|
+ drawLinkedTextBox(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ `${annotationUID}-text`,
|
|
|
+ textLines,
|
|
|
+ textBoxPosition,
|
|
|
+ [angleLineEnd],
|
|
|
+ {},
|
|
|
+ { color: 'rgb(255, 0, 0)' }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制控制点(只绘制前4个点:上下两条线的端点)
|
|
|
+ const isSelected = annotation.isSelected || false;
|
|
|
+ const handleRadius = isSelected ? 12 : 6;
|
|
|
+
|
|
|
+ // 只绘制上椎体端板线的两个端点和下椎体端板线的两个端点
|
|
|
+ const visibleHandles = [
|
|
|
+ canvasPoints[0], // 上椎体端板线起点
|
|
|
+ canvasPoints[1], // 上椎体端板线终点
|
|
|
+ canvasPoints[4], // 下椎体端板线起点
|
|
|
+ canvasPoints[5], // 下椎体端板线终点
|
|
|
+ ];
|
|
|
+
|
|
|
+ drawHandles(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ '0',
|
|
|
+ visibleHandles,
|
|
|
+ { color: 'rgb(255, 255, 255)', handleRadius: handleRadius }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ renderStatus = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return renderStatus;
|
|
|
+ };
|
|
|
+}
|