TibialPlateauAngleTool.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
  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. drawPath: drawPathSvg,
  19. } = drawing;
  20. import {
  21. calculateMidpoint,
  22. vectorSubtract,
  23. vectorAdd,
  24. vectorScale,
  25. getAngleBetweenLines,
  26. degrees,
  27. } from './mathUtils';
  28. import {
  29. PublicToolProps,
  30. ToolProps,
  31. EventTypes,
  32. SVGDrawingHelper,
  33. } from '@cornerstonejs/tools/dist/esm/types';
  34. // 注解初始配置接口
  35. interface AnnotationInitialConfig {
  36. axisLineLength?: number;
  37. plateauLineLength?: number;
  38. axisLineCenterX?: number;
  39. axisLineCenterY?: number;
  40. plateauLineCenterX?: number;
  41. plateauLineCenterY?: number;
  42. }
  43. // 注解数据接口
  44. interface TibialPlateauAngleAnnotation extends Types.Annotation {
  45. data: {
  46. handles: {
  47. points: CoreTypes.Point3[];
  48. activeHandleIndex: number | null;
  49. textBoxTPA?: CoreTypes.Point2;
  50. };
  51. cachedStats?: {
  52. [targetId: string]: {
  53. TPA: number;
  54. functionalAxisVector: CoreTypes.Point3;
  55. plateauLineVector: CoreTypes.Point3;
  56. perpendicularVector: CoreTypes.Point3;
  57. plateauMidpoint: CoreTypes.Point3;
  58. intersectionPoint?: CoreTypes.Point3; // 功能轴线与中垂线的交点
  59. };
  60. };
  61. };
  62. }
  63. // 导出的注解数据接口
  64. export interface ExportedAnnotationData {
  65. points: CoreTypes.Point3[];
  66. TPA: number;
  67. textBoxPosition?: CoreTypes.Point2;
  68. metadata: {
  69. viewPlaneNormal: CoreTypes.Point3;
  70. viewUp: CoreTypes.Point3;
  71. FrameOfReferenceUID: string;
  72. referencedImageId: string;
  73. };
  74. }
  75. export default class TibialPlateauAngleTool extends AnnotationTool {
  76. static toolName = 'TibialPlateauAngleTool';
  77. editData: {
  78. annotation: Types.Annotation;
  79. viewportIdsToRender: string[];
  80. handleIndex?: number;
  81. newAnnotation?: boolean;
  82. hasMoved?: boolean;
  83. textBoxBeingMoved?: boolean;
  84. textBoxOffset?: CoreTypes.Point2;
  85. } | null = null;
  86. isDrawing: boolean = false;
  87. static createDefaultAnnotation(
  88. element: HTMLDivElement,
  89. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
  90. config?: AnnotationInitialConfig
  91. ): TibialPlateauAngleAnnotation {
  92. const enabledElement = getEnabledElement(element);
  93. if (!enabledElement) {
  94. throw new Error('Element is not enabled');
  95. }
  96. const canvas = viewport.canvas;
  97. const { width, height } = canvas;
  98. const centerX = width / 2;
  99. const centerY = height / 2;
  100. const axisLineLength = config?.axisLineLength ?? 200;
  101. const plateauLineLength = config?.plateauLineLength ?? 150;
  102. const axisLineCenterX = config?.axisLineCenterX ?? centerX - 100;
  103. const axisLineCenterY = config?.axisLineCenterY ?? centerY;
  104. const plateauLineCenterX = config?.plateauLineCenterX ?? centerX + 100;
  105. const plateauLineCenterY = config?.plateauLineCenterY ?? centerY;
  106. // 点1、2: 胫骨内侧平台线 (水平线)
  107. const plateauHalfLength = plateauLineLength / 2;
  108. const point1Canvas: CoreTypes.Point2 = [
  109. plateauLineCenterX - plateauHalfLength,
  110. plateauLineCenterY
  111. ];
  112. const point2Canvas: CoreTypes.Point2 = [
  113. plateauLineCenterX + plateauHalfLength,
  114. plateauLineCenterY
  115. ];
  116. // 点3、4: 胫骨功能轴线 (垂直线)
  117. const axisHalfLength = axisLineLength / 2;
  118. const point3Canvas: CoreTypes.Point2 = [
  119. axisLineCenterX,
  120. axisLineCenterY - axisHalfLength
  121. ];
  122. const point4Canvas: CoreTypes.Point2 = [
  123. axisLineCenterX,
  124. axisLineCenterY + axisHalfLength
  125. ];
  126. const canvasPoints: CoreTypes.Point2[] = [
  127. point1Canvas,
  128. point2Canvas,
  129. point3Canvas,
  130. point4Canvas,
  131. ];
  132. const worldPoints = canvasPoints.map((canvasPoint) =>
  133. viewport.canvasToWorld(canvasPoint)
  134. );
  135. const camera = viewport.getCamera();
  136. const { viewPlaneNormal, viewUp } = camera;
  137. if (viewPlaneNormal === undefined || viewUp === undefined) {
  138. throw new Error('Camera parameters undefined');
  139. }
  140. return {
  141. invalidated: true,
  142. highlighted: false,
  143. metadata: {
  144. viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3,
  145. viewUp: [...viewUp] as CoreTypes.Point3,
  146. FrameOfReferenceUID: viewport.getFrameOfReferenceUID(),
  147. referencedImageId: viewport.getCurrentImageId?.() || '',
  148. toolName: TibialPlateauAngleTool.toolName,
  149. },
  150. data: {
  151. label: '',
  152. handles: {
  153. points: worldPoints,
  154. activeHandleIndex: null,
  155. },
  156. cachedStats: {},
  157. },
  158. } as TibialPlateauAngleAnnotation;
  159. }
  160. static exportAnnotationData(
  161. annotation: TibialPlateauAngleAnnotation,
  162. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  163. ): ExportedAnnotationData | null {
  164. const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`;
  165. const cachedStats = annotation.data.cachedStats?.[targetId];
  166. if (!cachedStats || cachedStats.TPA === undefined) {
  167. return null;
  168. }
  169. return {
  170. points: [...annotation.data.handles.points],
  171. TPA: cachedStats.TPA,
  172. textBoxPosition: annotation.data.handles.textBoxTPA,
  173. metadata: {
  174. viewPlaneNormal: annotation.metadata?.viewPlaneNormal || [0, 0, 1],
  175. viewUp: annotation.metadata?.viewUp || [0, 1, 0],
  176. FrameOfReferenceUID: annotation.metadata?.FrameOfReferenceUID || '',
  177. referencedImageId: annotation.metadata?.referencedImageId || '',
  178. },
  179. };
  180. }
  181. static restoreFromExportedData(
  182. exportedData: ExportedAnnotationData,
  183. element: HTMLDivElement,
  184. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  185. ): TibialPlateauAngleAnnotation {
  186. const enabledElement = getEnabledElement(element);
  187. if (!enabledElement) {
  188. throw new Error('Element is not enabled');
  189. }
  190. return {
  191. invalidated: true,
  192. highlighted: false,
  193. metadata: {
  194. ...exportedData.metadata,
  195. toolName: TibialPlateauAngleTool.toolName,
  196. },
  197. data: {
  198. label: '',
  199. handles: {
  200. points: [...exportedData.points],
  201. activeHandleIndex: null,
  202. textBoxTPA: exportedData.textBoxPosition,
  203. },
  204. cachedStats: {},
  205. },
  206. } as TibialPlateauAngleAnnotation;
  207. }
  208. constructor(
  209. toolProps: PublicToolProps = {},
  210. defaultToolProps: ToolProps = {
  211. supportedInteractionTypes: ['Mouse', 'Touch'],
  212. configuration: {
  213. shadow: true,
  214. preventHandleOutsideImage: false,
  215. },
  216. }
  217. ) {
  218. super(toolProps, defaultToolProps);
  219. }
  220. addNewAnnotation(evt: EventTypes.InteractionEventType): TibialPlateauAngleAnnotation {
  221. evt.preventDefault();
  222. return {} as TibialPlateauAngleAnnotation;
  223. }
  224. isPointNearTool(
  225. element: HTMLDivElement,
  226. annotation: TibialPlateauAngleAnnotation,
  227. canvasCoords: CoreTypes.Point2,
  228. proximity: number
  229. ): boolean {
  230. const enabledElement = getEnabledElement(element);
  231. if (!enabledElement) return false;
  232. const { viewport } = enabledElement;
  233. const points = annotation.data.handles.points;
  234. for (let i = 0; i < points.length; i++) {
  235. const canvasPoint = viewport.worldToCanvas(points[i]);
  236. const distance = Math.sqrt(
  237. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  238. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  239. );
  240. if (distance < proximity) return true;
  241. }
  242. return false;
  243. }
  244. private _isPointInTextBox(
  245. canvasPoint: CoreTypes.Point2,
  246. textBoxPosition: CoreTypes.Point2,
  247. textWidth: number = 100,
  248. textHeight: number = 40
  249. ): boolean {
  250. const [x, y] = canvasPoint;
  251. const [tx, ty] = textBoxPosition;
  252. const margin = 10;
  253. return (
  254. x >= tx - margin &&
  255. x <= tx + textWidth + margin &&
  256. y >= ty - margin &&
  257. y <= ty + textHeight + margin
  258. );
  259. }
  260. private _createAngleArcPoints(
  261. center: CoreTypes.Point2,
  262. vector1: CoreTypes.Point2,
  263. vector2: CoreTypes.Point2,
  264. radius: number
  265. ): CoreTypes.Point2[] {
  266. const angle1 = Math.atan2(vector1[1], vector1[0]);
  267. const angle2 = Math.atan2(vector2[1], vector2[0]);
  268. let startAngle = angle1;
  269. let endAngle = angle2;
  270. let angleDiff = endAngle - startAngle;
  271. if (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
  272. else if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
  273. if (angleDiff < 0) {
  274. [startAngle, endAngle] = [endAngle, startAngle];
  275. angleDiff = -angleDiff;
  276. }
  277. const numPoints = Math.max(10, Math.ceil(Math.abs(angleDiff) / (Math.PI / 18)));
  278. const points: CoreTypes.Point2[] = [];
  279. for (let i = 0; i <= numPoints; i++) {
  280. const t = i / numPoints;
  281. const angle = startAngle + angleDiff * t;
  282. points.push([
  283. center[0] + radius * Math.cos(angle),
  284. center[1] + radius * Math.sin(angle)
  285. ]);
  286. }
  287. return points;
  288. }
  289. cancel(element: HTMLDivElement): string {
  290. if (this.isDrawing) {
  291. this.isDrawing = false;
  292. this._deactivateDraw(element);
  293. this._deactivateModify(element);
  294. const enabledElement = getEnabledElement(element);
  295. if (enabledElement) {
  296. const viewportIdsToRender =
  297. utilities.viewportFilters.getViewportIdsWithToolToRender(
  298. element,
  299. this.getToolName()
  300. );
  301. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  302. }
  303. this.editData = null;
  304. return this.getToolName();
  305. }
  306. return '';
  307. }
  308. _activateDraw(element: HTMLDivElement): void {
  309. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener);
  310. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener);
  311. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener);
  312. }
  313. _deactivateDraw(element: HTMLDivElement): void {
  314. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener);
  315. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener);
  316. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener);
  317. }
  318. _activateModify(element: HTMLDivElement): void {
  319. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener);
  320. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener);
  321. element.addEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener);
  322. }
  323. _deactivateModify(element: HTMLDivElement): void {
  324. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener);
  325. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener);
  326. element.removeEventListener('CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener);
  327. }
  328. _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  329. const eventDetail = evt.detail;
  330. const { element, currentPoints } = eventDetail;
  331. const canvasCoords = currentPoints.canvas;
  332. const enabledElement = getEnabledElement(element);
  333. if (!enabledElement) return;
  334. const { viewport } = enabledElement;
  335. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  336. if (!annotations || annotations.length === 0) return;
  337. // 先检查是否点击在文本框上
  338. for (const ann of annotations) {
  339. const customAnn = ann as TibialPlateauAngleAnnotation;
  340. const targetId = this.getTargetId(viewport);
  341. const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined;
  342. if (cachedStats?.intersectionPoint) {
  343. const intersectionCanvas = viewport.worldToCanvas(cachedStats.intersectionPoint);
  344. // 获取文本框位置,如果不存在则使用默认位置
  345. const textBoxTPA: CoreTypes.Point2 = customAnn.data.handles.textBoxTPA || [
  346. intersectionCanvas[0] + 40,
  347. intersectionCanvas[1] - 10,
  348. ];
  349. if (this._isPointInTextBox(canvasCoords, textBoxTPA)) {
  350. const viewportIdsToRender =
  351. utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
  352. // 计算鼠标点击位置相对于文本框左上角的偏移量
  353. const textBoxOffset: CoreTypes.Point2 = [
  354. canvasCoords[0] - textBoxTPA[0],
  355. canvasCoords[1] - textBoxTPA[1],
  356. ];
  357. this.editData = {
  358. annotation: customAnn,
  359. viewportIdsToRender,
  360. hasMoved: false,
  361. textBoxBeingMoved: true,
  362. textBoxOffset: textBoxOffset,
  363. };
  364. customAnn.highlighted = true;
  365. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  366. evt.preventDefault();
  367. evt.stopPropagation();
  368. return;
  369. }
  370. }
  371. }
  372. // 如果没有点击文本框,再检查是否点击在手柄上
  373. for (const ann of annotations) {
  374. const customAnn = ann as TibialPlateauAngleAnnotation;
  375. const handle = this.getHandleNearImagePoint(element, customAnn, canvasCoords, 6);
  376. if (handle) {
  377. const viewportIdsToRender =
  378. utilities.viewportFilters.getViewportIdsWithToolToRender(element, this.getToolName());
  379. this.editData = {
  380. annotation: customAnn,
  381. viewportIdsToRender,
  382. handleIndex: customAnn.data.handles.activeHandleIndex || 0,
  383. hasMoved: false,
  384. };
  385. customAnn.highlighted = true;
  386. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  387. evt.preventDefault();
  388. evt.stopPropagation();
  389. return;
  390. }
  391. }
  392. };
  393. _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  394. if (!this.editData) return;
  395. const eventDetail = evt.detail;
  396. const { currentPoints } = eventDetail;
  397. const canvasCoords = currentPoints.canvas;
  398. const enabledElement = getEnabledElement(eventDetail.element);
  399. if (!enabledElement) return;
  400. const { annotation: ann, viewportIdsToRender, textBoxBeingMoved } = this.editData;
  401. const customAnn = ann as TibialPlateauAngleAnnotation;
  402. const { data } = customAnn;
  403. if (textBoxBeingMoved && this.editData.textBoxOffset) {
  404. data.handles.textBoxTPA = [
  405. canvasCoords[0] - this.editData.textBoxOffset[0],
  406. canvasCoords[1] - this.editData.textBoxOffset[1],
  407. ];
  408. this.editData.hasMoved = true;
  409. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  410. evt.preventDefault();
  411. evt.stopPropagation();
  412. return;
  413. }
  414. const worldPos = currentPoints.world;
  415. const activeHandleIndex = data.handles.activeHandleIndex;
  416. if (activeHandleIndex !== null && activeHandleIndex >= 0 && activeHandleIndex < data.handles.points.length) {
  417. data.handles.points[activeHandleIndex] = worldPos;
  418. this._updateCachedStats(customAnn, enabledElement);
  419. this.editData.hasMoved = true;
  420. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  421. evt.preventDefault();
  422. evt.stopPropagation();
  423. }
  424. };
  425. _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  426. if (!this.editData) return;
  427. const { annotation: ann } = this.editData;
  428. const customAnn = ann as TibialPlateauAngleAnnotation;
  429. customAnn.data.handles.activeHandleIndex = null;
  430. customAnn.highlighted = false;
  431. const viewportIdsToRender =
  432. utilities.viewportFilters.getViewportIdsWithToolToRender(
  433. evt.detail.element,
  434. this.getToolName()
  435. );
  436. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  437. this.editData = null;
  438. evt.preventDefault();
  439. evt.stopPropagation();
  440. };
  441. handleSelectedCallback(
  442. evt: EventTypes.InteractionEventType,
  443. annotation: TibialPlateauAngleAnnotation
  444. ): void {
  445. annotation.highlighted = true;
  446. const viewportIdsToRender =
  447. utilities.viewportFilters.getViewportIdsWithToolToRender(
  448. evt.detail.element,
  449. this.getToolName()
  450. );
  451. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  452. evt.preventDefault();
  453. }
  454. toolSelectedCallback(
  455. evt: EventTypes.InteractionEventType,
  456. annotation: TibialPlateauAngleAnnotation
  457. ): void {
  458. // 实现工具选中逻辑
  459. }
  460. _dragCallback = (evt: EventTypes.InteractionEventType): void => {
  461. this.isDrawing = true;
  462. const enabledElement = getEnabledElement(evt.detail.element);
  463. if (!enabledElement || !this.editData) return;
  464. const { annotation, viewportIdsToRender } = this.editData;
  465. const { data } = annotation as TibialPlateauAngleAnnotation;
  466. if (annotation) {
  467. const worldPos = evt.detail.currentPoints.world;
  468. const points = data.handles.points;
  469. const activeHandleIndex = data.handles.activeHandleIndex;
  470. if (activeHandleIndex !== null && activeHandleIndex < points.length) {
  471. points[activeHandleIndex] = worldPos;
  472. this._updateCachedStats(annotation as TibialPlateauAngleAnnotation, enabledElement);
  473. this.editData!.hasMoved = true;
  474. }
  475. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  476. }
  477. };
  478. _endCallback = (evt: EventTypes.InteractionEventType): void => {
  479. if (!this.editData) return;
  480. if (this.editData.hasMoved) {
  481. this.editData.hasMoved = false;
  482. }
  483. };
  484. getHandleNearImagePoint(
  485. element: HTMLDivElement,
  486. annotation: TibialPlateauAngleAnnotation,
  487. canvasCoords: CoreTypes.Point2,
  488. proximity: number
  489. ): Types.ToolHandle | undefined {
  490. const enabledElement = getEnabledElement(element);
  491. if (!enabledElement) return undefined;
  492. const { viewport } = enabledElement;
  493. const points = annotation.data.handles.points;
  494. const handleProximity = Math.max(proximity, 15);
  495. for (let i = 0; i < points.length; i++) {
  496. const canvasPoint = viewport.worldToCanvas(points[i]);
  497. const distance = Math.sqrt(
  498. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  499. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  500. );
  501. if (distance < handleProximity) {
  502. annotation.data.handles.activeHandleIndex = i;
  503. return { worldPosition: points[i] } as Types.ToolHandle;
  504. }
  505. }
  506. annotation.data.handles.activeHandleIndex = null;
  507. return undefined;
  508. }
  509. _updateCachedStats(
  510. annotation: TibialPlateauAngleAnnotation,
  511. enabledElement: CoreTypes.IEnabledElement
  512. ): void {
  513. const { viewport } = enabledElement;
  514. const { data } = annotation;
  515. const points = data.handles.points;
  516. const targetId = this.getTargetId(viewport);
  517. if (!targetId) return;
  518. if (!data.cachedStats) data.cachedStats = {};
  519. if (!data.cachedStats[targetId]) {
  520. data.cachedStats[targetId] = {
  521. TPA: 0,
  522. functionalAxisVector: [0, 0, 0],
  523. plateauLineVector: [0, 0, 0],
  524. perpendicularVector: [0, 0, 0],
  525. plateauMidpoint: [0, 0, 0],
  526. };
  527. }
  528. const stats = data.cachedStats[targetId];
  529. if (points.length >= 4) {
  530. const point1 = points[0]; // 平台线起点
  531. const point2 = points[1]; // 平台线终点
  532. const point3 = points[2]; // 功能轴线起点
  533. const point4 = points[3]; // 功能轴线终点
  534. // 计算胫骨内侧平台线向量 (点1→点2)
  535. const plateauLineVector = vectorSubtract(point2, point1);
  536. stats.plateauLineVector = plateauLineVector;
  537. // 计算胫骨功能轴线向量 (点3→点4)
  538. const functionalAxisVector = vectorSubtract(point4, point3);
  539. stats.functionalAxisVector = functionalAxisVector;
  540. // 计算胫骨内侧平台线中点(用于显示参考)
  541. const plateauMidpoint = calculateMidpoint(point1, point2);
  542. stats.plateauMidpoint = plateauMidpoint;
  543. // 计算平台线与功能轴线的交点(作为中垂线的起点)
  544. const intersection = this._getLineIntersection(
  545. point1, // 平台线起点
  546. plateauLineVector, // 平台线方向
  547. point3, // 功能轴线起点
  548. functionalAxisVector // 功能轴线方向
  549. );
  550. if (intersection) {
  551. stats.intersectionPoint = intersection;
  552. }
  553. // 计算平台线的中垂线向量(2D平面上,向量(x,y)的逆时针垂直向量为(-y,x))
  554. // 这条垂线从交点出发,垂直于平台线
  555. const perpendicularVector: CoreTypes.Point3 = [
  556. -plateauLineVector[1],
  557. plateauLineVector[0],
  558. 0
  559. ];
  560. stats.perpendicularVector = perpendicularVector;
  561. // 计算TPA角度:功能轴线与平台线中垂线的夹角
  562. const angleRad = getAngleBetweenLines(functionalAxisVector, perpendicularVector);
  563. stats.TPA = Math.round(degrees(angleRad));
  564. }
  565. }
  566. private _normalizeVector(v: CoreTypes.Point3): CoreTypes.Point3 {
  567. const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
  568. if (length === 0) return [0, 0, 0];
  569. return [v[0] / length, v[1] / length, v[2] / length];
  570. }
  571. /**
  572. * 计算两条直线的交点
  573. */
  574. private _getLineIntersection(
  575. line1Start: CoreTypes.Point3,
  576. line1Dir: CoreTypes.Point3,
  577. line2Start: CoreTypes.Point3,
  578. line2Dir: CoreTypes.Point3
  579. ): CoreTypes.Point3 | null {
  580. const dx = line2Start[0] - line1Start[0];
  581. const dy = line2Start[1] - line1Start[1];
  582. const det = line1Dir[0] * line2Dir[1] - line1Dir[1] * line2Dir[0];
  583. if (Math.abs(det) < 1e-10) {
  584. return null; // 平行线
  585. }
  586. const t = (dx * line2Dir[1] - dy * line2Dir[0]) / det;
  587. return [
  588. line1Start[0] + t * line1Dir[0],
  589. line1Start[1] + t * line1Dir[1],
  590. line1Start[2] + t * line1Dir[2]
  591. ];
  592. }
  593. renderAnnotation = (
  594. enabledElement: CoreTypes.IEnabledElement,
  595. svgDrawingHelper: SVGDrawingHelper
  596. ): boolean => {
  597. let renderStatus = false;
  598. const { viewport } = enabledElement;
  599. const { element } = viewport;
  600. let annotations = annotation.state.getAnnotations(this.getToolName(), element);
  601. if (!annotations?.length) return renderStatus;
  602. for (let i = 0; i < annotations.length; i++) {
  603. const annotation = annotations[i] as TibialPlateauAngleAnnotation;
  604. const { annotationUID, data } = annotation;
  605. const points = data.handles.points;
  606. if (points.length < 4) continue;
  607. const targetId = this.getTargetId(viewport);
  608. const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined;
  609. const canvasPoints = points.map((p) => viewport.worldToCanvas(p));
  610. const lineColor = annotation.highlighted ? 'rgb(0, 150, 255)' : 'rgb(255, 255, 255)';
  611. // 绘制胫骨内侧平台线(点1→点2)
  612. if (annotationUID) {
  613. drawLineSvg(
  614. svgDrawingHelper,
  615. annotationUID,
  616. `${annotationUID}-plateauLine`,
  617. canvasPoints[0],
  618. canvasPoints[1],
  619. { color: lineColor, width: 2 }
  620. );
  621. // 绘制胫骨功能轴线(点3→点4)
  622. drawLineSvg(
  623. svgDrawingHelper,
  624. annotationUID,
  625. `${annotationUID}-functionalAxis`,
  626. canvasPoints[2],
  627. canvasPoints[3],
  628. { color: lineColor, width: 2 }
  629. );
  630. // 绘制胫骨内侧平台线的延长线(虚线):从最近的端点延伸到交点,并在交点后继续延长
  631. if (cachedStats && cachedStats.intersectionPoint && points.length >= 2) {
  632. const point1 = points[0]; // 平台线起点
  633. const point2 = points[1]; // 平台线终点
  634. const intersection = cachedStats.intersectionPoint;
  635. // 计算交点到两个端点的距离,选择较近的端点
  636. const dist1 = Math.sqrt(
  637. Math.pow(intersection[0] - point1[0], 2) +
  638. Math.pow(intersection[1] - point1[1], 2)
  639. );
  640. const dist2 = Math.sqrt(
  641. Math.pow(intersection[0] - point2[0], 2) +
  642. Math.pow(intersection[1] - point2[1], 2)
  643. );
  644. // 选择离交点较近的端点作为延长线起点
  645. const extensionStart = dist1 < dist2 ? point1 : point2;
  646. // 计算从端点到交点的方向向量
  647. const directionVector: CoreTypes.Point3 = [
  648. intersection[0] - extensionStart[0],
  649. intersection[1] - extensionStart[1],
  650. intersection[2] - extensionStart[2]
  651. ];
  652. // 归一化方向向量
  653. const normalizedDir = this._normalizeVector(directionVector);
  654. // 在交点后继续延长的长度(像素)
  655. const extraExtensionLength = 150;
  656. // 计算延长线的最终终点:交点 + 延长距离
  657. const extensionEnd = vectorAdd(
  658. intersection,
  659. vectorScale(normalizedDir, extraExtensionLength)
  660. );
  661. const extensionStartCanvas = viewport.worldToCanvas(extensionStart);
  662. const extensionEndCanvas = viewport.worldToCanvas(extensionEnd);
  663. // 绘制完整的延长线:从端点经过交点到延伸终点
  664. drawLineSvg(
  665. svgDrawingHelper,
  666. annotationUID,
  667. `${annotationUID}-plateauExtension`,
  668. extensionStartCanvas,
  669. extensionEndCanvas,
  670. { color: 'rgb(200, 200, 200)', width: 1, lineDash: '4,4' }
  671. );
  672. }
  673. // 绘制功能轴线的延长线(虚线):从最近的端点延伸到交点,并在交点后继续延长
  674. if (cachedStats && cachedStats.intersectionPoint && points.length >= 4) {
  675. const point3 = points[2]; // 功能轴线起点
  676. const point4 = points[3]; // 功能轴线终点
  677. const intersection = cachedStats.intersectionPoint;
  678. // 计算交点到两个端点的距离,选择较近的端点
  679. const dist3 = Math.sqrt(
  680. Math.pow(intersection[0] - point3[0], 2) +
  681. Math.pow(intersection[1] - point3[1], 2)
  682. );
  683. const dist4 = Math.sqrt(
  684. Math.pow(intersection[0] - point4[0], 2) +
  685. Math.pow(intersection[1] - point4[1], 2)
  686. );
  687. // 选择离交点较近的端点作为延长线起点
  688. const extensionStart = dist3 < dist4 ? point3 : point4;
  689. // 计算从端点到交点的方向向量
  690. const directionVector: CoreTypes.Point3 = [
  691. intersection[0] - extensionStart[0],
  692. intersection[1] - extensionStart[1],
  693. intersection[2] - extensionStart[2]
  694. ];
  695. // 归一化方向向量
  696. const normalizedDir = this._normalizeVector(directionVector);
  697. // 在交点后继续延长的长度(像素)
  698. const extraExtensionLength = 150;
  699. // 计算延长线的最终终点:交点 + 延长距离
  700. const extensionEnd = vectorAdd(
  701. intersection,
  702. vectorScale(normalizedDir, extraExtensionLength)
  703. );
  704. const extensionStartCanvas = viewport.worldToCanvas(extensionStart);
  705. const extensionEndCanvas = viewport.worldToCanvas(extensionEnd);
  706. // 绘制完整的延长线:从端点经过交点到延伸终点
  707. drawLineSvg(
  708. svgDrawingHelper,
  709. annotationUID,
  710. `${annotationUID}-axisExtension`,
  711. extensionStartCanvas,
  712. extensionEndCanvas,
  713. { color: 'rgb(200, 200, 200)', width: 1, lineDash: '4,4' }
  714. );
  715. }
  716. // 绘制平台线的中垂线(从交点向两端延伸)
  717. if (cachedStats && cachedStats.perpendicularVector && cachedStats.intersectionPoint) {
  718. // 归一化中垂线向量
  719. const normalizedPerp = this._normalizeVector(cachedStats.perpendicularVector);
  720. // 延长长度:从交点向两端各延伸150像素
  721. const extendLength = 150;
  722. // 中垂线起点:从交点向一个方向延伸
  723. const perpStart = vectorAdd(
  724. cachedStats.intersectionPoint,
  725. vectorScale(normalizedPerp, -extendLength)
  726. );
  727. // 中垂线终点:从交点向另一个方向延伸
  728. const perpEnd = vectorAdd(
  729. cachedStats.intersectionPoint,
  730. vectorScale(normalizedPerp, extendLength)
  731. );
  732. const perpStartCanvas = viewport.worldToCanvas(perpStart);
  733. const perpEndCanvas = viewport.worldToCanvas(perpEnd);
  734. drawLineSvg(
  735. svgDrawingHelper,
  736. annotationUID,
  737. `${annotationUID}-perpendicular`,
  738. perpStartCanvas,
  739. perpEndCanvas,
  740. { color: 'rgb(0, 255, 0)', width: 2, lineDash: '4,4' }
  741. );
  742. }
  743. // 绘制角度弧线和文本(在交点位置)
  744. if (cachedStats && cachedStats.functionalAxisVector && cachedStats.perpendicularVector && cachedStats.intersectionPoint) {
  745. // 使用交点作为角度弧线的中心
  746. const intersectionCanvas = viewport.worldToCanvas(cachedStats.intersectionPoint);
  747. const axisVector2D: CoreTypes.Point2 = [
  748. cachedStats.functionalAxisVector[0],
  749. cachedStats.functionalAxisVector[1]
  750. ];
  751. const perpVector2D: CoreTypes.Point2 = [
  752. cachedStats.perpendicularVector[0],
  753. cachedStats.perpendicularVector[1]
  754. ];
  755. const arcPoints = this._createAngleArcPoints(intersectionCanvas, axisVector2D, perpVector2D, 30);
  756. drawPathSvg(
  757. svgDrawingHelper,
  758. annotationUID,
  759. `${annotationUID}-arc`,
  760. arcPoints,
  761. { color: 'rgb(255, 255, 0)', width: 2 }
  762. );
  763. // 计算弧线中间位置:取弧线点数组的中间点
  764. const arcMidIndex = Math.floor(arcPoints.length / 2);
  765. const arcMidPoint: CoreTypes.Point2 = arcPoints[arcMidIndex];
  766. const textLines = [`TPA: ${cachedStats.TPA}°`];
  767. const textBoxPosition: CoreTypes.Point2 = data.handles.textBoxTPA || [
  768. intersectionCanvas[0] + 40,
  769. intersectionCanvas[1] - 10,
  770. ];
  771. // 文本虚线连接到弧线中间位置
  772. drawLinkedTextBox(
  773. svgDrawingHelper,
  774. annotationUID,
  775. `${annotationUID}-text`,
  776. textLines,
  777. textBoxPosition,
  778. [arcMidPoint],
  779. {},
  780. { color: 'rgb(255, 255, 0)' }
  781. );
  782. }
  783. // 绘制控制点
  784. drawHandles(
  785. svgDrawingHelper,
  786. annotationUID,
  787. '0',
  788. canvasPoints,
  789. { color: 'rgb(255, 255, 255)', handleRadius: 6 }
  790. );
  791. }
  792. renderStatus = true;
  793. }
  794. return renderStatus;
  795. };
  796. }