HipDorsalCoverageTool.ts 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720
  1. import { Point3, Point2 } from './mathUtils';
  2. import {
  3. utilities as csUtils,
  4. Types as CoreTypes,
  5. getEnabledElement,
  6. } from '@cornerstonejs/core';
  7. import {
  8. AnnotationTool,
  9. utilities,
  10. Types,
  11. annotation,
  12. drawing,
  13. } from '@cornerstonejs/tools';
  14. const {
  15. drawHandles,
  16. drawLinkedTextBox,
  17. drawLine: drawLineSvg,
  18. drawCircle: drawCircleSvg,
  19. } = drawing;
  20. import {
  21. vectorSubtract,
  22. vectorAdd,
  23. vectorScale,
  24. calculateDistance,
  25. fitCircleFrom3Points,
  26. getLineIntersection,
  27. getLineSegmentIntersection,
  28. fromPoint2ToPoint3,
  29. } from './mathUtils';
  30. import {
  31. PublicToolProps,
  32. ToolProps,
  33. EventTypes,
  34. SVGDrawingHelper,
  35. } from '@cornerstonejs/tools/dist/esm/types';
  36. // 注解初始配置接口
  37. interface AnnotationInitialConfig {
  38. // 圆的3个点位置(相对于中心的偏移)
  39. point1Offset?: [number, number];
  40. point2Offset?: [number, number];
  41. point3Offset?: [number, number];
  42. // 直线的2个点位置(相对于中心的偏移)
  43. line45Point1Offset?: [number, number];
  44. line45Point2Offset?: [number, number];
  45. }
  46. // 注解数据接口
  47. interface HipDorsalCoverageAnnotation extends Types.Annotation {
  48. data: {
  49. handles: {
  50. points: CoreTypes.Point3[]; // 5个点的世界坐标
  51. activeHandleIndex: number | null;
  52. };
  53. textBox?: CoreTypes.Point2; // 文本框位置(canvas坐标)
  54. cachedStats?: {
  55. [targetId: string]: {
  56. // 拟合圆信息
  57. circleCenter?: CoreTypes.Point3; // 圆心M
  58. circleRadius?: number; // 半径R
  59. // 关键点
  60. pointA?: CoreTypes.Point3; // 交点A
  61. pointB?: CoreTypes.Point3; // 交点B
  62. pointC?: CoreTypes.Point3; // 交点C
  63. // 距离
  64. distanceAC?: number;
  65. distanceBC?: number;
  66. distanceAB?: number; // A和B之间的距离(圆的直径)
  67. // 覆盖率
  68. coverageRatio?: number; // 0-1之间
  69. coveragePercent?: string; // 百分比字符串
  70. // 辅助向量(用于渲染)
  71. perpVector?: CoreTypes.Point3; // 垂线方向向量
  72. };
  73. };
  74. };
  75. }
  76. // 导出的注解数据接口
  77. export interface ExportedDorsalCoverageData {
  78. // 输入点
  79. points: CoreTypes.Point3[]; // 5个点的世界坐标
  80. // 拟合圆
  81. circleCenter: CoreTypes.Point3; // 圆心M
  82. circleRadius: number; // 半径R
  83. // 关键点
  84. pointA: CoreTypes.Point3; // 交点A
  85. pointB: CoreTypes.Point3; // 交点B
  86. pointC: CoreTypes.Point3; // 交点C
  87. // 测量结果
  88. distanceAC: number; // AC距离
  89. distanceBC: number; // BC距离
  90. coverageRatio: number; // 覆盖率(0-1)
  91. coveragePercent: string; // 覆盖率百分比
  92. // 文本框位置
  93. textBoxPosition?: CoreTypes.Point2;
  94. // 元数据
  95. metadata: {
  96. viewPlaneNormal: CoreTypes.Point3;
  97. viewUp: CoreTypes.Point3;
  98. FrameOfReferenceUID: string;
  99. referencedImageId: string;
  100. };
  101. }
  102. export default class HipDorsalCoverageTool extends AnnotationTool {
  103. static toolName = 'HipDorsalCoverageTool';
  104. editData: {
  105. annotation: Types.Annotation;
  106. viewportIdsToRender: string[];
  107. handleIndex?: number;
  108. newAnnotation?: boolean;
  109. hasMoved?: boolean;
  110. textBoxBeingMoved?: boolean;
  111. textBoxOffset?: CoreTypes.Point2;
  112. movingWholeTool?: boolean; // 是否正在移动整个工具
  113. wholeToolOffset?: CoreTypes.Point2; // 移动整个工具时的偏移量
  114. } | null = null;
  115. isDrawing: boolean = false;
  116. /**
  117. * 创建一个预设的注解,带有5个默认点
  118. * 确保前3个点严格在圆周上,以保证圆拟合精度
  119. */
  120. static createDefaultAnnotation(
  121. element: HTMLDivElement,
  122. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
  123. config?: AnnotationInitialConfig
  124. ): HipDorsalCoverageAnnotation {
  125. const enabledElement = getEnabledElement(element);
  126. if (!enabledElement) {
  127. throw new Error('Element is not enabled');
  128. }
  129. const canvas = viewport.canvas;
  130. const { width, height } = canvas;
  131. const centerX = width / 2;
  132. const centerY = height / 2;
  133. // 定义圆心和半径 - 使用固定的圆形参数确保点在圆周上
  134. const circleCenter: [number, number] = [centerX, centerY];
  135. const circleRadius = 80; // 固定的圆半径
  136. // 创建7个在圆周上的等分点(为了避免三点共线,选择非共线性角度)
  137. // 使用0°, 90°, 和270°的角度,确保这三个点确定一个圆
  138. const angles = [
  139. Math.PI, // 180° (最好避免0°因为可能共线)
  140. Math.PI / 2, // 90°
  141. Math.PI * 1.5 // 270°
  142. ];
  143. // 科学选择角度:0°, 120°, 240°是最好的,因为它们保证三点确定唯一圆
  144. const scientific_angles = [0, 2 * Math.PI / 3, 4 * Math.PI / 3]; // 0°, 120°, 240°
  145. const circlePoint1: CoreTypes.Point2 = [
  146. circleCenter[0] + circleRadius * Math.cos(scientific_angles[0]),
  147. circleCenter[1] + circleRadius * Math.sin(scientific_angles[0])
  148. ];
  149. const circlePoint2: CoreTypes.Point2 = [
  150. circleCenter[0] + circleRadius * Math.cos(scientific_angles[1]),
  151. circleCenter[1] + circleRadius * Math.sin(scientific_angles[1])
  152. ];
  153. const circlePoint3: CoreTypes.Point2 = [
  154. circleCenter[0] + circleRadius * Math.cos(scientific_angles[2]),
  155. circleCenter[1] + circleRadius * Math.sin(scientific_angles[2])
  156. ];
  157. // 直线的两个点(与圆无关) - 放置在圆的另一侧
  158. const line45Point1: CoreTypes.Point2 = [
  159. centerX + circleRadius * 1.2, // 圆右侧
  160. centerY - circleRadius * 0.6 // 偏上
  161. ];
  162. const line45Point2: CoreTypes.Point2 = [
  163. centerX - circleRadius * 0.8, // 圆左侧
  164. centerY + circleRadius * 0.6 // 偏下
  165. ];
  166. // 构建5个canvas坐标点:3个圆周点 + 2个直线上的点
  167. const canvasPoints: CoreTypes.Point2[] = [
  168. circlePoint1, // 点1: 圆周上的点 (0度)
  169. circlePoint2, // 点2: 圆周上的点 (120度)
  170. circlePoint3, // 点3: 圆周上的点 (240度)
  171. line45Point1, // 点4: 直线上的点
  172. line45Point2, // 点5: 直线上的点
  173. ];
  174. // 转换为world坐标
  175. const worldPoints = canvasPoints.map((canvasPoint) =>
  176. viewport.canvasToWorld(canvasPoint)
  177. );
  178. const camera = viewport.getCamera();
  179. const { viewPlaneNormal, viewUp } = camera;
  180. if (viewPlaneNormal === undefined || viewUp === undefined) {
  181. throw new Error('Camera parameters are undefined');
  182. }
  183. const annotationData = {
  184. invalidated: true,
  185. highlighted: false,
  186. metadata: {
  187. viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3,
  188. viewUp: [...viewUp] as CoreTypes.Point3,
  189. FrameOfReferenceUID: viewport.getFrameOfReferenceUID(),
  190. referencedImageId: viewport.getCurrentImageId?.() || '',
  191. toolName: HipDorsalCoverageTool.toolName,
  192. },
  193. data: {
  194. label: '',
  195. handles: {
  196. points: worldPoints,
  197. activeHandleIndex: null,
  198. },
  199. cachedStats: {},
  200. },
  201. } as HipDorsalCoverageAnnotation;
  202. return annotationData;
  203. }
  204. /**
  205. * 导出注解数据
  206. */
  207. static exportAnnotationData(
  208. annotation: HipDorsalCoverageAnnotation,
  209. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  210. ): ExportedDorsalCoverageData | null {
  211. const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`;
  212. const cachedStats = annotation.data.cachedStats?.[targetId];
  213. if (!cachedStats || !cachedStats.circleCenter || !cachedStats.pointA ||
  214. !cachedStats.pointB || !cachedStats.pointC) {
  215. return null;
  216. }
  217. return {
  218. points: [...annotation.data.handles.points],
  219. circleCenter: cachedStats.circleCenter,
  220. circleRadius: cachedStats.circleRadius || 0,
  221. pointA: cachedStats.pointA,
  222. pointB: cachedStats.pointB,
  223. pointC: cachedStats.pointC,
  224. distanceAC: cachedStats.distanceAC || 0,
  225. distanceBC: cachedStats.distanceBC || 0,
  226. coverageRatio: cachedStats.coverageRatio || 0,
  227. coveragePercent: cachedStats.coveragePercent || '0.0%',
  228. textBoxPosition: annotation.data.textBox,
  229. metadata: {
  230. viewPlaneNormal: annotation.metadata?.viewPlaneNormal || [0, 0, 1],
  231. viewUp: annotation.metadata?.viewUp || [0, 1, 0],
  232. FrameOfReferenceUID: annotation.metadata?.FrameOfReferenceUID || '',
  233. referencedImageId: annotation.metadata?.referencedImageId || '',
  234. },
  235. };
  236. }
  237. /**
  238. * 从导出的数据恢复注解
  239. */
  240. static restoreFromExportedData(
  241. exportedData: ExportedDorsalCoverageData,
  242. element: HTMLDivElement,
  243. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  244. ): HipDorsalCoverageAnnotation {
  245. const enabledElement = getEnabledElement(element);
  246. if (!enabledElement) {
  247. throw new Error('Element is not enabled');
  248. }
  249. const annotationData = {
  250. invalidated: true,
  251. highlighted: false,
  252. metadata: {
  253. ...exportedData.metadata,
  254. toolName: HipDorsalCoverageTool.toolName,
  255. },
  256. data: {
  257. label: '',
  258. handles: {
  259. points: [...exportedData.points],
  260. activeHandleIndex: null,
  261. },
  262. textBox: exportedData.textBoxPosition,
  263. cachedStats: {},
  264. },
  265. } as HipDorsalCoverageAnnotation;
  266. return annotationData;
  267. }
  268. constructor(
  269. toolProps: PublicToolProps = {},
  270. defaultToolProps: ToolProps = {
  271. supportedInteractionTypes: ['Mouse', 'Touch'],
  272. configuration: {
  273. shadow: true,
  274. preventHandleOutsideImage: false,
  275. },
  276. }
  277. ) {
  278. super(toolProps, defaultToolProps);
  279. }
  280. addNewAnnotation(
  281. evt: EventTypes.InteractionEventType
  282. ): HipDorsalCoverageAnnotation {
  283. evt.preventDefault();
  284. return {} as HipDorsalCoverageAnnotation;
  285. }
  286. isPointNearTool(
  287. element: HTMLDivElement,
  288. annotation: HipDorsalCoverageAnnotation,
  289. canvasCoords: CoreTypes.Point2,
  290. proximity: number
  291. ): boolean {
  292. const enabledElement = getEnabledElement(element);
  293. if (!enabledElement) {
  294. return false;
  295. }
  296. const { viewport } = enabledElement;
  297. const points = annotation.data.handles.points;
  298. // 检查是否靠近手柄点
  299. for (let i = 0; i < points.length; i++) {
  300. const point = points[i];
  301. const canvasPoint = viewport.worldToCanvas(point);
  302. const distance = Math.sqrt(
  303. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  304. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  305. );
  306. if (distance < proximity) {
  307. return true;
  308. }
  309. }
  310. const targetId = this.getTargetId(viewport);
  311. const cachedStats = targetId ? annotation.data.cachedStats?.[targetId] : undefined;
  312. // 检查是否靠近圆周
  313. if (this._isPointNearCircle(canvasCoords, annotation, enabledElement)) {
  314. return true;
  315. }
  316. // 检查是否靠近线段 MC
  317. if (cachedStats?.circleCenter && cachedStats?.pointC) {
  318. const centerCanvas = viewport.worldToCanvas(cachedStats.circleCenter);
  319. const pointCCanvas = viewport.worldToCanvas(cachedStats.pointC);
  320. const distance = this._distanceToSegment(canvasCoords, centerCanvas, pointCCanvas);
  321. if (distance < proximity) {
  322. return true;
  323. }
  324. }
  325. // 检查是否靠近直线45
  326. if (this._isPointNearLine45(canvasCoords, annotation, viewport, proximity)) {
  327. return true;
  328. }
  329. return false;
  330. }
  331. private _isPointInTextBox(
  332. canvasPoint: CoreTypes.Point2,
  333. textBoxPosition: CoreTypes.Point2,
  334. textWidth: number = 150,
  335. textHeight: number = 60
  336. ): boolean {
  337. const [x, y] = canvasPoint;
  338. const [tx, ty] = textBoxPosition;
  339. const margin = 10;
  340. return (
  341. x >= tx - margin &&
  342. x <= tx + textWidth + margin &&
  343. y >= ty - margin &&
  344. y <= ty + textHeight + margin
  345. );
  346. }
  347. /**
  348. * 计算点到线段的距离
  349. */
  350. private _distanceToSegment(
  351. point: CoreTypes.Point2,
  352. lineStart: CoreTypes.Point2,
  353. lineEnd: CoreTypes.Point2
  354. ): number {
  355. const x = point[0];
  356. const y = point[1];
  357. const x1 = lineStart[0];
  358. const y1 = lineStart[1];
  359. const x2 = lineEnd[0];
  360. const y2 = lineEnd[1];
  361. const A = x - x1;
  362. const B = y - y1;
  363. const C = x2 - x1;
  364. const D = y2 - y1;
  365. const dot = A * C + B * D;
  366. const lenSq = C * C + D * D;
  367. let param = -1;
  368. if (lenSq !== 0) {
  369. param = dot / lenSq;
  370. }
  371. let xx, yy;
  372. if (param < 0) {
  373. xx = x1;
  374. yy = y1;
  375. } else if (param > 1) {
  376. xx = x2;
  377. yy = y2;
  378. } else {
  379. xx = x1 + param * C;
  380. yy = y1 + param * D;
  381. }
  382. const dx = x - xx;
  383. const dy = y - yy;
  384. return Math.sqrt(dx * dx + dy * dy);
  385. }
  386. /**
  387. * 检查点是否靠近圆周
  388. */
  389. private _isPointNearCircle(
  390. canvasPoint: CoreTypes.Point2,
  391. annotation: HipDorsalCoverageAnnotation,
  392. enabledElement: CoreTypes.IEnabledElement
  393. ): boolean {
  394. const { viewport } = enabledElement;
  395. const targetId = this.getTargetId(viewport);
  396. const cachedStats = targetId ? annotation.data.cachedStats?.[targetId] : undefined;
  397. if (!cachedStats?.circleCenter || !cachedStats?.circleRadius) {
  398. return false;
  399. }
  400. // 获取圆的像素半径
  401. const circleRadiusPixels = this._getCircleRadiusInPixels(
  402. {
  403. center: cachedStats.circleCenter,
  404. radius: cachedStats.circleRadius,
  405. },
  406. viewport
  407. );
  408. // 计算鼠标点到圆心的像素距离
  409. const circleCenterCanvas = viewport.worldToCanvas(cachedStats.circleCenter);
  410. const distanceToCenter = Math.sqrt(
  411. Math.pow(canvasPoint[0] - circleCenterCanvas[0], 2) +
  412. Math.pow(canvasPoint[1] - circleCenterCanvas[1], 2)
  413. );
  414. // 检查是否在圆周范围内(容差为8像素)
  415. const circleTolerance = 8;
  416. return Math.abs(distanceToCenter - circleRadiusPixels) <= circleTolerance;
  417. }
  418. /**
  419. * 检查点是否靠近直线45
  420. */
  421. private _isPointNearLine45(
  422. canvasPoint: CoreTypes.Point2,
  423. annotation: HipDorsalCoverageAnnotation,
  424. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
  425. proximity: number
  426. ): boolean {
  427. const points = annotation.data.handles.points;
  428. if (points.length < 5) {
  429. return false;
  430. }
  431. const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
  432. const distance = this._distanceToSegment(canvasPoint, canvasPoints[3], canvasPoints[4]);
  433. return distance < proximity;
  434. }
  435. /**
  436. * 将世界坐标圆半径转换为像素半径
  437. */
  438. private _getCircleRadiusInPixels(
  439. circle: { center: CoreTypes.Point3; radius: number },
  440. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  441. ): number {
  442. const { center, radius } = circle;
  443. // 沿X轴方向获取距离圆心radius的点
  444. const radiusVector: CoreTypes.Point3 = [radius, 0, 0];
  445. const endPoint: CoreTypes.Point3 = vectorAdd(center, radiusVector);
  446. // 分别转换圆心和圆周上的点到canvas坐标
  447. const centerCanvas = viewport.worldToCanvas(center);
  448. const endCanvas = viewport.worldToCanvas(endPoint);
  449. // 计算两个canvas点之间的距离就是像素半径
  450. return Math.sqrt(
  451. Math.pow(endCanvas[0] - centerCanvas[0], 2) +
  452. Math.pow(endCanvas[1] - centerCanvas[1], 2)
  453. );
  454. }
  455. /**
  456. * 计算两条无限直线的交点(不限制方向)
  457. */
  458. private _calculateLineIntersection(
  459. line1Start: CoreTypes.Point3,
  460. line1Dir: CoreTypes.Point3,
  461. line2Start: CoreTypes.Point3,
  462. line2Dir: CoreTypes.Point3
  463. ): CoreTypes.Point3 | null {
  464. // 使用参数方程求解交点
  465. // line1: P1 + t * D1
  466. // line2: P2 + s * D2
  467. const dx = line2Start[0] - line1Start[0];
  468. const dy = line2Start[1] - line1Start[1];
  469. const det = line1Dir[0] * line2Dir[1] - line1Dir[1] * line2Dir[0];
  470. if (Math.abs(det) < 1e-10) {
  471. // 平行线,无交点
  472. return null;
  473. }
  474. const t = (dx * line2Dir[1] - dy * line2Dir[0]) / det;
  475. // 返回交点坐标,不检查方向限制
  476. return [
  477. line1Start[0] + t * line1Dir[0],
  478. line1Start[1] + t * line1Dir[1],
  479. 0
  480. ];
  481. }
  482. /**
  483. * 计算两条线段的交点(检查交点是否在线段范围内)
  484. */
  485. private _calculateLineSegmentIntersection(
  486. p1: CoreTypes.Point3,
  487. p2: CoreTypes.Point3,
  488. p3: CoreTypes.Point3,
  489. p4: CoreTypes.Point3
  490. ): CoreTypes.Point3 | null {
  491. // 提取2D坐标
  492. const x1 = p1[0], y1 = p1[1];
  493. const x2 = p2[0], y2 = p2[1];
  494. const x3 = p3[0], y3 = p3[1];
  495. const x4 = p4[0], y4 = p4[1];
  496. // 计算方向向量
  497. const dx1 = x2 - x1;
  498. const dy1 = y2 - y1;
  499. const dx2 = x4 - x3;
  500. const dy2 = y4 - y3;
  501. // 计算行列式
  502. const denominator = dx1 * dy2 - dy1 * dx2;
  503. // 检查是否平行
  504. if (Math.abs(denominator) < 1e-10) {
  505. return null; // 平行,无交点
  506. }
  507. // 计算参数t和s
  508. const t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denominator;
  509. const s = ((x3 - x1) * dy1 - (y3 - y1) * dx1) / denominator;
  510. // 检查交点是否在线段范围内
  511. if (t >= 0 && t <= 1 && s >= 0 && s <= 1) {
  512. // 计算交点坐标
  513. const intersectionX = x1 + t * dx1;
  514. const intersectionY = y1 + t * dy1;
  515. return [intersectionX, intersectionY, 0];
  516. }
  517. return null; // 交点不在线段范围内
  518. }
  519. cancel(element: HTMLDivElement): string {
  520. if (this.isDrawing) {
  521. this.isDrawing = false;
  522. this._deactivateDraw(element);
  523. this._deactivateModify(element);
  524. const enabledElement = getEnabledElement(element);
  525. if (enabledElement) {
  526. const viewportIdsToRender =
  527. utilities.viewportFilters.getViewportIdsWithToolToRender(
  528. element,
  529. this.getToolName()
  530. );
  531. utilities.triggerAnnotationRenderForViewportIds(
  532. viewportIdsToRender
  533. );
  534. }
  535. this.editData = null;
  536. return this.getToolName();
  537. }
  538. return '';
  539. }
  540. _activateDraw(element: HTMLDivElement): void {
  541. element.addEventListener(
  542. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  543. this._dragCallback as EventListener
  544. );
  545. element.addEventListener(
  546. 'CORNERSTONE_TOOLS_MOUSE_UP',
  547. this._endCallback as EventListener
  548. );
  549. element.addEventListener(
  550. 'CORNERSTONE_TOOLS_MOUSE_CLICK',
  551. this._endCallback as EventListener
  552. );
  553. }
  554. _deactivateDraw(element: HTMLDivElement): void {
  555. element.removeEventListener(
  556. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  557. this._dragCallback as EventListener
  558. );
  559. element.removeEventListener(
  560. 'CORNERSTONE_TOOLS_MOUSE_UP',
  561. this._endCallback as EventListener
  562. );
  563. element.removeEventListener(
  564. 'CORNERSTONE_TOOLS_MOUSE_CLICK',
  565. this._endCallback as EventListener
  566. );
  567. }
  568. _activateModify(element: HTMLDivElement): void {
  569. element.addEventListener(
  570. 'CORNERSTONE_TOOLS_MOUSE_DOWN',
  571. this._mouseDownModifyCallback as EventListener
  572. );
  573. element.addEventListener(
  574. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  575. this._mouseDragModifyCallback as EventListener
  576. );
  577. element.addEventListener(
  578. 'CORNERSTONE_TOOLS_MOUSE_UP',
  579. this._mouseUpModifyCallback as EventListener
  580. );
  581. element.addEventListener(
  582. 'CORNERSTONE_TOOLS_MOUSE_MOVE',
  583. this._mouseMoveModifyCallback as EventListener
  584. );
  585. element.addEventListener(
  586. 'keydown',
  587. this._keyDownCallback as EventListener
  588. );
  589. }
  590. _deactivateModify(element: HTMLDivElement): void {
  591. element.removeEventListener(
  592. 'CORNERSTONE_TOOLS_MOUSE_DOWN',
  593. this._mouseDownModifyCallback as EventListener
  594. );
  595. element.removeEventListener(
  596. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  597. this._mouseDragModifyCallback as EventListener
  598. );
  599. element.removeEventListener(
  600. 'CORNERSTONE_TOOLS_MOUSE_UP',
  601. this._mouseUpModifyCallback as EventListener
  602. );
  603. element.removeEventListener(
  604. 'CORNERSTONE_TOOLS_MOUSE_MOVE',
  605. this._mouseMoveModifyCallback as EventListener
  606. );
  607. element.removeEventListener(
  608. 'keydown',
  609. this._keyDownCallback as EventListener
  610. );
  611. }
  612. _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  613. const eventDetail = evt.detail;
  614. const { element, currentPoints } = eventDetail;
  615. const canvasCoords = currentPoints.canvas;
  616. const enabledElement = getEnabledElement(element);
  617. if (!enabledElement) {
  618. return;
  619. }
  620. const { viewport } = enabledElement;
  621. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  622. if (!annotations || annotations.length === 0) {
  623. return;
  624. }
  625. // 优先检测文本框点击
  626. for (const ann of annotations) {
  627. const customAnn = ann as HipDorsalCoverageAnnotation;
  628. const targetId = this.getTargetId(viewport);
  629. const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
  630. if (cachedStats?.circleCenter && customAnn.data.textBox) {
  631. if (this._isPointInTextBox(canvasCoords, customAnn.data.textBox)) {
  632. const viewportIdsToRender =
  633. utilities.viewportFilters.getViewportIdsWithToolToRender(
  634. element,
  635. this.getToolName()
  636. );
  637. const textBoxOffset: CoreTypes.Point2 = [
  638. canvasCoords[0] - customAnn.data.textBox[0],
  639. canvasCoords[1] - customAnn.data.textBox[1],
  640. ];
  641. this.editData = {
  642. annotation: customAnn,
  643. viewportIdsToRender,
  644. hasMoved: false,
  645. textBoxBeingMoved: true,
  646. textBoxOffset: textBoxOffset,
  647. };
  648. customAnn.isSelected = true;
  649. customAnn.highlighted = true;
  650. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  651. evt.preventDefault();
  652. evt.stopPropagation();
  653. return;
  654. }
  655. }
  656. }
  657. // 次优先检测手柄点击
  658. for (const ann of annotations) {
  659. const customAnn = ann as HipDorsalCoverageAnnotation;
  660. const handle = this.getHandleNearImagePoint(
  661. element,
  662. customAnn,
  663. canvasCoords,
  664. 6
  665. );
  666. if (handle) {
  667. const viewportIdsToRender =
  668. utilities.viewportFilters.getViewportIdsWithToolToRender(
  669. element,
  670. this.getToolName()
  671. );
  672. this.editData = {
  673. annotation: customAnn,
  674. viewportIdsToRender,
  675. handleIndex: customAnn.data.handles.activeHandleIndex || 0,
  676. hasMoved: false,
  677. };
  678. customAnn.isSelected = true;
  679. customAnn.highlighted = true;
  680. utilities.triggerAnnotationRenderForViewportIds(
  681. viewportIdsToRender
  682. );
  683. evt.preventDefault();
  684. evt.stopPropagation();
  685. return;
  686. }
  687. }
  688. // 再次检测圆周、线段点击(用于整体移动)
  689. for (const ann of annotations) {
  690. const customAnn = ann as HipDorsalCoverageAnnotation;
  691. if (this._isPointNearCircle(canvasCoords, customAnn, enabledElement) ||
  692. this._isPointNearLine45(canvasCoords, customAnn, viewport, 10)) {
  693. const viewportIdsToRender =
  694. utilities.viewportFilters.getViewportIdsWithToolToRender(
  695. element,
  696. this.getToolName()
  697. );
  698. // 计算整体移动时的偏移量,使用第一个点作为基准
  699. const firstPoint = customAnn.data.handles.points[0];
  700. const firstPointCanvas = viewport.worldToCanvas(firstPoint);
  701. const wholeToolOffset: CoreTypes.Point2 = [
  702. canvasCoords[0] - firstPointCanvas[0],
  703. canvasCoords[1] - firstPointCanvas[1],
  704. ];
  705. this.editData = {
  706. annotation: customAnn,
  707. viewportIdsToRender,
  708. hasMoved: false,
  709. movingWholeTool: true,
  710. wholeToolOffset: wholeToolOffset,
  711. };
  712. customAnn.isSelected = true;
  713. customAnn.highlighted = true;
  714. utilities.triggerAnnotationRenderForViewportIds(
  715. viewportIdsToRender
  716. );
  717. evt.preventDefault();
  718. evt.stopPropagation();
  719. return;
  720. }
  721. }
  722. // 如果没有点击在工具上,取消所有选中状态
  723. for (const ann of annotations) {
  724. const customAnn = ann as HipDorsalCoverageAnnotation;
  725. customAnn.isSelected = false;
  726. customAnn.highlighted = false;
  727. }
  728. const viewportIdsToRender =
  729. utilities.viewportFilters.getViewportIdsWithToolToRender(
  730. element,
  731. this.getToolName()
  732. );
  733. utilities.triggerAnnotationRenderForViewportIds(
  734. viewportIdsToRender
  735. );
  736. };
  737. _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  738. if (!this.editData) {
  739. return;
  740. }
  741. const eventDetail = evt.detail;
  742. const { currentPoints } = eventDetail;
  743. const canvasCoords = currentPoints.canvas;
  744. const enabledElement = getEnabledElement(eventDetail.element);
  745. if (!enabledElement) {
  746. return;
  747. }
  748. const { viewport } = enabledElement;
  749. const { annotation: ann, viewportIdsToRender, textBoxBeingMoved, movingWholeTool } = this.editData;
  750. const customAnn = ann as HipDorsalCoverageAnnotation;
  751. const { data } = customAnn;
  752. // 处理文本框拖拽
  753. if (textBoxBeingMoved && this.editData.textBoxOffset) {
  754. const newTextBoxPosition: CoreTypes.Point2 = [
  755. canvasCoords[0] - this.editData.textBoxOffset[0],
  756. canvasCoords[1] - this.editData.textBoxOffset[1],
  757. ];
  758. data.textBox = newTextBoxPosition;
  759. this.editData.hasMoved = true;
  760. utilities.triggerAnnotationRenderForViewportIds(
  761. viewportIdsToRender
  762. );
  763. evt.preventDefault();
  764. evt.stopPropagation();
  765. return;
  766. }
  767. // 处理整体移动工具
  768. if (movingWholeTool && this.editData.wholeToolOffset && data.handles.points) {
  769. // 计算新的中点位置(基于圆心或者几何中心)
  770. const newCenterCanvas: CoreTypes.Point2 = [
  771. canvasCoords[0] - this.editData.wholeToolOffset[0],
  772. canvasCoords[1] - this.editData.wholeToolOffset[1],
  773. ];
  774. const newCenterWorld = viewport.canvasToWorld(newCenterCanvas);
  775. // 计算移动偏移量
  776. // 使用第一个点作为基准点来计算偏移
  777. const oldCenter = data.handles.points[0];
  778. const offset = vectorSubtract(newCenterWorld, oldCenter);
  779. // 应用偏移量移动所有控制点
  780. for (let i = 0; i < data.handles.points.length; i++) {
  781. data.handles.points[i] = vectorAdd(data.handles.points[i], offset);
  782. }
  783. // 更新文本框位置(使用相同的偏移量)
  784. if (data.textBox) {
  785. data.textBox[0] += offset[0];
  786. data.textBox[1] += offset[1];
  787. }
  788. // 重新计算几何属性
  789. this._updateCachedStats(customAnn, enabledElement);
  790. this.editData.hasMoved = true;
  791. utilities.triggerAnnotationRenderForViewportIds(
  792. viewportIdsToRender
  793. );
  794. evt.preventDefault();
  795. evt.stopPropagation();
  796. return;
  797. }
  798. // 处理手柄拖拽
  799. const worldPos = currentPoints.world;
  800. const activeHandleIndex = data.handles.activeHandleIndex;
  801. if (activeHandleIndex !== null && activeHandleIndex >= 0 && activeHandleIndex < data.handles.points.length) {
  802. data.handles.points[activeHandleIndex] = worldPos;
  803. this._updateCachedStats(customAnn, enabledElement);
  804. this.editData.hasMoved = true;
  805. utilities.triggerAnnotationRenderForViewportIds(
  806. viewportIdsToRender
  807. );
  808. evt.preventDefault();
  809. evt.stopPropagation();
  810. }
  811. };
  812. /**
  813. * 鼠标移动回调 - 处理悬停高亮和光标反馈
  814. */
  815. _mouseMoveModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  816. const eventDetail = evt.detail;
  817. const { element, currentPoints } = eventDetail;
  818. const canvasCoords = currentPoints?.canvas;
  819. if (!canvasCoords) {
  820. // 鼠标位置无效,重置所有高亮状态
  821. element.style.cursor = 'default';
  822. this._clearAllHighlights(element);
  823. return;
  824. }
  825. const enabledElement = getEnabledElement(element);
  826. if (!enabledElement) {
  827. return;
  828. }
  829. const { viewport } = enabledElement;
  830. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  831. if (!annotations || annotations.length === 0) {
  832. element.style.cursor = 'default';
  833. return;
  834. }
  835. // 清除之前的高亮状态,为新的检测做准备
  836. this._clearAllHighlights(element);
  837. // 按优先级顺序检测鼠标悬停区域
  838. let hoverResult = this._detectHoverTarget(annotations, canvasCoords, viewport, enabledElement, element);
  839. // 应用检测到的悬停状态
  840. this._applyHoverEffect(hoverResult, element, annotations);
  841. // 触发渲染更新
  842. const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender(
  843. element,
  844. this.getToolName()
  845. );
  846. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  847. };
  848. /**
  849. * 按优先级检测鼠标悬停的目标
  850. */
  851. private _detectHoverTarget(
  852. annotations: Types.Annotation[],
  853. canvasCoords: CoreTypes.Point2,
  854. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
  855. enabledElement: CoreTypes.IEnabledElement,
  856. element: HTMLDivElement
  857. ): { annotation: HipDorsalCoverageAnnotation | null; cursor: string; area: string } {
  858. // 默认返回值
  859. const defaultResult = {
  860. annotation: null as HipDorsalCoverageAnnotation | null,
  861. cursor: 'default',
  862. area: 'none'
  863. };
  864. // 优先级1: 检查文本框悬停(最高优先级)
  865. for (const ann of annotations) {
  866. const customAnn = ann as HipDorsalCoverageAnnotation;
  867. const targetId = this.getTargetId(viewport);
  868. const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
  869. if (cachedStats?.circleCenter && customAnn.data.textBox) {
  870. if (this._isPointInTextBox(canvasCoords, customAnn.data.textBox)) {
  871. return {
  872. annotation: customAnn,
  873. cursor: 'pointer',
  874. area: 'textbox'
  875. };
  876. }
  877. }
  878. }
  879. // 优先级2: 检查手柄点悬停
  880. for (const ann of annotations) {
  881. const customAnn = ann as HipDorsalCoverageAnnotation;
  882. const handle = this.getHandleNearImagePoint(element, customAnn, canvasCoords, 6);
  883. if (handle) {
  884. return {
  885. annotation: customAnn,
  886. cursor: 'crosshair',
  887. area: 'handle'
  888. };
  889. }
  890. }
  891. // 优先级3: 检查圆周悬停
  892. for (const ann of annotations) {
  893. const customAnn = ann as HipDorsalCoverageAnnotation;
  894. if (this._isPointNearCircle(canvasCoords, customAnn, enabledElement)) {
  895. return {
  896. annotation: customAnn,
  897. cursor: 'crosshair',
  898. area: 'circle'
  899. };
  900. }
  901. }
  902. // 优先级4: 检查直线45悬停
  903. for (const ann of annotations) {
  904. const customAnn = ann as HipDorsalCoverageAnnotation;
  905. const targetId = this.getTargetId(viewport);
  906. const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
  907. if (cachedStats?.perpVector && this._isPointNearLine45(canvasCoords, customAnn, viewport, 10)) {
  908. return {
  909. annotation: customAnn,
  910. cursor: 'crosshair',
  911. area: 'line'
  912. };
  913. }
  914. }
  915. return defaultResult;
  916. };
  917. /**
  918. * 清除所有注解的高亮状态
  919. */
  920. private _clearAllHighlights(element: HTMLDivElement): void {
  921. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  922. if (annotations) {
  923. annotations.forEach(ann => {
  924. // 只清除临时悬停高亮,不影响持久选中状态
  925. if (!ann.isSelected) {
  926. ann.highlighted = false;
  927. }
  928. });
  929. }
  930. };
  931. /**
  932. * 应用悬停效果
  933. */
  934. private _applyHoverEffect(
  935. hoverResult: { annotation: HipDorsalCoverageAnnotation | null; cursor: string; area: string },
  936. element: HTMLDivElement,
  937. annotations: Types.Annotation[]
  938. ): void {
  939. const { annotation: hoveredAnn, cursor } = hoverResult;
  940. // 设置光标样式
  941. element.style.cursor = cursor;
  942. // 设置高亮状态
  943. if (hoveredAnn && hoverResult.area !== 'none') {
  944. // 只对未选中的工具应用悬停高亮
  945. if (!hoveredAnn.isSelected) {
  946. hoveredAnn.highlighted = true;
  947. }
  948. } else {
  949. // 确保所有未选中的工具都没有悬停高亮
  950. annotations.forEach(ann => {
  951. if (!ann.isSelected) {
  952. ann.highlighted = false;
  953. }
  954. });
  955. }
  956. };
  957. _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  958. if (!this.editData) {
  959. return;
  960. }
  961. const { annotation: ann, hasMoved, movingWholeTool } = this.editData;
  962. const customAnn = ann as HipDorsalCoverageAnnotation;
  963. customAnn.data.handles.activeHandleIndex = null;
  964. // 如果没有拖拽且点击在线段/圆周上,则进入选中状态
  965. if (!hasMoved && movingWholeTool) {
  966. customAnn.isSelected = true;
  967. customAnn.highlighted = true;
  968. } else {
  969. customAnn.highlighted = false;
  970. }
  971. const eventDetail = evt.detail;
  972. const { element } = eventDetail;
  973. const viewportIdsToRender =
  974. utilities.viewportFilters.getViewportIdsWithToolToRender(
  975. element,
  976. this.getToolName()
  977. );
  978. utilities.triggerAnnotationRenderForViewportIds(
  979. viewportIdsToRender
  980. );
  981. this.editData = null;
  982. evt.preventDefault();
  983. evt.stopPropagation();
  984. };
  985. handleSelectedCallback(
  986. evt: EventTypes.InteractionEventType,
  987. annotation: HipDorsalCoverageAnnotation
  988. ): void {
  989. const eventDetail = evt.detail;
  990. const { element } = eventDetail;
  991. annotation.highlighted = true;
  992. const viewportIdsToRender =
  993. utilities.viewportFilters.getViewportIdsWithToolToRender(
  994. element,
  995. this.getToolName()
  996. );
  997. utilities.triggerAnnotationRenderForViewportIds(
  998. viewportIdsToRender
  999. );
  1000. evt.preventDefault();
  1001. }
  1002. toolSelectedCallback(
  1003. evt: EventTypes.InteractionEventType,
  1004. annotation: HipDorsalCoverageAnnotation
  1005. ): void {
  1006. // 实现工具选中逻辑
  1007. }
  1008. /**
  1009. * 键盘按下回调 - 处理键盘删除
  1010. */
  1011. _keyDownCallback = (evt: KeyboardEvent): void => {
  1012. if (evt.key === 'Delete') {
  1013. const targetElement = evt.target as HTMLDivElement;
  1014. const enabledElement = getEnabledElement(targetElement);
  1015. if (!enabledElement) {
  1016. return;
  1017. }
  1018. const annotations = annotation.state.getAnnotations(this.getToolName(), targetElement);
  1019. if (!annotations || annotations.length === 0) {
  1020. return;
  1021. }
  1022. const targetId = this.getTargetId(enabledElement.viewport);
  1023. if (!targetId) {
  1024. return;
  1025. }
  1026. // 查找选中的注解并删除
  1027. for (const ann of annotations) {
  1028. const customAnn = ann as HipDorsalCoverageAnnotation;
  1029. if (customAnn.isSelected) {
  1030. annotation.state.removeAnnotation(ann.annotationUID??'');
  1031. const viewportIdsToRender =
  1032. utilities.viewportFilters.getViewportIdsWithToolToRender(
  1033. targetElement,
  1034. this.getToolName()
  1035. );
  1036. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  1037. break;
  1038. }
  1039. }
  1040. evt.preventDefault();
  1041. evt.stopPropagation();
  1042. }
  1043. };
  1044. _dragCallback = (evt: EventTypes.InteractionEventType): void => {
  1045. this.isDrawing = true;
  1046. const eventDetail = evt.detail;
  1047. const { element } = eventDetail;
  1048. const enabledElement = getEnabledElement(element);
  1049. if (!enabledElement || !this.editData) {
  1050. return;
  1051. }
  1052. const { annotation, viewportIdsToRender } = this.editData;
  1053. const { data } = annotation as HipDorsalCoverageAnnotation;
  1054. if (annotation) {
  1055. const { currentPoints } = eventDetail;
  1056. const worldPos = currentPoints.world;
  1057. const points = data.handles.points;
  1058. const activeHandleIndex = data.handles.activeHandleIndex;
  1059. if (activeHandleIndex !== null && activeHandleIndex < points.length) {
  1060. points[activeHandleIndex] = worldPos;
  1061. this._updateCachedStats(
  1062. annotation as HipDorsalCoverageAnnotation,
  1063. enabledElement
  1064. );
  1065. this.editData!.hasMoved = true;
  1066. }
  1067. utilities.triggerAnnotationRenderForViewportIds(
  1068. viewportIdsToRender
  1069. );
  1070. }
  1071. };
  1072. _endCallback = (evt: EventTypes.InteractionEventType): void => {
  1073. const eventDetail = evt.detail;
  1074. const { element } = eventDetail;
  1075. if (!this.editData) {
  1076. return;
  1077. }
  1078. const { annotation, hasMoved } = this.editData;
  1079. if (hasMoved) {
  1080. this.editData.hasMoved = false;
  1081. }
  1082. };
  1083. getHandleNearImagePoint(
  1084. element: HTMLDivElement,
  1085. annotation: HipDorsalCoverageAnnotation,
  1086. canvasCoords: CoreTypes.Point2,
  1087. proximity: number
  1088. ): Types.ToolHandle | undefined {
  1089. const enabledElement = getEnabledElement(element);
  1090. if (!enabledElement) {
  1091. return undefined;
  1092. }
  1093. const { viewport } = enabledElement;
  1094. const points = annotation.data.handles.points;
  1095. const handleProximity = Math.max(proximity, 15);
  1096. for (let i = 0; i < points.length; i++) {
  1097. const point = points[i];
  1098. const canvasPoint = viewport.worldToCanvas(point);
  1099. const distance = Math.sqrt(
  1100. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  1101. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  1102. );
  1103. if (distance < handleProximity) {
  1104. annotation.data.handles.activeHandleIndex = i;
  1105. return {
  1106. worldPosition: point,
  1107. } as Types.ToolHandle;
  1108. }
  1109. }
  1110. annotation.data.handles.activeHandleIndex = null;
  1111. return undefined;
  1112. }
  1113. _updateCachedStats(
  1114. annotation: HipDorsalCoverageAnnotation,
  1115. enabledElement: CoreTypes.IEnabledElement
  1116. ): void {
  1117. const { viewport } = enabledElement;
  1118. const { data } = annotation;
  1119. const points = data.handles.points;
  1120. const targetId = this.getTargetId(viewport);
  1121. if (!targetId) {
  1122. return;
  1123. }
  1124. if (!data.cachedStats) {
  1125. data.cachedStats = {};
  1126. }
  1127. if (!data.cachedStats[targetId]) {
  1128. data.cachedStats[targetId] = {};
  1129. }
  1130. const stats = data.cachedStats[targetId];
  1131. if (points.length >= 5) {
  1132. const point1 = points[0];
  1133. const point2 = points[1];
  1134. const point3 = points[2];
  1135. const point4 = points[3];
  1136. const point5 = points[4];
  1137. const circleResult = fitCircleFrom3Points(point1, point2, point3);
  1138. if (!circleResult) {
  1139. console.warn('圆拟合失败,三点可能共线');
  1140. return;
  1141. }
  1142. const centerM = circleResult.center;
  1143. const radius = circleResult.radius;
  1144. stats.circleCenter = centerM;
  1145. stats.circleRadius = radius;
  1146. // 计算直线45的方向向量
  1147. const line45Direction = vectorSubtract(point5, point4);
  1148. const line45Length = Math.sqrt(
  1149. line45Direction[0] * line45Direction[0] +
  1150. line45Direction[1] * line45Direction[1]
  1151. );
  1152. if (line45Length === 0) {
  1153. console.warn('直线45长度为0');
  1154. return;
  1155. }
  1156. const line45Unit = [
  1157. line45Direction[0] / line45Length,
  1158. line45Direction[1] / line45Length,
  1159. 0
  1160. ];
  1161. // 垂线方向:垂直于直线45
  1162. // 垂线应该朝向股骨头覆盖的方向
  1163. const perpVector: CoreTypes.Point3 = [
  1164. -line45Unit[1], // -dy
  1165. line45Unit[0], // dx
  1166. 0
  1167. ];
  1168. stats.perpVector = perpVector;
  1169. // 计算垂线与圆的交点A和B
  1170. const pointA = vectorAdd(centerM, vectorScale(perpVector, radius));
  1171. const pointB = vectorSubtract(centerM, vectorScale(perpVector, radius));
  1172. stats.pointA = pointA;
  1173. stats.pointB = pointB;
  1174. // 计算直线45与AB线段的交点C
  1175. // AB线段是从pointA到pointB的线段,直线45是从point4到point5
  1176. // 我们需要计算AB线段与直线45(无限直线)的交点
  1177. const pointC = this._calculateLineIntersection(
  1178. pointA,
  1179. vectorSubtract(pointB, pointA), // AB线段的方向向量
  1180. point4,
  1181. line45Direction // 直线45的方向向量
  1182. );
  1183. if (!pointC) {
  1184. console.warn('直线45与AB线段平行或无交点');
  1185. // 如果没有交点,显示NaN%
  1186. stats.coverageRatio = NaN;
  1187. stats.coveragePercent = 'NaN%';
  1188. return;
  1189. }
  1190. // 检查交点C是否在AB线段上
  1191. // 计算参数t,表示C在AB线段上的位置
  1192. const abVector = vectorSubtract(pointB, pointA);
  1193. const acVector = vectorSubtract(pointC, pointA);
  1194. const abLengthSquared = abVector[0] * abVector[0] + abVector[1] * abVector[1];
  1195. if (abLengthSquared === 0) {
  1196. console.warn('AB线段长度为0');
  1197. stats.coverageRatio = NaN;
  1198. stats.coveragePercent = 'NaN%';
  1199. return;
  1200. }
  1201. // 计算t = (C - A) · (B - A) / |B - A|²
  1202. const t = (acVector[0] * abVector[0] + acVector[1] * abVector[1]) / abLengthSquared;
  1203. // 检查t是否在[0, 1]范围内
  1204. if (t < 0 || t > 1) {
  1205. console.warn('交点C不在AB线段范围内, t =', t);
  1206. // 如果交点不在AB线段上,显示NaN%
  1207. stats.coverageRatio = NaN;
  1208. stats.coveragePercent = 'NaN%';
  1209. return;
  1210. }
  1211. stats.pointC = pointC;
  1212. // 将点转换为canvas坐标再计算距离(在图像平面上进行测量)
  1213. const pointACanvas = viewport.worldToCanvas(pointA);
  1214. const pointBCanvas = viewport.worldToCanvas(pointB);
  1215. const pointCCanvas = viewport.worldToCanvas(pointC);
  1216. // 计算距离(在canvas坐标系中,将Point2转换为Point3)
  1217. const distanceAC = calculateDistance(fromPoint2ToPoint3(pointACanvas), fromPoint2ToPoint3(pointCCanvas));
  1218. const distanceBC = calculateDistance(fromPoint2ToPoint3(pointBCanvas), fromPoint2ToPoint3(pointCCanvas));
  1219. const distanceAB = distanceAC + distanceBC; // A和B之间的距离,即圆的直径
  1220. stats.distanceAC = distanceAC;
  1221. stats.distanceBC = distanceBC;
  1222. stats.distanceAB = distanceAB;
  1223. // 新的覆盖率计算方式:选择较小的那一侧进行计算
  1224. // 这是基于股骨头在髋臼中的实际覆盖情况
  1225. let coverageRatio: number;
  1226. if (distanceAC < distanceBC) {
  1227. coverageRatio = distanceAB > 0 ? distanceAC / distanceAB : 0;
  1228. } else {
  1229. coverageRatio = distanceAB > 0 ? distanceBC / distanceAB : 0;
  1230. }
  1231. stats.coverageRatio = coverageRatio;
  1232. stats.coveragePercent = `${(coverageRatio * 100).toFixed(1)}%`;
  1233. }
  1234. }
  1235. renderAnnotation = (
  1236. enabledElement: CoreTypes.IEnabledElement,
  1237. svgDrawingHelper: SVGDrawingHelper
  1238. ): boolean => {
  1239. let renderStatus = false;
  1240. const { viewport } = enabledElement;
  1241. const { element } = viewport;
  1242. let annotations = annotation.state.getAnnotations(this.getToolName(), element);
  1243. if (!annotations?.length) {
  1244. return renderStatus;
  1245. }
  1246. for (let i = 0; i < annotations.length; i++) {
  1247. const annotation = annotations[i] as HipDorsalCoverageAnnotation;
  1248. const { annotationUID, data } = annotation;
  1249. const points = data.handles.points;
  1250. if (points.length < 5) {
  1251. continue;
  1252. }
  1253. const targetId = this.getTargetId(viewport);
  1254. const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined;
  1255. const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
  1256. const lineColor = annotation.highlighted
  1257. ? 'rgb(0, 120, 255)'
  1258. : 'rgb(255, 255, 255)';
  1259. // 绘制拟合圆
  1260. if (cachedStats?.circleCenter && cachedStats?.circleRadius && annotationUID) {
  1261. const circleCenterCanvas = viewport.worldToCanvas(cachedStats.circleCenter);
  1262. const radiusCanvas = this._getCircleRadiusInPixels({
  1263. center: cachedStats.circleCenter,
  1264. radius: cachedStats.circleRadius
  1265. }, viewport);
  1266. const circleUID = `${annotationUID}-fitted-circle`;
  1267. drawCircleSvg(
  1268. svgDrawingHelper,
  1269. annotationUID,
  1270. circleUID,
  1271. circleCenterCanvas,
  1272. radiusCanvas,
  1273. {
  1274. color: annotation.isSelected ? 'rgb(0, 255, 0)' : 'rgb(0, 255, 255)', // 选中时绿色,未选中时青色
  1275. lineWidth: 2,
  1276. }
  1277. );
  1278. // 绘制圆心M
  1279. const centerMarkUID = `${annotationUID}-center-mark`;
  1280. drawCircleSvg(
  1281. svgDrawingHelper,
  1282. annotationUID,
  1283. centerMarkUID,
  1284. circleCenterCanvas,
  1285. 5,
  1286. {
  1287. color: 'rgb(255, 0, 0)', // 红色
  1288. fill: 'rgb(255, 0, 0)',
  1289. }
  1290. );
  1291. }
  1292. // 绘制直线45
  1293. if (points.length >= 5 && annotationUID) {
  1294. const lineUID = `${annotationUID}-line45`;
  1295. drawLineSvg(
  1296. svgDrawingHelper,
  1297. annotationUID,
  1298. lineUID,
  1299. canvasPoints[3],
  1300. canvasPoints[4],
  1301. {
  1302. color: 'rgb(255, 255, 0)', // 黄色
  1303. width: 2,
  1304. }
  1305. );
  1306. }
  1307. // 绘制垂线和交点
  1308. if (cachedStats?.circleCenter && cachedStats?.pointA && cachedStats?.pointB && annotationUID) {
  1309. const centerCanvas = viewport.worldToCanvas(cachedStats.circleCenter);
  1310. const pointACanvas = viewport.worldToCanvas(cachedStats.pointA);
  1311. const pointBCanvas = viewport.worldToCanvas(cachedStats.pointB);
  1312. // MA段(蓝色虚线)- A点到圆心
  1313. const lineMAUID = `${annotationUID}-line-MA`;
  1314. drawLineSvg(
  1315. svgDrawingHelper,
  1316. annotationUID,
  1317. lineMAUID,
  1318. centerCanvas,
  1319. pointACanvas,
  1320. {
  1321. color: 'rgb(0, 0, 255)', // 蓝色
  1322. width: 1,
  1323. lineDash: '4,4',
  1324. }
  1325. );
  1326. // MB段(紫色虚线)- B点到圆心
  1327. const lineMBUID = `${annotationUID}-line-MB`;
  1328. drawLineSvg(
  1329. svgDrawingHelper,
  1330. annotationUID,
  1331. lineMBUID,
  1332. centerCanvas,
  1333. pointBCanvas,
  1334. {
  1335. color: 'rgb(128, 0, 128)', // 紫色
  1336. width: 1,
  1337. lineDash: '4,4',
  1338. }
  1339. );
  1340. // AB段(橙色虚线)- A点到B点
  1341. const lineABUID = `${annotationUID}-line-AB`;
  1342. drawLineSvg(
  1343. svgDrawingHelper,
  1344. annotationUID,
  1345. lineABUID,
  1346. pointACanvas,
  1347. pointBCanvas,
  1348. {
  1349. color: 'rgb(255, 165, 0)', // 橙色
  1350. width: 1,
  1351. lineDash: '2,2',
  1352. }
  1353. );
  1354. // 绘制交点标记
  1355. const pointAMarkUID = `${annotationUID}-point-A`;
  1356. drawCircleSvg(
  1357. svgDrawingHelper,
  1358. annotationUID,
  1359. pointAMarkUID,
  1360. pointACanvas,
  1361. 4,
  1362. {
  1363. color: 'rgb(0, 0, 255)', // 蓝色
  1364. fill: 'rgb(0, 0, 255)',
  1365. }
  1366. );
  1367. const pointBMarkUID = `${annotationUID}-point-B`;
  1368. drawCircleSvg(
  1369. svgDrawingHelper,
  1370. annotationUID,
  1371. pointBMarkUID,
  1372. pointBCanvas,
  1373. 4,
  1374. {
  1375. color: 'rgb(128, 0, 128)', // 紫色
  1376. fill: 'rgb(128, 0, 128)',
  1377. }
  1378. );
  1379. // 只有当pointC存在时才绘制MC段和C点
  1380. if (cachedStats?.pointC) {
  1381. const pointCCanvas = viewport.worldToCanvas(cachedStats.pointC);
  1382. // MC段(绿色实线)
  1383. const lineMCUID = `${annotationUID}-line-MC`;
  1384. drawLineSvg(
  1385. svgDrawingHelper,
  1386. annotationUID,
  1387. lineMCUID,
  1388. centerCanvas,
  1389. pointCCanvas,
  1390. {
  1391. color: 'rgb(0, 255, 0)', // 绿色
  1392. width: 2,
  1393. }
  1394. );
  1395. const pointCMarkUID = `${annotationUID}-point-C`;
  1396. drawCircleSvg(
  1397. svgDrawingHelper,
  1398. annotationUID,
  1399. pointCMarkUID,
  1400. pointCCanvas,
  1401. 4,
  1402. {
  1403. color: 'rgb(0, 255, 0)', // 绿色
  1404. fill: 'rgb(0, 255, 0)',
  1405. }
  1406. );
  1407. }
  1408. }
  1409. // 绘制手柄点
  1410. if (annotationUID) {
  1411. const handleGroupUID = '0';
  1412. // 根据选中或悬停状态调整手柄半径:选中或悬停时为8,否则为6
  1413. const isHighlighted = annotation.isSelected || annotation.highlighted;
  1414. const handleRadius = isHighlighted ? 8 : 6;
  1415. drawHandles(
  1416. svgDrawingHelper,
  1417. annotationUID,
  1418. handleGroupUID,
  1419. canvasPoints,
  1420. {
  1421. color: annotation.isSelected ? 'rgb(0, 255, 0)' : 'rgb(255, 255, 255)', // 选中时绿色,未选中时白色
  1422. handleRadius: handleRadius,
  1423. }
  1424. );
  1425. }
  1426. // 绘制覆盖率文本框
  1427. if (cachedStats?.coveragePercent && cachedStats?.circleCenter && annotationUID) {
  1428. const circleCenterCanvas = viewport.worldToCanvas(cachedStats.circleCenter);
  1429. // 确保文本框位置被初始化
  1430. if (!data.textBox) {
  1431. data.textBox = [
  1432. circleCenterCanvas[0] + 10,
  1433. circleCenterCanvas[1] - 10,
  1434. ];
  1435. }
  1436. const textLines = [
  1437. `${cachedStats.coveragePercent}`,
  1438. //`覆盖率: ${cachedStats.coveragePercent}`,
  1439. // `AC: ${cachedStats.distanceAC?.toFixed(2)}`,
  1440. // `BC: ${cachedStats.distanceBC?.toFixed(2)}`,
  1441. // `AB: ${cachedStats.distanceAB?.toFixed(2)}`,
  1442. ];
  1443. const textBoxPosition: CoreTypes.Point2 = data.textBox;
  1444. const textUID = `${annotationUID}-text-box`;
  1445. drawLinkedTextBox(
  1446. svgDrawingHelper,
  1447. annotationUID,
  1448. textUID,
  1449. textLines,
  1450. textBoxPosition,
  1451. [circleCenterCanvas],
  1452. {},
  1453. { color: 'rgb(255, 255, 0)' } // 黄色
  1454. );
  1455. }
  1456. renderStatus = true;
  1457. }
  1458. return renderStatus;
  1459. };
  1460. }