diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index 3a1ee6e0d..7f599f262 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -33,8 +33,20 @@ const tags: Tag[] = [ { name: 'RescaleIntercept', tag: '0028|1052' }, { name: 'RescaleSlope', tag: '0028|1053' }, { name: 'NumberOfFrames', tag: '0028|0008' }, + { name: 'SequenceOfUltrasoundRegions', tag: '0018|6011' }, + { name: 'PhysicalDeltaX', tag: '0018|602c' }, + { name: 'PhysicalDeltaY', tag: '0018|602e' }, + { name: 'PhysicalUnitsXDirection', tag: '0018|6024' }, + { name: 'PhysicalUnitsYDirection', tag: '0018|6026' }, ]; export const TAG_TO_NAME = new Map(tags.map((t) => [t.tag, t.name])); export const NAME_TO_TAG = new Map(tags.map((t) => [t.name, t.tag])); export const Tags = Object.fromEntries(tags.map((t) => [t.name, t.tag])); + +// Splits an itk-wasm-style "GGGG|EEEE" tag into the numeric [group, element] +// pair emitted by the streaming DICOM parser. +export const tagToGroupElement = (tag: string): [number, number] => { + const [group, element] = tag.split('|'); + return [parseInt(group, 16), parseInt(element, 16)]; +}; diff --git a/src/core/streaming/chunk.ts b/src/core/streaming/chunk.ts index 9be70033b..69126de39 100644 --- a/src/core/streaming/chunk.ts +++ b/src/core/streaming/chunk.ts @@ -86,6 +86,10 @@ export class Chunk { return this.metaLoader.metaBlob; } + get ultrasoundRegions() { + return this.metaLoader.ultrasoundRegions ?? null; + } + get dataBlob() { return this.dataLoader.data; } diff --git a/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts new file mode 100644 index 000000000..d9b2afb38 --- /dev/null +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import type { DataElement } from '@/src/core/streaming/dicom/dicomParser'; +import { + decodeUltrasoundRegion, + parseUltrasoundRegionFromBlob, + unitToMm, + US_UNIT_CENTIMETERS, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; + +const u16LE = (v: number) => { + const b = new Uint8Array(2); + new DataView(b.buffer).setUint16(0, v, true); + return b; +}; + +const f64LE = (v: number) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setFloat64(0, v, true); + return b; +}; + +const concat = (parts: Uint8Array[]) => { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + parts.forEach((p) => { + out.set(p, offset); + offset += p.length; + }); + return out; +}; + +// Builds an explicit-VR-LE data element for a VR with 2-byte length (US/FD/UI/etc). +const shortVRElement = ( + group: number, + element: number, + vr: string, + value: Uint8Array +) => { + const header = new Uint8Array(8); + const dv = new DataView(header.buffer); + dv.setUint16(0, group, true); + dv.setUint16(2, element, true); + header[4] = vr.charCodeAt(0); + header[5] = vr.charCodeAt(1); + dv.setUint16(6, value.length, true); + return concat([header, value]); +}; + +type Item = { group: number; element: number; vr: string; value: Uint8Array }; + +// Fake parsed sequence data mirroring what readSequenceValue emits. +const fakeSequenceData = (items: Item[][]): DataElement['data'] => + items.map( + (elements) => + elements.map((e) => ({ + group: e.group, + element: e.element, + vr: e.vr, + length: e.value.length, + data: e.value, + })) as DataElement[] + ); + +const wellFormedItem: Item[] = [ + { + group: 0x0018, + element: 0x6024, + vr: 'US', + value: u16LE(US_UNIT_CENTIMETERS), + }, + { + group: 0x0018, + element: 0x6026, + vr: 'US', + value: u16LE(US_UNIT_CENTIMETERS), + }, + { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(0.05) }, + { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(0.1) }, +]; + +describe('decodeUltrasoundRegion', () => { + it('decodes the first item of the sequence', () => { + const result = decodeUltrasoundRegion(fakeSequenceData([wellFormedItem])); + expect(result).toEqual({ + region: { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }, + regionCount: 1, + }); + }); + + it('returns a null region with zero count when the sequence is empty', () => { + expect(decodeUltrasoundRegion([])).toEqual({ + region: null, + regionCount: 0, + }); + }); + + it('returns a null region with zero count when the data is not a sequence', () => { + expect(decodeUltrasoundRegion(undefined)).toEqual({ + region: null, + regionCount: 0, + }); + expect(decodeUltrasoundRegion(new Uint8Array(4))).toEqual({ + region: null, + regionCount: 0, + }); + }); + + it('returns a null region but reports the count when a required field is missing', () => { + const missingDeltaY = wellFormedItem.filter( + (e) => !(e.group === 0x0018 && e.element === 0x602e) + ); + expect(decodeUltrasoundRegion(fakeSequenceData([missingDeltaY]))).toEqual({ + region: null, + regionCount: 1, + }); + }); + + it('decodes only the first item but reports the total count', () => { + const second: Item[] = [ + { group: 0x0018, element: 0x6024, vr: 'US', value: u16LE(0) }, + { group: 0x0018, element: 0x6026, vr: 'US', value: u16LE(0) }, + { group: 0x0018, element: 0x602c, vr: 'FD', value: f64LE(999) }, + { group: 0x0018, element: 0x602e, vr: 'FD', value: f64LE(999) }, + ]; + const result = decodeUltrasoundRegion( + fakeSequenceData([wellFormedItem, second]) + ); + expect(result.region?.physicalDeltaX).toBe(0.05); + expect(result.regionCount).toBe(2); + }); +}); + +describe('unitToMm', () => { + it('returns 10 for centimetres (code 3)', () => { + expect(unitToMm(US_UNIT_CENTIMETERS)).toBe(10); + }); + + it('returns null for non-spatial unit codes', () => { + // Per DICOM PS3.3 C.8.5.5.1.15: 0=none, 1=percent, 2=dB, 4=seconds, + // 5=hertz, 6=dB/sec, 7=cm/sec, 8=cm², 9=cm²/sec, 10=degrees. + [0, 1, 2, 4, 5, 6, 7, 8, 9, 10].forEach((code) => { + expect(unitToMm(code)).toBeNull(); + }); + }); +}); + +// Builds a minimal DICOM P10 byte stream containing only what the ultrasound +// parser needs: preamble, DICM magic, a TransferSyntaxUID (Explicit VR LE), +// and a SequenceOfUltrasoundRegions with one populated item. +const buildDicomBlob = (item: Item[]) => { + const preamble = new Uint8Array(128); + const magic = new TextEncoder().encode('DICM'); + + // File Meta Info: just TransferSyntaxUID. The parser exits the meta block + // as soon as it peeks a non-0x0002 group, so FileMetaInformationGroupLength + // is not required here. + const tsxValue = new TextEncoder().encode('1.2.840.10008.1.2.1\0'); + const tsx = shortVRElement(0x0002, 0x0010, 'UI', tsxValue); + + const itemBody = concat( + item.map((e) => shortVRElement(e.group, e.element, e.vr, e.value)) + ); + + // Item header: (fffe,e000) tag + 4-byte length. + const itemHeader = new Uint8Array(8); + const ivh = new DataView(itemHeader.buffer); + ivh.setUint16(0, 0xfffe, true); + ivh.setUint16(2, 0xe000, true); + ivh.setUint32(4, itemBody.length, true); + + const sequenceBody = concat([itemHeader, itemBody]); + + // SQ header: tag + "SQ" + 2 reserved + 4-byte length. + const sqHeader = new Uint8Array(12); + const sqh = new DataView(sqHeader.buffer); + sqh.setUint16(0, 0x0018, true); + sqh.setUint16(2, 0x6011, true); + sqHeader[4] = 'S'.charCodeAt(0); + sqHeader[5] = 'Q'.charCodeAt(0); + sqh.setUint32(8, sequenceBody.length, true); + + // Pixel data tag so the parser reaches its stop condition cleanly. + const pixelDataTag = new Uint8Array(4); + new DataView(pixelDataTag.buffer).setUint16(0, 0x7fe0, true); + new DataView(pixelDataTag.buffer).setUint16(2, 0x0010, true); + + return new Blob([ + concat([preamble, magic, tsx, sqHeader, sequenceBody, pixelDataTag]), + ]); +}; + +describe('parseUltrasoundRegionFromBlob', () => { + it('extracts the region and count from a synthetic DICOM blob', async () => { + const blob = buildDicomBlob(wellFormedItem); + const result = await parseUltrasoundRegionFromBlob(blob); + expect(result).toEqual({ + region: { + physicalDeltaX: 0.05, + physicalDeltaY: 0.1, + physicalUnitsXDirection: US_UNIT_CENTIMETERS, + physicalUnitsYDirection: US_UNIT_CENTIMETERS, + }, + regionCount: 1, + }); + }); + + it('returns null when the blob has no SequenceOfUltrasoundRegions', async () => { + // Build a blob with only the TransferSyntaxUID + pixel data tag. + const preamble = new Uint8Array(128); + const magic = new TextEncoder().encode('DICM'); + const tsxValue = new TextEncoder().encode('1.2.840.10008.1.2.1\0'); + const tsx = shortVRElement(0x0002, 0x0010, 'UI', tsxValue); + const pixelDataTag = new Uint8Array(4); + new DataView(pixelDataTag.buffer).setUint16(0, 0x7fe0, true); + new DataView(pixelDataTag.buffer).setUint16(2, 0x0010, true); + const blob = new Blob([concat([preamble, magic, tsx, pixelDataTag])]); + + expect(await parseUltrasoundRegionFromBlob(blob)).toBeNull(); + }); +}); diff --git a/src/core/streaming/dicom/dicomFileMetaLoader.ts b/src/core/streaming/dicom/dicomFileMetaLoader.ts index ec6adab43..ee387420a 100644 --- a/src/core/streaming/dicom/dicomFileMetaLoader.ts +++ b/src/core/streaming/dicom/dicomFileMetaLoader.ts @@ -1,9 +1,15 @@ import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoader'; import { MetaLoader } from '@/src/core/streaming/types'; import { Maybe } from '@/src/types'; +import { Tags } from '@/src/core/dicomTags'; +import { + parseUltrasoundRegionFromBlob, + UltrasoundRegions, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; export class DicomFileMetaLoader implements MetaLoader { public tags: Maybe>; + public ultrasoundRegions: UltrasoundRegions | null = null; private file: File; constructor( @@ -24,6 +30,11 @@ export class DicomFileMetaLoader implements MetaLoader { async load() { if (this.tags) return; this.tags = await this.readDicomTags(this.file); + + const modality = new Map(this.tags).get(Tags.Modality)?.trim(); + if (modality === 'US') { + this.ultrasoundRegions = await parseUltrasoundRegionFromBlob(this.file); + } } stop() { diff --git a/src/core/streaming/dicom/dicomMetaLoader.ts b/src/core/streaming/dicom/dicomMetaLoader.ts index 2be42d2c1..feb10198c 100644 --- a/src/core/streaming/dicom/dicomMetaLoader.ts +++ b/src/core/streaming/dicom/dicomMetaLoader.ts @@ -9,6 +9,11 @@ import { Awaitable } from '@vueuse/core'; import { toAscii } from '@/src/utils'; import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; import { Tags } from '@/src/core/dicomTags'; +import { + decodeUltrasoundRegion, + SEQUENCE_OF_ULTRASOUND_REGIONS, + UltrasoundRegions, +} from '@/src/core/streaming/dicom/ultrasoundRegion'; export type ReadDicomTagsFunction = ( file: File @@ -28,6 +33,7 @@ export class DicomMetaLoader implements MetaLoader { private fetcher: Fetcher; private readDicomTags: ReadDicomTagsFunction; private blob: Blob | null; + public ultrasoundRegions: UltrasoundRegions | null = null; constructor(fetcher: Fetcher, readDicomTags: ReadDicomTagsFunction) { this.fetcher = fetcher; @@ -51,6 +57,7 @@ export class DicomMetaLoader implements MetaLoader { let explicitVr = true; let dicomUpToPixelDataIdx = -1; let modality: string | undefined; + let ultrasoundRegions: UltrasoundRegions | null = null; const parse = createDicomParser({ stopAtElement(group, element) { @@ -66,6 +73,13 @@ export class DicomMetaLoader implements MetaLoader { if (el.group === 0x0008 && el.element === 0x0060 && el.data) { modality = toAscii(el.data as Uint8Array).trim(); } + if ( + el.group === SEQUENCE_OF_ULTRASOUND_REGIONS[0] && + el.element === SEQUENCE_OF_ULTRASOUND_REGIONS[1] && + !ultrasoundRegions + ) { + ultrasoundRegions = decodeUltrasoundRegion(el.data); + } }, }); @@ -115,6 +129,10 @@ export class DicomMetaLoader implements MetaLoader { const metadataFile = new File([validPixelDataBlob], 'file.dcm'); this.tags = await this.readDicomTags(metadataFile); + + if (modality === 'US' && ultrasoundRegions) { + this.ultrasoundRegions = ultrasoundRegions; + } } stop() { diff --git a/src/core/streaming/dicom/ultrasoundRegion.ts b/src/core/streaming/dicom/ultrasoundRegion.ts new file mode 100644 index 000000000..2b0f1ce45 --- /dev/null +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -0,0 +1,144 @@ +import { + createDicomParser, + DataElement, +} from '@/src/core/streaming/dicom/dicomParser'; +import { Tags, tagToGroupElement } from '@/src/core/dicomTags'; + +// DICOM unit codes for PhysicalUnitsXDirection / YDirection. +// See DICOM PS3.3 C.8.5.5.1.15. The only spatial spacing code defined for +// this field is 3 (cm). Other codes (0=none, 1=percent, 2=dB, 4=seconds, +// 5=hertz, 6=dB/seconds, 7=cm/sec, 8=cm², 9=cm²/sec, A=degrees) are time, +// frequency, velocity, area, or angle, so they are not converted to a VTK +// image spacing. +export const US_UNIT_CENTIMETERS = 3; + +// Returns the multiplier that converts a physical-delta value in the given +// unit to millimetres, or null when the unit is not a spatial spacing. +export const unitToMm = (code: number): number | null => { + if (code === US_UNIT_CENTIMETERS) return 10; + return null; +}; + +export type UltrasoundRegion = { + physicalDeltaX: number; + physicalDeltaY: number; + physicalUnitsXDirection: number; + physicalUnitsYDirection: number; +}; + +// First-region spacing plus the total number of regions in the source +// SequenceOfUltrasoundRegions. Multi-region images (e.g. dual-pane B-mode + +// Doppler) cannot be fully represented with a single VTK image spacing, so +// we expose the count to let callers warn about partial support. +export type UltrasoundRegions = { + region: UltrasoundRegion | null; + regionCount: number; +}; + +export const SEQUENCE_OF_ULTRASOUND_REGIONS = tagToGroupElement( + Tags.SequenceOfUltrasoundRegions +); +const PHYSICAL_DELTA_X = tagToGroupElement(Tags.PhysicalDeltaX); +const PHYSICAL_DELTA_Y = tagToGroupElement(Tags.PhysicalDeltaY); +const PHYSICAL_UNITS_X_DIRECTION = tagToGroupElement( + Tags.PhysicalUnitsXDirection +); +const PHYSICAL_UNITS_Y_DIRECTION = tagToGroupElement( + Tags.PhysicalUnitsYDirection +); + +const isTag = (el: DataElement, [group, element]: [number, number]) => + el.group === group && el.element === element; + +const readFloat64LE = (bytes: Uint8Array) => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64( + 0, + true + ); + +const readUint16LE = (bytes: Uint8Array) => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16( + 0, + true + ); + +/** + * Decodes the first item of a SequenceOfUltrasoundRegions element and + * reports the total number of regions found. The first region's spacing + * is what gets applied to the VTK image; the count lets callers warn when + * additional regions exist (multi-region images are only partially + * supported because VTK image data has a single global spacing). + */ +export function decodeUltrasoundRegion( + sequenceData: DataElement['data'] +): UltrasoundRegions { + if (!Array.isArray(sequenceData) || sequenceData.length === 0) { + return { region: null, regionCount: 0 }; + } + const [firstItem] = sequenceData; + + const findBytes = (target: [number, number]) => { + const el = firstItem.find((inner) => isTag(inner, target)); + if (!el || !(el.data instanceof Uint8Array)) return null; + return el.data; + }; + + const deltaXBytes = findBytes(PHYSICAL_DELTA_X); + const deltaYBytes = findBytes(PHYSICAL_DELTA_Y); + const unitsXBytes = findBytes(PHYSICAL_UNITS_X_DIRECTION); + const unitsYBytes = findBytes(PHYSICAL_UNITS_Y_DIRECTION); + + const regionCount = sequenceData.length; + if (!deltaXBytes || !deltaYBytes || !unitsXBytes || !unitsYBytes) { + return { region: null, regionCount }; + } + + return { + region: { + physicalDeltaX: readFloat64LE(deltaXBytes), + physicalDeltaY: readFloat64LE(deltaYBytes), + physicalUnitsXDirection: readUint16LE(unitsXBytes), + physicalUnitsYDirection: readUint16LE(unitsYBytes), + }, + regionCount, + }; +} + +/** + * Parses a DICOM blob and returns the first ultrasound region plus the + * total region count, if a SequenceOfUltrasoundRegions is present. + */ +export async function parseUltrasoundRegionFromBlob( + blob: Blob +): Promise { + let regions: UltrasoundRegions | null = null; + + const parse = createDicomParser({ + stopAtElement(group, element) { + return group === 0x7fe0 && element === 0x0010; + }, + onDataElement(el) { + if (regions) return; + if (isTag(el, SEQUENCE_OF_ULTRASOUND_REGIONS)) { + regions = decodeUltrasoundRegion(el.data); + } + }, + }); + + const stream = blob.stream(); + const reader = stream.getReader(); + try { + while (!regions) { + const { value, done } = await reader.read(); + if (done) break; + const result = parse(value); + if (result.done) break; + } + } catch { + return null; + } finally { + reader.releaseLock(); + } + + return regions; +} diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 2e9904b65..f671b4f10 100644 --- a/src/core/streaming/dicomChunkImage.ts +++ b/src/core/streaming/dicomChunkImage.ts @@ -26,6 +26,7 @@ import { import { ensureError } from '@/src/utils'; import { computed } from 'vue'; import vtkITKHelper from '@kitware/vtk.js/Common/DataModel/ITKHelper'; +import { unitToMm } from '@/src/core/streaming/dicom/ultrasoundRegion'; const { fastComputeRange } = vtkDataArray; @@ -279,6 +280,36 @@ export default class DicomChunkImage private reallocateImage() { this.vtkImageData.value.delete(); this.vtkImageData.value = allocateImageFromChunks(this.chunks); + this.applyUltrasoundSpacing(); + } + + private applyUltrasoundSpacing() { + if (this.getModality() !== 'US') return; + + const regions = this.chunks[0]?.ultrasoundRegions ?? null; + if (!regions?.region) return; + + // VTK image data has a single global spacing, so multi-region images + // (e.g. dual-pane B-mode + Doppler) cannot be fully represented. The + // first region's spacing is applied to the whole image; warn so the + // mismatch on additional panes is at least visible in the console. + if (regions.regionCount > 1) { + console.warn( + `Ultrasound image has ${regions.regionCount} regions; only the first region's physical spacing is applied. Multi-region (e.g. dual-pane B-mode + Doppler) ultrasound is not fully supported.` + ); + } + + const { region } = regions; + const xFactor = unitToMm(region.physicalUnitsXDirection); + const yFactor = unitToMm(region.physicalUnitsYDirection); + if (xFactor === null || yFactor === null) return; + + const [, , zSpacing] = this.vtkImageData.value.getSpacing(); + this.vtkImageData.value.setSpacing([ + region.physicalDeltaX * xFactor, + region.physicalDeltaY * yFactor, + zSpacing, + ]); } private updateDataRangeFromChunks() { diff --git a/src/core/streaming/types.ts b/src/core/streaming/types.ts index d34d5ec3b..a79c26fc4 100644 --- a/src/core/streaming/types.ts +++ b/src/core/streaming/types.ts @@ -1,5 +1,6 @@ import { Maybe } from '@/src/types'; import { Awaitable } from '@vueuse/core'; +import type { UltrasoundRegions } from '@/src/core/streaming/dicom/ultrasoundRegion'; export type LoaderEvents = { error: any; @@ -17,6 +18,7 @@ interface Loader { export interface MetaLoader extends Loader { meta: Maybe>; metaBlob: Maybe; + ultrasoundRegions?: UltrasoundRegions | null; } /** diff --git a/tests/specs/configTestUtils.ts b/tests/specs/configTestUtils.ts index 0fff6a9cd..6e045dfb2 100644 --- a/tests/specs/configTestUtils.ts +++ b/tests/specs/configTestUtils.ts @@ -73,6 +73,14 @@ export const FETUS_DATASET = { name: 'fetus.zip', } as const; +// Multiframe ultrasound DICOM from pydicom public test data. +// SequenceOfUltrasoundRegions: PhysicalDeltaX/Y = 0.05104970559 cm/pixel +// (unit code 3 = cm), so with US spacing fix the VTK spacing is ~0.5105 mm. +export const US_MULTIFRAME_DICOM = { + url: 'https://data.kitware.com/api/v1/file/69e1630646ef98a20f563020/download', + name: 'US_multiframe_30frames.dcm', +} as const; + export type DatasetResource = { url: string; name?: string; diff --git a/tests/specs/ultrasound-spacing.e2e.ts b/tests/specs/ultrasound-spacing.e2e.ts new file mode 100644 index 000000000..e48f84b3a --- /dev/null +++ b/tests/specs/ultrasound-spacing.e2e.ts @@ -0,0 +1,92 @@ +import { US_MULTIFRAME_DICOM } from './configTestUtils'; +import { openUrls } from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; + +const clickAt = async (x: number, y: number) => { + await browser + .action('pointer') + .move({ x: Math.round(x), y: Math.round(y) }) + .down() + .up() + .perform(); +}; + +// Offset between the two ruler clicks (in canvas pixels). +// The measured ruler length in mm depends on this offset, the canvas size, +// and the image spacing. With the US spacing fix the VTK spacing comes from +// SequenceOfUltrasoundRegions (~0.5105 mm); without the fix it falls back to +// 1.0 mm, which makes the measured length ~1.96x larger. +const CLICK_DX = 0; +const CLICK_DY = 100; + +// Calibrated length (mm) that the ruler reports when the US spacing fix is +// active. Obtained by running this test once with the fix enabled. +// Without the fix the VTK spacing falls back to 1.0 mm/pixel, which makes +// the measured length grow to ~97 mm (~1.96x) and this assertion fails. +const EXPECTED_LENGTH_MM = 49.35; +const LENGTH_TOLERANCE_MM = 1.5; + +describe('Ultrasound image spacing', () => { + it('ruler length reflects physical spacing from SequenceOfUltrasoundRegions', async () => { + await openUrls([US_MULTIFRAME_DICOM]); + + // Activate the ruler tool + const rulerBtn = await $('button span i[class~=mdi-ruler]'); + await rulerBtn.waitForClickable(); + await rulerBtn.click(); + + // Place the ruler on the first view's canvas + const views = await volViewPage.views; + const canvas = views[0]; + const loc = await canvas.getLocation(); + const size = await canvas.getSize(); + const cx = loc.x + size.width / 2; + const cy = loc.y + size.height / 2; + + await clickAt(cx - CLICK_DX / 2, cy - CLICK_DY / 2); + await clickAt(cx + CLICK_DX / 2, cy + CLICK_DY / 2); + + // Open Annotations > Measurements to read the ruler length + const annotationsTab = await $( + 'button[data-testid="module-tab-Annotations"]' + ); + await annotationsTab.click(); + + const measurementsTab = await $('button.v-tab*=Measurements'); + await measurementsTab.waitForClickable(); + await measurementsTab.click(); + + // The ruler details panel renders `{value}mm`; read the first length. + let lengthMm = 0; + await browser.waitUntil( + async () => { + const spans = await $$('.v-list-item .value'); + for (const span of spans) { + const text = await span.getText(); + const match = text.match(/([\d.]+)\s*mm/); + if (match) { + lengthMm = parseFloat(match[1]); + return lengthMm > 0; + } + } + return false; + }, + { + timeout: 10_000, + timeoutMsg: 'Ruler length (mm) not found in measurements sidebar', + } + ); + + console.log(`[ultrasound-spacing] measured ruler length: ${lengthMm} mm`); + + if (EXPECTED_LENGTH_MM > 0) { + expect(lengthMm).toBeGreaterThan( + EXPECTED_LENGTH_MM - LENGTH_TOLERANCE_MM + ); + expect(lengthMm).toBeLessThan(EXPECTED_LENGTH_MM + LENGTH_TOLERANCE_MM); + } else { + // Calibration mode: any positive value passes, actual number is logged. + expect(lengthMm).toBeGreaterThan(0); + } + }); +});