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:
| Brush | Shape | Depth |
|---|---|---|
| Circle / ellipse | createEllipseShape, flat | From the view slab thickness |
| Sphere | createCircleShape with depthRadius | Its own radius |
| Rectangle | createRectangleShape, flat | One 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 oneT_vaway. 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.
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
| File | Role |
|---|---|
core/src/utilities/voxelSlab/iterateVoxelsInShape.ts | The iterator |
core/src/utilities/voxelSlab/shapes/ | The shapes, including createUnionShape |
tools/src/tools/segmentation/strategies/utils/brushVoxelSlab.ts | The brush shapes and the fill |
tools/src/tools/segmentation/strategies/compositions/regionFill.ts | The Fill callback |
tools/src/tools/segmentation/LabelmapBaseTool.ts | The contour to labelmap fill |
tools/src/utilities/sampleAreaAnnotationVoxels.ts | The annotation side, and the shared index bounds |