Skip to content

Commit 5d1d7fd

Browse files
sedghiwayfarer3130
andauthored
feat(tools): new world crosshairs and intersect planes (#2781)
* init * feat(WorldCrosshairTool): enhance 3D crosshair rendering and viewport navigation - Introduced support for rendering the world crosshair as intersecting lines in Generic 3D viewports. - Added configuration options for 3D rendering, including line length and visibility settings. - Implemented navigation functionality to align the viewport slice with a specified world point for both volume-backed slices and image stacks. - Enhanced the WorldCrosshairTool to auto-initialize the crosshair point based on the current slice center. - Updated the rendering logic to accommodate new marker styles ('crosshair' and 'point') and improved off-slice display options. - Refactored viewport navigation logic to streamline interactions and improve usability. - Updated example descriptions to reflect new features and clarify tool functionalities. * fix(examples): enable clickToSet in world crosshair demos to match instructions The demos instruct users to click (empty space) to set the reference point, but WorldCrosshairTool defaults clickToSet to false, so a plain click did nothing without Shift. Pass clickToSet: true so the documented behavior works. In the combined demo, line-near clicks are still grabbed by SliceIntersectionTool before addNewAnnotation fires. Addresses CodeRabbit review threads on both example files. * fix(WorldCrosshairTool): skip centered anchor/render when navigation fails The centered jump branch called navigatePlanarViewportToPoint but ignored its return value, then unconditionally pinned anchorWorld/anchorCanvas and rendered. When navigation fails (e.g. a stack viewport with no valid closest image) this anchored the viewport to a point it never navigated to, producing a visibly wrong pan. Early-return on failure, matching the slice-only branch. Addresses CodeRabbit review thread. * fix(translateViewportAlongNormal): guard getViewportICamera against throw getViewportICamera can throw for viewports without a valid/rendered camera. Wrap it in try/catch returning false, mirroring the sibling getViewportPlane, so this exported utility is robust to a bad viewport regardless of caller. Addresses CodeRabbit review thread. * feat(WorldCrosshairTool): add MIP support for snapping reference points to hottest voxel * refactor(tools): apply review feedback for WorldCrosshair and SliceIntersection tools - Hoist the standard _activateModify/_deactivateModify modify-listener wiring into AnnotationTool as protected members; WorldCrosshairTool and SliceIntersectionTool inherit them, and the identical private copies in LivewireContourTool and SplineROITool are removed - Guard the direct getViewportICamera calls in _maybeAutoInitialize and _jumpViewportToPoint against unrendered viewports (same pattern as getViewportPlane) - setWorldPoint: regroup the annotation when the frameOfReferenceUID changes even if the point itself is unchanged - SliceIntersectionTool: cache plane groups and viewport-id lookups per synchronous render/interaction pass (cleared in a microtask), removing the repeated per-target enabled-element and camera resolves - Keep boundary/intermediate slice lines in their own boundaryLineSegments field instead of overloading slabLineSegments - Fix the 3D marker doc comments (three axis lines, not two) and document rotateViewportAroundWorldPoint as legacy-viewports-only --------- Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
1 parent e261793 commit 5d1d7fd

35 files changed

Lines changed: 8290 additions & 50 deletions

packages/core/src/utilities/scroll.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ export default function scroll(
4646
if (viewport instanceof VolumeViewport) {
4747
scrollVolume(viewport, volumeId, delta, scrollSlabs);
4848
} else {
49+
if (
50+
typeof (viewport as IStackViewport).getCurrentImageIdIndex !==
51+
'function' ||
52+
typeof (viewport as IStackViewport).scroll !== 'function'
53+
) {
54+
// Viewports without index-based scrolling (e.g. Generic 3D volume
55+
// rendering) ignore scroll requests instead of throwing.
56+
return;
57+
}
58+
4959
const imageIdIndex = viewport.getCurrentImageIdIndex();
5060

5161
if (
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { PlanarViewport, Types } from '@cornerstonejs/core';
2+
import {
3+
RenderingEngine,
4+
Enums,
5+
getRenderingEngine,
6+
utilities,
7+
} from '@cornerstonejs/core';
8+
import {
9+
initDemo,
10+
createImageIdsAndCacheMetaData,
11+
setTitleAndDescription,
12+
addManipulationBindings,
13+
ctVoiRange,
14+
getLocalUrl,
15+
} from '../../../../utils/demo/helpers';
16+
import * as cornerstoneTools from '@cornerstonejs/tools';
17+
18+
// This is for debugging purposes
19+
console.warn(
20+
'Click on index.ts to open source code for this example --------->'
21+
);
22+
23+
const {
24+
ToolGroupManager,
25+
Enums: csToolsEnums,
26+
SliceIntersectionTool,
27+
} = cornerstoneTools;
28+
29+
const { MouseBindings } = csToolsEnums;
30+
const { ViewportType, OrientationAxis } = Enums;
31+
32+
const volumeId = 'cornerstoneStreamingImageVolume:SLICE_INTERSECTION_CT';
33+
const mprDataId = 'sliceIntersections:ct-mpr';
34+
const toolGroupId = 'SLICE_INTERSECTION_TOOLGROUP_ID';
35+
const viewportIds = ['CT_AXIAL', 'CT_SAGITTAL', 'CT_CORONAL'];
36+
const orientations = [
37+
OrientationAxis.AXIAL,
38+
OrientationAxis.SAGITTAL,
39+
OrientationAxis.CORONAL,
40+
];
41+
const renderingEngineId = 'myRenderingEngine';
42+
43+
// ======== Set up page ======== //
44+
setTitleAndDescription(
45+
'Slice Intersections',
46+
'The SliceIntersectionTool renders one intersection line per plane (red = axial, yellow = sagittal, green = coronal) on native PLANAR_NEXT (generic) viewports. Every line is a true plane-plane intersection: there is no shared crosshair center. Drag a line to scroll that plane; use the handles to rotate it or change its slab thickness.'
47+
);
48+
49+
const size = '500px';
50+
const content = document.getElementById('content');
51+
const viewportGrid = document.createElement('div');
52+
53+
viewportGrid.style.display = 'flex';
54+
viewportGrid.style.flexDirection = 'row';
55+
56+
const elements = viewportIds.map(() => {
57+
const element = document.createElement('div');
58+
element.style.width = size;
59+
element.style.height = size;
60+
element.oncontextmenu = (e) => e.preventDefault();
61+
viewportGrid.appendChild(element);
62+
return element;
63+
});
64+
65+
content.appendChild(viewportGrid);
66+
67+
const instructions = document.createElement('p');
68+
instructions.innerText = `
69+
- Each viewport shows one line per other plane: red = axial, yellow = sagittal, green = coronal.
70+
- Drag an intersection line to scroll that plane (every viewport showing it follows).
71+
- Hover a line and drag its round handles (quarter points) to rotate the plane.
72+
- Drag the hollow handles near the line end to change the slab thickness (dashed boundary lines).
73+
`;
74+
75+
content.append(instructions);
76+
77+
/**
78+
* Runs the demo
79+
*/
80+
async function run() {
81+
await initDemo();
82+
83+
cornerstoneTools.addTool(SliceIntersectionTool);
84+
85+
const imageIds = await createImageIdsAndCacheMetaData({
86+
StudyInstanceUID:
87+
'1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463',
88+
SeriesInstanceUID:
89+
'1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561',
90+
wadoRsRoot:
91+
getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
92+
});
93+
94+
const renderingEngine = new RenderingEngine(renderingEngineId);
95+
96+
viewportIds.forEach((viewportId, index) => {
97+
renderingEngine.enableElement({
98+
viewportId,
99+
type: ViewportType.PLANAR_NEXT,
100+
element: elements[index],
101+
defaultOptions: {
102+
orientation: orientations[index],
103+
background: <Types.Point3>[0, 0, 0],
104+
},
105+
});
106+
});
107+
108+
// One shared volume display set across the three MPR viewports.
109+
utilities.genericViewportDisplaySetMetadataProvider.add(mprDataId, {
110+
imageIds,
111+
kind: 'planar',
112+
volumeId,
113+
initialImageIdIndex: Math.floor(imageIds.length / 2),
114+
});
115+
116+
await Promise.all(
117+
viewportIds.map((viewportId, index) => {
118+
const viewport = getRenderingEngine(renderingEngineId).getViewport(
119+
viewportId
120+
) as PlanarViewport;
121+
return viewport.setDisplaySets({
122+
displaySetId: mprDataId,
123+
options: { orientation: orientations[index] },
124+
});
125+
})
126+
);
127+
128+
viewportIds.forEach((viewportId) => {
129+
const viewport = getRenderingEngine(renderingEngineId).getViewport(
130+
viewportId
131+
) as PlanarViewport;
132+
viewport.setDisplaySetPresentation(mprDataId, { voiRange: ctVoiRange });
133+
});
134+
135+
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
136+
addManipulationBindings(toolGroup);
137+
138+
viewportIds.forEach((viewportId) => {
139+
toolGroup.addViewport(viewportId, renderingEngineId);
140+
});
141+
142+
toolGroup.addTool(SliceIntersectionTool.toolName);
143+
144+
toolGroup.setToolActive(SliceIntersectionTool.toolName, {
145+
bindings: [{ mouseButton: MouseBindings.Primary }],
146+
});
147+
148+
renderingEngine.render();
149+
}
150+
151+
run();
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { PlanarViewport, Types } from '@cornerstonejs/core';
2+
import {
3+
RenderingEngine,
4+
Enums,
5+
getRenderingEngine,
6+
utilities,
7+
} from '@cornerstonejs/core';
8+
import {
9+
initDemo,
10+
createImageIdsAndCacheMetaData,
11+
setTitleAndDescription,
12+
addManipulationBindings,
13+
addToggleButtonToToolbar,
14+
ctVoiRange,
15+
getLocalUrl,
16+
} from '../../../../utils/demo/helpers';
17+
import * as cornerstoneTools from '@cornerstonejs/tools';
18+
19+
// This is for debugging purposes
20+
console.warn(
21+
'Click on index.ts to open source code for this example --------->'
22+
);
23+
24+
const {
25+
ToolGroupManager,
26+
Enums: csToolsEnums,
27+
WorldCrosshairTool,
28+
} = cornerstoneTools;
29+
30+
const { MouseBindings } = csToolsEnums;
31+
const { ViewportType, OrientationAxis } = Enums;
32+
33+
const volumeId = 'cornerstoneStreamingImageVolume:TEN_VIEWPORT_CT';
34+
const mprDataId = 'tenViewportReferencePoint:ct-mpr';
35+
const toolGroupId = 'TEN_VIEWPORT_TOOLGROUP_ID';
36+
const renderingEngineId = 'myRenderingEngine';
37+
38+
// Ten viewports with mixed orientations.
39+
const viewportConfigs: Array<{
40+
viewportId: string;
41+
orientation: Enums.OrientationAxis;
42+
}> = [
43+
{ viewportId: 'AXIAL_1', orientation: OrientationAxis.AXIAL },
44+
{ viewportId: 'SAGITTAL_1', orientation: OrientationAxis.SAGITTAL },
45+
{ viewportId: 'CORONAL_1', orientation: OrientationAxis.CORONAL },
46+
{ viewportId: 'AXIAL_2', orientation: OrientationAxis.AXIAL },
47+
{ viewportId: 'SAGITTAL_2', orientation: OrientationAxis.SAGITTAL },
48+
{ viewportId: 'CORONAL_2', orientation: OrientationAxis.CORONAL },
49+
{ viewportId: 'AXIAL_3', orientation: OrientationAxis.AXIAL },
50+
{ viewportId: 'SAGITTAL_3', orientation: OrientationAxis.SAGITTAL },
51+
{ viewportId: 'CORONAL_3', orientation: OrientationAxis.CORONAL },
52+
{ viewportId: 'AXIAL_4', orientation: OrientationAxis.AXIAL },
53+
];
54+
55+
const viewportIds = viewportConfigs.map(({ viewportId }) => viewportId);
56+
57+
// ======== Set up page ======== //
58+
setTitleAndDescription(
59+
'Ten Viewport Reference Point',
60+
'A large grid of native PLANAR_NEXT (generic) viewports with the WorldCrosshairTool: one persistent world-space reference point appears in every linked viewport.'
61+
);
62+
63+
const size = '290px';
64+
const content = document.getElementById('content');
65+
const viewportGrid = document.createElement('div');
66+
67+
viewportGrid.style.display = 'grid';
68+
viewportGrid.style.gridTemplateColumns = `repeat(5, ${size})`;
69+
viewportGrid.style.gap = '2px';
70+
71+
const elements = viewportIds.map(() => {
72+
const element = document.createElement('div');
73+
element.style.width = size;
74+
element.style.height = size;
75+
element.oncontextmenu = (e) => e.preventDefault();
76+
viewportGrid.appendChild(element);
77+
return element;
78+
});
79+
80+
content.appendChild(viewportGrid);
81+
82+
const instructions = document.createElement('p');
83+
instructions.innerText = `
84+
- Click anywhere to set the reference point: it appears in all ten linked viewports (dashed when off-slice).
85+
- Scroll viewports: the point stays fixed in world space.
86+
`;
87+
88+
content.append(instructions);
89+
90+
addToggleButtonToToolbar({
91+
title: 'Center gap',
92+
defaultToggle: true,
93+
onClick: (toggle) => {
94+
const instance = ToolGroupManager.getToolGroup(
95+
toolGroupId
96+
)?.getToolInstance(WorldCrosshairTool.toolName);
97+
if (!instance) {
98+
return;
99+
}
100+
instance.configuration = {
101+
...instance.configuration,
102+
centerGapRadius: toggle ? 12 : 0,
103+
};
104+
cornerstoneTools.utilities.triggerAnnotationRenderForViewportIds(
105+
viewportIds
106+
);
107+
},
108+
});
109+
110+
/**
111+
* Runs the demo
112+
*/
113+
async function run() {
114+
await initDemo();
115+
116+
cornerstoneTools.addTool(WorldCrosshairTool);
117+
118+
const imageIds = await createImageIdsAndCacheMetaData({
119+
StudyInstanceUID:
120+
'1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463',
121+
SeriesInstanceUID:
122+
'1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561',
123+
wadoRsRoot:
124+
getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb',
125+
});
126+
127+
const renderingEngine = new RenderingEngine(renderingEngineId);
128+
129+
viewportConfigs.forEach(({ viewportId, orientation }, index) => {
130+
renderingEngine.enableElement({
131+
viewportId,
132+
type: ViewportType.PLANAR_NEXT,
133+
element: elements[index],
134+
defaultOptions: {
135+
orientation,
136+
background: <Types.Point3>[0, 0, 0],
137+
},
138+
});
139+
});
140+
141+
// One shared volume display set across all ten viewports.
142+
utilities.genericViewportDisplaySetMetadataProvider.add(mprDataId, {
143+
imageIds,
144+
kind: 'planar',
145+
volumeId,
146+
initialImageIdIndex: Math.floor(imageIds.length / 2),
147+
});
148+
149+
await Promise.all(
150+
viewportConfigs.map(({ viewportId, orientation }) => {
151+
const viewport = getRenderingEngine(renderingEngineId).getViewport(
152+
viewportId
153+
) as PlanarViewport;
154+
return viewport.setDisplaySets({
155+
displaySetId: mprDataId,
156+
options: { orientation },
157+
});
158+
})
159+
);
160+
161+
viewportIds.forEach((viewportId) => {
162+
const viewport = getRenderingEngine(renderingEngineId).getViewport(
163+
viewportId
164+
) as PlanarViewport;
165+
viewport.setDisplaySetPresentation(mprDataId, { voiRange: ctVoiRange });
166+
});
167+
168+
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
169+
addManipulationBindings(toolGroup);
170+
171+
viewportIds.forEach((viewportId) => {
172+
toolGroup.addViewport(viewportId, renderingEngineId);
173+
});
174+
175+
// WorldCrosshairTool first so empty-space clicks set the reference point
176+
// (classic click-to-set enabled for this demo).
177+
toolGroup.addTool(WorldCrosshairTool.toolName, {
178+
clickToSet: true,
179+
// In a large grid, jumping ten viewports on every click can be
180+
// disorienting; set the point without jumping.
181+
jumpOnSet: false,
182+
});
183+
toolGroup.setToolActive(WorldCrosshairTool.toolName, {
184+
bindings: [{ mouseButton: MouseBindings.Primary }],
185+
});
186+
187+
renderingEngine.render();
188+
}
189+
190+
run();

0 commit comments

Comments
 (0)