diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewReferenceController.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewReferenceController.ts index b24e2f3da7..b0a7425117 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewReferenceController.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewReferenceController.ts @@ -17,6 +17,7 @@ import { } from '../genericViewportReferenceCompatibility'; import type { LoadedData } from '../ViewportArchitectureTypes'; import type { PlanarRendering } from './planarRuntimeTypes'; +import { getVolumeImageIdIndexWorldPoint } from './planarSliceBasis'; import { getPlanarReferencedImageId, getPlanarViewReference, @@ -451,7 +452,7 @@ class PlanarViewReferenceController { const isOppositeNormal = !shouldReorient && this.areNormalsOpposite(currentViewPlaneNormal, effectiveViewPlaneNormal); - const nextImageIdIndex = this.resolveVolumeReferenceImageIdIndex( + const sliceTarget = this.resolveVolumeReferenceSliceTarget( referenceContext, normalizedViewRef, effectiveViewPlaneNormal, @@ -472,8 +473,11 @@ class PlanarViewReferenceController { const sliceWorldPoint = normalizedViewRef.cameraFocalPoint ?? - (typeof nextImageIdIndex === 'number' - ? this.host.getVolumeSliceWorldPointForImageIdIndex(nextImageIdIndex) + sliceTarget?.sliceWorldPoint ?? + (typeof sliceTarget?.imageIdIndex === 'number' + ? this.host.getVolumeSliceWorldPointForImageIdIndex( + sliceTarget.imageIdIndex + ) : undefined); if (sliceWorldPoint) { @@ -609,12 +613,27 @@ class PlanarViewReferenceController { ); } - private resolveVolumeReferenceImageIdIndex( + /** + * Resolves the slice a volume view reference targets, in one of two forms: + * + * - `sliceIndex`-based references address the CAMERA (scroll) ordering — + * the domain of `getResolvedView({ sliceIndex })` / the slice basis — and + * resolve to an `imageIdIndex` in that domain (with the opposite-normal + * flip preserved). + * - imageId-anchored references (`referencedImageId` / a plane point + * snapped via `getClosestImageId`) address the volume's imageId LIST + * ordering, which runs along the scan axis — for the acquisition + * orientation that is the OPPOSITE of the camera ordering. Those resolve + * directly to the slice's exact world center (`sliceWorldPoint`), which + * is ordering-independent; feeding the list index into the camera-order + * basis instead would navigate to the mirrored slice. + */ + private resolveVolumeReferenceSliceTarget( referenceContext: PlanarReferenceContext, viewRef: ViewReference, effectiveViewPlaneNormal: Point3, isOppositeNormal: boolean - ): number | undefined { + ): { imageIdIndex?: number; sliceWorldPoint?: Point3 } | undefined { const { rendering } = referenceContext; if ( @@ -636,7 +655,9 @@ class PlanarViewReferenceController { ? maxImageIdIndex - viewRef.sliceIndex : viewRef.sliceIndex; - return Math.min(Math.max(0, targetSliceIndex), maxImageIdIndex); + return { + imageIdIndex: Math.min(Math.max(0, targetSliceIndex), maxImageIdIndex), + }; } const referencedImageIndex = this.findImageIdIndexByReference( @@ -646,7 +667,10 @@ class PlanarViewReferenceController { ); if (typeof referencedImageIndex === 'number') { - return referencedImageIndex; + return this.toVolumeSliceTarget( + rendering.imageVolume, + referencedImageIndex + ); } const targetPoint = @@ -658,15 +682,51 @@ class PlanarViewReferenceController { targetPoint, effectiveViewPlaneNormal ); + const closestImageIndex = this.findImageIdIndexByReference( + imageIds, + referencedImageId + ); + + if (typeof closestImageIndex === 'number') { + return this.toVolumeSliceTarget( + rendering.imageVolume, + closestImageIndex + ); + } - return this.findImageIdIndexByReference(imageIds, referencedImageId); + return; } if (typeof viewRef.sliceIndex === 'number') { - return Math.min(Math.max(0, viewRef.sliceIndex), maxImageIdIndex); + return { + imageIdIndex: Math.min( + Math.max(0, viewRef.sliceIndex), + maxImageIdIndex + ), + }; } } + /** + * Converts an index into the volume's imageId list to a slice target. The + * exact world center is preferred; when the volume has no vtkImageData to + * compute it from, falls back to treating the index as a camera-order + * index (the previous behavior). + */ + private toVolumeSliceTarget( + imageVolume: Parameters[0], + imageIdListIndex: number + ): { imageIdIndex?: number; sliceWorldPoint?: Point3 } { + const sliceWorldPoint = getVolumeImageIdIndexWorldPoint( + imageVolume, + imageIdListIndex + ); + + return sliceWorldPoint + ? { sliceWorldPoint } + : { imageIdIndex: imageIdListIndex }; + } + private getImageIdsForReferenceContext( referenceContext: PlanarReferenceContext ): string[] { diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts index 824e9754ec..7449803a4b 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts @@ -86,6 +86,7 @@ import { import { createPlanarCpuVolumeSliceBasis, createPlanarVolumeSliceBasis, + getVolumeImageIdIndexWorldPoint, } from './planarSliceBasis'; import type { PlanarRendering } from './planarRuntimeTypes'; import PlanarMountedData from './PlanarMountedData'; @@ -2160,6 +2161,26 @@ class PlanarViewport extends GenericViewport< orientation === OrientationAxis.ACQUISITION ? planarData.initialImageIdIndex : undefined; + + // An explicit initial slice indexes payload.imageIds (= the volume's + // imageId list), so anchor it at that slice's exact IJK center. Feeding the + // index into the slice basis instead would count it in camera order — the + // acquisition normal is the negated scan axis — and open on the mirrored + // slice. + if (typeof initialImageIdIndex === 'number') { + const sliceWorldPoint = getVolumeImageIdIndexWorldPoint( + planarData.imageVolume, + initialImageIdIndex + ); + + if (sliceWorldPoint) { + return { + kind: 'volumePoint', + sliceWorldPoint, + }; + } + } + const { sliceBasis } = createSliceBasis({ canvasHeight: height, canvasWidth: width, diff --git a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarSliceBasis.ts b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarSliceBasis.ts index 0e671ba44a..9f078a242a 100644 --- a/packages/core/src/RenderingEngine/GenericViewport/Planar/planarSliceBasis.ts +++ b/packages/core/src/RenderingEngine/GenericViewport/Planar/planarSliceBasis.ts @@ -320,6 +320,39 @@ function getGeometricImageVolumeCenter( return vec3.scale(center, center, 1 / corners.length) as Point3; } +/** + * World-space center of the volume slice that backs `imageVolume.imageIds[k]`. + * + * Volumes are constructed so `imageIds[k]` is IJK slice k, so the exact slice + * center is `indexToWorld([centerI, centerJ, k])`. This is the ordering-safe + * way to anchor a slice for an index expressed in the volume's imageId list: + * the alternative — walking `min + k * spacing` along the viewPlaneNormal in + * `buildPlanarVolumeSliceBasis` — counts slices in CAMERA order, and for the + * acquisition orientation the camera normal is the negated scan axis, so an + * imageId-list index fed into that walk lands on the mirrored slice (and on + * oblique volumes drifts off slice centers, since the corner projections span + * the voxel bounding box rather than slice centers). + */ +export function getVolumeImageIdIndexWorldPoint( + imageVolume: IImageVolume | undefined, + imageIdIndex: number +): Point3 | undefined { + const imageData = imageVolume?.imageData; + + if (!imageData) { + return; + } + + const dimensions = imageData.getDimensions(); + const k = Math.min(Math.max(0, imageIdIndex), dimensions[2] - 1); + + return imageData.indexToWorld([ + (dimensions[0] - 1) / 2, + (dimensions[1] - 1) / 2, + k, + ]) as Point3; +} + /** * Computes the min/max projections of a volume's corners onto the * viewPlaneNormal, along with the spacing between adjacent slices and diff --git a/packages/core/test/planarSliceBasis.jest.js b/packages/core/test/planarSliceBasis.jest.js index 075032b535..a03544dd57 100644 --- a/packages/core/test/planarSliceBasis.jest.js +++ b/packages/core/test/planarSliceBasis.jest.js @@ -15,6 +15,7 @@ import { createPlanarCpuImageSliceBasis, createPlanarVolumeSliceBasis, createPlanarCpuVolumeSliceBasis, + getVolumeImageIdIndexWorldPoint, resolvePlanarVolumeImageIdIndex, shouldUsePlanarCpuVolumeSliceBasis, } from '../src/RenderingEngine/GenericViewport/Planar/planarSliceBasis'; @@ -849,6 +850,49 @@ describe('resolvePlanarVolumeImageIdIndex', () => { }); }); +describe('getVolumeImageIdIndexWorldPoint', () => { + it('returns the exact IJK slice center for an imageId-list index', () => { + const volume = createOrthonormalVolume(); + + // imageIds[2] is IJK slice 2: indexToWorld([4.5, 3.5, 2]). + expectPoint3Close( + getVolumeImageIdIndexWorldPoint(volume, 2), + [102.25, 203.5, 304] + ); + }); + + it('follows the k axis wherever it points (flipped-Z volume)', () => { + const volume = createFlippedZVolume(); + + // The k axis is negated, so slice 2 sits BELOW the origin — the point + // tracks the volume geometry, not a world-axis or camera direction. + expectPoint3Close( + getVolumeImageIdIndexWorldPoint(volume, 2), + [102.25, 203.5, 296] + ); + }); + + it('clamps the index to the volume k range', () => { + const volume = createOrthonormalVolume(); + + expectPoint3Close( + getVolumeImageIdIndexWorldPoint(volume, 99), + [102.25, 203.5, 310] + ); + expectPoint3Close( + getVolumeImageIdIndexWorldPoint(volume, -3), + [102.25, 203.5, 300] + ); + }); + + it('returns undefined without vtkImageData', () => { + expect( + getVolumeImageIdIndexWorldPoint({ imageData: undefined }, 1) + ).toBeUndefined(); + expect(getVolumeImageIdIndexWorldPoint(undefined, 1)).toBeUndefined(); + }); +}); + describe('shouldUsePlanarCpuVolumeSliceBasis', () => { it('selects the CPU basis only for nearest-neighbor interpolation', () => { expect(shouldUsePlanarCpuVolumeSliceBasis(InterpolationType.NEAREST)).toBe( diff --git a/packages/core/test/planarViewReference.jest.js b/packages/core/test/planarViewReference.jest.js index b23ae45e0b..54e43cbf79 100644 --- a/packages/core/test/planarViewReference.jest.js +++ b/packages/core/test/planarViewReference.jest.js @@ -140,7 +140,12 @@ function configureMetaDataMock() { rowCosines: [1, 0, 0], frameOfReferenceUID: VOLUME_FOR, imageOrientationPatient: [1, 0, 0, 0, 1, 0], - imagePositionPatient: [0, 0, numSlices - 1 - index], + // Volumes are built so imageIds[k] IS IJK slice k: with the harness's + // identity direction and indexToWorld, imageIds[k] sits at world + // z = k. (A mirrored z here would model a volume whose imageId list + // runs against its own k axis, which createAndCacheVolume never + // produces, and would mask ordering bugs in index<->world code.) + imagePositionPatient: [0, 0, index], }; } @@ -378,6 +383,9 @@ describe('planarViewReference', () => { const data = createVolumeData(volume); const rendering = createVolumeRendering(volume, 2); + // A specifier sliceIndex addresses the CAMERA (scroll) ordering; for the + // axial normal [0, 0, -1] camera slice 4 of 5 sits at world z = 0, which + // is imageIds[0] (imageIds[k] sits at world z = k). expect( getPlanarReferencedImageId({ viewState: axialViewState(2), @@ -386,7 +394,7 @@ describe('planarViewReference', () => { renderContext, viewRefSpecifier: { sliceIndex: 4 }, }) - ).toBe(volume.imageIds[4]); + ).toBe(volume.imageIds[0]); }); }); @@ -522,7 +530,10 @@ describe('planarViewReference', () => { viewRefSpecifier: { sliceIndex: 4 }, }); - expect(viewRef.referencedImageId).toBe(volume.imageIds[4]); + // sliceIndex counts in CAMERA (scroll) order while referencedImageId + // names the image actually displayed there: camera slice 4 sits at + // world z = expectedAxialFocalZ(5, 4) = 0 = imageIds[0]. + expect(viewRef.referencedImageId).toBe(volume.imageIds[0]); expect(viewRef.sliceIndex).toBe(4); expect(viewRef.cameraFocalPoint[2]).toBeCloseTo( expectedAxialFocalZ(5, 4), @@ -1436,6 +1447,30 @@ describe('PlanarViewReferenceController', () => { }); }); + it('navigates a referencedImageId to that slice`s exact world center, not its camera-order mirror', () => { + const { harness, volume } = createHarness(); + + harness.controller.setViewReference({ + FrameOfReferenceUID: volume.metadata.FrameOfReferenceUID, + referencedImageId: volume.imageIds[1], + }); + + // imageIds[k] is IJK slice k, so the target is indexToWorld([1.5, 1.5, 1]) + // — computed from the volume geometry, NOT via the camera-order + // getVolumeSliceWorldPointForImageIdIndex walk (whose ordering runs + // against the imageId list for the acquisition/axial normal and would + // land on the mirrored slice). + expect( + harness.getVolumeSliceWorldPointForImageIdIndex + ).not.toHaveBeenCalled(); + expect(harness.getViewState().slice).toEqual({ + kind: 'volumePoint', + sliceWorldPoint: [1.5, 1.5, 1], + }); + // Round-trip: the viewport now reports the referenced image as current. + expect(harness.controller.getCurrentImageId()).toBe(volume.imageIds[1]); + }); + it('resolves a world focal point to the geometrically closest slice', () => { const { harness, volume } = createHarness(); @@ -1451,7 +1486,7 @@ describe('PlanarViewReferenceController', () => { sliceWorldPoint: [0, 0, 2], }); // The volume-point slice state should resolve, geometrically, back to - // imageId index 2 (z = numSlices - 1 - index = 5 - 1 - 2 = 2). + // imageId index 2 (imageIds[k] sits at world z = k). expect(harness.controller.getCurrentImageId()).toBe(volume.imageIds[2]); }); diff --git a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after-interaction.png b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after-interaction.png index c120987033..ba97c800bc 100644 Binary files a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after-interaction.png and b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after-interaction.png differ diff --git a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after.png b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after.png index c120987033..ba97c800bc 100644 Binary files a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after.png and b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-after.png differ diff --git a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-before.png b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-before.png index d1b9c92ef4..2aa2477da1 100644 Binary files a/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-before.png and b/tests/screenshots/chromium/MPRReformat.spec.ts/compat-mpr-reformat-before.png differ