Custom Cursor Geometry & Fill Strategies
Segmentation tools expose BrushStrategy hooks that let you draw any cursor footprint and reuse the exact same geometry while painting. The cursor is not just a visual hint—BrushTool copies the geometry calculated during hover into the operationData that powers the fill strategy. This section explains how to customize both halves so a new cursor footprint (e.g. a square, diamond, or oblique polygon) fills exactly the pixels that the user expects.
Lifecycle Overview
BrushToolbuildshoverData(seeLabelmapBaseTool.createHoverData) whenever the pointer moves.- The active
BrushStrategyis asked to runStrategyCallbacks.CalculateCursorGeometry. This callback can populatehoverData.brushCursor.data.handleswith world-space points that describe the cursor. - Immediately afterwards
StrategyCallbacks.RenderCursorreceives the sameoperationDataas well as anSVGDrawingHelper. Use this callback to render the cursor in canvas space. - When the user paints,
LabelmapBaseTool.getOperationDatacopiesbrushCursor.data.handles.pointsintooperationData.pointsand forwards the originalhoverDatato the active strategy. - The strategy's
StrategyCallbacks.Initializeimplementation maps those points into index space, computesoperationData.isInObject, and optionally updatesoperationData.strokePointsWorldso the fill and cursor stay aligned even while dragging.
Keeping the cursor computation and the fill predicate in sync ensures that the sweep volume painted into the labelmap matches what was rendered on screen.
Step 1: Calculate World-Space Geometry
Implement a composition that handles StrategyCallbacks.CalculateCursorGeometry. The callback receives the enabled element, the tool configuration, and the latest hoverData:
import { Enums } from '@cornerstonejs/tools';
import type { Types } from '@cornerstonejs/core';
const { StrategyCallbacks } = Enums;
export const hexCursorComposition = {
[StrategyCallbacks.CalculateCursorGeometry]: (enabledElement, operationData) => {
const { viewport } = enabledElement;
const { configuration, hoverData } = operationData;
const { brushCursor, centerCanvas } = hoverData;
const camera = viewport.getCamera();
const brushRadius = configuration.brushSize;
const centerWorld = viewport.canvasToWorld(centerCanvas) as Types.Point3;
const polygonWorld = createHexagonCorners(
centerWorld,
camera.viewUp,
camera.viewPlaneNormal,
brushRadius
);
// BrushTool automatically copies handles.points into operationData.points.
brushCursor.data.handles = {
points: buildOrthogonalHandles(polygonWorld),
polygonWorld,
};
brushCursor.data.invalidated = false;
},
};
Guidelines:
- Normalize
viewUp/viewPlaneNormalbefore derivingviewRightso oblique planes behave consistently. - Always populate
handles.pointsin the[bottom, top, left, right]order. Existing strategies (e.g.fillCircle.ts) expect that ordering when computing centers and radii. - Attach any extra data you need (such as
polygonWorldor precomputed normals) onbrushCursor.data.handles. It will be available throughoperationData.hoverDatainside your fill strategy.
Step 2: Render the Custom Cursor
The render callback is responsible for drawing canvas-space overlays based on the world-space geometry calculated earlier. Leverage the shared SVG helpers in packages/tools/src/drawingSvg to keep the output consistent with the rest of the tooling:
import { Enums, drawing } from '@cornerstonejs/tools';
const { StrategyCallbacks } = Enums;
const { drawPolyline: drawPolylineSvg } = drawing;
hexCursorComposition[StrategyCallbacks.RenderCursor] = (
enabledElement,
operationData,
svgDrawingHelper
) => {
const { viewport } = enabledElement;
const { brushCursor } = operationData.hoverData;
const polygonWorld = brushCursor.data.handles?.polygonWorld ?? [];
if (polygonWorld.length === 0) {
return;
}
const polygonCanvas = polygonWorld.map((point) =>
viewport.worldToCanvas(point)
);
const annotationUID = brushCursor.metadata?.brushCursorUID;
drawPolylineSvg(svgDrawingHelper, annotationUID, 'hexagon', polygonCanvas, {
color: `rgb(${brushCursor.metadata.segmentColor?.slice(0, 3) ?? [0, 255, 0]})`,
lineDash:
operationData.centerSegmentIndexInfo.segmentIndex === 0 ? [1, 2] : undefined,
closed: true,
});
};
Keep the render step lightweight—BrushTool triggers it on every mouse move. Avoid re-computing world-space data here; cache everything during CalculateCursorGeometry.
Step 3: Build a Matching Fill Strategy
A fill strategy is a BrushStrategy instance that wires together reusable compositions. The class lives in packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts (or @cornerstonejs/tools/dist/tools/segmentation/strategies/BrushStrategy when consuming the npm package). The StrategyCallbacks.Initialize portion is where you convert the cursor geometry into the predicate used by compositions.regionFill:
import BrushStrategy from '@cornerstonejs/tools/dist/tools/segmentation/strategies/BrushStrategy';
import { Enums, utilities } from '@cornerstonejs/tools';
import { utilities as csUtils } from '@cornerstonejs/core';
const { StrategyCallbacks } = Enums;
const { getBoundingBoxAroundShapeIJK } = utilities.boundingBox;
const { transformWorldToIndex } = csUtils;
const {
regionFill,
setValue,
determineSegmentIndex,
preview,
labelmapStatistics,
} = BrushStrategy.COMPOSITIONS;
const initializeHexagon = {
[StrategyCallbacks.Initialize]: (operationData) => {
const { segmentationImageData, hoverData } = operationData;
const worldPolygon = hoverData?.brushCursor?.data?.handles?.polygonWorld;
if (!Array.isArray(worldPolygon) || worldPolygon.length === 0) {
return;
}
const polygonIJK = worldPolygon.map((worldPoint) =>
transformWorldToIndex(segmentationImageData, worldPoint)
);
operationData.isInObject = createPointInPolygon(worldPolygon, segmentationImageData);
operationData.isInObjectBoundsIJK = getBoundingBoxAroundShapeIJK(
polygonIJK,
segmentationImageData.getDimensions()
);
// Preserve stroke continuity for drag operations.
operationData.strokePointsWorld = [
...(operationData.strokePointsWorld ?? []),
...worldPolygon,
];
},
};
export const HEXAGON_STRATEGY = new BrushStrategy(
'Hexagon',
regionFill,
setValue,
initializeHexagon,
determineSegmentIndex,
preview,
labelmapStatistics,
hexCursorComposition
);
export const fillInsideHexagon = HEXAGON_STRATEGY.strategyFunction;
createPointInPolygon above represents whichever predicate you implement to classify voxels. Many strategies cache both the polygon plane and its normal so the predicate can avoid redundant transforms.
Important details:
operationData.isInObjectmust be an efficient point-in-shape predicate because it runs on every candidate voxel.- Always update
operationData.isInObjectBoundsIJK;regionFillshort-circuits iteration using this bounding box. - Reuse
operationData.strokePointsWorldto describe the swept volume of a drag. Strategies such asfillCircle.tsdensify the stroke to avoid holes when the cursor moves faster than the event rate. - Compose existing helpers such as
getBoundingBoxAroundShapeIJK,pointInSphere, or custom polygon math to keep the predicates deterministic.
Wiring the Strategy into BrushTool
Register the new strategy function with the BrushTool configuration inside your ToolGroup:
import { addTool, BrushTool, ToolGroupManager, Enums } from '@cornerstonejs/tools';
import { fillInsideHexagon } from './strategies/fillHexagon';
addTool(BrushTool);
const toolGroup = ToolGroupManager.createToolGroup('segmentationGroup');
toolGroup.addTool(BrushTool.toolName);
const brushConfig =
toolGroup.getToolConfiguration(BrushTool.toolName) ?? {};
toolGroup.setToolConfiguration(
BrushTool.toolName,
{
...brushConfig,
strategies: {
...(brushConfig.strategies ?? {}),
FILL_INSIDE_HEXAGON: fillInsideHexagon,
},
defaultStrategy: 'FILL_INSIDE_HEXAGON',
activeStrategy: 'FILL_INSIDE_HEXAGON',
},
true
);
toolGroup.setToolActive(BrushTool.toolName, {
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
strategy: 'FILL_INSIDE_HEXAGON',
});
Use setToolConfiguration if you need to swap strategies at runtime:
toolGroup.setToolConfiguration(BrushTool.toolName, {
activeStrategy: 'FILL_INSIDE_HEXAGON',
});
BrushStrategy automatically falls back to the default circular cursor when a composition does not implement the cursor callbacks, so you can selectively apply the custom cursor only to the strategies that require it.
Matching Cursor and Fill Logic: Best Practices
- Share world-space data: Write every geometry primitive you need into
brushCursor.data.handles. The fill strategy can read it back fromoperationData.hoverDatawithout recomputing. - Stay idempotent: Callbacks may run multiple times per frame. Avoid mutating shared instances; clone vectors with
vec3.clonebefore caching. - Obey coordinate systems:
CalculateCursorGeometryworks in world coordinates,RenderCursorworks in canvas coordinates, andInitializemust convert to IJK usingtransformWorldToIndex. - Guard performance: Keep predicates branch-light and memoize expensive transforms.
BrushStrategyexecutes inside tight voxel loops. - Test point-in-shape functions: Jest unit tests similar to
packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.tshelp catch regressions. - Handle fast drags: Populate
operationData.strokePointsWorld(and densify long segments) so the predicate covers every point swept by the cursor.
By following these steps you can confidently deliver new cursor footprints together with matching fill behavior, ensuring artists see exactly what will be written into their segmentations.