Skip to main content

Planar Fill Iteration

This page documents which voxels a brush fill touches, and how the fill finds them. It is aimed at contributors who write a fill strategy, or who need to reason about the behaviour on a rotated or oblique viewport.

The brush fills use the same iterator, the same shapes and the same membership rule as the area annotation tools. That rule is normative, and Voxel Statistics defines it. Read that page first; this page only covers what is specific to a fill.

Background: the fill contract

The Initialize step of a brush strategy puts two things on operationData:

  • isInObjectBoundsIJK — an IJK bounding box [[iMin, iMax], [jMin, jMax], [kMin, kMax]] that limits which voxels the fill visits.
  • isInObject(pointLPS, pointIJK) — a predicate that decides whether a visited voxel is inside the shape.

The Fill step (regionFill) hands both to the voxel manager, which walks every voxel in the box and calls the predicate:

for (let k = kMin; k <= kMax; k++) {
for (let j = jMin; j <= jMax; j++) {
for (let i = iMin; i <= iMax; i++) {
const pointLPS = indexToWorld([i, j, k]);
if (pointInShapeFn(pointLPS, [i, j, k])) {
// fill
}
}
}
}

Why the box walk fails on an oblique plane

A circle or a rectangle brush paints a thin sheet. When the sheet is axis-aligned its IJK box is one voxel thick, so the loop above is already tight. When the sheet is oblique the axis-aligned box that encloses it is large along all three axes, while only an O(N²) sheet lies inside it. The loop therefore tests O(N³) voxels to fill O(N²), and the waste grows with the obliquity.

The cost is the smaller problem. The predicate must also reject every off-plane voxel the loop should never have visited, and it does that with a depth-tolerance term. Too small a tolerance leaves holes in the sheet; too large a tolerance bleeds the fill into the neighbouring slices. No single value is right for every orientation, which is what made the oblique brush shapes wrong.

The shared voxel slab iterator

strategies/utils/brushVoxelSlab.ts replaces both halves. It describes the brush as a VoxelSlabShape anchored on the view plane, and enumerates the voxels of that shape with csUtils.voxelSlab.iterateVoxelsInShape.

The iterator emits exact integer runs along one voxel axis, nested inside the slab's own bounds along the normal, so:

  • it visits only voxels the brush covers, plus the rows it touches — never the volume of the bounding box;
  • it visits each voxel exactly once, which matters because a fill that writes a voxel twice records two undo entries for it;
  • it needs no depth tolerance, because the slab bound is exact for every orientation.

Each brush builds one shape per stroke centre, and createUnionShape merges their runs into a disjoint sequence:

BrushShapeDepth
Circle / ellipsecreateEllipseShape, flatFrom the view slab thickness
SpherecreateCircleShape with depthRadiusIts own radius
RectanglecreateRectangleShape, flatOne voxel along the normal

regionFill falls back to the box walk when a strategy builds no fill — a degenerate brush, or a strategy that has not moved onto the iterator yet — so isInObject and isInObjectBoundsIJK are still required.

What the view slab thickness controls

A circle or a rectangle brush is flat: it lies in the view plane and carries no depth of its own. The reference plane thickness therefore decides how deep the fill reaches along the normal.

  • Thin (single-slice) view — the default, and what you get when the viewport reports no slab thickness. The depth is one voxel measured along the normal, so the fill paints one oblique layer. Rule F makes the half width T_v / 2, and a slab of that thickness is a standard digital plane: it holds every voxel that the plane passes through, and it shares no voxel with the fill one T_v away. A thinner slab leaves holes, and a thicker slab writes two layers. The depth interval of Rule F is half-open, which gives the shared boundary to exactly one of two consecutive fills. An open interval gives that boundary to neither of the two, and at 45 degrees an open interval therefore makes half of the voxels unreachable by any fill.
  • Full-thickness (thick-slab) view — the view thickness passes through as the fill depth, so the flat disc becomes a short cylinder and the fill paints every layer through the slab. The half-open interval makes the count of layers exactly F / T_v, whatever the position of the plane between two layers.

A sphere brush is the exception. It carries its own depth, reports that depth through getRequiredThickness, and so ignores the view slab entirely. A sphere keeps Rule M, because the shape's own runs already bound the fill.

caution

A fill and the viewport must step by the same distance. The digital planes of Rule F are T_v apart, and T_v is the L1 measure. A volume viewport steps by getSpacingInNormalDirection, which is the L2 measure, and L2 is shorter than L1 for an oblique normal. Two consecutive oblique slices therefore fall inside one digital plane, and both slices show the same fill. The fill is correct in that case, and the step is wrong.

Area semantics for a thick-slab fill

A thin fill is one voxel deep, so its in-plane area is the painted voxel count times the voxel area. A thick-slab fill is a volume, not a planar region: an area computed over its voxels must divide by the depth in voxels, or the extra layers over-count the area. This affects area measurements only. A freeform fill that computes no area is unaffected.

A stroke

The pointer reports a handful of positions per stroke, and a fast drag leaves them further apart than the brush is wide. The union of discs at only those positions would be a dotted line, so brushVoxelSlab.ts resamples the stroke at half the smaller radius, which guarantees that consecutive discs overlap.

The circle brush projects every disc onto the one view plane, so a stroke paints a single oblique layer however far the pointer travelled. The sphere brush does not project its centres, so a stroke sweeps a true tube.

A contour that becomes a labelmap

LabelmapBaseTool converts a closed contour annotation to labelmap voxels, and that conversion is a fill. It therefore uses this same design: the polyline is the shape, through createPolylineShape, and iterateVoxelsInShape enumerates the voxels.

createPolylineShape is flat, so it reports no thickness of its own. The conversion takes a depth of one voxel along the contour's own normal, and the coverage 'centerInside', which is Rule F. The contour plane comes from the annotation and never from the camera, so the same contour over the same volume writes the same voxels at any zoom, any pan and any slab thickness.

Where the code lives

FileRole
core/src/utilities/voxelSlab/iterateVoxelsInShape.tsThe iterator
core/src/utilities/voxelSlab/shapes/The shapes, including createUnionShape
tools/src/tools/segmentation/strategies/utils/brushVoxelSlab.tsThe brush shapes and the fill
tools/src/tools/segmentation/strategies/compositions/regionFill.tsThe Fill callback
tools/src/tools/segmentation/LabelmapBaseTool.tsThe contour to labelmap fill
tools/src/utilities/sampleAreaAnnotationVoxels.tsThe annotation side, and the shared index bounds