Skip to content

Conversation

@philippotto
Copy link
Member

@philippotto philippotto commented Oct 9, 2025

image

URL of deployed dev instance (used for testing):

  • https://___.webknossos.xyz

Steps to test:

  • activate the pipette tool and hover over voxel values
  • pin the tooltip with left click and pan the viewport. the tooltip should follow the clicked position and stick to the viewport edges
  • test with transformed layers
  • test with an activated segmentation mapping
  • test with flycam rotation
  • use shift click to activate a segment id (this should work in the pipette tool as well as in the brush/erase tools like before)

TODOs:

  • fix tooltip position having an offset
  • use correct mag
  • support segmentation layers (only the visible one for now)
  • get feedback
  • support transformed datasets
  • allow to hide the tooltip to almost get old picker with shift behavior (tooltip will still be shown but instead of pinning it the segment id is activated)
  • support picking when no segmentation layer exists
  • increase offset if tooltip follows mouse
  • support mappings
  • clean up
  • adapt docs
  • fix tests
  • sort layer names like in sidebar
  • fix rgb layer

Issues:


(Please delete unneeded items, merge only when none are left open)

  • Added changelog entry (create a $PR_NUMBER.md file in unreleased_changes or use ./tools/create-changelog-entry.py)
  • Added migration guide entry if applicable (edit the same file as for the changelog)
  • Updated documentation if applicable
  • Adapted wk-libs python client if relevant API parts change
  • Removed dev-only changes like prints and application.conf edits
  • Considered common edge cases
  • Needs datastore update after deployment

@philippotto philippotto self-assigned this Oct 9, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 9, 2025

📝 Walkthrough

Walkthrough

Introduces a new "Voxel Pipette" tool that replaces the "Pick Cell" tool, enabling users to view intensity values for all visible layers via tooltip while supporting shift-click segment activation. Updates include tool controllers, UI components, test assertions, API changes, and supporting accessor functions for mouse position and segment ID queries.

Changes

Cohort / File(s) Change Summary
Documentation
docs/ui/toolbar.md, docs/volume_annotation/tools.md, unreleased_changes/8989.md
Added Voxel Pipette tool description to toolbar docs; removed Segment Picker tool documentation; added release notes.
Core Tool Controller
frontend/javascripts/viewer/controller/combinations/tool_controls.ts
Renamed PickCellToolController to VoxelPipetteToolController with shift-click cell-picking and measurement tooltip logic; added _activeToolWithoutModifiers parameter to all tool controller getActionDescriptors methods; added onToolDeselected and mouseMove bindings to voxel pipette.
Segment ID & Volume Handlers
frontend/javascripts/viewer/controller/combinations/volume_handlers.ts
Introduced getSegmentIdInfoForPosition() public API returning both mapped and unmapped segment IDs; refactored handlePickCellFromGlobalPosition() to use layer-space position transformation.
Voxel Pipette Tooltip
frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx
New React component displaying per-layer voxel values with copy-to-clipboard functionality and viewport-aware positioning logic.
Tool Registration & UI
frontend/javascripts/viewer/view/action-bar/tools/tool_buttons.tsx, frontend/javascripts/viewer/model/accessors/tool_accessor.ts
Replaced PickCellTool with VoxelPipetteTool in tool registry; updated VOXEL_PIPETTE in VOLUME_TOOLS, READ_ONLY_TOOLS, and SPLIT_SEGMENTS groupings; removed volume-modification guard.
Plane Controller & Tool Wiring
frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx
Updated keyboard and mouse bindings to use VoxelPipetteToolController and VOXEL_PIPETTE instead of PICK_CELL references.
API & Accessors
frontend/javascripts/viewer/api/api_latest.ts, frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts, frontend/javascripts/viewer/model/accessors/flycam_accessor.ts
Added respectMapping parameter to getDataValue(); introduced getGlobalMousePosition() and getGlobalMousePositionFloating() public accessors; added getCurrentMagIndex() function.
Model & Zoom Step Methods
frontend/javascripts/viewer/model.ts
Renamed position parameters to positionInLayerSpace in zoom-step methods; removed getHoveredCellId() method and related import.
Tool Availability & State
frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts, frontend/javascripts/viewer/model/accessors/tool_accessor.ts
Added VOXEL_PIPETTE to always-enabled tools; replaced PICK_CELL with VOXEL_PIPETTE in volume-disabled state mappings.
Volume Tracing Saga
frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx
Replaced model-based hover segment calculation with getSegmentIdInfoForPosition() helper; updated to use getGlobalMousePositionFloating().
UI Layout & Tooltips
frontend/javascripts/viewer/view/layouting/tracing_layout_view.tsx, frontend/javascripts/viewer/view/distance_measurement_tooltip.tsx
Added VoxelValueTooltip rendering when VOXEL_PIPETTE active; refactored distance measurement tooltip positioning with dynamic OFFSET logic.
Status Bar & Input
frontend/javascripts/viewer/view/statusbar.tsx, frontend/javascripts/viewer/view/input_catcher.tsx, frontend/javascripts/viewer/view/context_menu.tsx
Centralized mouse-position handling via getGlobalMousePosition selector; updated cursor mapping and context menu tool-state checks to VOXEL_PIPETTE.
Data & Styling
frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts, frontend/stylesheets/trace_view/_tracing_view.less
Added voxel coordinate flooring in getDataValue(); renamed tooltip CSS class from .measurement-tooltip to .cursor-tooltip and adjusted arrow positioning.
Test Updates
frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts, frontend/javascripts/test/sagas/annotation_tool_disabled_info.spec.ts, frontend/javascripts/test/sagas/annotation_tool_saga.spec.ts
Updated test assertions to expect VOXEL_PIPETTE instead of PICK_CELL; added VOXEL_PIPETTE to tool exclusion arrays; replaced PickCellToolController with VoxelPipetteToolController in controller tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The PR involves substantial structural changes across multiple domains: tool controller refactoring with new parameter signatures, new public APIs for position and segment queries, introduction of a new UI component, and widespread test updates. While changes are cohesive and follow a clear pattern (PICK_CELL → VOXEL_PIPETTE), the variety of affected subsystems and density of logic modifications (new tooltip positioning, measurement state handling, layer-space transformations) warrant careful review.

Possibly related PRs

Suggested labels

new feature, volume, documentation

Suggested reviewers

  • MichaelBuessemeyer

Poem

🥕 A rabbit's ode to the voxel pipette

A pipette for voxels, a measurement delight,
No more just picking cells in the night—
Now see all the values, layer by layer,
With shift-click precision, a data-seeking player! 🔬✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.71% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title Check ✅ Passed The title "Voxel data pipette tool" directly and clearly describes the main change in this pull request. The changeset focuses on introducing a new generalized voxel pipette tool that replaces the previous segment picker tool, with the ability to display data values from multiple visible layers at the cursor position. The title accurately captures this primary objective without being vague, overly broad, or using generic terms like "misc updates."
Linked Issues Check ✅ Passed The code changes comprehensively address all the requirements from issue #8976. The implementation introduces a voxel pipette tool that displays intensity/data values per visible layer with a tooltip at the cursor, generalizes the previous segment picker tool to handle multiple layer types, preserves shift-click segment activation behavior, supports transformed datasets and mappings, and provides copy-to-clipboard functionality for voxel values. Core changes include replacing PICK_CELL with VOXEL_PIPETTE, creating VoxelValueTooltip component, adding segment ID lookup utilities (getSegmentIdInfoForPosition), and updating all related tool controllers and APIs to support the new functionality.
Out of Scope Changes Check ✅ Passed All changes in this pull request are appropriately scoped to the voxel pipette tool implementation. Documentation updates reflect the new tool's capabilities, test modifications update references from PICK_CELL to VOXEL_PIPETTE, tool controller changes rename and rewire the tool implementation, and API modifications (parameter renames, new utility functions like getSegmentIdInfoForPosition and getGlobalMousePosition) serve as supporting infrastructure for position tracking, transformation handling, and mapping support. The tooltip positioning adjustments and new VoxelValueTooltip component are direct implementations of the required feature. No unrelated changes or technical debt modifications are present.
Description Check ✅ Passed The pull request description is directly related to the changeset and provides meaningful context about the changes. It describes the new voxel pipette tool functionality, includes specific testing steps for the tool's features (hovering over values, pinning tooltips, testing with different layer types, using shift-click for segment activation), references the specific issue being fixed (#8976), and lists concrete TODOs and implementation progress. The description clearly conveys what the changes accomplish and how to validate them, making it substantive rather than generic or off-topic.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch voxel-data-pipette

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 and usage tips.

@philippotto philippotto changed the title [WIP] Voxel data pipette tool Voxel data pipette tool Oct 23, 2025
@philippotto philippotto marked this pull request as ready for review October 23, 2025 14:35

{status === "loaded" && <WkContextMenu />}

{status === "loaded" && distanceMeasurementTooltipPosition != null && (
Copy link
Member Author

Choose a reason for hiding this comment

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

moving was necessary to get the positioning relative to the canvas (otherwise, the navbar height caused problems).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/javascripts/viewer/view/statusbar.tsx (1)

386-395: Boolean null-check bug hides SegmentInfo logic.

hasVisibleSegmentation is a boolean. Checking == null is always false; SegmentInfo will render even without a visible segmentation.

- if (hasVisibleSegmentation == null) {
+ if (!hasVisibleSegmentation) {
   return null;
 }
🧹 Nitpick comments (9)
frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (1)

427-443: New function looks good; consider refactoring to reduce duplication.

The implementation correctly mirrors getCurrentMag by returning the mag index instead of the mag vector. The logic is sound and documentation is clear.

Since getCurrentMagIndex and getCurrentMag (lines 414-425) share nearly identical logic, consider extracting the common portion into a helper function to improve maintainability:

function _getExistingMagIndex(
  state: WebknossosState,
  layerName: string,
): number | null | undefined {
  const magInfo = getMagInfo(getLayerByName(state.dataset, layerName).mags);
  const magIndex = getActiveMagIndexForLayer(state, layerName);
  return magInfo.getIndexOrClosestHigherIndex(magIndex);
}

export function getCurrentMag(
  state: WebknossosState,
  layerName: string,
): Vector3 | null | undefined {
  const existingMagIndex = _getExistingMagIndex(state, layerName);
  if (existingMagIndex == null) {
    return null;
  }
  return getMagInfo(getLayerByName(state.dataset, layerName).mags).getMagByIndex(existingMagIndex);
}

export function getCurrentMagIndex(
  state: WebknossosState,
  layerName: string,
): number | null | undefined {
  const existingMagIndex = _getExistingMagIndex(state, layerName);
  if (existingMagIndex == null) {
    return null;
  }
  return existingMagIndex;
}
frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx (1)

26-26: Use rounded mouse position to reduce churn and stabilize hovered IDs.

Floating coordinates trigger expensive lookups on every pixel move within a voxel. Prefer rounded global mouse position for hovered segment ID updates.

Apply:

- import { getGlobalMousePositionFloating } from "viewer/model/accessors/view_mode_accessor";
+ import { getGlobalMousePosition } from "viewer/model/accessors/view_mode_accessor";
- const globalMousePosition = yield* select(getGlobalMousePositionFloating);
+ const globalMousePosition = yield* select(getGlobalMousePosition);

Also applies to: 616-625

frontend/javascripts/viewer/view/action-bar/tools/tool_buttons.tsx (1)

263-281: Mention Shift+Click to activate a segment in the pipette’s description.

Improves discoverability and aligns with tool behavior.

- description="Inspect a voxel by showing the data values per layer in a tooltip. Clicking on a voxel will pin the tooltip to make the values selectable with the mouse cursor."
+ description="Inspect voxel values per layer in a tooltip. Click to pin the tooltip for selection; Shift+Click activates the segment ID."
frontend/javascripts/viewer/controller/combinations/volume_handlers.ts (1)

96-136: Memoization key is too narrow; results can be stale across state changes.

getSegmentIdInfoForPosition is memoized only by globalPos, but output also depends on:

  • Visible segmentation layer
  • Mapping state (name/lock, HDF5 availability)
  • additionalCoordinates
  • Rendered zoom step

This can return stale IDs when these change without mouse movement.

Options:

  • Include state-derived keys in the cache key (e.g., layerName, mappingVersion, additionalCoordinates.join(","), zoomStep) or
  • Compare by voxel index (e.g., floor layer-space position) to reduce thrash, or
  • Remove memoization (let upper layers debounce if needed).

Also consider de-duplicating logic between _getSegmentIdForPosition and _getSegmentIdInfoForPosition to avoid divergence.

frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx (3)

33-50: Prevent event bubbling on copy; support numeric values.

Stop click propagation and stringify numeric values for the clipboard.

-function VoxelValueEntry({
+function VoxelValueEntry({
   layerName,
   value,
   category,
-}: { layerName: string; value: string; category: "color" | "segmentation" }) {
+}: { layerName: string; value: string | number; category: "color" | "segmentation" }) {
@@
-      <Tooltip title="Copy to clipboard">
+      <Tooltip title="Copy to clipboard">
         <CopyOutlined
-          onClick={() => {
-            copyToClipboad(value);
+          onClick={(e) => {
+            e.stopPropagation();
+            void copyToClipboad(String(value));
           }}
         />
       </Tooltip>

175-177: Widen value type to include numbers.

API values are typically numeric; widen the map type.

-  const voxelValuesByLayer: Record<string, ["color" | "segmentation", string]> | null =
+  const voxelValuesByLayer: Record<string, ["color" | "segmentation", string | number]> | null =
     layerNamesWithDataValue != null ? Object.fromEntries(layerNamesWithDataValue) : null;

104-118: Hide tooltip → also end measuring.

When auto-hiding because the point left the plane, also clear measuring to keep state consistent.

-      dispatch(hideMeasurementTooltipAction());
+      dispatch(hideMeasurementTooltipAction());
+      dispatch(setIsMeasuringAction(false));
frontend/javascripts/viewer/controller/combinations/tool_controls.ts (2)

622-625: Also reset measuring on tool deselect.

Keep measurement state consistent when switching tools.

   static onToolDeselected() {
     Store.dispatch(hideMeasurementTooltipAction());
+    Store.dispatch(setIsMeasuringAction(false));
   }

636-641: Label suggestion: clarify toggle action.

Optional: When pinned, show “Unpin Tooltip” instead of “Pin Tooltip” to reflect the second click.

I can wire this by reading lastMeasuredPosition in the descriptor builder if desired.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 155e22a and 11706bc.

📒 Files selected for processing (25)
  • docs/ui/toolbar.md (2 hunks)
  • docs/volume_annotation/tools.md (0 hunks)
  • frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts (1 hunks)
  • frontend/javascripts/test/sagas/annotation_tool_disabled_info.spec.ts (2 hunks)
  • frontend/javascripts/test/sagas/annotation_tool_saga.spec.ts (4 hunks)
  • frontend/javascripts/viewer/api/api_latest.ts (3 hunks)
  • frontend/javascripts/viewer/controller/combinations/tool_controls.ts (12 hunks)
  • frontend/javascripts/viewer/controller/combinations/volume_handlers.ts (2 hunks)
  • frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx (5 hunks)
  • frontend/javascripts/viewer/model.ts (3 hunks)
  • frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts (2 hunks)
  • frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (2 hunks)
  • frontend/javascripts/viewer/model/accessors/tool_accessor.ts (6 hunks)
  • frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (4 hunks)
  • frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts (3 hunks)
  • frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx (4 hunks)
  • frontend/javascripts/viewer/view/action-bar/tools/tool_buttons.tsx (2 hunks)
  • frontend/javascripts/viewer/view/context_menu.tsx (1 hunks)
  • frontend/javascripts/viewer/view/distance_measurement_tooltip.tsx (3 hunks)
  • frontend/javascripts/viewer/view/input_catcher.tsx (1 hunks)
  • frontend/javascripts/viewer/view/layouting/tracing_layout_view.tsx (5 hunks)
  • frontend/javascripts/viewer/view/statusbar.tsx (4 hunks)
  • frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx (1 hunks)
  • frontend/stylesheets/trace_view/_tracing_view.less (1 hunks)
  • unreleased_changes/8989.md (1 hunks)
💤 Files with no reviewable changes (1)
  • docs/volume_annotation/tools.md
🧰 Additional context used
🧬 Code graph analysis (18)
frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (2)
frontend/javascripts/viewer/store.ts (1)
  • WebknossosState (575-595)
frontend/javascripts/viewer/model/accessors/dataset_accessor.ts (2)
  • getMagInfo (40-40)
  • getLayerByName (147-166)
frontend/javascripts/viewer/model.ts (2)
frontend/javascripts/viewer/constants.ts (1)
  • Vector3 (14-14)
frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (1)
  • getActiveMagIndexForLayer (405-407)
frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx (2)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/controller/combinations/tool_controls.ts (1)
  • VoxelPipetteToolController (590-643)
frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx (11)
frontend/javascripts/admin/voxelytics/utils.ts (1)
  • copyToClipboad (62-65)
frontend/javascripts/viewer/constants.ts (2)
  • Vector3 (14-14)
  • OrthoView (66-66)
frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (4)
  • calculateInViewportPos (371-371)
  • getGlobalMousePositionFloating (373-375)
  • calculateMaybePlaneScreenPos (370-370)
  • getInputCatcherRect (38-44)
frontend/javascripts/libs/react_hooks.ts (1)
  • useWkSelector (292-294)
frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (3)
  • getPosition (365-365)
  • getRotationInRadian (367-367)
  • getCurrentMagIndex (432-443)
frontend/javascripts/viewer/model/scaleinfo.ts (1)
  • getBaseVoxelFactorsInUnit (26-33)
frontend/javascripts/viewer/model/actions/ui_actions.ts (1)
  • hideMeasurementTooltipAction (200-203)
frontend/javascripts/viewer/model.ts (2)
  • getColorLayers (85-90)
  • getVisibleSegmentationLayer (121-135)
frontend/javascripts/libs/react_helpers.tsx (1)
  • useFetch (35-52)
frontend/javascripts/viewer/model/accessors/dataset_layer_transformation_accessor.ts (1)
  • globalToLayerTransformedPosition (487-506)
frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts (1)
  • getReadableNameForLayerName (176-188)
frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx (2)
frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (1)
  • getGlobalMousePositionFloating (373-375)
frontend/javascripts/viewer/controller/combinations/volume_handlers.ts (1)
  • getSegmentIdInfoForPosition (134-136)
frontend/javascripts/test/sagas/annotation_tool_saga.spec.ts (2)
frontend/javascripts/viewer/controller/combinations/tool_controls.ts (2)
  • VoxelPipetteToolController (590-643)
  • FillCellToolController (644-675)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts (1)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (1)
frontend/javascripts/viewer/view/action-bar/tools/tool_buttons.tsx (1)
  • VoxelPipetteTool (263-281)
frontend/javascripts/test/sagas/annotation_tool_disabled_info.spec.ts (1)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/view/layouting/tracing_layout_view.tsx (3)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (3)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
  • MeasurementTools (155-158)
frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx (1)
  • VoxelValueTooltip (74-216)
frontend/javascripts/viewer/view/distance_measurement_tooltip.tsx (1)
  • DistanceMeasurementTooltip (61-175)
frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts (1)
frontend/javascripts/viewer/constants.ts (1)
  • Vector3 (14-14)
frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts (1)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/view/statusbar.tsx (3)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/libs/react_hooks.ts (1)
  • useWkSelector (292-294)
frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (1)
  • getGlobalMousePosition (372-372)
frontend/javascripts/viewer/view/action-bar/tools/tool_buttons.tsx (3)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/libs/react_hooks.ts (1)
  • useWkSelector (292-294)
frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts (1)
  • getDisabledInfoForTools (422-424)
frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (1)
frontend/javascripts/viewer/store.ts (1)
  • WebknossosState (575-595)
frontend/javascripts/viewer/controller/combinations/tool_controls.ts (4)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (2)
  • AnnotationTool (91-105)
  • AnnotationTool (109-109)
frontend/javascripts/viewer/constants.ts (2)
  • OrthoView (66-66)
  • Point2 (27-30)
frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (1)
  • calculateGlobalPos (368-368)
frontend/javascripts/viewer/model/actions/ui_actions.ts (3)
  • setLastMeasuredPositionAction (204-208)
  • hideMeasurementTooltipAction (200-203)
  • setIsMeasuringAction (209-213)
frontend/javascripts/viewer/api/api_latest.ts (3)
frontend/javascripts/viewer/model/accessors/tool_accessor.ts (1)
  • AnnotationToolId (11-11)
frontend/javascripts/viewer/constants.ts (2)
  • Vector3 (14-14)
  • AdditionalCoordinate (2-2)
frontend/javascripts/viewer/model/accessors/dataset_accessor.ts (3)
  • getLayerByName (147-166)
  • getMagInfo (40-40)
  • getMappingInfoOrNull (675-683)
frontend/javascripts/viewer/controller/combinations/volume_handlers.ts (4)
frontend/javascripts/viewer/constants.ts (1)
  • Vector3 (14-14)
frontend/javascripts/viewer/model/accessors/dataset_layer_transformation_accessor.ts (1)
  • globalToLayerTransformedPosition (487-506)
frontend/javascripts/viewer/model.ts (1)
  • getVisibleSegmentationLayer (121-135)
frontend/javascripts/viewer/model/actions/volumetracing_actions.ts (1)
  • setActiveCellAction (206-218)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: build-smoketest-push
  • GitHub Check: backend-tests
  • GitHub Check: frontend-tests
🔇 Additional comments (35)
frontend/javascripts/viewer/model/accessors/flycam_accessor.ts (1)

403-403: LGTM! Documentation clarification.

The wording updates improve consistency in the documentation.

Also applies to: 410-410

docs/ui/toolbar.md (1)

128-130: LGTM! Clear documentation for the new tool.

The documentation clearly explains the voxel pipette functionality including hover behavior, tooltip pinning, and shift-click segment activation.

frontend/stylesheets/trace_view/_tracing_view.less (1)

176-196: LGTM! Tooltip styling updated for new orientation.

The tooltip arrow repositioning from bottom-left to top-right makes sense for a pipette tool that follows the cursor. The CSS changes correctly implement the new arrow orientation.

frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts (2)

131-137: LGTM! Clear explanation of rounding behavior.

The comment clarifies why rounding is used for XY dimensions but floor for Z, explaining that floor is used for Z to match what's rendered in the viewport.


967-978: Voxel coordinate flooring in getDataValue is safe for all callers.

The addition of Math.floor is a necessary and correct safeguard. Analysis shows:

  • Callers using pre-rounded coordinates (e.g., plane_controller.tsx with .rounded): Flooring is idempotent and harmless.
  • Callers explicitly flooring before calling (e.g., voxel_pipette_tooltip.tsx): Their explicit flooring becomes redundant but remains valid.
  • Callers passing potentially float coordinates (e.g., merger_mode.ts with matrix-transformed positions): The flooring now provides essential protection.

The semantics are correct—voxels are discrete cells and require integer indices. All examined callers are compatible with this change.

frontend/javascripts/viewer/model/accessors/view_mode_accessor.ts (2)

294-320: LGTM! Well-structured mouse position accessors.

The new _getGlobalMousePosition and _getGlobalMousePositionFloating functions provide both rounded and floating-point global positions, which is useful for different use cases (rounded for node placement, floating for smooth animations).


241-241: Removal of navbarHeight was intentional and part of tooltip positioning fix.

The git history shows commit eb984903 ("fix tooltip positioning", Oct 23 14:56) deliberately removed navbarHeight from the _calculateMaybePlaneScreenPos calculation. The developer changed Math.round(point[1] + height / 2 + top + navbarHeight) to Math.round(point[1] + height / 2 + top) as a fix. Since getInputCatcherRect returns pre-calculated viewport-relative coordinates from state, the navbar offset adjustment was unnecessary and was causing incorrect positioning. This change is verified as correct through the commit message and git history.

frontend/javascripts/viewer/api/api_latest.ts (3)

1520-1521: LGTM! Documentation updated for new tool name.

The documentation correctly references VOXEL_PIPETTE as one of the available annotation tools.

Also applies to: 1528-1529


1865-1903: LGTM! Well-implemented mapping support.

The new respectMapping parameter provides a clean way to optionally apply segment mappings during data retrieval. The implementation correctly:

  • Fetches state once for efficiency
  • Uses getMappingInfoOrNull to safely resolve the mapping
  • Checks for MappingStatusEnum.ENABLED before applying the mapping
  • Passes the resolved mapping (or null) to the cube

1908-1923: LGTM! Parameter naming clarifies coordinate space.

Renaming position to positionInLayerSpace makes it clear that these positions should already be in layer space, which helps prevent coordinate space confusion.

frontend/javascripts/viewer/model.ts (1)

167-203: LGTM! Consistent parameter naming across model methods.

The renaming of position to positionInLayerSpace in getCurrentlyRenderedZoomStepAtPosition and getUltimatelyRenderedZoomStepAtPosition is consistent with the API layer changes and clarifies the expected coordinate space.

frontend/javascripts/viewer/view/input_catcher.tsx (1)

104-104: LGTM! Tool rename in cursor mapping.

The cursor mapping correctly updates PICK_CELL to VOXEL_PIPETTE while keeping the appropriate eye-dropper icon.

unreleased_changes/8989.md (1)

1-2: LGTM! Clear changelog entry.

The changelog accurately describes the generalization from "Segment Picker" to "Voxel Pipette" and notes that the shift-click behavior preserves backward compatibility.

frontend/javascripts/viewer/view/context_menu.tsx (1)

428-428: LGTM! Tool reference correctly updated.

The condition properly uses the renamed VOXEL_PIPETTE tool to gate the "select-cell" action visibility in the context menu, maintaining consistent behavior with the tool rename.

frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts (1)

195-195: LGTM! Test expectation correctly updated.

The test now expects VOXEL_PIPETTE after cycling past FILL_CELL, which aligns with the tool rename and maintains the correct tool sequence.

frontend/javascripts/test/sagas/annotation_tool_disabled_info.spec.ts (2)

9-9: LGTM! Zoom sensitivity correctly configured.

The VOXEL_PIPETTE tool is appropriately excluded from zoom-sensitive tools, allowing users to inspect voxel values at any zoom level, which is the expected behavior for a read-only inspection tool.


155-155: LGTM! Rotation behavior correctly configured.

The VOXEL_PIPETTE tool is correctly included in toolsDisregardingRotation, allowing voxel inspection even when the dataset is rotated. This is appropriate for a non-editing inspection tool.

frontend/javascripts/viewer/model/accessors/disabled_tool_accessor.ts (2)

94-94: LGTM! Always-enabled configuration is appropriate.

The VOXEL_PIPETTE tool is correctly added to ALWAYS_ENABLED_TOOL_INFOS, which is appropriate for a read-only inspection tool that should always be available regardless of annotation state.


307-307: LGTM! Volume-enabled state correctly configured.

The VOXEL_PIPETTE is mapped to NOT_DISABLED_INFO when volume annotation is enabled, ensuring the inspection tool remains available alongside volume editing tools.

frontend/javascripts/viewer/view/layouting/tracing_layout_view.tsx (2)

398-403: LGTM! Tool-based tooltip rendering improves separation of concerns.

The change from position-based to tool-based tooltip rendering is well-structured:

  • VoxelValueTooltip shows voxel data when VOXEL_PIPETTE is active
  • DistanceMeasurementTooltip shows measurements when measurement tools are active

This provides better modularity and clearer component responsibilities.


446-446: LGTM! State selector correctly updated.

Adding activeTool to the component props enables the tool-based tooltip rendering logic above.

frontend/javascripts/test/sagas/annotation_tool_saga.spec.ts (2)

41-41: LGTM! Controller reference correctly updated.

The import and usage of VoxelPipetteToolController correctly replaces the previous PickCellToolController reference, maintaining consistency across the test suite.

Also applies to: 59-59


101-101: LGTM! Test expectations correctly updated.

The test assertions now verify VoxelPipetteToolController.onToolDeselected is called at the appropriate points in the tool cycling sequence, ensuring the tool lifecycle saga works correctly with the renamed controller.

Also applies to: 142-145

frontend/javascripts/viewer/view/distance_measurement_tooltip.tsx (3)

17-17: LGTM! Simplified logic with parent-controlled visibility.

The removal of the MeasurementTools check is correct since the parent component (tracing_layout_view.tsx lines 401-403) now controls when this tooltip is rendered. This improves separation of concerns and makes the component's responsibilities clearer.

Also applies to: 122-122


146-159: LGTM! Dynamic offset improves tooltip UX.

The OFFSET logic correctly adapts based on interaction state:

  • 8px when measuring (tooltip follows cursor) provides clearance from the cursor
  • 0px when pinned (tooltip is fixed) allows precise positioning

The updated positioning calculations properly incorporate the dynamic offset to keep the tooltip within viewport bounds.


164-164: LGTM! Consistent styling class.

The className change from "measurement-tooltip" to "cursor-tooltip" aligns with the voxel pipette tooltip component, ensuring consistent styling across tooltip types.

frontend/javascripts/viewer/model/accessors/tool_accessor.ts (4)

21-21: LGTM! Voxel Pipette tool properly defined.

The VoxelPipetteTool class is correctly implemented:

  • Added to _AnnotationToolHelper enum
  • Extends AbstractAnnotationTool with appropriate id and readableName
  • Registered in the AnnotationTool public API

The implementation follows the established pattern for other annotation tools.

Also applies to: 64-67, 99-99


128-141: LGTM! Tool correctly categorized in toolkits.

The VOXEL_PIPETTE tool is appropriately included in:

  • VOLUME_TOOLS - since it operates on volumetric data
  • READ_ONLY_TOOLS - correctly categorized as non-editing
  • SPLIT_SEGMENTS - useful for inspection during segmentation workflows

These categorizations align with the tool's read-only inspection purpose.


146-150: LGTM! VolumeTools composition correctly updated.

Excluding both MOVE and VOXEL_PIPETTE from VolumeTools is correct, as this collection represents editing-specific volume tools. The Voxel Pipette is a read-only inspection tool and should not be included in editing tool groups.


205-205: LGTM! Shortcut behavior consistently implemented.

The adaptActiveToolToShortcuts function correctly maps Shift key presses to VOXEL_PIPETTE at lines 205 and 222, maintaining consistency with the existing behavior where Shift switches to a picker/inspection tool. This provides intuitive keyboard shortcuts aligned with the brush and trace tool workflows.

Also applies to: 222-222

frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx (1)

24-25: Voxel pipette wiring looks consistent.

Controller import, control aggregation, and the ‘p’ keybinding integration are correct. No issues spotted.

Also applies to: 356-357, 379-399

frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx (2)

144-156: Handle null/undefined magIndex before calling getDataValue.

getCurrentMagIndex can return null; clarify API expectations and guard.

Apply a safe fallback or skip layer on null:

-          const magIndex = getCurrentMagIndex(Store.getState(), layer.name);
+          const magIndex = getCurrentMagIndex(Store.getState(), layer.name);
+          if (magIndex == null) {
+            return [getReadableNameForLayerName(dataset, annotation, layer.name), [layer.category, "n/a"] as const];
+          }
@@
-            magIndex,
-            additionalCoordinates,
+            magIndex,
+            additionalCoordinates || undefined,
             true,

Please confirm whether api.data.getDataValue accepts null; if yes, adjust accordingly.


160-164: Keying by “readable name” can collide.

Two layers may resolve to the same readable name; prefer the unique layer.name as key and render the readable label separately.

I can adjust to use layer.name as key and pass the readable name for display if you confirm collisions are possible in your datasets.

frontend/javascripts/viewer/controller/combinations/tool_controls.ts (2)

593-601: Guard against TDView in pipette.

Pipette logic can’t resolve a plane in 3D; ignore clicks there to avoid flicker (tooltip immediately hidden by the view-side guard).

-      leftClick: (position: Point2, plane: OrthoView, event: MouseEvent) => {
+      leftClick: (position: Point2, plane: OrthoView, event: MouseEvent) => {
+        if (plane === OrthoViews.TDView) {
+          return;
+        }
         if (event.shiftKey) {

593-601: Alt-move fallback is good.

Mouse move delegates to MoveHandlers when alt is pressed; matches other tools.

Comment on lines +602 to +617
if (event.shiftKey) {
VolumeHandlers.handlePickCell(position);
return;
}

const state = Store.getState();
const lastMeasuredGlobalPosition =
state.uiInformation.measurementToolInfo.lastMeasuredPosition;

if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
}
Store.dispatch(setIsMeasuringAction(true));
Copy link
Contributor

@coderabbitai coderabbitai bot Oct 23, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Left click toggle leaves measuring=true after hiding.

When unpinning (hide), set measuring=false; otherwise the UI stays in “measuring” state.

       if (lastMeasuredGlobalPosition == null) {
         const globalPosition = calculateGlobalPos(state, position, plane).floating;
         Store.dispatch(setLastMeasuredPositionAction(globalPosition));
       } else {
         Store.dispatch(hideMeasurementTooltipAction());
+        Store.dispatch(setIsMeasuringAction(false));
+        return;
       }
-      Store.dispatch(setIsMeasuringAction(true));
+      Store.dispatch(setIsMeasuringAction(true));
📝 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
if (event.shiftKey) {
VolumeHandlers.handlePickCell(position);
return;
}
const state = Store.getState();
const lastMeasuredGlobalPosition =
state.uiInformation.measurementToolInfo.lastMeasuredPosition;
if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
}
Store.dispatch(setIsMeasuringAction(true));
if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
Store.dispatch(setIsMeasuringAction(false));
return;
}
Store.dispatch(setIsMeasuringAction(true));
🤖 Prompt for AI Agents
In frontend/javascripts/viewer/controller/combinations/tool_controls.ts around
lines 602 to 617, the left-click branch that hides (unpins) the measurement
tooltip does not clear the measuring flag so the UI remains in measuring=true;
when lastMeasuredGlobalPosition is null you should continue to set
measuring=true after storing the computed globalPosition, but in the else branch
where you dispatch hideMeasurementTooltipAction() also dispatch
setIsMeasuringAction(false) to turn off measuring; implement that single
additional dispatch in the hide branch so measuring is cleared when unpinning.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say the AI is correct here by solely reading the changes

Copy link
Contributor

Choose a reason for hiding this comment

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

@MichaelBuessemeyer Thanks for confirming! You're absolutely right—the current implementation leaves isMeasuring=true even after hiding the tooltip, which is incorrect.

The fix is straightforward: when unpinning (the else branch where lastMeasuredGlobalPosition already exists), we should dispatch setIsMeasuringAction(false) to properly clear the measuring state. The suggested code change correctly handles both cases:

  1. First click (pin): Set position → set measuring=true
  2. Second click (unpin): Hide tooltip → set measuring=false → early return

The early return in the else branch is also good practice to make the control flow explicit and avoid the subsequent setIsMeasuringAction(true) call.

Feel free to apply the suggested fix! 🐰

Comment on lines +182 to +195
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);

const visibleSegmentationLayer = getVisibleSegmentationLayer(Store.getState());
if (visibleSegmentationLayer == null) {
const segmentId = getSegmentIdForPosition(posInLayerSpace);

if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));

Copy link
Contributor

@coderabbitai coderabbitai bot Oct 23, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Double transform bug: posInLayerSpace passed to a function that transforms again.

getSegmentIdForPosition expects global coordinates and performs its own global→layer transform. You pass posInLayerSpace, causing an extra transform and incorrect IDs when layer transforms exist.

Use the layer-space position directly with the cube (and the provided additionalCoordinates), or call the new helper with the global position.

Apply one of:

Option A (use cube directly):

- const segmentId = getSegmentIdForPosition(posInLayerSpace);
+ const zoomStep = api.data.getRenderedZoomStepAtPosition(
+   visibleSegmentationLayer.name,
+   posInLayerSpace,
+ );
+ const segmentId = visibleSegmentationLayer.cube.getMappedDataValue(
+   posInLayerSpace,
+   additionalCoordinates,
+   zoomStep,
+ );

Option B (use new helper with global pos):

- const segmentId = getSegmentIdForPosition(posInLayerSpace);
+ const { mapped: segmentId } = getSegmentIdInfoForPosition(globalPos);
📝 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 posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);
const visibleSegmentationLayer = getVisibleSegmentationLayer(Store.getState());
if (visibleSegmentationLayer == null) {
const segmentId = getSegmentIdForPosition(posInLayerSpace);
if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);
const zoomStep = api.data.getRenderedZoomStepAtPosition(
visibleSegmentationLayer.name,
posInLayerSpace,
);
const segmentId = visibleSegmentationLayer.cube.getMappedDataValue(
posInLayerSpace,
additionalCoordinates,
zoomStep,
);
if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));
Suggested change
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);
const visibleSegmentationLayer = getVisibleSegmentationLayer(Store.getState());
if (visibleSegmentationLayer == null) {
const segmentId = getSegmentIdForPosition(posInLayerSpace);
if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);
const { mapped: segmentId } = getSegmentIdInfoForPosition(globalPos);
if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));
🤖 Prompt for AI Agents
In frontend/javascripts/viewer/controller/combinations/volume_handlers.ts around
lines 182-195, you're passing posInLayerSpace into getSegmentIdForPosition which
expects global coordinates and re-applies a global→layer transform, causing a
double transform and wrong segment IDs; fix by either A) using the layer-space
position directly with the cube-based lookup (call the cube API that accepts
layer-space coords and pass posInLayerSpace plus additionalCoordinates) or B)
keep the existing getSegmentIdForPosition but pass the original globalPos (and
then dispatch setActiveCellAction with the returned segmentId and globalPos);
pick one option and remove the extra transform so the coordinate space matches
the lookup function.

Copy link
Contributor

Choose a reason for hiding this comment

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

seems like the ai noticed the same thing here

Copy link
Contributor

Choose a reason for hiding this comment

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

@MichaelBuessemeyer Yes, that's a genuine bug! The double transformation will cause incorrect segment IDs when layer transformations are active.

I'd recommend Option B (using getSegmentIdInfoForPosition(globalPos)) since it's cleaner and reuses the new helper that was added in this PR specifically for this purpose. It also returns both mapped and unmapped IDs in one call, which could be useful if needed later.

The fix is straightforward—just pass the original globalPos to the helper instead of the already-transformed posInLayerSpace to getSegmentIdForPosition.

Comment on lines +132 to +169
const layerNamesWithDataValue = useFetch(
async () => {
if (positionToPick == null) {
return null;
}

return Promise.all(
layers.map(async (layer) => {
// getCurrentMagIndex depends on the current state, but we don't
// want to refetch these data values here every time the state changes.
// This is not ideal, but the downsides should be negligible (e.g., when
// zooming, the data value won't be read again with the changed mag).
const magIndex = getCurrentMagIndex(Store.getState(), layer.name);
const positionInLayer = globalToLayerTransformedPosition(
positionToPick,
layer.name,
layer.category,
Store.getState(),
);

const dataValue = await api.data.getDataValue(
layer.name,
positionInLayer.map((el) => Math.floor(el)) as Vector3,
magIndex,
additionalCoordinates,
true,
);

return [
getReadableNameForLayerName(dataset, annotation, layer.name),
[layer.category, dataValue],
];
}),
);
},
[],
[positionToPick],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid hammering the API on every mouse move; restore proper “Loading…” state.

  • Fetch runs on every pixel change for each visible layer; add throttling/debouncing or a short polling interval, and drop stale responses. Also make defaultValue null so the UI can show “Loading…”.

Minimal changes to restore loading state and type:

-  const layerNamesWithDataValue = useFetch(
+  const layerNamesWithDataValue = useFetch<
+    Array<[string, ["color" | "segmentation", string | number]]> | null
+  >(
     async () => {
       if (positionToPick == null) {
         return null;
       }
@@
-    [],
+    null,
-    [positionToPick],
+    [positionToPick], // consider a throttled key, e.g., positionToPick rounded or debounced
   );

Follow-up (perf): debounce positionToPick (e.g., to 30–60 ms) or fetch only when pinned, or batch per-layer requests if backend supports it. I can draft this if you decide the strategy.

📝 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 layerNamesWithDataValue = useFetch(
async () => {
if (positionToPick == null) {
return null;
}
return Promise.all(
layers.map(async (layer) => {
// getCurrentMagIndex depends on the current state, but we don't
// want to refetch these data values here every time the state changes.
// This is not ideal, but the downsides should be negligible (e.g., when
// zooming, the data value won't be read again with the changed mag).
const magIndex = getCurrentMagIndex(Store.getState(), layer.name);
const positionInLayer = globalToLayerTransformedPosition(
positionToPick,
layer.name,
layer.category,
Store.getState(),
);
const dataValue = await api.data.getDataValue(
layer.name,
positionInLayer.map((el) => Math.floor(el)) as Vector3,
magIndex,
additionalCoordinates,
true,
);
return [
getReadableNameForLayerName(dataset, annotation, layer.name),
[layer.category, dataValue],
];
}),
);
},
[],
[positionToPick],
);
const layerNamesWithDataValue = useFetch<
Array<[string, ["color" | "segmentation", string | number]]> | null
>(
async () => {
if (positionToPick == null) {
return null;
}
return Promise.all(
layers.map(async (layer) => {
// getCurrentMagIndex depends on the current state, but we don't
// want to refetch these data values here every time the state changes.
// This is not ideal, but the downsides should be negligible (e.g., when
// zooming, the data value won't be read again with the changed mag).
const magIndex = getCurrentMagIndex(Store.getState(), layer.name);
const positionInLayer = globalToLayerTransformedPosition(
positionToPick,
layer.name,
layer.category,
Store.getState(),
);
const dataValue = await api.data.getDataValue(
layer.name,
positionInLayer.map((el) => Math.floor(el)) as Vector3,
magIndex,
additionalCoordinates,
true,
);
return [
getReadableNameForLayerName(dataset, annotation, layer.name),
[layer.category, dataValue],
];
}),
);
},
null,
[positionToPick], // consider a throttled key, e.g., positionToPick rounded or debounced
);
🤖 Prompt for AI Agents
In frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx around lines
132–169, the current fetch triggers on every mouse-move pixel and returns
immediate values so the UI never shows a “Loading…” state; change the useFetch
default value to null so the UI can render “Loading…”, debounce positionToPick
input (30–60ms) before calling the fetch to reduce request frequency, and ignore
stale responses by stamping each fetch with an incrementing requestId (or use an
AbortController) and only set results when the requestId matches the latest;
keep changes minimal: set defaultValue null, wrap the call trigger in a short
debounce, and add requestId/abort logic inside the async fetch to drop stale
responses.

Comment on lines +181 to +192
const tooltipWidth = tooltipRef.current?.offsetWidth ?? 0;
// Position tooltip just below and to the left of the cursor
const left = clamp(
viewportLeft - tooltipWidth + OFFSET, // min
tooltipPosition[0] - tooltipWidth - OFFSET, // desired position (left of cursor, small offset)
viewportLeft + viewportWidth - tooltipWidth - OFFSET, // max (stay in viewport)
);
const top = clamp(
viewportTop, // min
tooltipPosition[1] + OFFSET, // just below cursor
viewportTop + viewportHeight - OFFSET, // max
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fix tooltip clamping (can overflow viewport and ignores height).

Left min should be viewportLeft (not viewportLeft - tooltipWidth) and bottom clamp must subtract tooltip height.

Apply:

-  const tooltipWidth = tooltipRef.current?.offsetWidth ?? 0;
+  const tooltipWidth = tooltipRef.current?.offsetWidth ?? 0;
+  const tooltipHeight = tooltipRef.current?.offsetHeight ?? 0;
   // Position tooltip just below and to the left of the cursor
   const left = clamp(
-    viewportLeft - tooltipWidth + OFFSET, // min
-    tooltipPosition[0] - tooltipWidth - OFFSET, // desired position (left of cursor, small offset)
-    viewportLeft + viewportWidth - tooltipWidth - OFFSET, // max (stay in viewport)
+    viewportLeft + OFFSET, // min (stay in viewport)
+    tooltipPosition[0] - tooltipWidth - OFFSET, // desired (left of cursor)
+    viewportLeft + viewportWidth - tooltipWidth - OFFSET, // max (stay in viewport)
   );
   const top = clamp(
-    viewportTop, // min
-    tooltipPosition[1] + OFFSET, // just below cursor
-    viewportTop + viewportHeight - OFFSET, // max
+    viewportTop + OFFSET, // min
+    tooltipPosition[1] + OFFSET, // just below cursor
+    viewportTop + viewportHeight - tooltipHeight - OFFSET, // max
   );
📝 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 tooltipWidth = tooltipRef.current?.offsetWidth ?? 0;
// Position tooltip just below and to the left of the cursor
const left = clamp(
viewportLeft - tooltipWidth + OFFSET, // min
tooltipPosition[0] - tooltipWidth - OFFSET, // desired position (left of cursor, small offset)
viewportLeft + viewportWidth - tooltipWidth - OFFSET, // max (stay in viewport)
);
const top = clamp(
viewportTop, // min
tooltipPosition[1] + OFFSET, // just below cursor
viewportTop + viewportHeight - OFFSET, // max
);
const tooltipWidth = tooltipRef.current?.offsetWidth ?? 0;
const tooltipHeight = tooltipRef.current?.offsetHeight ?? 0;
// Position tooltip just below and to the left of the cursor
const left = clamp(
viewportLeft + OFFSET, // min (stay in viewport)
tooltipPosition[0] - tooltipWidth - OFFSET, // desired (left of cursor)
viewportLeft + viewportWidth - tooltipWidth - OFFSET, // max (stay in viewport)
);
const top = clamp(
viewportTop + OFFSET, // min
tooltipPosition[1] + OFFSET, // just below cursor
viewportTop + viewportHeight - tooltipHeight - OFFSET, // max
);
🤖 Prompt for AI Agents
In frontend/javascripts/viewer/view/voxel_pipette_tooltip.tsx around lines
181-192, the tooltip clamping is wrong: change the left clamp minimum from
viewportLeft - tooltipWidth to viewportLeft, and compute tooltipHeight (e.g.
tooltipRef.current?.offsetHeight ?? 0) and use it to set the top clamp maximum
to viewportTop + viewportHeight - tooltipHeight - OFFSET so the tooltip cannot
overflow the bottom of the viewport; keep the desired positions (left:
tooltipPosition[0] - tooltipWidth - OFFSET, top: tooltipPosition[1] + OFFSET)
and the existing max for left (viewportLeft + viewportWidth - tooltipWidth -
OFFSET).

Copy link
Contributor

@MichaelBuessemeyer MichaelBuessemeyer left a comment

Choose a reason for hiding this comment

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

Hej, thanks for taking on the task of implementing this cool new tool 🎉.

I left a few comments regarding the code changes and identified a bug.

Moreover, rgb layers still need to be supported, but this is already tracked in the issue description.

Comment on lines +602 to +617
if (event.shiftKey) {
VolumeHandlers.handlePickCell(position);
return;
}

const state = Store.getState();
const lastMeasuredGlobalPosition =
state.uiInformation.measurementToolInfo.lastMeasuredPosition;

if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
}
Store.dispatch(setIsMeasuringAction(true));
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd say the AI is correct here by solely reading the changes

}

static onToolDeselected() {
Store.dispatch(hideMeasurementTooltipAction());
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe also add Store.dispatch(setIsMeasuringAction(false));?

return {
leftClick: "Pick Segment",
leftClick:
_activeToolWithoutModifiers === AnnotationTool.VOXEL_PIPETTE && !shiftKey
Copy link
Contributor

Choose a reason for hiding this comment

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

Without checking the code too deeply:
Isn't it ensured that once this function is called AnnotationTool.VOXEL_PIPETTE is always the active tool? Thus, this check might not be needed 🤔

Comment on lines +182 to +189
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);

const visibleSegmentationLayer = getVisibleSegmentationLayer(Store.getState());
if (visibleSegmentationLayer == null) {
const segmentId = getSegmentIdForPosition(posInLayerSpace);
Copy link
Contributor

Choose a reason for hiding this comment

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

Am I missing something or is the globalToLayerTransformedPosition applied twice to globalPos. This seems incorrect to me.
The first call of globalToLayerTransformedPosition is here and the second one is within getSegmentIdForPosition.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok somehow this seems to work. But still. It seems like you are applying the transformations twice 🤔

Copy link
Contributor

@MichaelBuessemeyer MichaelBuessemeyer Oct 29, 2025

Choose a reason for hiding this comment

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

Ok some more looking at the code & testing showed that this is indeed broken.

  1. The "double transformation applying" of handlePickCellFromGlobalPosition does break the new voxel inspection because it is not used by the tool. Only when picking the segment id via shift + click.
  2. handlePickCellFromGlobalPosition is only used by handlePickCell which is used by other tools as well. All these tools are broken currently when using this function. e.g. the Move Tool:

  1. Open an annotation of a dataset with transformations configured (e.g. a rotation via the dataset settings).
  2. Render the active volume layer without transformations.
  3. Use the new voxel pipette tool to activate a segment (shift + click).
  4. Turn on some transformation for the volume layer (e.g. rotation) and do the same again for the same position / cell.
  5. The now copied cell has the wrong id.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can likely be fixed by applying the volume layers transformations only once I think

Comment on lines +182 to +195
const posInLayerSpace = globalToLayerTransformedPosition(
globalPos,
visibleSegmentationLayer.name,
"segmentation",
Store.getState(),
);

const visibleSegmentationLayer = getVisibleSegmentationLayer(Store.getState());
if (visibleSegmentationLayer == null) {
const segmentId = getSegmentIdForPosition(posInLayerSpace);

if (segmentId === 0) {
return;
}
Store.dispatch(setActiveCellAction(segmentId, posInLayerSpace, additionalCoordinates));

Copy link
Contributor

Choose a reason for hiding this comment

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

seems like the ai noticed the same thing here

return Math.abs(posInViewport[thirdDim]) < 1;
}

export default function VoxelValueTooltip() {
Copy link
Contributor

Choose a reason for hiding this comment

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

VoxelValueTooltip and DistanceMeasurementTooltip do have quite a lot in common. Can we maybe combine both and make specialized "sup functional components" via inheritance?

return;
}
Store.dispatch(setActiveCellAction(segmentId, globalPos, additionalCoordinates));
const posInLayerSpace = globalToLayerTransformedPosition(
Copy link
Contributor

Choose a reason for hiding this comment

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

handlePickCellFromGlobalPosition no longer needs to be exported. No other file seems to import it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tool to see voxel values at mouse cursor

3 participants