Skip to content

feat(tools): new world crosshairs and intersect planes#2781

Merged
sedghi merged 10 commits into
mainfrom
worldCrosshairsSimplified
Jul 12, 2026
Merged

feat(tools): new world crosshairs and intersect planes#2781
sedghi merged 10 commits into
mainfrom
worldCrosshairsSimplified

Conversation

@sedghi

@sedghi sedghi commented Jul 3, 2026

Copy link
Copy Markdown
Member

Context

This PR adds two independent, world-space navigation/annotation tools built exclusively for the Generic ("next") viewport architecture (PLANAR_NEXT, plus VOLUME_3D_NEXT for the 3D crosshair). Both operate through the native view-state API (getResolvedView / setViewReference / setViewState); legacy stack/volume viewports and their compatibility adapters are intentionally ignored. The two tools share nothing and can be enabled together or separately.

Tools

1. WorldCrosshairTool — "Reference Point"

Stores and renders one persistent world-space point of interest per tool group. The user picks an anatomical/world point and the tool keeps it fixed while viewports scroll, pan, zoom or rotate — the point is never derived from camera state (the tool deliberately ignores CAMERA_MODIFIED / view-state resets).

  • Renders the point in every linked planar viewport as a crosshair (or a filled dot via markerStyle).
  • Off-slice display: when the point is not on the displayed slice it is drawn dashed with its signed distance in mm (projectedWithDistance | projected | hide).
  • Optional 3D rendering: mirrors the point into configured VOLUME_3D_NEXT viewports as world-space intersecting lines (via WorldCrosshairLines3D).
  • Linking policies (frameOfReferenceUID | toolGroup | explicit) control which viewports show / jump to the point.
  • Jump modes: sliceOnly (navigate along the normal, keep pan) or centered (also pin the point to canvas center).
  • Interaction: Shift+click / Shift+drag to move the point; clickToSet enables plain-click; optional live update on Shift-move. Programmatic API: setWorldPoint / clearWorldPoint / jumpLinkedViewportsToWorldPoint.
  • Events: WORLD_CROSSHAIR_POINT_CHANGED, WORLD_CROSSHAIR_POINT_CLEARED, WORLD_CROSSHAIR_JUMPED_TO_POINT.

It renders no intersection lines and never rotates/translates planes or touches slab thickness — that belongs to the other tool.

2. SliceIntersectionTool — "Slice Intersections"

Renders and manipulates slice plane intersection lines. It thinks in planes, not viewports: viewports showing the same plane family over the same frame of reference (e.g. CT axial + PT axial + a 2D axial stack) form one plane group, rendered as a single line.

  • Each viewport shows exactly one line per other plane group (no duplicate near-identical lines). Every line is the true plane–plane intersection of the target and group-leader planes; there is no shared crosshair center.
  • Drag a line → translates every member of that group (each along its own normal; stacks snap to their closest image).
  • Rotation handles → reorient the volume-backed members together (stacks cannot reorient and keep following translations only).
  • Slab handles → adjust the slab thickness of the volume-backed members.
  • Optional boundary / intermediate slice lines (capped), per-family colors, and a configurable rotation-pivot policy.
  • Events: SLICE_INTERSECTION_LINE_SELECTED, SLICE_INTERSECTION_MANIPULATION_STARTED, SLICE_INTERSECTION_MANIPULATION_ENDED, SLICE_INTERSECTION_SLAB_THICKNESS_CHANGED.

Stores no persistent world point and does not render in 3D viewports.

3. WorldCrosshairLines3D (helper)

3D rendering extension used by WorldCrosshairTool to draw the reference point in Generic 3D (VOLUME_3D_NEXT) viewports as three world-space axis-aligned lines crossing at the point, attached through the viewport's getRenderer() hook. Holds no tool state.

Supporting spatial utilities (packages/tools/src/utilities/spatial)

Shared, architecture-agnostic geometry helpers, unit-tested in spatial.spec.ts:

Utility Purpose
getViewportPlane Slice plane (normalized normal + focal point) from a viewport camera; null for unrendered / non-image viewports.
intersectPlanes Line of intersection of two planes (null if parallel).
distancePointToPlane Signed distance from a point to a plane.
projectPointToPlane Orthogonal projection of a point onto a plane.
clipWorldLineToViewportCanvas Clip a world-space line to the viewport canvas → two canvas points (or null).
translateViewportAlongNormal Move a viewport camera along its view-plane normal by a signed distance.
rotateViewportAroundWorldPoint Rigidly rotate a viewport camera around a world pivot.
areViewportsSpatiallyLinked Whether two viewports share a frame of reference (directly or via a registration transform).
getDisplayedCanvasSize Displayed element size used for geometry (not the hidden canvas).

Plus genericViewportToolHelpers (navigatePlanarViewportToPoint, getNativeSourceProperties) for driving generic viewports by view reference.

Examples

Example Shows
World Crosshair (Reference Point) The reference point across three MPR generic viewports.
Ten Viewport Reference Point One point mirrored across a 10-viewport grid.
Slice Intersections One intersection line per plane; drag to scroll, handles to rotate / change slab.
World Crosshair + Slice Intersections Both tools together and fully independent.
World Crosshair + Slice Intersections (PET/CT) Both tools on a PET/CT study incl. a 2D stack and a 3D volume rendering; plane groups spanning CT + PT + stack.

Other changes

  • core/utilities/scroll.ts: scroll now safely ignores viewports without index-based scrolling (e.g. Generic 3D volume rendering) instead of throwing.
  • New tool events added to enums/Events.ts (listed above).
  • Both tools registered in the tools / utilities barrels.

Testing

  • Unit tests: WorldCrosshairTool.spec.ts, SliceIntersectionTool.spec.ts, spatial.spec.ts — run with yarn test:unit (Jest).
  • Manual: launch any of the examples above via the example runner.

Checklist

PR

  • My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • "OS:
  • "Node version:
  • "Browser:

Summary by CodeRabbit

  • New Features
    • Added World Crosshair and Slice Intersections tools, plus new advanced demos: world crosshair, MIP snapping, slice intersections, combined interactions, PET/CT-style linked layouts, and a ten-viewport reference point.
    • Added spatial + slab intensity picking utilities to improve navigation and MIP-based snapping.
    • Re-exported the tools for public use.
  • Bug Fixes
    • Improved scrolling safety by ignoring scroll requests for unsupported Stack/3D-style viewports.
  • Tests / Documentation
    • Added Jest coverage for the tools, spatial utilities, and intensity picking; expanded tool event documentation.

sedghi added 2 commits July 2, 2026 20:14
… 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.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds spatial geometry utilities, persistent world-crosshair and slice-intersection tools, slab-intensity picking, extensive tests and exports, multiple demos, and a guard for unsupported stack scrolling.

Changes

World Crosshair and Slice Intersection Feature

Layer / File(s) Summary
Spatial utilities and navigation
packages/tools/src/utilities/spatial/*, packages/tools/src/utilities/genericViewportToolHelpers.ts, packages/tools/src/utilities/index.ts
Adds spatial types and helpers for plane geometry, viewport linking, clipping, camera movement, generic planar navigation, and slab-intensity picking support.
WorldCrosshairTool and 3D lines
packages/tools/src/tools/WorldCrosshairTool.ts, packages/tools/src/tools/worldCrosshair/*, packages/tools/src/tools/WorldCrosshairTool.spec.ts
Adds persistent world-point rendering, linked viewport navigation, MIP/MinIP interaction snapping, optional 3D crosshair lines, and tests.
SliceIntersectionTool
packages/tools/src/tools/SliceIntersectionTool.ts, packages/tools/src/tools/SliceIntersectionTool.spec.ts
Adds plane-group intersection rendering, line selection, translation, rotation, slab adjustment, and validation tests.
Exports and event documentation
packages/tools/src/index.ts, packages/tools/src/tools/index.ts, packages/tools/src/utilities/index.ts, packages/tools/src/enums/Events.ts
Exports the new tools and utilities and documents related events.

Demo examples and metadata

Layer / File(s) Summary
Demo entrypoints and metadata
packages/tools/examples/*, utils/ExampleRunner/example-info.json
Adds demonstrations for world crosshairs, slice intersections, combined tools, PET/CT layouts, MIP snapping, and ten linked viewports.

Core Scroll Guard

Layer / File(s) Summary
Stack scroll capability guard
packages/core/src/utilities/scroll.ts
Returns early when a stack-like viewport lacks getCurrentImageIdIndex or scroll.

Estimated code review effort: 5 (Critical) | ~150 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant WorldCrosshairTool
  participant PlanarViewport
  participant WorldCrosshairLines3D
  participant RenderingEngine

  User->>WorldCrosshairTool: setWorldPoint()
  WorldCrosshairTool->>PlanarViewport: navigate linked viewports
  WorldCrosshairTool->>WorldCrosshairLines3D: update 3D crosshair lines
  WorldCrosshairLines3D->>RenderingEngine: render viewport
  PlanarViewport->>RenderingEngine: render linked viewports
Loading
sequenceDiagram
  participant User
  participant SliceIntersectionTool
  participant TargetViewport
  participant GroupMemberViewport
  participant RenderingEngine

  User->>SliceIntersectionTool: drag intersection line
  SliceIntersectionTool->>TargetViewport: compute clipped intersection lines
  SliceIntersectionTool->>GroupMemberViewport: translate, rotate, or change slab
  SliceIntersectionTool->>RenderingEngine: render affected viewports
Loading

Suggested reviewers: wayfarer3130

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title follows semantic-release format and matches the main tools added.
Description check ✅ Passed Mostly matches the template with Context, Testing, and detailed changes, though the checklist is still unchecked and Changes & Results is implicit.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worldCrosshairsSimplified

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (7)
packages/core/src/utilities/scroll.ts (1)

49-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a debug log for the silently-ignored path.

The guard silently no-ops when a stack-like viewport lacks getCurrentImageIdIndex/scroll methods. This is a deliberate behavior change (previously such a call would throw), but silent ignoring can make misconfigurations (e.g., an unexpected viewport type routed here) hard to diagnose.

♻️ Optional: add a debug-level warning
     if (
       typeof (viewport as IStackViewport).getCurrentImageIdIndex !==
         'function' ||
       typeof (viewport as IStackViewport).scroll !== 'function'
     ) {
       // Viewports without index-based scrolling (e.g. Generic 3D volume
       // rendering) ignore scroll requests instead of throwing.
+      console.debug(
+        'Scroll::Viewport does not support index-based scrolling, ignoring request'
+      );
       return;
     }
🤖 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/utilities/scroll.ts` around lines 49 - 58, The scroll guard
in scroll() now silently ignores viewports missing getCurrentImageIdIndex or
scroll, which can hide unexpected routing or misconfiguration. Add a debug-level
log in this early-return path that records the viewport type/state and makes it
clear scroll was intentionally skipped. Keep the existing no-throw behavior, but
make the silent branch diagnosable by logging from the scroll utility before
returning.
packages/tools/src/utilities/spatial/distancePointToPlane.ts (1)

10-18: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider guarding against a zero-length plane.normal.

vec3.normalize on a zero vector yields NaN components, which would silently propagate through projectPointToPlane (no downstream finite-check) since it also relies on this function.

🛡️ Optional guard for degenerate normals
 export default function distancePointToPlane(
   point: Types.Point3,
   plane: Plane
 ): number {
+  const normalLengthSquared = vec3.squaredLength(plane.normal);
+  if (normalLengthSquared === 0) {
+    return NaN;
+  }
   const normal = vec3.normalize(vec3.create(), plane.normal);
   const toPoint = vec3.subtract(vec3.create(), point, plane.point);

   return vec3.dot(toPoint, normal);
 }
🤖 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/tools/src/utilities/spatial/distancePointToPlane.ts` around lines 10
- 18, Guard `distancePointToPlane` against a degenerate `plane.normal` before
calling `vec3.normalize`, since normalizing a zero vector will produce `NaN` and
propagate into `projectPointToPlane`. Update the `distancePointToPlane` function
to detect a zero-length normal and handle it explicitly, either by throwing a
clear error or returning a safe fallback value, while keeping the existing
dot-product logic for valid normals.
packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts (1)

44-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reallocates VTK points/cell-array on every update, including during interactive drag.

setCrosshairLinePoints always creates a brand-new vtkPoints and vtkCellArray instance, even in the "update existing entry" path (Line 122). Since updateWorldCrosshairLines3D is invoked on every setWorldPoint call, this runs on every mouse-move tick while dragging the world crosshair with liveUpdateOnShiftMouseMove enabled, causing avoidable per-frame allocations/GC churn in the 3D viewport's render loop.

Consider mutating the existing points buffer in place (entry.polyData.getPoints().setPoint(...)) and only recreating points/cells once, on first creation.

♻️ Suggested approach
 function setCrosshairLinePoints(
   polyData: ReturnType<typeof vtkPolyData.newInstance>,
   worldPoint: Types.Point3,
   halfLength: number
 ): void {
   const [x, y, z] = worldPoint;
-  const points = vtkPoints.newInstance();
-  points.setNumberOfPoints(6);
+  let points = polyData.getPoints();
+  if (!points || points.getNumberOfPoints() !== 6) {
+    points = vtkPoints.newInstance();
+    points.setNumberOfPoints(6);
+    polyData.setPoints(points);
+    polyData.setLines(
+      vtkCellArray.newInstance({ values: [2, 0, 1, 2, 2, 3, 2, 4, 5] })
+    );
+  }
   points.setPoint(0, x - halfLength, y, z);
   points.setPoint(1, x + halfLength, y, z);
   points.setPoint(2, x, y - halfLength, z);
   points.setPoint(3, x, y + halfLength, z);
   points.setPoint(4, x, y, z - halfLength);
   points.setPoint(5, x, y, z + halfLength);
-
-  const lines = vtkCellArray.newInstance({
-    values: [2, 0, 1, 2, 2, 3, 2, 4, 5],
-  });
-
-  polyData.setPoints(points);
-  polyData.setLines(lines);
+  points.modified();
   polyData.modified();
 }

Also applies to: 121-123

🤖 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/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts` around
lines 44 - 66, The crosshair update path in setCrosshairLinePoints is allocating
new vtkPoints and vtkCellArray objects on every call, including the
updateExistingEntry flow in updateWorldCrosshairLines3D. Update the existing
entry.polyData points in place via the current vtkPoints instance instead of
recreating buffers each mouse move, and only create the points/cell array once
when the crosshair entry is first initialized.
packages/tools/src/tools/WorldCrosshairTool.ts (2)

1185-1256: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Link-policy branching duplicated between _getLinkedViewports and _isViewportLinked.

Both methods re-implement the same explicit / frameOfReferenceUID / toolGroup policy switch. A future change to one policy's semantics risks only being applied to one of the two call sites (e.g. _isViewportLinked is used per-render for hit-testing/rendering while _getLinkedViewports drives jumping/render fan-out).

Consider deriving _isViewportLinked(viewport) from _getLinkedViewports().some(v => v.id === viewport.id), or factoring the policy predicate into a shared private method taking a single viewport.

🤖 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/tools/src/tools/WorldCrosshairTool.ts` around lines 1185 - 1256, The
link-policy logic is duplicated between _getLinkedViewports and
_isViewportLinked in WorldCrosshairTool, so update the implementation to use one
shared source of truth. Factor the explicit/frameOfReferenceUID/toolGroup
decision into a single private helper, or have _isViewportLinked derive its
result from _getLinkedViewports(), and keep both methods aligned through that
shared logic. Make sure the shared path still preserves the source viewport
special case and the planar/known-viewport checks.

841-859: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated marker geometry logic between renderAnnotation and _getMarkerCanvasPosition.

Both methods independently recompute the plane, distance-to-plane, off-slice tolerance, projected point and canvas point. Any future change to this geometry (e.g. a new off-slice rule) needs to be kept in sync in two places.

Consider extracting a shared private helper (e.g. _computeMarkerGeometry(viewport): { canvasPoint, distanceMm, inSlice } | null) used by both call sites.

Also applies to: 1272-1314

🤖 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/tools/src/tools/WorldCrosshairTool.ts` around lines 841 - 859, The
marker geometry calculation in WorldCrosshairTool is duplicated between
renderAnnotation and _getMarkerCanvasPosition, so extract the shared
plane/distance/tolerance/projection logic into a private helper such as
_computeMarkerGeometry(viewport) that returns the canvas point, distanceMm, and
inSlice state. Update both call sites to use this helper and keep all off-slice
and canvas validation behavior centralized so future geometry changes only need
to be made once.
packages/tools/src/tools/SliceIntersectionTool.ts (2)

1019-1077: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Potential NaN slab-handle canvas positions when the clipped line is degenerate.

_computeSlabHandles falls back to vec2.normalize(vec2.create(), vec2.subtract(vec2.create(), end, start)) when the slab is collapsed. If start and end are equal or nearly equal (a near-zero-length clipped canvas line, e.g. near-parallel planes), vec2.normalize divides by a ~0 length, producing NaN handle coordinates that would then be drawn into the SVG.

A short guard (skip handle placement, or default to a fixed direction, when vec2.length(vec2.subtract(...)) < epsilon) would avoid this edge case.

🤖 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/tools/src/tools/SliceIntersectionTool.ts` around lines 1019 - 1077,
_computeSlabHandles in SliceIntersectionTool can generate NaN canvas positions
when the clipped line is degenerate and vec2.normalize is called on a near-zero
vector. Add a length/epsilon guard before computing the perpendicular fallback
from start/end: if the line segment is too short, either skip handle placement
or use a stable default direction instead of normalizing. Keep the fix localized
to _computeSlabHandles and ensure both the plus/minus handle paths return finite
canvas points.

206-226: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Boundary/intermediate lines share the "slab" field name and SVG id with actual slab-thickness lines.

_appendBoundaryGeometry pushes volume boundary/intermediate slice segments into the same lineInfo.slabLineSegments array that _appendSlabGeometry uses for slab-thickness boundaries, and renderAnnotation draws all of them with the same slab-${groupId}-${index} id and dashed style. These are conceptually different features (showSlabThickness vs. showBoundaryLines/showIntermediateLines) that happen to share styling today; reusing the same field/id makes it harder to later give them independent styling or to debug which lines are rendering from the DOM.

Consider a more neutral field name (e.g. dashedLineSegments) or separate arrays per concept.

Also applies to: 844-935

🤖 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/tools/src/tools/SliceIntersectionTool.ts` around lines 206 - 226,
The boundary/intermediate slice segments are being stored and rendered through
the same slab-specific field and SVG id path as true slab-thickness geometry,
which conflates two different concepts. Update SliceIntersectionTool’s
RenderedIntersectionLine and the related _appendBoundaryGeometry,
_appendSlabGeometry, and renderAnnotation flow to use a more neutral shared name
like dashedLineSegments, or split the arrays by purpose, and generate distinct
SVG ids for boundary/intermediate vs slab-thickness lines so they can be styled
and debugged independently.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/tools/examples/worldCrosshair/index.ts`:
- Around line 71-77: The worldCrosshair demo instructions currently claim that a
plain click sets the reference point, but WorldCrosshairTool defaults clickToSet
to false so that behavior is not enabled. Update the WorldCrosshairTool setup in
the worldCrosshair example to pass clickToSet: true, or revise the instructions
text to remove the click-based wording; use the WorldCrosshairTool and
instructions block to locate the change.

In `@packages/tools/examples/worldCrosshairAndSliceIntersections/index.ts`:
- Around line 70-75: The demo instructions currently imply a plain click sets
the reference point, but `WorldCrosshairTool` is configured with `clickToSet:
false` by default, so empty-space clicks only work with Shift unless
`clickToSet` is enabled. Update the text in the `instructions` block to match
the actual behavior, or change the `WorldCrosshairTool` setup to pass `{
clickToSet: true }` if you want plain clicks to work.

In `@packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts`:
- Line 34: The WorldCrosshairLines3D cache can retain stale 3D crosshair entries
when a viewport disappears before onSetToolDisabled() runs. Update the cleanup
logic around linesByKey, _get3DViewports(), and the disabled-tool flow so
missing/destroyed viewports are pruned too, and consider wiring ELEMENT_DISABLED
to remove any LinesEntry no longer backed by an active viewport. Make sure the
cache deletes dead actors/polyData for all keys, not just currently returned
viewports.

In `@packages/tools/src/tools/WorldCrosshairTool.ts`:
- Around line 1052-1102: The centered jump path in
WorldCrosshairTool._jumpViewportToPoint ignores the return value from
navigatePlanarViewportToPoint, unlike the slice-only branch. Update the centered
branch to only set anchorWorld, anchorCanvas, and call viewport.render() when
navigation succeeds; otherwise return early so failed navigation on invalid
viewports does not leave the viewport pinned to an incorrect point.

In `@packages/tools/src/utilities/spatial/translateViewportAlongNormal.ts`:
- Around line 25-30: Wrap the getViewportICamera call inside
translateViewportAlongNormal in a try/catch, following the same defensive
pattern used by getViewportPlane, because it can throw when the viewport camera
is unavailable or not yet rendered. If the call fails, return false from
translateViewportAlongNormal so SliceIntersectionTool._applyTranslate can
continue processing the remaining viewports in its forEach loop without aborting
the interaction.

---

Nitpick comments:
In `@packages/core/src/utilities/scroll.ts`:
- Around line 49-58: The scroll guard in scroll() now silently ignores viewports
missing getCurrentImageIdIndex or scroll, which can hide unexpected routing or
misconfiguration. Add a debug-level log in this early-return path that records
the viewport type/state and makes it clear scroll was intentionally skipped.
Keep the existing no-throw behavior, but make the silent branch diagnosable by
logging from the scroll utility before returning.

In `@packages/tools/src/tools/SliceIntersectionTool.ts`:
- Around line 1019-1077: _computeSlabHandles in SliceIntersectionTool can
generate NaN canvas positions when the clipped line is degenerate and
vec2.normalize is called on a near-zero vector. Add a length/epsilon guard
before computing the perpendicular fallback from start/end: if the line segment
is too short, either skip handle placement or use a stable default direction
instead of normalizing. Keep the fix localized to _computeSlabHandles and ensure
both the plus/minus handle paths return finite canvas points.
- Around line 206-226: The boundary/intermediate slice segments are being stored
and rendered through the same slab-specific field and SVG id path as true
slab-thickness geometry, which conflates two different concepts. Update
SliceIntersectionTool’s RenderedIntersectionLine and the related
_appendBoundaryGeometry, _appendSlabGeometry, and renderAnnotation flow to use a
more neutral shared name like dashedLineSegments, or split the arrays by
purpose, and generate distinct SVG ids for boundary/intermediate vs
slab-thickness lines so they can be styled and debugged independently.

In `@packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts`:
- Around line 44-66: The crosshair update path in setCrosshairLinePoints is
allocating new vtkPoints and vtkCellArray objects on every call, including the
updateExistingEntry flow in updateWorldCrosshairLines3D. Update the existing
entry.polyData points in place via the current vtkPoints instance instead of
recreating buffers each mouse move, and only create the points/cell array once
when the crosshair entry is first initialized.

In `@packages/tools/src/tools/WorldCrosshairTool.ts`:
- Around line 1185-1256: The link-policy logic is duplicated between
_getLinkedViewports and _isViewportLinked in WorldCrosshairTool, so update the
implementation to use one shared source of truth. Factor the
explicit/frameOfReferenceUID/toolGroup decision into a single private helper, or
have _isViewportLinked derive its result from _getLinkedViewports(), and keep
both methods aligned through that shared logic. Make sure the shared path still
preserves the source viewport special case and the planar/known-viewport checks.
- Around line 841-859: The marker geometry calculation in WorldCrosshairTool is
duplicated between renderAnnotation and _getMarkerCanvasPosition, so extract the
shared plane/distance/tolerance/projection logic into a private helper such as
_computeMarkerGeometry(viewport) that returns the canvas point, distanceMm, and
inSlice state. Update both call sites to use this helper and keep all off-slice
and canvas validation behavior centralized so future geometry changes only need
to be made once.

In `@packages/tools/src/utilities/spatial/distancePointToPlane.ts`:
- Around line 10-18: Guard `distancePointToPlane` against a degenerate
`plane.normal` before calling `vec3.normalize`, since normalizing a zero vector
will produce `NaN` and propagate into `projectPointToPlane`. Update the
`distancePointToPlane` function to detect a zero-length normal and handle it
explicitly, either by throwing a clear error or returning a safe fallback value,
while keeping the existing dot-product logic for valid normals.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cacbfc96-6b86-44be-8927-6512ddb74504

📥 Commits

Reviewing files that changed from the base of the PR and between 357796f and 133fe4b.

📒 Files selected for processing (29)
  • packages/core/src/utilities/scroll.ts
  • packages/tools/examples/sliceIntersections/index.ts
  • packages/tools/examples/tenViewportReferencePoint/index.ts
  • packages/tools/examples/worldCrosshair/index.ts
  • packages/tools/examples/worldCrosshairAndSliceIntersections/index.ts
  • packages/tools/examples/worldCrosshairSliceIntersectionsPetCt/index.ts
  • packages/tools/src/enums/Events.ts
  • packages/tools/src/index.ts
  • packages/tools/src/tools/SliceIntersectionTool.spec.ts
  • packages/tools/src/tools/SliceIntersectionTool.ts
  • packages/tools/src/tools/WorldCrosshairTool.spec.ts
  • packages/tools/src/tools/WorldCrosshairTool.ts
  • packages/tools/src/tools/index.ts
  • packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts
  • packages/tools/src/utilities/genericViewportToolHelpers.ts
  • packages/tools/src/utilities/index.ts
  • packages/tools/src/utilities/spatial/areViewportsSpatiallyLinked.ts
  • packages/tools/src/utilities/spatial/clipWorldLineToViewportCanvas.ts
  • packages/tools/src/utilities/spatial/distancePointToPlane.ts
  • packages/tools/src/utilities/spatial/getDisplayedCanvasSize.ts
  • packages/tools/src/utilities/spatial/getViewportPlane.ts
  • packages/tools/src/utilities/spatial/index.ts
  • packages/tools/src/utilities/spatial/intersectPlanes.ts
  • packages/tools/src/utilities/spatial/projectPointToPlane.ts
  • packages/tools/src/utilities/spatial/rotateViewportAroundWorldPoint.ts
  • packages/tools/src/utilities/spatial/spatial.spec.ts
  • packages/tools/src/utilities/spatial/translateViewportAlongNormal.ts
  • packages/tools/src/utilities/spatial/types.ts
  • utils/ExampleRunner/example-info.json

Comment thread packages/tools/examples/worldCrosshair/index.ts
polyData: ReturnType<typeof vtkPolyData.newInstance>;
};

const linesByKey = new Map<string, LinesEntry>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and surrounding symbols first.
ast-grep outline packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts --view expanded

# Read the file with line numbers, focusing on the cache and cleanup logic.
sed -n '1,240p' packages/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts | cat -n

# Find related cleanup / disable lifecycle references in the tool.
rg -n "removeWorldCrosshairLines3D|onSetToolDisabled|ELEMENT_DISABLED|getEnabledElementByViewportId|linesByKey|WorldCrosshairLines3D" packages/tools/src/tools/worldCrosshair -S

Repository: cornerstonejs/cornerstone3D

Length of output: 7338


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the tool lifecycle and any viewport-destroy cleanup hooks.
ast-grep outline packages/tools/src/tools/WorldCrosshairTool.ts --view expanded || true
ast-grep outline packages/tools/src/tools/worldCrosshair --view expanded

rg -n "removeWorldCrosshairLines3D|updateWorldCrosshairLines3D|onSetToolDisabled|ELEMENT_DISABLED|disableElement|viewport.*destroy|destroy.*viewport|getEnabledElementByViewportId|addEventListener|removeEventListener" packages/tools/src -S

# Read the main tool file if present.
fd -a "WorldCrosshairTool.ts" packages/tools/src

Repository: cornerstonejs/cornerstone3D

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant lifecycle methods and 3D viewport enumeration.
sed -n '461,500p' packages/tools/src/tools/WorldCrosshairTool.ts | cat -n
sed -n '1111,1155p' packages/tools/src/tools/WorldCrosshairTool.ts | cat -n

# Check whether there is any cleanup tied to element-disable or viewport-destroy events in this tool.
rg -n "ELEMENT_DISABLED|disableElement|removeWorldCrosshairLines3D|_get3DViewports|onSetToolDisabled" packages/tools/src/tools/WorldCrosshairTool.ts packages/tools/src/tools/worldCrosshair -S

Repository: cornerstonejs/cornerstone3D

Length of output: 4124


Remove stale 3D crosshair cache entries when a viewport is disabled
linesByKey is only cleared for viewports returned by _get3DViewports(), so if a 3D viewport is destroyed before onSetToolDisabled() runs, its LinesEntry can stay pinned in this module. Hooking ELEMENT_DISABLED (or otherwise pruning missing viewports) would prevent the cache from retaining dead actors/polyData and leaking GPU resources over repeated study/engine lifecycles.

🤖 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/tools/src/tools/worldCrosshair/WorldCrosshairLines3D.ts` at line 34,
The WorldCrosshairLines3D cache can retain stale 3D crosshair entries when a
viewport disappears before onSetToolDisabled() runs. Update the cleanup logic
around linesByKey, _get3DViewports(), and the disabled-tool flow so
missing/destroyed viewports are pruned too, and consider wiring ELEMENT_DISABLED
to remove any LinesEntry no longer backed by an active viewport. Make sure the
cache deletes dead actors/polyData for all keys, not just currently returned
viewports.

Comment thread packages/tools/src/tools/WorldCrosshairTool.ts
Comment thread packages/tools/src/utilities/spatial/translateViewportAlongNormal.ts Outdated
sedghi added 3 commits July 3, 2026 09:09
…structions

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.
…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.
…hrow

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.
@sedghi sedghi changed the title worldCrosshairsSimplified feat(tools): new world crosshairs and intersect planes Jul 3, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/tools/src/utilities/pickIntensityPointInSlab.spec.ts (1)

87-169: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the sample-cap and missing-plane branches.

The suite thoroughly covers max/min picking, slab bounds, and non-applicable modes, but doesn't exercise the MAX_SAMPLES cap (large slab thickness relative to spacing) or the getViewportPlane returning falsy. Both are real code paths in pickIntensityPointInSlab.ts (Lines 143-153, 133-135) that affect correctness for full-volume MIP demos like worldCrosshairMip.

🤖 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/tools/src/utilities/pickIntensityPointInSlab.spec.ts` around lines
87 - 169, Add tests in pickIntensityPointInSlab.spec.ts to cover the two
untested branches in pickIntensityPointInSlab/getSlabIntensityPickContext: one
case should use a very large slabThickness to force the MAX_SAMPLES cap path and
assert the picked point still resolves correctly, and another should make
getViewportPlane return a falsy value and verify pickIntensityPointInSlab
returns null. Use the existing helpers like createFakeMipViewport,
stubCachedVolume, and pickIntensityPointInSlab so the new specs sit alongside
the current max/min and non-applicable mode coverage.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@packages/tools/src/utilities/pickIntensityPointInSlab.spec.ts`:
- Around line 87-169: Add tests in pickIntensityPointInSlab.spec.ts to cover the
two untested branches in pickIntensityPointInSlab/getSlabIntensityPickContext:
one case should use a very large slabThickness to force the MAX_SAMPLES cap path
and assert the picked point still resolves correctly, and another should make
getViewportPlane return a falsy value and verify pickIntensityPointInSlab
returns null. Use the existing helpers like createFakeMipViewport,
stubCachedVolume, and pickIntensityPointInSlab so the new specs sit alongside
the current max/min and non-applicable mode coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a09ea91-9999-4a60-b824-a8fb2d604372

📥 Commits

Reviewing files that changed from the base of the PR and between 7e20b85 and c68e5ac.

📒 Files selected for processing (7)
  • packages/tools/examples/worldCrosshairMip/index.ts
  • packages/tools/src/tools/WorldCrosshairTool.spec.ts
  • packages/tools/src/tools/WorldCrosshairTool.ts
  • packages/tools/src/utilities/index.ts
  • packages/tools/src/utilities/pickIntensityPointInSlab.spec.ts
  • packages/tools/src/utilities/pickIntensityPointInSlab.ts
  • utils/ExampleRunner/example-info.json
✅ Files skipped from review due to trivial changes (1)
  • utils/ExampleRunner/example-info.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/tools/src/tools/WorldCrosshairTool.ts

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.


CROSSHAIR_TOOL_CENTER_CHANGED = 'CORNERSTONE_TOOLS_CROSSHAIR_TOOL_CENTER_CHANGED',

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These feel like a lot of event types that are fairly specialized. Is there some way to just get events on tool changed by tool type, and have them have a tool type changed?
Alternatively, do we need to eventually get a way to use zustand to allow filtering for specific behaviour changes?

}
};

_activateModify = (element: HTMLDivElement): void => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These functions seems like they are really standard for a lot of tools - any chance they could just be declared in the parent class and then inheritted?

@wayfarer3130 wayfarer3130 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

there are a few comments inline, plus:
Correctness
Unguarded getViewportICamera calls. getViewportPlane.ts:30-35 and translateViewportAlongNormal.ts:28-33 deliberately wrap getViewportICamera in try/catch because it can throw on an unrendered viewport. But WorldCrosshairTool.ts:1005 (_maybeAutoInitialize) and WorldCrosshairTool.ts:1081 (_jumpViewportToPoint) call it directly. _maybeAutoInitialize runs at the very top of renderAnnotation and also from onSetToolEnabled while iterating possibly-unrendered enabled viewports — a throw there would escape the enable/render path. Low real-world risk (guarded by _isPlanarViewport), but for consistency it should use the same defensive pattern.

setWorldPoint skips _syncAnnotation when the point is unchanged but the FoR changed. WorldCrosshairTool.ts:355-374 — if a caller passes a new frameOfReferenceUID with a ~identical point, _state.frameOfReferenceUID updates but the annotation isn't regrouped. A genuine edge case, essentially harmless, worth being aware of.

Performance
SliceIntersectionTool recomputes plane groups and viewport lookups heavily per render pass. renderAnnotation → _computeLinesForTarget → _getPlaneGroups() runs for each target viewport, and _getPlaneGroups() calls _getViewportById() per viewport, which rebuilds the whole list via _getToolGroupViewports() (fresh getEnabledElementByIds + getResolvedView/camera resolve for every viewport). That's roughly O(V³) enabled-element/camera resolves per full render pass. On the 10-viewport example during a drag or scroll this is a lot of repeated work. Recommend memoizing the plane-group computation and a viewport-id→viewport map for the duration of a single render pass (or per animation frame). See SliceIntersectionTool.ts:492-552 and SliceIntersectionTool.ts:1956-1965.

pickIntensityPointInSlab runs on every shift-mouse-move when liveUpdateOnShiftMouseMove is on (up to 2001 volume samples per move). The MAX_SAMPLES cap makes this bounded and probably fine, but it's worth confirming it stays smooth on large PET slabs.

Consistency / Documentation
"two lines" vs. three. WorldCrosshairTool.ts:165-167 and the PR description say the 3D point renders as "two world-space intersecting lines," but WorldCrosshairLines3D.ts:44-66 draws three (one per patient axis). Update the comment.

rotateViewportAroundWorldPoint is dead for these tools. It returns false for generic viewports (rotateViewportAroundWorldPoint.ts:27-29) — the only viewport type these tools support — which is exactly why SliceIntersectionTool._applyRotate reimplements rotation inline via setViewReference. Shipping a public utility that no-ops on the supported surface is a minor smell; consider documenting it as legacy-only or reconciling with the inline path.

slabLineSegments reused for boundary lines. _appendBoundaryGeometry pushes boundary/intermediate lines into lineInfo.slabLineSegments (SliceIntersectionTool.ts:924-934). Harmless (they render identically) but the field name is now misleading.

that has at lesat a few things worth addressing.

…tersection 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
@sedghi sedghi merged commit 5d1d7fd into main Jul 12, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants