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..0a31cdea5 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; + } + 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..8b4968529 --- /dev/null +++ b/src/core/streaming/dicom/__tests__/ultrasoundRegion.spec.ts @@ -0,0 +1,219 @@ +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 no region with zero count when the sequence is empty', () => { + expect(decodeUltrasoundRegion([])).toEqual({ regionCount: 0 }); + }); + + it('returns no region with zero count when the data is not a sequence', () => { + expect(decodeUltrasoundRegion(undefined)).toEqual({ regionCount: 0 }); + expect(decodeUltrasoundRegion(new Uint8Array(4))).toEqual({ + regionCount: 0, + }); + }); + + it('returns no 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({ + 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=cm³, + // 11=cm³/sec, 12=degrees. + [0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12].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 undefined 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)).toBeUndefined(); + }); +}); diff --git a/src/core/streaming/dicom/dicomFileMetaLoader.ts b/src/core/streaming/dicom/dicomFileMetaLoader.ts index ec6adab43..2f7a5c0c7 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 | undefined; 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..6f3690340 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 | undefined; 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 | undefined; const parse = createDicomParser({ stopAtElement(group, element) { @@ -66,6 +73,19 @@ 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 + ) { + // Decoding can throw if a malformed FD/US value has an unexpected + // length; swallow rather than abort the whole metadata load. + try { + ultrasoundRegions = decodeUltrasoundRegion(el.data); + } catch (err) { + console.warn('Failed to decode SequenceOfUltrasoundRegions:', err); + } + } }, }); @@ -115,6 +135,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..3fab3a12b --- /dev/null +++ b/src/core/streaming/dicom/ultrasoundRegion.ts @@ -0,0 +1,145 @@ +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/sec, 7=cm/sec, 8=cm², 9=cm²/sec, 10=cm³, 11=cm³/sec, +// 12=degrees) are time, frequency, velocity, area, volume, 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; + 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 { 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 undefined; + 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 { 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 | undefined; + + 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 (err) { + console.warn('Failed to parse SequenceOfUltrasoundRegions:', err); + return undefined; + } finally { + reader.releaseLock(); + } + + return regions; +} diff --git a/src/core/streaming/dicomChunkImage.ts b/src/core/streaming/dicomChunkImage.ts index 2e9904b65..a49d176e4 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,48 @@ 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; + + // Ultrasound DICOMs are typically a single multi-frame chunk, so the + // region table on chunk[0] applies to the whole image. If a US series + // ever spanned multiple chunks with differing regions, this would + // silently use the first chunk's spacing for all of them. + const regions = this.chunks[0]?.ultrasoundRegions; + 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); + // All-or-nothing: if either axis lacks a spatial unit (e.g. one axis is + // cm and the other is seconds, or unitless) the metadata can't be trusted + // as a 2D physical spacing, so leave the default 1mm fallback in place. + if (xFactor === null || yFactor === null) { + console.warn( + `Ultrasound spacing not applied: PhysicalUnitsXDirection=${region.physicalUnitsXDirection}, PhysicalUnitsYDirection=${region.physicalUnitsYDirection}; only code 3 (cm) is converted to mm.` + ); + 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..33cdce8b1 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; } /** 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..8253ebc4e --- /dev/null +++ b/tests/specs/ultrasound-spacing.e2e.ts @@ -0,0 +1,70 @@ +import { US_MULTIFRAME_DICOM } from './configTestUtils'; +import { openUrls } from './utils'; +import { volViewPage } from '../pageobjects/volview.page'; + +// Vertical ruler in canvas pixels. The reported length in mm depends on +// canvas size, image-fit zoom, and the applied spacing. With the fix the +// VTK spacing comes from SequenceOfUltrasoundRegions (~0.5105 mm/image-px); +// without the fix it falls back to 1 mm/image-px and the ruler reports +// roughly 1.96× the with-fix value. +const CLICK_DY = 100; + +// At the shared viewport (1200×800), with the fix active the ruler reports +// about 49 mm. Without the fix the same ruler reports about 97 mm. A wide +// tolerance lets the assertion absorb minor canvas-size jitter between +// runners while still excluding the 1 mm fallback. +const EXPECTED_LENGTH_MM = 49; +const LENGTH_TOLERANCE_MM = 8; + +describe('Ultrasound image spacing', () => { + it('ruler length reflects physical spacing from SequenceOfUltrasoundRegions', async () => { + await openUrls([US_MULTIFRAME_DICOM]); + await volViewPage.waitForViews(); + + const rulerBtn = await $('button span i[class~=mdi-ruler]'); + await rulerBtn.waitForClickable(); + await rulerBtn.click(); + + // element.click({ x, y }) offsets are measured from the element's center, + // so x:0 / y:0 is the canvas center. + const views = await volViewPage.views; + const canvas = views[0]; + + await canvas.click({ x: 0, y: -CLICK_DY / 2 }); + await canvas.click({ x: 0, y: CLICK_DY / 2 }); + + 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(); + + 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`); + + expect(lengthMm).toBeGreaterThan(EXPECTED_LENGTH_MM - LENGTH_TOLERANCE_MM); + expect(lengthMm).toBeLessThan(EXPECTED_LENGTH_MM + LENGTH_TOLERANCE_MM); + }); +});