diff --git a/resources/api/dwv.api.md b/resources/api/dwv.api.md index 75ff03f3f3..50ad7e7dc4 100644 --- a/resources/api/dwv.api.md +++ b/resources/api/dwv.api.md @@ -1412,6 +1412,7 @@ export class ViewController { stop(): void; unbindImageAndLayer(viewLayer: ViewLayer): void; updatePlaneHelper(): void; + validatePlanePoint(point2D: Point2D): boolean; } // @public diff --git a/src/dicom/dicomCode.js b/src/dicom/dicomCode.js index cfaa1c0734..3287b6a528 100644 --- a/src/dicom/dicomCode.js +++ b/src/dicom/dicomCode.js @@ -372,7 +372,14 @@ const QuantificationName2DictItem = { * @returns {DicomCode|undefined} The code. */ export function getConceptNameCode(name) { - const item = QuantificationName2DictItem[name]; + // Map 'longAxis' to 'a' and 'shortAxis' to 'b' for DICOM export + let lookupName = name; + if (name === 'longAxis') { + lookupName = 'a'; + } else if (name === 'shortAxis') { + lookupName = 'b'; + } + const item = QuantificationName2DictItem[lookupName]; let code; if (typeof item !== 'undefined') { code = getDicomCode(item.key, item.scheme); diff --git a/src/dicom/dicomSpatialCoordinate.js b/src/dicom/dicomSpatialCoordinate.js index 108bfac1b1..85f76dae1e 100644 --- a/src/dicom/dicomSpatialCoordinate.js +++ b/src/dicom/dicomSpatialCoordinate.js @@ -10,6 +10,7 @@ import {logger} from '../utils/logger.js'; // doc imports /* eslint-disable no-unused-vars */ import {DataElement} from './dataElement.js'; +import {BidimensionalLine} from '../math/bidimensionalLine.js'; /* eslint-enable no-unused-vars */ /** @@ -126,9 +127,12 @@ export function getDicomSpatialCoordinateItem(scoord) { /** * Get a DICOM spatial coordinate (SCOORD) from a mathematical shape. + * Supports all standard shapes, including BidimensionalLine (exported as a + * polyline with 4 points). * - * @param {Point2D|Line|Protractor|ROI|Circle|Ellipse|Rectangle} shape - * The math shape. + * @param {Point2D|Line|Protractor|ROI|Circle|Ellipse|Rectangle| + * BidimensionalLine} shape + * The math shape, including BidimensionalLine. * @returns {SpatialCoordinate} The DICOM scoord. */ export function getScoordFromShape(shape) { @@ -211,17 +215,64 @@ export function getScoordFromShape(shape) { begin.getY().toString() ]; scoord.graphicType = GraphicTypes.polyline; - } + } else if (shape instanceof BidimensionalLine) { + let perpUnitX = 0; + let perpUnitY = 0; + const mainAxisStart = shape.getBegin(); + const mainAxisEnd = shape.getEnd(); + + // Use the actual short axis center if available, otherwise use centroid + const shortAxisCenter = + shape.shortAxisCenter instanceof Point2D + ? shape.shortAxisCenter + : shape.getCentroid(); + + const mainDx = mainAxisEnd.getX() - mainAxisStart.getX(); + const mainDy = mainAxisEnd.getY() - mainAxisStart.getY(); + const mainLength = Math.sqrt(mainDx * mainDx + mainDy * mainDy); + + // Perpendicular unit vector to main axis + if (mainLength !== 0) { + perpUnitX = -mainDy / mainLength; + perpUnitY = mainDx / mainLength; + } + + const shortAxisL1 = shape.shortAxisL1 ?? (shape.shortAxisLength / 2); + const shortAxisL2 = shape.shortAxisL2 ?? (shape.shortAxisLength / 2); + const shortAxisEnd1 = new Point2D( + shortAxisCenter.getX() + perpUnitX * shortAxisL1, + shortAxisCenter.getY() + perpUnitY * shortAxisL1 + ); + const shortAxisEnd2 = new Point2D( + shortAxisCenter.getX() - perpUnitX * shortAxisL2, + shortAxisCenter.getY() - perpUnitY * shortAxisL2 + ); + + scoord.graphicData = [ + mainAxisStart.getX().toString(), + mainAxisStart.getY().toString(), + mainAxisEnd.getX().toString(), + mainAxisEnd.getY().toString(), + shortAxisEnd1.getX().toString(), + shortAxisEnd1.getY().toString(), + shortAxisEnd2.getX().toString(), + shortAxisEnd2.getY().toString() + ]; + scoord.graphicType = GraphicTypes.polyline; + } return scoord; }; /** * Get a mathematical shape from a DICOM spatial coordinate (SCOORD). + * Supports all standard shapes, including BidimensionalLine + * (polyline with 4 points). * * @param {SpatialCoordinate} scoord The DICOM scoord. - * @returns {Point2D|Line|Protractor|ROI|Circle|Ellipse|Rectangle|undefined} - * The math shape. + * @returns {Point2D|Line|Protractor|ROI|Circle|Ellipse|Rectangle| + * BidimensionalLine|undefined} + * The reconstructed math shape, including BidimensionalLine if applicable. */ export function getShapeFromScoord(scoord) { // no shape if no graphic data @@ -290,6 +341,52 @@ export function getShapeFromScoord(scoord) { shape = new Line(points[0], points[1]); } else if (points.length === 3) { shape = new Protractor([points[0], points[1], points[2]]); + } else if (points.length === 4) { + // BidimensionalLine: points 0,1 = main axis; 2,3 = short axis + const mainAxisStart = points[0]; + const mainAxisEnd = points[1]; + const shortAxisEnd1 = points[2]; + const shortAxisEnd2 = points[3]; + let projection = 0.5; + + shape = new BidimensionalLine(mainAxisStart, mainAxisEnd); + + const shortAxisCenterX = + (shortAxisEnd1.getX() + shortAxisEnd2.getX()) / 2; + const shortAxisCenterY = + (shortAxisEnd1.getY() + shortAxisEnd2.getY()) / 2; + + const l1Dx = shortAxisEnd1.getX() - shortAxisCenterX; + const l1Dy = shortAxisEnd1.getY() - shortAxisCenterY; + const l2Dx = shortAxisCenterX - shortAxisEnd2.getX(); + const l2Dy = shortAxisCenterY - shortAxisEnd2.getY(); + + const shortAxisL1 = Math.sqrt(l1Dx * l1Dx + l1Dy * l1Dy); + const shortAxisL2 = Math.sqrt(l2Dx * l2Dx + l2Dy * l2Dy); + const shortAxisLength = shortAxisL1 + shortAxisL2; + + const mainDx = mainAxisEnd.getX() - mainAxisStart.getX(); + const mainDy = mainAxisEnd.getY() - mainAxisStart.getY(); + const mainLength = Math.sqrt(mainDx * mainDx + mainDy * mainDy); + + let shortAxisT = 0.5; + if (mainLength > 0) { + const centerVx = shortAxisCenterX - mainAxisStart.getX(); + const centerVy = shortAxisCenterY - mainAxisStart.getY(); + const numerator = centerVx * mainDx + centerVy * mainDy; + const denominator = mainLength * mainLength; + if (denominator !== 0) { + projection = numerator / denominator; + } + shortAxisT = Math.max(0, Math.min(1, projection)); + } + + shape.shortAxisLength = shortAxisLength; + shape.shortAxisT = shortAxisT; + shape.shortAxisL1 = shortAxisL1; + shape.shortAxisL2 = shortAxisL2; + shape.shortAxisCenter = new Point2D(shortAxisCenterX, shortAxisCenterY); + shape.hasShortAxisInteraction = true; } } else { if (points.length === 5) { diff --git a/src/gui/drawLayer.js b/src/gui/drawLayer.js index 6430f10ba4..727b317c02 100644 --- a/src/gui/drawLayer.js +++ b/src/gui/drawLayer.js @@ -35,6 +35,7 @@ import {PlaneHelper} from '../image/planeHelper.js'; import {Annotation} from '../image/annotation.js'; import {AnnotationGroup} from '../image/annotationGroup.js'; import {DrawShapeHandler} from '../tools/drawShapeHandler.js'; +import {BidimensionalLine} from '../math/bidimensionalLine.js'; /* eslint-enable no-unused-vars */ /** @@ -1419,8 +1420,39 @@ export function konvaToAnnotation(drawings, drawingsDetails, refImage) { new Point2D(absPosition.x, absPosition.y), shape.radius() ); - } + } else if (stateGroup.name() === 'bidimensional-group') { + const points = shape.points(); + const bidim = new BidimensionalLine( + new Point2D(points[0], points[1]), + new Point2D(points[2], points[3]) + ); + const details = drawingsDetails && drawingsDetails[stateGroup.id()] + ? drawingsDetails[stateGroup.id()] + : undefined; + const quant = details && details.meta && details.meta.quantification + ? details.meta.quantification + : {}; + + if ( + typeof BidimensionalLine.restorePropertiesFromQuantification === + 'function' + ) { + BidimensionalLine.restorePropertiesFromQuantification( + annotation, + bidim, + quant + ); + } else { + if (typeof quant.shortAxisLength === 'number') { + bidim.shortAxisLength = quant.shortAxisLength; + } + if (typeof quant.shortAxisT === 'number') { + bidim.shortAxisT = quant.shortAxisT; + } + annotation.mathShape = bidim; + } + } // details if (drawingsDetails) { const details = drawingsDetails[stateGroup.id()]; diff --git a/src/image/annotationGroupFactory.js b/src/image/annotationGroupFactory.js index 2fad4625f6..625289f6b4 100644 --- a/src/image/annotationGroupFactory.js +++ b/src/image/annotationGroupFactory.js @@ -431,8 +431,20 @@ export class AnnotationGroupFactory { } if (content.valueType === ValueTypes.num && content.relationshipType === relation) { - const quantifName = - getQuantificationName(content.conceptNameCode); + let quantifName = getQuantificationName(content.conceptNameCode); + // If quantifName is 'a' or 'b' and shape is BidimensionalLine, + // map to longAxis/shortAxis + if ( + (quantifName === 'a' || quantifName === 'b') && + annotation.mathShape && annotation.mathShape.constructor && + annotation.mathShape.constructor.name === 'BidimensionalLine' + ) { + if (quantifName === 'a') { + quantifName = 'longAxis'; + } else if (quantifName === 'b') { + quantifName = 'shortAxis'; + } + } if (typeof quantifName !== 'undefined') { const measuredValue = content.value.measuredValue; const quantifUnit = getQuantificationUnit( diff --git a/src/math/bidimensionalLine.js b/src/math/bidimensionalLine.js new file mode 100644 index 0000000000..77fc786b3d --- /dev/null +++ b/src/math/bidimensionalLine.js @@ -0,0 +1,329 @@ +import {Point2D} from './point.js'; +import {Line} from './line.js'; + +// doc imports +/* eslint-disable no-unused-vars */ +import {ViewController} from '../app/viewController.js'; +import {Annotation} from '../image/annotation.js'; + +/** + * BidimensionalLine shape. + * Represents a line with a main (long) axis and a perpendicular (short) axis. + */ +export class BidimensionalLine { + + /** + * Line begin point. + * + * @type {Point2D} + */ + #begin; + + /** + * Line end point. + * + * @type {Point2D} + */ + #end; + + /** + * Optional length of the short axis (perpendicular to main axis). + * + * @type {number} + */ + shortAxisLength; + + /** + * Optional length of the short axis (perpendicular to main axis). + * + * @type {Point2D | undefined} + */ + shortAxisCenter; + + /** + * Optional length from center to one end of the short axis. + * + * @type {number} + */ + shortAxisL1; + + /** + * Optional length from center to the other end of the short axis. + * + * @type {number} + */ + shortAxisL2; + + /** + * Optional relative position (0-1) of the short axis center + * along the main axis. + * + * @type {number} + */ + shortAxisT; + + /** + * @param {Point2D} begin The beginning point of the main axis. + * @param {Point2D} end The ending point of the main axis. + */ + constructor(begin, end) { + this.#begin = begin; + this.#end = end; + /** + * Center of the short axis. + * + * @type {Point2D|undefined} + */ + this.shortAxisCenter = undefined; + + /** + * Short axis T value. + * + * @type {number|undefined} + */ + this.shortAxisT = undefined; + + /** + * Short axis L1 value. + * + * @type {number|undefined} + */ + this.shortAxisL1 = undefined; + + /** + * Short axis L2 value. + * + * @type {number|undefined} + */ + this.shortAxisL2 = undefined; + + /** + * Short axis length. + * + * @type {number|undefined} + */ + this.shortAxisLength = undefined; + + /** + * Indicates if the annotation has short axis interaction. + * + * @type {boolean} + */ + this.hasShortAxisInteraction = false; + } + + /** + * Get the begin point of the main axis. + * + * @returns {Point2D} The beginning point. + */ + getBegin() { + return this.#begin; + } + + /** + * Get the end point of the main axis. + * + * @returns {Point2D} The ending point. + */ + getEnd() { + return this.#end; + } + + /** + * Get the centroid (midpoint) of the main axis. + * + * @returns {Point2D} The centroid point. + */ + getCentroid() { + return new Point2D( + (this.#begin.getX() + this.#end.getX()) / 2, + (this.#begin.getY() + this.#end.getY()) / 2, + ); + } + + /** + * Get the length of the main axis. + * + * @returns {number} The length. + */ + getLength() { + const dx = this.getDeltaX(); + const dy = this.getDeltaY(); + return Math.hypot(dx, dy); + } + + /** + * Get the slope of the main axis. + * + * @returns {number} The slope. + */ + getSlope() { + const dx = this.getDeltaX(); + const dy = this.getDeltaY(); + if (dx === 0) { + return Infinity; + } + return dy / dx; + } + + /** + * Get the delta in the X direction for the main axis. + * + * @returns {number} The delta X. + */ + getDeltaX() { + return this.#end.getX() - this.#begin.getX(); + } + + /** + * Get the delta in the Y direction for the main axis. + * + * @returns {number} The delta Y. + */ + getDeltaY() { + return this.#end.getY() - this.#begin.getY(); + } + + /** + * Quantify the bidimensional line according to view information. + * Returns the length (main axis) and width (short axis) if available. + * + * @param {ViewController} viewController The associated view controller. + * @returns {{ + * longAxis: { value: number, unit: string }, + * shortAxis: { value: number, unit: string } + * }} + * Quantification object. + */ + quantify(viewController) { + // Get pixel spacing (default to 1 if not provided) + const spacing2D = viewController.get2DSpacing?.() ?? {x: 1, y: 1}; + + // Calculate the main (long) axis length in world units + const longLine = new Line(this.#begin, this.#end); + const longWorld = longLine.getWorldLength(spacing2D); + + let shortWorld = null; + + if ( + typeof this.shortAxisLength === 'number' && + !Number.isNaN(this.shortAxisLength) + ) { + const dx = this.getDeltaX(); + const dy = this.getDeltaY(); + const len = this.getLength(); + + if (len > 0) { + // 1. FIX POSITION: Use shortAxisT instead of getCentroid() + const t = typeof this.shortAxisT === 'number' ? this.shortAxisT : 0.5; + const anchorX = this.#begin.getX() + dx * t; + const anchorY = this.#begin.getY() + dy * t; + const anchorPoint = new Point2D(anchorX, anchorY); + + // Perpendicular direction + const px = -dy / len; + const py = dx / len; + + const l1 = typeof this.shortAxisL1 === 'number' + ? this.shortAxisL1 + : this.shortAxisLength / 2; + const l2 = typeof this.shortAxisL2 === 'number' + ? this.shortAxisL2 + : this.shortAxisLength / 2; + + // Calculate endpoints using the specific side lengths + const p1 = new Point2D( + anchorPoint.getX() + px * l1, + anchorPoint.getY() + py * l1 + ); + const p2 = new Point2D( + anchorPoint.getX() - px * l2, + anchorPoint.getY() - py * l2 + ); + + shortWorld = new Line(p1, p2).getWorldLength(spacing2D); + + this.shortAxisCenter = anchorPoint; + } + } + + const hasShort = + typeof shortWorld === 'number' && !Number.isNaN(shortWorld); + + if (longWorld !== null) { + return { + longAxis: { + value: hasShort + ? Math.max(longWorld, shortWorld) + : longWorld, + unit: viewController.getLengthUnit() + }, + shortAxis: { + value: hasShort ? Math.min(longWorld, shortWorld) : null, + unit: viewController.getLengthUnit() + } + }; + } + } + + /** + * Restore all bidimensional (short axis) properties from quantification data + * and a BidimensionalLine instance to the annotation object. + * This ensures that after loading from a saved drawing, the annotation has + * all the properties needed for correct display and quantification. + * + * @param {Annotation} annotation The annotation to update. + * @param {BidimensionalLine} bidim The BidimensionalLine math shape. + * @param {object} quant The quantification object containing + * saved properties. + */ + static restorePropertiesFromQuantification(annotation, bidim, quant) { + if (typeof quant.shortAxisLength === 'number') { + bidim.shortAxisLength = quant.shortAxisLength; + } + if (typeof quant.shortAxisT === 'number') { + bidim.shortAxisT = quant.shortAxisT; + } + annotation.mathShape = bidim; + + // Recalculate endpoints and all derived properties + // This logic mirrors the previous restoreBidimensionalProperties + // (requires annotation to have mathShape set) + if (typeof annotation.getFactory === 'function' && + typeof annotation.getFactory().getShortAxisEndpoints === 'function') { + const factory = annotation.getFactory(); + const [sa1, sa2] = factory.getShortAxisEndpoints(annotation); + if (sa1 && sa2) { + const main0 = bidim.getBegin(); + const main1 = bidim.getEnd(); + const centerX = (sa1.getX() + sa2.getX()) / 2; + const centerY = (sa1.getY() + sa2.getY()) / 2; + annotation.mathShape.shortAxisCenter = {x: centerX, y: centerY}; + + const dx = main1.getX() - main0.getX(); + const dy = main1.getY() - main0.getY(); + const len = Math.sqrt(dx * dx + dy * dy); + let t = 0.5; + if (len > 0) { + const ux = dx / len; + const uy = dy / len; + const vx = centerX - main0.getX(); + const vy = centerY - main0.getY(); + t = Math.max(0, Math.min(1, (vx * ux + vy * uy) / len)); + } + annotation.mathShape.shortAxisT = t; + + const l1 = Math.sqrt( + Math.pow(sa1.getX() - centerX, 2) + Math.pow(sa1.getY() - centerY, 2) + ); + const l2 = Math.sqrt( + Math.pow(sa2.getX() - centerX, 2) + Math.pow(sa2.getY() - centerY, 2) + ); + annotation.mathShape.shortAxisL1 = l1; + annotation.mathShape.shortAxisL2 = l2; + annotation.mathShape.shortAxisLength = l1 + l2; + } + } + } +} \ No newline at end of file diff --git a/src/tools/bidimensional.js b/src/tools/bidimensional.js new file mode 100644 index 0000000000..37b171a571 --- /dev/null +++ b/src/tools/bidimensional.js @@ -0,0 +1,939 @@ +import Konva from 'konva'; +import {BidimensionalLine} from '../math/bidimensionalLine.js'; +import {getPerpendicularLine} from '../math/line.js'; +import {Point2D} from '../math/point.js'; +import {LabelFactory} from './labelFactory.js'; +import { + getDefaultAnchor, + getAnchorShape, + defaultLabelTexts, +} from './drawBounds.js'; +import {custom} from '../app/custom.js'; +// doc imports +/* eslint-disable no-unused-vars */ +import {Style} from '../gui/style.js'; +import {Annotation} from '../image/annotation.js'; +import {ViewController} from '../app/viewController.js'; + +/** + * Bidimensional (long/short axis) annotation factory. + */ +export class BidimensionalFactory { + + /** + * The name of the factory. + * + * @type {string} + */ + #name = 'bidimensional'; + + /** + * The associated label factory. + * + * @type {LabelFactory} + */ + #labelFactory = new LabelFactory(this.#getDefaultLabelPosition); + + /** + * Does this factory support the input math shape. + * + * @param {object} mathShape The mathematical shape. + * @returns {boolean} True if supported. + */ + static supports(mathShape) { + return mathShape instanceof BidimensionalLine; + } + + /** + * Get the name of the factory. + * + * @returns {string} The name. + */ + getName() { + return this.#name; + } + + /** + * Get the name of the shape group. + * + * @returns {string} The name. + */ + getGroupName() { + return this.#name + '-group'; + } + + /** + * Get the number of points needed to build the shape. + * + * @returns {number} The number of points. + */ + getNPoints() { + return 2; + } + + /** + * Get the timeout between point storage. + * + * @returns {number} The timeout in milliseconds. + */ + getTimeout() { + return 0; + } + + /** + * Get the default label template for the annotation. + * Returns the draft label if the short axis is not yet set. + * + * @param {Annotation} annotation The annotation. + * @returns {object} The label template object. + */ + #getDefaultLabel(annotation) { + if ( + custom.labelTexts !== undefined && + custom.labelTexts[this.#name] !== undefined + ) { + return custom.labelTexts[this.#name]; + } else { + if ( + annotation.mathShape.hasShortAxisInteraction === true && + annotation.quantification?.shortAxis?.value !== null + ) { + return defaultLabelTexts[this.#name]; + } + return defaultLabelTexts[this.#name + 'Drawing']; + } + } + + /** + * Set an annotation math shape from input points. + * Initializes the short axis and related properties if needed. + * + * @param {Annotation} annotation The annotation. + * @param {Point2D[]} points The points. + */ + setAnnotationMathShape(annotation, points) { + const line = new BidimensionalLine(points[0], points[1]); + const totalLength = line.getLength(); + + // Initialize individual side lengths to half of the long axis (default) + if (typeof line.shortAxisL1 !== 'number') { + line.shortAxisL1 = totalLength / 2; + line.shortAxisL2 = totalLength / 2; + } + + if (typeof line.shortAxisT !== 'number') { + line.shortAxisT = 0.5; + } + + // Store the absolute world position of short axis center + if (!line.shortAxisCenter) { + const mid = this.getPointAlongLine(line, line.shortAxisT); + line.shortAxisCenter = mid; + } + + // Keep total length synced for quantification/labels + line.shortAxisLength = line.shortAxisL1 + line.shortAxisL2; + + annotation.mathShape = line; + annotation.setTextExpr(this.#getDefaultLabel(annotation)); + annotation.updateQuantification(); + } + + /** + * Create a Konva group for the bidimensional annotation. + * + * @param {Annotation} annotation The annotation. + * @param {Style} style The drawing style. + * @returns {Konva.Group} The Konva group. + */ + createShapeGroup(annotation, style) { + const group = new Konva.Group(); + group.name(this.getGroupName()); + group.visible(true); + group.id(annotation.trackingUid); + + // Main axis line + const shape = this.#createShape(annotation, style); + group.add(shape); + + // Extras: ticks and short axis + const extras = this.#createShapeExtras(annotation, style); + for (const extra of extras) { + group.add(extra); + } + + // Label + const label = this.#labelFactory.create(annotation, style); + group.add(label); + + // Connector + const connectorsPos = this.#getConnectorsPositions(shape); + group.add(this.#labelFactory.getConnector(connectorsPos, label, style)); + + // Mouse events for solid/dashed short axis + group.on('mouseenter', () => { + group.getLayer()?.draw(); + this.updateShortAxisToSolid(group); + }); + group.on('mouseleave', () => { + group.getLayer()?.draw(); + this.updateShortAxisToSolid(group); + }); + + // Attach annotation to group for anchor logic + group?.setAttr('annotation', annotation); + group.on('dragend', () => { + const pos = group.position(); + if (pos.x !== 0 || pos.y !== 0) { + // Apply translation to the model (WORLD space) + this.updateAnnotationOnTranslation(group.getAttr('annotation'), pos); + group.position({x: 0, y: 0}); + group.getLayer()?.draw(); + } + }); + + // Highlight short axis and ticks on mouse enter + const shortAxis = group.findOne('.bidimensional-short-axis'); + const saTick0 = group.findOne('.short-axis-tick0'); + const saTick1 = group.findOne('.short-axis-tick1'); + const setShortAxisOpacity = (opacity) => { + if (shortAxis) { + shortAxis.opacity(opacity); + } + if (saTick0) { + saTick0.opacity(opacity); + } + if (saTick1) { + saTick1.opacity(opacity); + } + group.getLayer()?.draw(); + }; + [shortAxis, saTick0, saTick1].forEach((line) => { + if (line) { + line.on('mouseenter', () => setShortAxisOpacity(1)); + } + }); + + return group; + } + + /** + * Create the main axis line shape. + * + * @param {Annotation} annotation The annotation. + * @param {Style} style The drawing style. + * @returns {Konva.Line} The Konva line. + */ + #createShape(annotation, style) { + const line = annotation.mathShape; + const kline = new Konva.Line({ + points: [ + line.getBegin().getX(), + line.getBegin().getY(), + line.getEnd().getX(), + line.getEnd().getY(), + ], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + opacity: 1, + name: 'shape', + }); + + // Add a larger hit zone using hitFunc (similar to ruler tool) + const tickLen = 20; + const linePerp0 = getPerpendicularLine( + line, + line.getBegin(), + tickLen, + style.getZoomScale ? style.getZoomScale() : {x: 1, y: 1} + ); + const linePerp1 = getPerpendicularLine( + line, + line.getEnd(), + tickLen, + style.getZoomScale ? style.getZoomScale() : {x: 1, y: 1} + ); + kline.hitFunc(function (context) { + context.beginPath(); + context.moveTo(linePerp0.getBegin().getX(), linePerp0.getBegin().getY()); + context.lineTo(linePerp0.getEnd().getX(), linePerp0.getEnd().getY()); + context.lineTo(linePerp1.getEnd().getX(), linePerp1.getEnd().getY()); + context.lineTo(linePerp1.getBegin().getX(), linePerp1.getBegin().getY()); + context.closePath(); + context.fillStrokeShape(kline); + }); + + return kline; + } + + /** + * Create extra shapes: main axis ticks, short axis, and short axis ticks. + * + * @param {Annotation} annotation The annotation. + * @param {Style} style The drawing style. + * @returns {Array} The Konva shape extras. + */ + #createShapeExtras(annotation, style) { + const line = annotation.mathShape; + const shortAxisTickLen = 10; + const longAxisTickLen = 20; + const zoom = style.getZoomScale ? style.getZoomScale() : {x: 1, y: 1}; + + // Main axis ticks + const linePerp0 = getPerpendicularLine( + line, + line.getBegin(), + longAxisTickLen, + zoom, + ); + const ktick0 = new Konva.Line({ + points: [ + linePerp0.getBegin().getX(), + linePerp0.getBegin().getY(), + linePerp0.getEnd().getX(), + linePerp0.getEnd().getY(), + ], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + name: 'shape-tick0', + }); + + const linePerp1 = getPerpendicularLine( + line, + line.getEnd(), + longAxisTickLen, + zoom, + ); + const ktick1 = new Konva.Line({ + points: [ + linePerp1.getBegin().getX(), + linePerp1.getBegin().getY(), + linePerp1.getEnd().getX(), + linePerp1.getEnd().getY(), + ], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + name: 'shape-tick1', + }); + + // 2. Short axis - NOW USING INDEPENDENT ENDPOINTS + // This ensures the line length matches the anchor positions exactly + const [sa1, sa2] = this.getShortAxisEndpoints(annotation); + + const shortAxis = new Konva.Line({ + points: [sa1.getX(), sa1.getY(), sa2.getX(), sa2.getY()], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + dash: annotation.mathShape.hasShortAxisInteraction + ? [] + : [8, 8], + opacity: 0.5, + name: 'bidimensional-short-axis', + }); + + // 3. Short axis ticks (perpendicular to the short axis line) + const dx = sa2.getX() - sa1.getX(); + const dy = sa2.getY() - sa1.getY(); + const len = Math.hypot(dx, dy); + + let nx, ny; + if (len === 0) { + nx = 1; + ny = 0; + } else { + nx = -dy / len; + ny = dx / len; + } + + // Tick at sa1 + const saTick0 = new Konva.Line({ + points: [ + sa1.getX() - (nx * shortAxisTickLen) / 2, + sa1.getY() - (ny * shortAxisTickLen) / 2, + sa1.getX() + (nx * shortAxisTickLen) / 2, + sa1.getY() + (ny * shortAxisTickLen) / 2, + ], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + opacity: 0.5, + name: 'short-axis-tick0', + }); + const saTick1 = new Konva.Line({ + points: [ + sa2.getX() - (nx * shortAxisTickLen) / 2, + sa2.getY() - (ny * shortAxisTickLen) / 2, + sa2.getX() + (nx * shortAxisTickLen) / 2, + sa2.getY() + (ny * shortAxisTickLen) / 2, + ], + stroke: annotation.colour, + strokeWidth: style.getStrokeWidth(), + strokeScaleEnabled: false, + opacity: 0.5, + name: 'short-axis-tick1', + }); + + return [ktick0, ktick1, shortAxis, saTick0, saTick1]; + } + + /** + * Get the connector positions for the label. + * + * @param {Konva.Line} shape The main axis shape. + * @returns {Point2D[]} The connector positions. + */ + #getConnectorsPositions(shape) { + const points = shape.points(); + const sx = shape.x(); + const sy = shape.y(); + const centerX = (points[0] + points[2]) / 2 + sx; + const centerY = (points[1] + points[3]) / 2 + sy; + return [new Point2D(centerX, centerY)]; + } + + /** + * Get the anchor positions for the shape. + * + * @param {Konva.Line} shape The main axis shape. + * @param {Style} style The drawing style. + * @returns {Point2D[]} The anchor positions. + */ + #getAnchorsPositions(shape, style) { + // Main axis endpoints (from the shape) + const points = shape.points(); + const sx = shape.x(); + const sy = shape.y(); + const main0 = new Point2D(points[0] + sx, points[1] + sy); + const main1 = new Point2D(points[2] + sx, points[3] + sy); + + // Short axis endpoints (from the model) + const group = shape.getParent(); + const annotation = group?.getAttr('annotation'); + if (!annotation || !annotation.mathShape) { + return [main0, main1, main0, main1]; + } + + // Always use the model to get the current short axis endpoints + const [sa1, sa2] = this.getShortAxisEndpoints(annotation); + + // Return all four anchor positions + return [main0, main1, sa1, sa2]; + } + + /** + * Get anchors for the shape. + * + * @param {Konva.Line} shape The main axis shape. + * @param {Style} style The drawing style. + * @returns {Konva.Ellipse[]} The anchors. + */ + getAnchors(shape, style) { + const positions = this.#getAnchorsPositions(shape, style); + const anchors = []; + for (let i = 0; i < positions.length; ++i) { + anchors.push( + getDefaultAnchor( + positions[i].getX(), + positions[i].getY(), + 'anchor' + i, // anchor0, anchor1, anchor2, anchor3 + style + ) + ); + } + return anchors; + } + + /** + * Constrain anchor movement for short axis anchors. + * + * @param {Konva.Ellipse} anchor The active anchor. + */ + constrainAnchorMove(anchor) { + const group = anchor.getParent(); + const annotation = group?.getAttr('annotation'); + const mathShape = annotation.mathShape; + + // Handle SHORT AXIS anchors (anchor2 and anchor3) + if (anchor.id() === 'anchor2' || anchor.id() === 'anchor3') { + const begin = mathShape.getBegin(); + const end = mathShape.getEnd(); + const dx = end.getX() - begin.getX(); + const dy = end.getY() - begin.getY(); + const len = Math.hypot(dx, dy); + + if (len === 0) { + return; + } + + const ux = dx / len; + const uy = dy / len; + const px = -uy; // Perpendicular unit vector + const py = ux; + + const vx = anchor.x() - begin.getX(); + const vy = anchor.y() - begin.getY(); + + // 1. Update position along the long axis (T) + const tWorld = vx * ux + vy * uy; + mathShape.shortAxisT = Math.max(0, Math.min(1, tWorld / len)); + + // NEW: Update absolute center position + const center = this.getPointAlongLine(mathShape, mathShape.shortAxisT); + mathShape.shortAxisCenter = center; + + // 2. Update specific side length with CLAMPING + const distFromLongAxis = vx * px + vy * py; + + if (anchor.id() === 'anchor2') { + mathShape.shortAxisL1 = Math.max(0.1, distFromLongAxis); + } else { + mathShape.shortAxisL2 = Math.max(0.1, -distFromLongAxis); + } + + // Update total length for labels/quantification + mathShape.shortAxisLength = + mathShape.shortAxisL1 + mathShape.shortAxisL2; + + // 3. Re-lock anchor position to the clamped value + const finalDist = + (anchor.id() === 'anchor2') + ? mathShape.shortAxisL1 + : -mathShape.shortAxisL2; + anchor.x(center.getX() + px * finalDist); + anchor.y(center.getY() + py * finalDist); + } + } + /** + * Update shape and label on anchor move. + * + * @param {Annotation} annotation The annotation. + * @param {Konva.Ellipse} anchor The active anchor. + * @param {Style} style The drawing style. + * @param {ViewController} viewController The view controller. + */ + updateShapeGroupOnAnchorMove(annotation, anchor, style, viewController) { + const group = anchor.getParent(); + if (!(group instanceof Konva.Group)) { + return; + } + + // Update shape and anchors + this.#updateShape(annotation, anchor, style, viewController); + + // Update label + this.updateLabelContent(annotation, group, style); + + // Update label position if default position + if (annotation.labelPosition === undefined) { + this.#labelFactory.updatePosition(annotation, group); + } + + // Update connector + this.updateConnector(group); + } + + /** + * Update the shape and anchors after anchor move. + * + * @param {Annotation} annotation The annotation. + * @param {Konva.Ellipse} anchor The active anchor. + * @param {Style} style The drawing style. + * @param {ViewController} viewController The view controller. + */ + #updateShape(annotation, anchor, style, viewController) { + const line = annotation.mathShape; + const group = anchor.getParent(); + if (!(group instanceof Konva.Group)) { + return; + } + + // 1. Update Main Axis Line + const kline = group.findOne('.shape'); + if (kline && kline instanceof Konva.Line) { + kline.position({x: 0, y: 0}); + kline.points([ + line.getBegin().getX(), + line.getBegin().getY(), + line.getEnd().getX(), + line.getEnd().getY(), + ]); + } + + // 2. Update Main Axis Ticks (Ends of the long axis) + const ktick0 = group.findOne('.shape-tick0'); + const ktick1 = group.findOne('.shape-tick1'); + const tickLen = 20; + const zoom = style.getZoomScale(); + + const linePerp0 = getPerpendicularLine( + line, + line.getBegin(), + tickLen, + zoom, + ); + if (ktick0 && ktick0 instanceof Konva.Line) { + ktick0.position({x: 0, y: 0}); + ktick0.points([ + linePerp0.getBegin().getX(), + linePerp0.getBegin().getY(), + linePerp0.getEnd().getX(), + linePerp0.getEnd().getY(), + ]); + } + + const linePerp1 = getPerpendicularLine(line, line.getEnd(), tickLen, zoom); + if (ktick1 && ktick1 instanceof Konva.Line) { + ktick1.position({x: 0, y: 0}); + ktick1.points([ + linePerp1.getBegin().getX(), + linePerp1.getBegin().getY(), + linePerp1.getEnd().getX(), + linePerp1.getEnd().getY(), + ]); + } + + // 3. Handle Anchor interaction + if (anchor.id() === 'anchor2' || anchor.id() === 'anchor3') { + line.shortAxisLength = annotation.mathShape.shortAxisLength; + line.shortAxisT = annotation.mathShape.shortAxisT; + } + + // 4. Get the Independent Endpoints + const [sa1, sa2] = this.getShortAxisEndpoints(annotation); + + // 5. Update Short Axis Line + const shortAxis = group.findOne('.bidimensional-short-axis'); + if (shortAxis && shortAxis instanceof Konva.Line) { + shortAxis.position({x: 0, y: 0}); + shortAxis.points([sa1.getX(), sa1.getY(), sa2.getX(), sa2.getY()]); + } + + // 6. Update Short Axis Ticks and Anchor Positions + this.updateShortAxisTicks(group, sa1, sa2); + const a2 = getAnchorShape(group, 2); + const a3 = getAnchorShape(group, 3); + if (a2 && a3) { + a2.x(sa1.getX()); + a2.y(sa1.getY()); + a3.x(sa2.getX()); + a3.y(sa2.getY()); + } + + annotation.mathShape.hasShortAxisInteraction = true; + annotation.setTextExpr(this.#getDefaultLabel(annotation)); + annotation.updateQuantification?.(); + group.getLayer()?.draw(); + } + + /** + * Update the label content. + * + * @param {Annotation} annotation The annotation. + * @param {Konva.Group} group The shape group. + * @param {Style} _style The drawing style. + */ + updateLabelContent(annotation, group, _style) { + this.#labelFactory.updateContent(annotation, group); + } + + /** + * Update the label connector. + * + * @param {Konva.Group} group The shape group. + */ + updateConnector(group) { + const kshape = group.findOne('.shape'); + if (kshape && kshape instanceof Konva.Line) { + const connectorsPos = this.#getConnectorsPositions(kshape); + this.#labelFactory.updateConnector(group, connectorsPos); + } + } + + /** + * Get the default label position (lowest point). + * + * @param {Annotation} annotation The annotation. + * @returns {Point2D} The label position. + */ + #getDefaultLabelPosition(annotation) { + const line = annotation.mathShape; + const begin = line.getBegin(); + const end = line.getEnd(); + let res = begin; + if (begin.getY() < end.getY()) { + res = end; + } + return res; + } + + /** + * Update annotation on translation (shape move). + * + * @param {Annotation} annotation The annotation. + * @param {object} translation The translation. + */ + updateAnnotationOnTranslation(annotation, translation) { + const line = annotation.mathShape; + const begin = line.getBegin(); + const end = line.getEnd(); + + // Calculate new world positions for long axis + const newBegin = new Point2D( + begin.getX() + translation.x, + begin.getY() + translation.y, + ); + const newEnd = new Point2D( + end.getX() + translation.x, + end.getY() + translation.y, + ); + const newLine = new BidimensionalLine(newBegin, newEnd); + + newLine.shortAxisLength = line.shortAxisLength; + newLine.shortAxisT = line.shortAxisT; + newLine.shortAxisL1 = line.shortAxisL1; + newLine.shortAxisL2 = line.shortAxisL2; + if ( + line.shortAxisCenter instanceof Object && + 'getX' in line.shortAxisCenter && + 'getY' in line.shortAxisCenter + ) { + newLine.shortAxisCenter = new Point2D( + line.shortAxisCenter.getX() + translation.x, + line.shortAxisCenter.getY() + translation.y + ); + } + newLine.hasShortAxisInteraction = line.hasShortAxisInteraction; + + annotation.mathShape = newLine; + annotation.updateQuantification(); + } + + /** + * Update annotation on anchor move. + * + * @param {Annotation} annotation The annotation. + * @param {Konva.Shape} anchor The anchor. + */ + updateAnnotationOnAnchorMove(annotation, anchor) { + const group = anchor.getParent(); + if (!(group instanceof Konva.Group)) { + return; + } + + const kline = group.findOne('.shape'); + const anchor0 = getAnchorShape(group, 0); + const anchor1 = getAnchorShape(group, 1); + + // Get the positions compensating for group/shape translation + const pointBegin = new Point2D( + anchor0.x() - kline.x(), + anchor0.y() - kline.y(), + ); + const pointEnd = new Point2D( + anchor1.x() - kline.x(), + anchor1.y() - kline.y() + ); + const newLine = new BidimensionalLine(pointBegin, pointEnd); + + // Preserve all custom independent properties + const oldLine = annotation.mathShape; + newLine.shortAxisLength = oldLine.shortAxisLength; + newLine.shortAxisT = oldLine.shortAxisT; + newLine.shortAxisL1 = oldLine.shortAxisL1; + newLine.shortAxisL2 = oldLine.shortAxisL2; + if ( + oldLine.shortAxisCenter instanceof Object && + 'getX' in oldLine.shortAxisCenter && + 'getY' in oldLine.shortAxisCenter + ) { + newLine.shortAxisCenter = new Point2D( + oldLine.shortAxisCenter.getX(), + oldLine.shortAxisCenter.getY() + ); + } + newLine.hasShortAxisInteraction = oldLine.hasShortAxisInteraction; + + annotation.mathShape = newLine; + annotation.updateQuantification(); + } + + /** + * Set the short axis to solid (remove dash). + * + * @param {Konva.Group} group The shape group. + */ + updateShortAxisToSolid(group) { + const shortAxis = group.findOne('.bidimensional-short-axis'); + if (shortAxis && shortAxis instanceof Konva.Line) { + shortAxis.dash([]); // Always set to solid + shortAxis.getLayer()?.draw(); + } + } + + /** + * Update the short axis ticks. + * + * @param {Konva.Group} group The shape group. + * @param {Point2D} sa1 The first short axis endpoint. + * @param {Point2D} sa2 The second short axis endpoint. + */ + updateShortAxisTicks(group, sa1, sa2) { + + const tickLen = 10; + const dx = sa2.getX() - sa1.getX(); + const dy = sa2.getY() - sa1.getY(); + const len = Math.hypot(dx, dy); + + let nx, ny; + if (len === 0) { + nx = 1; + ny = 0; + } else { + nx = -dy / len; + ny = dx / len; + } + // Tick at sa1 + const tickSA1Start = new Point2D( + sa1.getX() - (nx * tickLen) / 2, + sa1.getY() - (ny * tickLen) / 2, + ); + const tickSA1End = new Point2D( + sa1.getX() + (nx * tickLen) / 2, + sa1.getY() + (ny * tickLen) / 2, + ); + const saTick0 = group.findOne('.short-axis-tick0'); + if (saTick0 && saTick0 instanceof Konva.Line) { + saTick0.position({x: 0, y: 0}); + saTick0.points([ + tickSA1Start.getX(), + tickSA1Start.getY(), + tickSA1End.getX(), + tickSA1End.getY(), + ]); + saTick0.opacity(0.5); + } + + // Tick at sa2 + const tickSA2Start = new Point2D( + sa2.getX() - (nx * tickLen) / 2, + sa2.getY() - (ny * tickLen) / 2, + ); + const tickSA2End = new Point2D( + sa2.getX() + (nx * tickLen) / 2, + sa2.getY() + (ny * tickLen) / 2, + ); + const saTick1 = group.findOne('.short-axis-tick1'); + if (saTick1 && saTick1 instanceof Konva.Line) { + saTick1.position({x: 0, y: 0}); + saTick1.points([ + tickSA2Start.getX(), + tickSA2Start.getY(), + tickSA2End.getX(), + tickSA2End.getY(), + ]); + saTick1.opacity(0.5); + } + } + + /** + * Get the endpoints of the short axis. + * + * @param {Annotation} annotation The annotation. + * @returns {Point2D[]} The endpoints as an array of two Point2D objects. + */ + getShortAxisEndpoints(annotation) { + const line = annotation.mathShape; + // Use absolute center position if available and valid + let mid; + if (annotation.mathShape.shortAxisCenter instanceof Point2D) { + // Project the absolute center onto the current long axis + const begin = line.getBegin(); + const end = line.getEnd(); + const dx = end.getX() - begin.getX(); + const dy = end.getY() - begin.getY(); + const len = Math.hypot(dx, dy); + if (len === 0) { + mid = annotation.mathShape.shortAxisCenter; + } else { + const ux = dx / len; + const uy = dy / len; + // Vector from begin to absolute center + const vx = annotation.mathShape.shortAxisCenter.getX() - begin.getX(); + const vy = annotation.mathShape.shortAxisCenter.getY() - begin.getY(); + // Project onto long axis + const projection = vx * ux + vy * uy; + const t = Math.max(0, Math.min(1, projection / len)); + // Update T to match the projection + annotation.mathShape.shortAxisT = t; + mid = this.getPointAlongLine(line, t); + } + } else { + // Fallback to T-based positioning + const t = + typeof annotation.mathShape.shortAxisT === 'number' + ? annotation.mathShape.shortAxisT + : 0.5; + mid = this.getPointAlongLine(line, t); + } + const begin = line.getBegin(); + const end = line.getEnd(); + const dx = end.getX() - begin.getX(); + const dy = end.getY() - begin.getY(); + const len = Math.hypot(dx, dy); + if ( + len === 0 || + Number.isNaN(mid.getX()) || + Number.isNaN(mid.getY()) + ) { + return [mid, mid]; + } + // Perpendicular unit vector + const px = -dy / len; + const py = dx / len; + // Fallback to half length if L1/L2 aren't set yet (defensive coding) + let l1; + if (typeof annotation.mathShape.shortAxisL1 === 'number') { + l1 = annotation.mathShape.shortAxisL1; + } else if (typeof annotation.mathShape.shortAxisLength === 'number') { + l1 = annotation.mathShape.shortAxisLength / 2; + } else { + l1 = 0; + } + let l2; + if (typeof annotation.mathShape.shortAxisL2 === 'number') { + l2 = annotation.mathShape.shortAxisL2; + } else if (typeof annotation.mathShape.shortAxisLength === 'number') { + l2 = annotation.mathShape.shortAxisLength / 2; + } else { + l2 = 0; + } + if (Number.isNaN(l1) || Number.isNaN(l2)) { + return [mid, mid]; + } + return [ + new Point2D(mid.getX() + px * l1, mid.getY() + py * l1), + new Point2D(mid.getX() - px * l2, mid.getY() - py * l2) + ]; + } + + /** + * Get a point along the main axis line. + * + * @param {BidimensionalLine} line The main axis line. + * @param {number} t The interpolation parameter [0, 1]. + * @returns {Point2D} The point along the line. + */ + getPointAlongLine(line, t) { + const b = line.getBegin(); + const e = line.getEnd(); + + return new Point2D( + b.getX() + (e.getX() - b.getX()) * t, + b.getY() + (e.getY() - b.getY()) * t, + ); + } +} \ No newline at end of file diff --git a/src/tools/drawBounds.js b/src/tools/drawBounds.js index 689076ea4d..ccc512a1c6 100644 --- a/src/tools/drawBounds.js +++ b/src/tools/drawBounds.js @@ -34,6 +34,12 @@ export const defaultLabelTexts = { }, ruler: { '*': '{length}' + }, + bidimensional: { + '*': '{longAxis} x {shortAxis}' + }, + bidimensionalDrawing: { + '*': '{longAxis}' } }; diff --git a/src/tools/index.js b/src/tools/index.js index 3d4d1e6c07..388293e381 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -19,6 +19,7 @@ import {Filter, Threshold, Sobel, Sharpen} from './filter.js'; import {ScrollWheel} from './scrollWheel.js'; import {DrawShapeHandler} from './drawShapeHandler.js'; +import {BidimensionalFactory} from './bidimensional.js'; export { DrawShapeHandler, @@ -159,7 +160,8 @@ export const defaultToolOptions = { ProtractorFactory, RectangleFactory, RoiFactory, - RulerFactory + RulerFactory, + BidimensionalFactory }, filter: { Threshold, diff --git a/tests/annotation/annotation.test.js b/tests/annotation/annotation.test.js index 6bfdd1981f..d1be5800db 100644 --- a/tests/annotation/annotation.test.js +++ b/tests/annotation/annotation.test.js @@ -11,6 +11,11 @@ import {Point2D} from '../../src/math/point.js'; import {Protractor} from '../../src/math/protractor.js'; import {Rectangle} from '../../src/math/rectangle.js'; import {ROI} from '../../src/math/roi.js'; +import {BidimensionalLine} from '../../src/math/bidimensionalLine.js'; +import { + getScoordFromShape, + getShapeFromScoord +} from '../../src/dicom/dicomSpatialCoordinate.js'; // doc imports /* eslint-disable no-unused-vars */ @@ -32,6 +37,7 @@ import tid1500v0Protractor from './tid1500-0/sr-protractor.dcm?inline'; import tid1500v0Rectangle from './tid1500-0/sr-rectangle.dcm?inline'; import tid1500v0Roi from './tid1500-0/sr-roi.dcm?inline'; import tid1500v0Ruler from './tid1500-0/sr-ruler.dcm?inline'; +import tid1500v0Bidimensional from './tid1500-0/sr-bidimensional.dcm?inline'; /** * Tests for the annotation I/O. @@ -39,7 +45,6 @@ import tid1500v0Ruler from './tid1500-0/sr-ruler.dcm?inline'; /** @module tests/annotation */ describe('annotation', () => { - /** * Get an annotation group from a buffer string. * @@ -318,6 +323,31 @@ describe('annotation', () => { } } + /** + * Check a bidimensional annotation group. + * + * @param {AnnotationGroup} annotationGroup The group to check. + */ + function checkBidimensionalGroup(annotationGroup) { + const annotations = annotationGroup.getList(); + for (let i = 0; i < annotations.length; ++i) { + const annotation = annotations[i]; + const prefix = 'bidimensional annotation ' + i; + assert.ok(annotation.mathShape instanceof BidimensionalLine, + prefix + ' mathShape'); + assert.ok( + annotation.textExpr === '{longAxis} x {shortAxis}' || + annotation.textExpr === '{longAxis}', + prefix + ' annotation ' + i + ' good textExpr (' + + annotation.textExpr + ')' + ); + assert.ok(typeof annotation.quantification.longAxis !== 'undefined', + prefix + ' quantification.longAxis'); + assert.ok(typeof annotation.quantification.shortAxis !== 'undefined', + prefix + ' quantification.shortAxis'); + } + } + //---------------------------------------------------- // dwv 0.34 //---------------------------------------------------- @@ -480,4 +510,80 @@ describe('annotation', () => { checkRulerGroup(annotationGroup); }); + /** + * Test BidimensionalLine DICOM SR import/export roundtrip. + */ + test('BidimensionalLine DICOM SR import/export roundtrip', () => { + // Create a BidimensionalLine with all properties + const p1 = new Point2D(10, 20); + const p2 = new Point2D(30, 40); + const b = new BidimensionalLine(p1, p2); + b.shortAxisLength = 12; + b.shortAxisT = 0.6; + b.shortAxisL1 = 7; + b.shortAxisL2 = 5; + // Set shortAxisCenter for export + b.shortAxisCenter = new Point2D(22, 32); + + // Export to DICOM SR (SCOORD) + const scoord = getScoordFromShape(b); + // Import back to BidimensionalLine + const b2 = getShapeFromScoord(scoord); + + // Check type and main axis + assert.ok( + b2 instanceof BidimensionalLine, + 'Imported shape is BidimensionalLine' + ); + assert.equal(b2.getBegin().getX(), b.getBegin().getX(), 'Begin X matches'); + assert.equal(b2.getBegin().getY(), b.getBegin().getY(), 'Begin Y matches'); + assert.equal(b2.getEnd().getX(), b.getEnd().getX(), 'End X matches'); + assert.equal(b2.getEnd().getY(), b.getEnd().getY(), 'End Y matches'); + + // Check short axis properties + assert.closeTo( + b2.shortAxisLength, + b.shortAxisLength, + 1e-6, + 'shortAxisLength matches' + ); + // L1 and L2 may be swapped or recalculated, but their sum should match + assert.closeTo( + b2.shortAxisL1 + b2.shortAxisL2, + b.shortAxisLength, + 1e-6, + 'shortAxisL1 + shortAxisL2 matches shortAxisLength' + ); + assert.ok( + b2.shortAxisL1 > 0 && b2.shortAxisL2 > 0, + 'shortAxisL1 and L2 positive' + ); + assert.closeTo(b2.shortAxisT, b.shortAxisT, 1e-2, 'shortAxisT matches'); + // Center is recalculated, but should be close + assert.closeTo( + b2.shortAxisCenter.getX(), + b.shortAxisCenter.getX(), + 1, + 'shortAxisCenter X close' + ); + assert.closeTo( + b2.shortAxisCenter.getY(), + b.shortAxisCenter.getY(), + 1, + 'shortAxisCenter Y close' + ); + }); + + /** + * Tests for {@link Annotation} from tid1500 v0 containing a + * BidimensionalLine. + * + * @function module:tests/annotation~read-tid1500-v0-bidimensional + */ + test('Read tid1500 v0 bidimensional', () => { + const annotationGroup = getAnnotationGroup(tid1500v0Bidimensional); + checkGroupCommonProperties(annotationGroup, 'bidimensional'); + checkBidimensionalGroup(annotationGroup); + }); + }); diff --git a/tests/annotation/tid1500-0/sr-bidimensional.dcm b/tests/annotation/tid1500-0/sr-bidimensional.dcm new file mode 100644 index 0000000000..4cfb9cb097 Binary files /dev/null and b/tests/annotation/tid1500-0/sr-bidimensional.dcm differ diff --git a/tests/dicom/dicomSpatialCoordinate.test.js b/tests/dicom/dicomSpatialCoordinate.test.js index 11bc539c0b..7829a4469b 100644 --- a/tests/dicom/dicomSpatialCoordinate.test.js +++ b/tests/dicom/dicomSpatialCoordinate.test.js @@ -15,6 +15,7 @@ import {ROI} from '../../src/math/roi.js'; import {Circle} from '../../src/math/circle.js'; import {Ellipse} from '../../src/math/ellipse.js'; import {Rectangle} from '../../src/math/rectangle.js'; +import {BidimensionalLine} from '../../src/math/bidimensionalLine.js'; /** * Related DICOM tag keys. @@ -439,9 +440,73 @@ describe('dicom', () => { assert.equal(scoord.graphicData[9], '10'); }); + /** + * Tests for {@link getScoordFromShape} with BidimensionalLine. + * + * @function module:tests/dicom~getscoordfromshape-bidimensionalline + */ + test('BidimensionalLine', () => { + // Main axis: (10,10)-(30,10), short axis center: (20,20), length: 10 + const mainStart = new Point2D(10, 10); + const mainEnd = new Point2D(30, 10); + const shortAxisCenter = {x: 20, y: 20}; + const shortAxisLength = 10; + const bline = new BidimensionalLine(mainStart, mainEnd); + bline.shortAxisCenter = shortAxisCenter; + bline.shortAxisLength = shortAxisLength; + + const scoord = getScoordFromShape(bline); + assert.equal(scoord.graphicType, GraphicTypes.polyline); + assert.equal(scoord.graphicData.length, 8); + // Main axis + assert.equal(scoord.graphicData[0], '10'); + assert.equal(scoord.graphicData[1], '10'); + assert.equal(scoord.graphicData[2], '30'); + assert.equal(scoord.graphicData[3], '10'); + // Short axis endpoints (should be symmetric about shortAxisCenter) + // Just check they are numbers + assert.ok(!isNaN(Number(scoord.graphicData[4]))); + assert.ok(!isNaN(Number(scoord.graphicData[5]))); + assert.ok(!isNaN(Number(scoord.graphicData[6]))); + assert.ok(!isNaN(Number(scoord.graphicData[7]))); + }); + }); describe('getShapeFromScoord', () => { + /** + * Tests for {@link getShapeFromScoord} with BidimensionalLine polyline. + * + * @function module:tests/dicom~getshapefromscoord-bidimensionalline + */ + test('BidimensionalLine polyline', () => { + // 4 points: main axis (10,10)-(30,10), short axis endpoints + const scoord = new SpatialCoordinate(); + scoord.graphicType = GraphicTypes.polyline; + // Use getScoordFromShape to generate valid data + const mainStart = new Point2D(10, 10); + const mainEnd = new Point2D(30, 10); + const shortAxisCenter = {x: 20, y: 20}; + const shortAxisLength = 10; + const bline = new BidimensionalLine(mainStart, mainEnd); + bline.shortAxisCenter = shortAxisCenter; + bline.shortAxisLength = shortAxisLength; + const scoordData = getScoordFromShape(bline); + scoord.graphicData = scoordData.graphicData; + + const shape = getShapeFromScoord(scoord); + assert.ok(shape instanceof BidimensionalLine); + assert.equal(shape.getBegin().getX(), 10); + assert.equal(shape.getBegin().getY(), 10); + assert.equal(shape.getEnd().getX(), 30); + assert.equal(shape.getEnd().getY(), 10); + // Check short axis properties + assert.ok(typeof shape.shortAxisLength === 'number'); + assert.ok(typeof shape.shortAxisT === 'number'); + assert.ok(typeof shape.shortAxisL1 === 'number'); + assert.ok(typeof shape.shortAxisL2 === 'number'); + assert.ok(typeof shape.shortAxisCenter === 'object'); + }); /** * Tests for {@link getShapeFromScoord} with no data. diff --git a/tests/math/bidimensionalLine.test.js b/tests/math/bidimensionalLine.test.js new file mode 100644 index 0000000000..9979da3084 --- /dev/null +++ b/tests/math/bidimensionalLine.test.js @@ -0,0 +1,100 @@ +import {describe, test, assert} from 'vitest'; +import {BidimensionalLine} from '../../src/math/bidimensionalLine.js'; +import {Point2D} from '../../src/math/point.js'; + + +describe('BidimensionalLine', () => { + test('constructor and getters', () => { + const p1 = new Point2D(1, 2); + const p2 = new Point2D(3, 4); + const b = new BidimensionalLine(p1, p2); + assert.strictEqual(b.getBegin(), p1); + assert.strictEqual(b.getEnd(), p2); + }); + + test('getCentroid returns midpoint', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(4, 4); + const b = new BidimensionalLine(p1, p2); + const c = b.getCentroid(); + assert.equal(c.getX(), 2); + assert.equal(c.getY(), 2); + }); + + test('getLength returns correct value', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(3, 4); + const b = new BidimensionalLine(p1, p2); + assert.equal(b.getLength(), 5); + }); + + test('getSlope returns correct value', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(2, 4); + const b = new BidimensionalLine(p1, p2); + assert.equal(b.getSlope(), 2); + // vertical line + const p3 = new Point2D(0, 0); + const p4 = new Point2D(0, 5); + const b2 = new BidimensionalLine(p3, p4); + assert.equal(b2.getSlope(), Infinity); + }); + + test('getDeltaX and getDeltaY', () => { + const p1 = new Point2D(1, 2); + const p2 = new Point2D(4, 6); + const b = new BidimensionalLine(p1, p2); + assert.equal(b.getDeltaX(), 3); + assert.equal(b.getDeltaY(), 4); + }); + + test('quantify returns correct axes (default spacing, no short axis)', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(3, 4); + const b = new BidimensionalLine(p1, p2); + // No shortAxisLength set + const viewController = { + get2DSpacing: () => ({x: 1, y: 1}), + getLengthUnit: () => 'mm', + }; + const result = b.quantify(viewController); + assert.deepEqual(result, { + longAxis: {value: 5, unit: 'mm'}, + shortAxis: {value: null, unit: 'mm'}, + }); + }); + + test('quantify returns correct axes (with short axis, spacing)', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(10, 0); + const b = new BidimensionalLine(p1, p2); + b.shortAxisLength = 6; + b.shortAxisT = 0.5; + b.shortAxisL1 = 3; + b.shortAxisL2 = 3; + const viewController = { + get2DSpacing: () => ({x: 2, y: 2}), + getLengthUnit: () => 'cm', + }; + const result = b.quantify(viewController); + // Main axis: (0,0)-(10,0) with spacing 2: length = 20 + // Short axis: endpoints (5,3)-(5,-3) with spacing 2: dx=0, dy=6, world=12 + assert.deepEqual(result, { + longAxis: {value: 20, unit: 'cm'}, + shortAxis: {value: 12, unit: 'cm'}, + }); + // shortAxisCenter should be set and is a Point2D + assert.ok(b.shortAxisCenter instanceof Point2D); + assert.closeTo(b.shortAxisCenter.getX(), 5, 1e-6); + assert.closeTo(b.shortAxisCenter.getY(), 0, 1e-6); + }); + + test('quantify throws if getLengthUnit missing', () => { + const p1 = new Point2D(0, 0); + const p2 = new Point2D(0, 10); + const b = new BidimensionalLine(p1, p2); + b.shortAxisLength = 4; + const viewController = {}; // no getLengthUnit + assert.throws(() => b.quantify(viewController), /getLengthUnit/); + }); +}); diff --git a/tests/pacs/viewer.js b/tests/pacs/viewer.js index 0ee6b45495..7b923cbf4f 100644 --- a/tests/pacs/viewer.js +++ b/tests/pacs/viewer.js @@ -213,7 +213,8 @@ function viewerSetup() { 'Ellipse', 'Rectangle', 'Protractor', - 'Roi' + 'Roi', + 'Bidimensional' ]}, Brush: {}, Floodfill: {}, diff --git a/tests/pacs/viewer.ui.icons.js b/tests/pacs/viewer.ui.icons.js index 36398c74b1..cd90b8711e 100644 --- a/tests/pacs/viewer.ui.icons.js +++ b/tests/pacs/viewer.ui.icons.js @@ -28,7 +28,9 @@ const icons = { // triangular ruler protractor: '\u{1F4D0}', // wavy dash - roi: '\u{3030}' + roi: '\u{3030}', + //FALLING DIAGONAL CROSSING NORTH EAST ARROW + bidimensional: '\u{292F}', }; /**