Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../genericViewportReferenceCompatibility';
import type { LoadedData } from '../ViewportArchitectureTypes';
import type { PlanarRendering } from './planarRuntimeTypes';
import { getVolumeImageIdIndexWorldPoint } from './planarSliceBasis';
import {
getPlanarReferencedImageId,
getPlanarViewReference,
Expand Down Expand Up @@ -451,7 +452,7 @@ class PlanarViewReferenceController {
const isOppositeNormal =
!shouldReorient &&
this.areNormalsOpposite(currentViewPlaneNormal, effectiveViewPlaneNormal);
const nextImageIdIndex = this.resolveVolumeReferenceImageIdIndex(
const sliceTarget = this.resolveVolumeReferenceSliceTarget(
referenceContext,
normalizedViewRef,
effectiveViewPlaneNormal,
Expand All @@ -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);
Comment on lines 474 to 481

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prioritize the resolved slice target over the raw focal point.

Line 475 currently wins whenever a reference carries cameraFocalPoint, so imageId/plane-point targets computed by resolveVolumeReferenceSliceTarget are discarded rather than snapping to the exact IJK slice center. This leaves combined references on an arbitrary focal point instead of the intended slice anchor.

Proposed fix
 const sliceWorldPoint =
-  normalizedViewRef.cameraFocalPoint ??
   sliceTarget?.sliceWorldPoint ??
   (typeof sliceTarget?.imageIdIndex === 'number'
     ? this.host.getVolumeSliceWorldPointForImageIdIndex(
         sliceTarget.imageIdIndex
       )
-    : undefined);
+    : undefined) ??
+  normalizedViewRef.cameraFocalPoint;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sliceWorldPoint =
normalizedViewRef.cameraFocalPoint ??
(typeof nextImageIdIndex === 'number'
? this.host.getVolumeSliceWorldPointForImageIdIndex(nextImageIdIndex)
sliceTarget?.sliceWorldPoint ??
(typeof sliceTarget?.imageIdIndex === 'number'
? this.host.getVolumeSliceWorldPointForImageIdIndex(
sliceTarget.imageIdIndex
)
: undefined);
const sliceWorldPoint =
sliceTarget?.sliceWorldPoint ??
(typeof sliceTarget?.imageIdIndex === 'number'
? this.host.getVolumeSliceWorldPointForImageIdIndex(
sliceTarget.imageIdIndex
)
: undefined) ??
normalizedViewRef.cameraFocalPoint;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewReferenceController.ts`
around lines 474 - 481, Update the sliceWorldPoint resolution in the relevant
controller method to prioritize the resolved sliceTarget anchor
(sliceWorldPoint, then imageIdIndex-derived point) before falling back to
normalizedViewRef.cameraFocalPoint. This ensures
resolveVolumeReferenceSliceTarget results determine the exact slice center for
combined references.


if (sliceWorldPoint) {
Expand Down Expand Up @@ -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 (
Expand All @@ -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(
Expand All @@ -646,7 +667,10 @@ class PlanarViewReferenceController {
);

if (typeof referencedImageIndex === 'number') {
return referencedImageIndex;
return this.toVolumeSliceTarget(
rendering.imageVolume,
referencedImageIndex
);
}

const targetPoint =
Expand All @@ -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<typeof getVolumeImageIdIndexWorldPoint>[0],
imageIdListIndex: number
): { imageIdIndex?: number; sliceWorldPoint?: Point3 } {
const sliceWorldPoint = getVolumeImageIdIndexWorldPoint(
imageVolume,
imageIdListIndex
);

return sliceWorldPoint
? { sliceWorldPoint }
: { imageIdIndex: imageIdListIndex };
}

private getImageIdsForReferenceContext(
referenceContext: PlanarReferenceContext
): string[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
import {
createPlanarCpuVolumeSliceBasis,
createPlanarVolumeSliceBasis,
getVolumeImageIdIndexWorldPoint,
} from './planarSliceBasis';
import type { PlanarRendering } from './planarRuntimeTypes';
import PlanarMountedData from './PlanarMountedData';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/core/test/planarSliceBasis.jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createPlanarCpuImageSliceBasis,
createPlanarVolumeSliceBasis,
createPlanarCpuVolumeSliceBasis,
getVolumeImageIdIndexWorldPoint,
resolvePlanarVolumeImageIdIndex,
shouldUsePlanarCpuVolumeSliceBasis,
} from '../src/RenderingEngine/GenericViewport/Planar/planarSliceBasis';
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 39 additions & 4 deletions packages/core/test/planarViewReference.jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
};
}

Expand Down Expand Up @@ -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),
Expand All @@ -386,7 +394,7 @@ describe('planarViewReference', () => {
renderContext,
viewRefSpecifier: { sliceIndex: 4 },
})
).toBe(volume.imageIds[4]);
).toBe(volume.imageIds[0]);
});
});

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();

Expand All @@ -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]);
});

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading