|
@@ -0,0 +1,1032 @@
|
|
|
+import { Point3, Point2, fitCircleFrom3Points, vectorAdd } from './mathUtils';
|
|
|
+import {
|
|
|
+ utilities as csUtils,
|
|
|
+ Types as CoreTypes,
|
|
|
+ getEnabledElement,
|
|
|
+} from '@cornerstonejs/core';
|
|
|
+
|
|
|
+import {
|
|
|
+ AnnotationTool,
|
|
|
+ utilities,
|
|
|
+ Types,
|
|
|
+ annotation,
|
|
|
+ drawing,
|
|
|
+} from '@cornerstonejs/tools';
|
|
|
+
|
|
|
+const {
|
|
|
+ drawHandles,
|
|
|
+ drawLinkedTextBox,
|
|
|
+ drawLine: drawLineSvg,
|
|
|
+ drawCircle: drawCircleSvg,
|
|
|
+} = drawing;
|
|
|
+
|
|
|
+import {
|
|
|
+ PublicToolProps,
|
|
|
+ ToolProps,
|
|
|
+ EventTypes,
|
|
|
+ SVGDrawingHelper,
|
|
|
+} from '@cornerstonejs/tools/dist/esm/types';
|
|
|
+
|
|
|
+// 注解初始配置接口
|
|
|
+interface AnnotationInitialConfig {
|
|
|
+ acetabulumRadius?: number; // 髋臼圆初始半径(像素)
|
|
|
+ femurRadius?: number; // 股骨头圆初始半径(像素)
|
|
|
+ circleDistance?: number; // 两圆心之间的初始距离(像素)
|
|
|
+}
|
|
|
+
|
|
|
+// 注解数据接口
|
|
|
+interface HipDIAnnotation extends Types.Annotation {
|
|
|
+ data: {
|
|
|
+ handles: {
|
|
|
+ points: CoreTypes.Point3[];
|
|
|
+ activeHandleIndex: number | null;
|
|
|
+ };
|
|
|
+ textBox?: CoreTypes.Point2; // DI文本框位置
|
|
|
+ cachedStats?: {
|
|
|
+ [targetId: string]: {
|
|
|
+ acetabulumCircle?: {
|
|
|
+ center: CoreTypes.Point3;
|
|
|
+ radius: number;
|
|
|
+ };
|
|
|
+ femurCircle?: {
|
|
|
+ center: CoreTypes.Point3;
|
|
|
+ radius: number;
|
|
|
+ };
|
|
|
+ distance: number;
|
|
|
+ DI: number;
|
|
|
+ };
|
|
|
+ };
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 导出的注解数据接口
|
|
|
+export interface ExportedDIData {
|
|
|
+ points: CoreTypes.Point3[]; // 6个点的世界坐标
|
|
|
+ circles: {
|
|
|
+ acetabulum: {
|
|
|
+ center: CoreTypes.Point3;
|
|
|
+ radius: number;
|
|
|
+ };
|
|
|
+ femur: {
|
|
|
+ center: CoreTypes.Point3;
|
|
|
+ radius: number;
|
|
|
+ };
|
|
|
+ };
|
|
|
+ measurements: {
|
|
|
+ distance: number;
|
|
|
+ DI: number;
|
|
|
+ };
|
|
|
+ textBoxPosition?: CoreTypes.Point2;
|
|
|
+ metadata: {
|
|
|
+ viewPlaneNormal: CoreTypes.Point3;
|
|
|
+ viewUp: CoreTypes.Point3;
|
|
|
+ FrameOfReferenceUID: string;
|
|
|
+ referencedImageId: string;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export default class HipDIMeasurementTool extends AnnotationTool {
|
|
|
+ static toolName = 'HipDIMeasurementTool';
|
|
|
+
|
|
|
+ editData: {
|
|
|
+ annotation: Types.Annotation;
|
|
|
+ viewportIdsToRender: string[];
|
|
|
+ handleIndex?: number;
|
|
|
+ newAnnotation?: boolean;
|
|
|
+ hasMoved?: boolean;
|
|
|
+ textBoxBeingMoved?: boolean;
|
|
|
+ textBoxOffset?: CoreTypes.Point2;
|
|
|
+ } | null = null;
|
|
|
+
|
|
|
+ isDrawing: boolean = false;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建一个预设的注解,带有6个默认点
|
|
|
+ * @param element HTML元素
|
|
|
+ * @param viewport Viewport实例
|
|
|
+ * @param config 可选的初始配置
|
|
|
+ */
|
|
|
+ static createDefaultAnnotation(
|
|
|
+ element: HTMLDivElement,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
|
|
|
+ config?: AnnotationInitialConfig
|
|
|
+ ): HipDIAnnotation {
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ throw new Error('Element is not enabled');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取viewport的尺寸
|
|
|
+ const canvas = viewport.canvas;
|
|
|
+ const { width, height } = canvas;
|
|
|
+ const centerX = width / 2;
|
|
|
+ const centerY = height / 2;
|
|
|
+
|
|
|
+ // 应用默认值
|
|
|
+ const acetabulumRadius = config?.acetabulumRadius ?? 80;
|
|
|
+ const femurRadius = config?.femurRadius ?? 60;
|
|
|
+ const circleDistance = config?.circleDistance ?? 100;
|
|
|
+
|
|
|
+ // 创建髋臼圆的3个点(左侧圆,点1,2,3)
|
|
|
+ // 在圆周上均匀分布3个点(0度、120度、240度)
|
|
|
+ const acetabulumCenterX = centerX - circleDistance / 2;
|
|
|
+ const acetabulumCenterY = centerY;
|
|
|
+
|
|
|
+ const angle1 = 0;
|
|
|
+ const angle2 = (2 * Math.PI) / 3; // 120度
|
|
|
+ const angle3 = (4 * Math.PI) / 3; // 240度
|
|
|
+
|
|
|
+ const acetabulumPoints: CoreTypes.Point2[] = [
|
|
|
+ [
|
|
|
+ acetabulumCenterX + acetabulumRadius * Math.cos(angle1),
|
|
|
+ acetabulumCenterY + acetabulumRadius * Math.sin(angle1)
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ acetabulumCenterX + acetabulumRadius * Math.cos(angle2),
|
|
|
+ acetabulumCenterY + acetabulumRadius * Math.sin(angle2)
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ acetabulumCenterX + acetabulumRadius * Math.cos(angle3),
|
|
|
+ acetabulumCenterY + acetabulumRadius * Math.sin(angle3)
|
|
|
+ ]
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 创建股骨头圆的3个点(右侧圆,点4,5,6)
|
|
|
+ const femurCenterX = centerX + circleDistance / 2;
|
|
|
+ const femurCenterY = centerY;
|
|
|
+
|
|
|
+ const femurPoints: CoreTypes.Point2[] = [
|
|
|
+ [
|
|
|
+ femurCenterX + femurRadius * Math.cos(angle1),
|
|
|
+ femurCenterY + femurRadius * Math.sin(angle1)
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ femurCenterX + femurRadius * Math.cos(angle2),
|
|
|
+ femurCenterY + femurRadius * Math.sin(angle2)
|
|
|
+ ],
|
|
|
+ [
|
|
|
+ femurCenterX + femurRadius * Math.cos(angle3),
|
|
|
+ femurCenterY + femurRadius * Math.sin(angle3)
|
|
|
+ ]
|
|
|
+ ];
|
|
|
+
|
|
|
+ const canvasPoints: CoreTypes.Point2[] = [
|
|
|
+ ...acetabulumPoints,
|
|
|
+ ...femurPoints
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 转换为world坐标
|
|
|
+ const worldPoints = canvasPoints.map((canvasPoint) =>
|
|
|
+ viewport.canvasToWorld(canvasPoint)
|
|
|
+ );
|
|
|
+
|
|
|
+ const camera = viewport.getCamera();
|
|
|
+ const { viewPlaneNormal, viewUp } = camera;
|
|
|
+ if (viewPlaneNormal === undefined) {
|
|
|
+ throw new Error('viewPlaneNormal is undefined');
|
|
|
+ }
|
|
|
+ if (viewUp === undefined) {
|
|
|
+ throw new Error('viewUp is undefined');
|
|
|
+ }
|
|
|
+
|
|
|
+ const annotationData = {
|
|
|
+ invalidated: true,
|
|
|
+ highlighted: false,
|
|
|
+ metadata: {
|
|
|
+ viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3,
|
|
|
+ viewUp: [...viewUp] as CoreTypes.Point3,
|
|
|
+ FrameOfReferenceUID: viewport.getFrameOfReferenceUID(),
|
|
|
+ referencedImageId: viewport.getCurrentImageId?.() || '',
|
|
|
+ toolName: HipDIMeasurementTool.toolName,
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ label: '',
|
|
|
+ handles: {
|
|
|
+ points: worldPoints,
|
|
|
+ activeHandleIndex: null,
|
|
|
+ },
|
|
|
+ cachedStats: {},
|
|
|
+ },
|
|
|
+ } as HipDIAnnotation;
|
|
|
+
|
|
|
+ return annotationData;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导出注解数据
|
|
|
+ * @param annotation 要导出的注解
|
|
|
+ * @returns 导出的数据对象
|
|
|
+ */
|
|
|
+ static exportAnnotationData(
|
|
|
+ annotation: HipDIAnnotation,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
|
|
|
+ ): ExportedDIData | null {
|
|
|
+ const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`;
|
|
|
+ const cachedStats = annotation.data.cachedStats?.[targetId];
|
|
|
+
|
|
|
+ if (!cachedStats || !cachedStats.acetabulumCircle || !cachedStats.femurCircle) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ points: [...annotation.data.handles.points],
|
|
|
+ circles: {
|
|
|
+ acetabulum: {
|
|
|
+ center: cachedStats.acetabulumCircle.center,
|
|
|
+ radius: cachedStats.acetabulumCircle.radius,
|
|
|
+ },
|
|
|
+ femur: {
|
|
|
+ center: cachedStats.femurCircle.center,
|
|
|
+ radius: cachedStats.femurCircle.radius,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ measurements: {
|
|
|
+ distance: cachedStats.distance,
|
|
|
+ DI: cachedStats.DI,
|
|
|
+ },
|
|
|
+ textBoxPosition: annotation.data.textBox,
|
|
|
+ metadata: {
|
|
|
+ viewPlaneNormal: annotation.metadata?.viewPlaneNormal || [0, 0, 1],
|
|
|
+ viewUp: annotation.metadata?.viewUp || [0, 1, 0],
|
|
|
+ FrameOfReferenceUID: annotation.metadata?.FrameOfReferenceUID || '',
|
|
|
+ referencedImageId: annotation.metadata?.referencedImageId || '',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从导出的数据恢复注解
|
|
|
+ * @param exportedData 导出的数据
|
|
|
+ * @param element HTML元素
|
|
|
+ * @param viewport Viewport实例
|
|
|
+ * @returns 恢复的注解
|
|
|
+ */
|
|
|
+ static restoreFromExportedData(
|
|
|
+ exportedData: ExportedDIData,
|
|
|
+ element: HTMLDivElement,
|
|
|
+ viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
|
|
|
+ ): HipDIAnnotation {
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ throw new Error('Element is not enabled');
|
|
|
+ }
|
|
|
+
|
|
|
+ const annotationData = {
|
|
|
+ invalidated: true,
|
|
|
+ highlighted: false,
|
|
|
+ metadata: {
|
|
|
+ ...exportedData.metadata,
|
|
|
+ toolName: HipDIMeasurementTool.toolName,
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ label: '',
|
|
|
+ handles: {
|
|
|
+ points: [...exportedData.points],
|
|
|
+ activeHandleIndex: null,
|
|
|
+ },
|
|
|
+ textBox: exportedData.textBoxPosition,
|
|
|
+ cachedStats: {},
|
|
|
+ },
|
|
|
+ } as HipDIAnnotation;
|
|
|
+
|
|
|
+ return annotationData;
|
|
|
+ }
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ toolProps: PublicToolProps = {},
|
|
|
+ defaultToolProps: ToolProps = {
|
|
|
+ supportedInteractionTypes: ['Mouse', 'Touch'],
|
|
|
+ configuration: {
|
|
|
+ shadow: true,
|
|
|
+ preventHandleOutsideImage: false,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ ) {
|
|
|
+ super(toolProps, defaultToolProps);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 添加新注解 - 禁用此功能
|
|
|
+ */
|
|
|
+ addNewAnnotation(
|
|
|
+ evt: EventTypes.InteractionEventType
|
|
|
+ ): HipDIAnnotation {
|
|
|
+ evt.preventDefault();
|
|
|
+ return {} as HipDIAnnotation;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查点是否靠近工具
|
|
|
+ */
|
|
|
+ isPointNearTool(
|
|
|
+ element: HTMLDivElement,
|
|
|
+ annotation: HipDIAnnotation,
|
|
|
+ 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 point = points[i];
|
|
|
+ const canvasPoint = viewport.worldToCanvas(point);
|
|
|
+ const distance = Math.sqrt(
|
|
|
+ Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
|
|
|
+ Math.pow(canvasPoint[1] - canvasCoords[1], 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (distance < proximity) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消操作
|
|
|
+ */
|
|
|
+ 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 HipDIAnnotation;
|
|
|
+ const targetId = this.getTargetId(viewport);
|
|
|
+ const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
|
|
|
+
|
|
|
+ if (cachedStats?.femurCircle) {
|
|
|
+ const femurCenterCanvas = viewport.worldToCanvas(cachedStats.femurCircle.center);
|
|
|
+ const textBox: CoreTypes.Point2 = customAnn.data.textBox || [
|
|
|
+ femurCenterCanvas[0] + 20,
|
|
|
+ femurCenterCanvas[1] - 20,
|
|
|
+ ];
|
|
|
+
|
|
|
+ if (this._isPointInTextBox(canvasCoords, textBox)) {
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+
|
|
|
+ const textBoxOffset: CoreTypes.Point2 = [
|
|
|
+ canvasCoords[0] - textBox[0],
|
|
|
+ canvasCoords[1] - textBox[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 HipDIAnnotation;
|
|
|
+ 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 HipDIAnnotation;
|
|
|
+ const { data } = customAnn;
|
|
|
+
|
|
|
+ // 如果正在拖拽文本框
|
|
|
+ if (textBoxBeingMoved && this.editData.textBoxOffset) {
|
|
|
+ const newTextBoxPosition: CoreTypes.Point2 = [
|
|
|
+ canvasCoords[0] - this.editData.textBoxOffset[0],
|
|
|
+ canvasCoords[1] - this.editData.textBoxOffset[1],
|
|
|
+ ];
|
|
|
+
|
|
|
+ data.textBox = newTextBoxPosition;
|
|
|
+ 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 HipDIAnnotation;
|
|
|
+
|
|
|
+ customAnn.data.handles.activeHandleIndex = null;
|
|
|
+ customAnn.highlighted = false;
|
|
|
+
|
|
|
+ const eventDetail = evt.detail;
|
|
|
+ const { element } = eventDetail;
|
|
|
+
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(
|
|
|
+ viewportIdsToRender
|
|
|
+ );
|
|
|
+
|
|
|
+ this.editData = null;
|
|
|
+
|
|
|
+ evt.preventDefault();
|
|
|
+ evt.stopPropagation();
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理选中回调
|
|
|
+ */
|
|
|
+ handleSelectedCallback(
|
|
|
+ evt: EventTypes.InteractionEventType,
|
|
|
+ annotation: HipDIAnnotation
|
|
|
+ ): void {
|
|
|
+ const eventDetail = evt.detail;
|
|
|
+ const { element } = eventDetail;
|
|
|
+
|
|
|
+ annotation.highlighted = true;
|
|
|
+
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const viewportIdsToRender =
|
|
|
+ utilities.viewportFilters.getViewportIdsWithToolToRender(
|
|
|
+ element,
|
|
|
+ this.getToolName()
|
|
|
+ );
|
|
|
+
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(
|
|
|
+ viewportIdsToRender
|
|
|
+ );
|
|
|
+
|
|
|
+ evt.preventDefault();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 工具选中回调
|
|
|
+ */
|
|
|
+ toolSelectedCallback(
|
|
|
+ evt: EventTypes.InteractionEventType,
|
|
|
+ annotation: HipDIAnnotation
|
|
|
+ ): void {
|
|
|
+ // 实现工具选中逻辑
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理鼠标拖拽
|
|
|
+ */
|
|
|
+ _dragCallback = (evt: EventTypes.InteractionEventType): void => {
|
|
|
+ this.isDrawing = true;
|
|
|
+ const eventDetail = evt.detail;
|
|
|
+ const { element } = eventDetail;
|
|
|
+
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement || !this.editData) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const { annotation, viewportIdsToRender } = this.editData;
|
|
|
+ const { data } = annotation as HipDIAnnotation;
|
|
|
+
|
|
|
+ if (annotation) {
|
|
|
+ const { currentPoints } = eventDetail;
|
|
|
+ const worldPos = currentPoints.world;
|
|
|
+
|
|
|
+ const points = data.handles.points;
|
|
|
+ const activeHandleIndex = data.handles.activeHandleIndex;
|
|
|
+
|
|
|
+ if (activeHandleIndex !== null && activeHandleIndex < points.length) {
|
|
|
+ points[activeHandleIndex] = worldPos;
|
|
|
+
|
|
|
+ this._updateCachedStats(
|
|
|
+ annotation as HipDIAnnotation,
|
|
|
+ enabledElement
|
|
|
+ );
|
|
|
+
|
|
|
+ this.editData!.hasMoved = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ utilities.triggerAnnotationRenderForViewportIds(
|
|
|
+ viewportIdsToRender
|
|
|
+ );
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理鼠标抬起/点击
|
|
|
+ */
|
|
|
+ _endCallback = (evt: EventTypes.InteractionEventType): void => {
|
|
|
+ const eventDetail = evt.detail;
|
|
|
+ const { element } = eventDetail;
|
|
|
+
|
|
|
+ if (!this.editData) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const { annotation, hasMoved } = this.editData;
|
|
|
+ const { data } = annotation as HipDIAnnotation;
|
|
|
+
|
|
|
+ if (hasMoved) {
|
|
|
+ this.editData.hasMoved = false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取靠近图像点的手柄
|
|
|
+ */
|
|
|
+ getHandleNearImagePoint(
|
|
|
+ element: HTMLDivElement,
|
|
|
+ annotation: HipDIAnnotation,
|
|
|
+ canvasCoords: CoreTypes.Point2,
|
|
|
+ proximity: number
|
|
|
+ ): Types.ToolHandle | undefined {
|
|
|
+ const enabledElement = getEnabledElement(element);
|
|
|
+ if (!enabledElement) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ const { viewport } = enabledElement;
|
|
|
+
|
|
|
+ const points = annotation.data.handles.points;
|
|
|
+
|
|
|
+ const handleProximity = Math.max(proximity, 15);
|
|
|
+
|
|
|
+ for (let i = 0; i < points.length; i++) {
|
|
|
+ const point = points[i];
|
|
|
+ const canvasPoint = viewport.worldToCanvas(point);
|
|
|
+ const distance = Math.sqrt(
|
|
|
+ Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
|
|
|
+ Math.pow(canvasPoint[1] - canvasCoords[1], 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (distance < handleProximity) {
|
|
|
+ annotation.data.handles.activeHandleIndex = i;
|
|
|
+ return {
|
|
|
+ worldPosition: point,
|
|
|
+ } as Types.ToolHandle;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ annotation.data.handles.activeHandleIndex = null;
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新缓存的统计数据
|
|
|
+ */
|
|
|
+ _updateCachedStats(
|
|
|
+ annotation: HipDIAnnotation,
|
|
|
+ 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] = {
|
|
|
+ distance: 0,
|
|
|
+ DI: 0,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const stats = data.cachedStats[targetId];
|
|
|
+
|
|
|
+ if (points.length >= 6) {
|
|
|
+ // 拟合髋臼圆(点1,2,3)
|
|
|
+ const acetabulumCircle = fitCircleFrom3Points(
|
|
|
+ points[0],
|
|
|
+ points[1],
|
|
|
+ points[2]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 拟合股骨头圆(点4,5,6)
|
|
|
+ const femurCircle = fitCircleFrom3Points(
|
|
|
+ points[3],
|
|
|
+ points[4],
|
|
|
+ points[5]
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!acetabulumCircle || !femurCircle) {
|
|
|
+ console.warn('圆拟合失败,三点可能共线');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 存储圆数据
|
|
|
+ stats.acetabulumCircle = acetabulumCircle;
|
|
|
+ stats.femurCircle = femurCircle;
|
|
|
+
|
|
|
+ // 计算圆心距离d
|
|
|
+ const M1 = acetabulumCircle.center;
|
|
|
+ const M2 = femurCircle.center;
|
|
|
+
|
|
|
+ const distance = Math.sqrt(
|
|
|
+ Math.pow(M2[0] - M1[0], 2) +
|
|
|
+ Math.pow(M2[1] - M1[1], 2) +
|
|
|
+ Math.pow(M2[2] - M1[2], 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ stats.distance = distance;
|
|
|
+
|
|
|
+ // 计算DI指数
|
|
|
+ const DI = distance / femurCircle.radius;
|
|
|
+ stats.DI = DI;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 渲染注解
|
|
|
+ */
|
|
|
+ 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 HipDIAnnotation;
|
|
|
+ const { annotationUID, data } = annotation;
|
|
|
+ const points = data.handles.points;
|
|
|
+
|
|
|
+ if (points.length < 1) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const targetId = this.getTargetId(viewport);
|
|
|
+ const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined;
|
|
|
+
|
|
|
+ // 确定颜色(选中状态为蓝色,非选中为白色)
|
|
|
+ const color = annotation.highlighted ? 'rgb(0, 0, 255)' : 'rgb(255, 255, 255)';
|
|
|
+
|
|
|
+ // 转换所有点为 canvas 坐标
|
|
|
+ const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
|
|
|
+
|
|
|
+ // 绘制髋臼圆
|
|
|
+ if (cachedStats?.acetabulumCircle && annotationUID) {
|
|
|
+ const center = cachedStats.acetabulumCircle.center;
|
|
|
+ const centerCanvas = viewport.worldToCanvas(center);
|
|
|
+
|
|
|
+ // 计算半径的像素值
|
|
|
+ const radiusVector: Point3 = [cachedStats.acetabulumCircle.radius, 0, 0];
|
|
|
+ const endPoint = vectorAdd(center, radiusVector);
|
|
|
+ const endCanvas = viewport.worldToCanvas(endPoint);
|
|
|
+ const radiusPixels = Math.sqrt(
|
|
|
+ Math.pow(endCanvas[0] - centerCanvas[0], 2) +
|
|
|
+ Math.pow(endCanvas[1] - centerCanvas[1], 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ const circleUID = `${annotationUID}-acetabulum-circle`;
|
|
|
+ drawCircleSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ circleUID,
|
|
|
+ centerCanvas,
|
|
|
+ radiusPixels,
|
|
|
+ {
|
|
|
+ color: color,
|
|
|
+ width: 2,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制股骨头圆
|
|
|
+ if (cachedStats?.femurCircle && annotationUID) {
|
|
|
+ const center = cachedStats.femurCircle.center;
|
|
|
+ const centerCanvas = viewport.worldToCanvas(center);
|
|
|
+
|
|
|
+ // 计算半径的像素值
|
|
|
+ const radiusVector: Point3 = [cachedStats.femurCircle.radius, 0, 0];
|
|
|
+ const endPoint = vectorAdd(center, radiusVector);
|
|
|
+ const endCanvas = viewport.worldToCanvas(endPoint);
|
|
|
+ const radiusPixels = Math.sqrt(
|
|
|
+ Math.pow(endCanvas[0] - centerCanvas[0], 2) +
|
|
|
+ Math.pow(endCanvas[1] - centerCanvas[1], 2)
|
|
|
+ );
|
|
|
+
|
|
|
+ const circleUID = `${annotationUID}-femur-circle`;
|
|
|
+ drawCircleSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ circleUID,
|
|
|
+ centerCanvas,
|
|
|
+ radiusPixels,
|
|
|
+ {
|
|
|
+ color: color,
|
|
|
+ width: 2,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制圆心连线
|
|
|
+ if (cachedStats?.acetabulumCircle && cachedStats?.femurCircle && annotationUID) {
|
|
|
+ const M1 = cachedStats.acetabulumCircle.center;
|
|
|
+ const M2 = cachedStats.femurCircle.center;
|
|
|
+ const M1Canvas = viewport.worldToCanvas(M1);
|
|
|
+ const M2Canvas = viewport.worldToCanvas(M2);
|
|
|
+
|
|
|
+ const lineUID = `${annotationUID}-center-line`;
|
|
|
+ drawLineSvg(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ lineUID,
|
|
|
+ M1Canvas,
|
|
|
+ M2Canvas,
|
|
|
+ {
|
|
|
+ color: color,
|
|
|
+ width: 2,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制6个控制点
|
|
|
+ if (annotationUID) {
|
|
|
+ const handleGroupUID = '0';
|
|
|
+ drawHandles(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ handleGroupUID,
|
|
|
+ canvasPoints,
|
|
|
+ {
|
|
|
+ color: 'rgb(255, 255, 255)',
|
|
|
+ handleRadius: 6,
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制DI数值标注(黄色,可拖拽)
|
|
|
+ if (cachedStats?.femurCircle && cachedStats.DI !== undefined && annotationUID) {
|
|
|
+ const femurCenterCanvas = viewport.worldToCanvas(cachedStats.femurCircle.center);
|
|
|
+ const textLines = [`DI: ${cachedStats.DI.toFixed(2)}`];
|
|
|
+ const textUID = `${annotationUID}-DI-text`;
|
|
|
+ const textBoxPosition: CoreTypes.Point2 = data.textBox || [
|
|
|
+ femurCenterCanvas[0] + 20,
|
|
|
+ femurCenterCanvas[1] - 20,
|
|
|
+ ];
|
|
|
+
|
|
|
+ drawLinkedTextBox(
|
|
|
+ svgDrawingHelper,
|
|
|
+ annotationUID,
|
|
|
+ textUID,
|
|
|
+ textLines,
|
|
|
+ textBoxPosition,
|
|
|
+ [femurCenterCanvas],
|
|
|
+ {},
|
|
|
+ {
|
|
|
+ color: 'rgb(255, 255, 0)', // 黄色
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ renderStatus = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return renderStatus;
|
|
|
+ };
|
|
|
+}
|