Skip to content

fix: improved brush shape for rotated images - #2743

Merged
wayfarer3130 merged 33 commits into
cornerstonejs:mainfrom
arul-trenser:fix-improved-brush-shape-for-rotated-images
Sep 16, 2026
Merged

wayfarer3130 merged 33 commits into
cornerstonejs:mainfrom
arul-trenser:fix-improved-brush-shape-for-rotated-images

Conversation

@Devu-trenser

@Devu-trenser Devu-trenser commented May 25, 2026

Copy link
Copy Markdown
Contributor

Context

The brush tools wrote the wrong voxels on a rotated or oblique viewport. A
circular brush and a spherical brush each drew a distorted shape. A rectangular
brush drew an unstable shape, and the rectangle changed when the user changed
the view orientation.

Fixes: #2651

FlyWheel.io contributed the first version of this pull
request.

The cause

Each brush fill did two steps. First the fill made an axis-aligned bounding box
in IJK index space. Then the fill tested every voxel in that box with a
predicate.

An oblique brush breaks both steps:

  • The box is large along all three axes, but the brush covers only a thin sheet
    inside the box. The fill therefore tests O(N³) voxels to write O(N²)
    voxels.
  • The predicate must reject every voxel that the box holds and the sheet does
    not contain. The predicate does this with a depth tolerance. A small
    tolerance leaves holes in the sheet. A large tolerance writes voxels in the
    neighbouring slices. No single value is correct for every orientation.

The second point is the defect. The first point is only a cost.

Changes

main carries the shared oblique-capable voxel iterator of #2893. This pull
request moves the brush fills onto that iterator.

Each brush is now a VoxelSlabShape on the view plane, and
csUtils.voxelSlab.iterateVoxelsInShape gives the voxels of that shape:

Brush Shape Depth along the normal
Circle and ellipse createEllipseShape, flat The view slab thickness
Sphere createCircleShape with depthRadius Its own radius
Rectangle createRectangleShape, flat One voxel

The iterator gives exact integer runs of voxels along one voxel axis, and the
slab of Rule M bounds those runs along the normal. No fill holds a depth
tolerance any more, because the slab bound is exact at every orientation.

The area annotation tools measure with the same iterator, the same shapes and
the same rule. A brush therefore writes the voxels that an annotation of the
same shape reports.

New file in core: createUnionShape

The shared design had no way to describe a stroke. A stroke writes the union of
one disc for each sample of the pointer, and one shape cannot describe that
union. createUnionShape merges the runs of its members into one disjoint
sequence in ascending order. Two results follow:

  • The iterator visits a voxel that two discs share one time only. A fill that
    writes a voxel two times records two undo entries for that voxel.
  • The cost is the member count for each row. A per-voxel predicate over the
    same members costs the member count for every voxel of the bounding box.

New file in tools: brushVoxelSlab.ts

This file holds the three shape builders, and the function that gives the
voxels to the regionFill composition. regionFill keeps the bounding box walk
as a fallback path, for a degenerate brush and for a strategy that this pull
request does not move.

The plane of the rectangle

The rectangle fill now takes its plane from the four corners that the user
drew, and no longer takes the plane from the camera. orderRectangleCorners
gives the corners a stable winding order, so the two edges and the diagonal are
always the same pairs of corners. This is what makes the rectangle stable when
the view orientation changes. RectangleScissorsTool passes viewPlaneNormal
and viewUp to the strategy, which the fill needs for a degenerate rectangle.

Shared index bounds

sampleAreaAnnotationVoxels exports its index bounds helper as
getShapeIndexBounds. The annotation code and the brush code now bound their
iteration with one implementation.

The files

The diff is 17 files, 2357 insertions and 186 deletions.

File Change
core/.../voxelSlab/shapes/createUnionShape.ts New. The union of shapes
core/test/voxelSlabUnionShape.jest.js New. Tests for the union
tools/.../strategies/utils/brushVoxelSlab.ts New. The brush shapes and fill
tools/.../strategies/__tests__/brushVoxelSlab.spec.ts New. Tests for the fills
tools/.../strategies/__tests__/fillRectangle.spec.ts New. Tests for the corner order
docs/.../segmentation/planar-fill-iteration.md New. The fill contract
tools/.../strategies/fillCircle.ts Builds the circle fill
tools/.../strategies/fillSphere.ts Builds the sphere fill
tools/.../strategies/fillRectangle.ts Builds the rectangle fill
tools/.../strategies/compositions/regionFill.ts Reads the fill, or falls back
tools/.../strategies/BrushStrategy.ts The brushVoxelSlabFill field
tools/.../segmentation/RectangleScissorsTool.ts Passes the view axes
tools/src/utilities/sampleAreaAnnotationVoxels.ts Exports getShapeIndexBounds
core/.../voxelSlab/shapes/index.ts Exports the union
docs/sidebars.js Adds the new page
docs/.../migration-guides/5x/1-migration-notes.md Adds a migration note
tools/examples/labelmapSegmentationTools/index.ts The controls for the manual test

A note on the commit history of this branch

The branch first added a second oblique voxel iterator of its own, in
csUtils.obliqueIntegerIterator, csUtils.iterateOverPlane,
csUtils.getInPlaneSpacingAndXYDirections and
strategies/utils/obliqueIntegerFill.ts. #2893 then merged the shared iterator
into main. The two solve the same problem, so a later commit on this branch
removes the second iterator and keeps the shared one. None of those four
modules reached a release, and none of them appears in the diff of this pull
request. A reviewer who reads the commits one at a time will see them, and a
reviewer who reads only the diff will not.

The same commit returns two files to the version in main, so neither file
appears in the diff:

  • getSubPixelSpacingAndXYDirections uses
    getEffectiveSpacingAlongDirection, which measures how far to step to cross
    one voxel. The branch had sent that path through
    getSpacingInNormalDirection, which answers a different question. The
    repository keeps one utility for one question.
  • The fallback bounds of fillSphere follow the stroke again. The branch had
    replaced those bounds with a box around one centre, and that box clips a drag.

Results

  • A circular brush, a spherical brush and a rectangular brush each write the
    correct shape at every view orientation.
  • A rectangular brush is stable, because its plane comes from the corners and
    not from the camera.
  • A thin view writes one oblique layer of voxels. A full-thickness view writes
    every layer through the slab. An area computation over a full-thickness fill
    must divide by the depth in voxels.
  • A fill writes one layer of voxel centres. The old bounding box walk wrote
    voxels in the neighbouring slices, and this fill does not.
  • One layer of voxels is still thicker than one oblique slice, because a voxel
    is a box and not a plane. The paint therefore shows on more than one oblique
    slice. The Testing section gives the measurements.

Testing

Automated tests, which I ran

  • packages/core: 42 test suites and 728 tests. All tests pass.
  • packages/tools: 55 test suites and 778 tests. All tests pass.
  • tsc --noEmit reports no error for packages/core and for packages/tools.
  • oxlint reports 0 errors. prettier --check reports no change for every
    file that this pull request touches.

packages/core/test/voxelSlabUnionShape.jest.js tests createUnionShape
against the brute-force reference implementation of Rule M. The test covers
oblique angles from 1 degree to 89 degrees, and it asserts that no voxel
appears two times.

packages/tools/src/tools/segmentation/strategies/__tests__/brushVoxelSlab.spec.ts
asserts that:

  • a thin view writes one layer, and a thick slab writes every layer;
  • a spherical brush writes a complete sphere, and writes no voxel outside that
    sphere, at every angle from 0 degrees to 90 degrees;
  • every voxel that a fill writes passes the depth test of Rule M against the
    plane that the user drew. This test covers the exact defect of the old
    bounding box walk.

Manual test with a cornerstonejs example

This pull request updates the labelmapSegmentationTools example, so that a
reviewer can make a plane oblique and then paint on that plane. The example now
holds a crosshairs tool, a slider for an exact oblique angle, and slice
navigation on the mouse wheel.

Run the example:

pnpm install
pnpm example labelmapSegmentationTools

The command opens http://localhost:3000/, and shows an axial viewport, a
sagittal viewport and a coronal viewport of one CT volume.

The controls:

Control Action
Left click Paint with the tool of the first dropdown
Shift + left click Erase with the circular brush
Middle click drag Move the crosshairs, or drag a reference line
Middle click drag on a circle handle Rotate the planes to an oblique angle
Mouse wheel Navigate the slices of the viewport below the pointer
Ctrl + left click Pan
Right click Zoom
Crosshairs button Turn the crosshairs off, and give the middle button to the pan tool
Axial Oblique Angle slider Tilt the axial plane by an exact angle
Reset Cameras button Return every viewport to its first orientation

The steps:

  1. Move the Axial Oblique Angle slider to 45 degrees. The axial viewport now
    shows an oblique plane, and the axial reference line in the sagittal
    viewport is at 45 degrees.
  2. Select CircularBrush in the first dropdown. Paint one stroke in the axial
    viewport.
  3. Look at the sagittal viewport and at the coronal viewport. The stroke must
    make one continuous oblique line, and that line must hold no hole. Before
    this pull request the line held holes, or the paint reached the
    neighbouring slices.
  4. Select SphereBrush, and paint at the same angle. The paint must show a
    circle in all three viewports.
  5. Select RectangleScissorsTool, and drag a rectangle in the axial viewport.
    Then rotate the planes with a circle handle of the crosshairs. The paint
    must keep the shape of the rectangle that you drew.
  6. Press Reset Cameras. Then rotate the planes with a circle handle of the
    crosshairs in the sagittal viewport, and do step 2 and step 3 again. The
    result must be the same for an angle that the crosshairs give.

What one layer of voxels looks like on an oblique plane

A reviewer who moves the mouse wheel one slice in the oblique viewport still
sees the paint. This is correct, and the reason is geometry, not the fill.

The example loads a CT volume with a voxel spacing of 0.977 mm by 0.977 mm by
3.27 mm. At an oblique angle of 45 degrees:

  • The voxel thickness along the normal is 3.003 mm. This is the L1 support
    width of the voxel box, which getVoxelThicknessAlongNormal gives.
  • One slice step along the same normal is 2.413 mm, which
    getSpacingInNormalDirection gives.

A voxel is a box, so one layer of voxels reaches about 1.5 mm to each side of
the plane, and the paint covers about 6 mm along the normal. Six millimetres is
about 2.5 slice steps. The paint therefore shows on 3 or 4 oblique slices, and
it is still one layer of voxels. The 3.27 mm slice thickness of the source data
causes this, and no fill can make the layer thinner.

Measurements in a browser

I painted in the example in Chromium, and then I read the labelmap voxels with
a script. The paint is one layer, and the layer holds no hole.

One stroke at 45 degrees, 6287 voxels:

  • The signed distance from a voxel centre to the plane is between -1.517 mm and
    +1.517 mm. This is 0.505 of the voxel thickness along the normal, so the
    paint is one layer of voxel centres.
  • 3231 columns of the volume hold one painted voxel, and 1528 columns hold two.
    No column holds three. Two voxels at the step of the staircase is the minimum
    for an oblique layer that holds no hole.

One click at each angle. For each test the script takes about 15000 points on
the plane inside the disc, finds the nearest voxel of each point, and asks
whether the fill painted that voxel. A point that finds an unpainted voxel is a
hole:

Angle Painted voxels Holes
7 degrees 2120 0 of 15018
23 degrees 2232 0 of 15032
38 degrees 2068 0 of 14995
45 degrees 2004 0 of 14915
52 degrees 1748 0 of 15008
60 degrees 1550 0 of 14995

A note on the thickness that the fill uses

fillCircle reads viewport.getSlabThickness(). An orthographic volume
viewport with no slab returns RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS,
which is 0.05 mm. That value is finite and greater than 0, so
resolveReferencePlaneThickness keeps it instead of the fallback of one voxel.
The half width of Rule M is therefore (0.05 + Tv) / 2, which is about
Tv / 2, and not the Tv that Rule M gives an annotation.

The measurements above show that this half width still leaves no hole. Tv is
an L1 width and the slice step is an L2 width, and an L1 width is never smaller
than an L2 width, so Tv / 2 always covers half a slice step. A half width of
Tv would paint a layer up to two times thicker, which a brush does not want.

A viewport with a real slab thickness is a different case, and I did not test
it. getSlabThickness returns a half thickness, so a thick slab fills half of
the depth that the viewport shows.

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: Windows 11"
  • "Node version: 24.20.0"
  • "Browser: Chromium 147.0.7727.15, which Playwright 1.59.1 gives"

🤖 Generated with Claude Code

@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

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@Devu-trenser
Devu-trenser force-pushed the fix-improved-brush-shape-for-rotated-images branch from 2c133c5 to c9fd4f5 Compare May 26, 2026 05:14
@Devu-trenser Devu-trenser changed the title Fix improved brush shape for rotated images fix: improved brush shape for rotated images Jun 15, 2026
@coderabbitai

coderabbitai Bot commented Jun 17, 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

Shared geometry helpers and an oblique integer voxel iterator are added, then wired into circle, rectangle, sphere, and region fill strategies. Polyline spacing now delegates to the shared helper. Tests and documentation cover the new behavior.

Changes

Planar spacing and fill iteration

Layer / File(s) Summary
In-plane spacing and plane iteration
packages/core/src/utilities/getInPlaneSpacingAndXYDirections.ts, packages/core/src/utilities/iterateOverPlane.ts, packages/core/src/utilities/index.ts, packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts
getInPlaneSpacingAndXYDirections computes in-plane spacing and directions from image geometry, iterateOverPlane visits unique voxels across an oriented plane or slab, and the polyline spacing helper now delegates to the shared geometry path. The utilities barrel exports both helpers and related iterator types.
Oblique integer basis and voxel enumeration
packages/core/src/utilities/obliqueIntegerIterator.ts, packages/core/src/utilities/index.ts
obliqueIntegerIterator defines the integer oblique basis, basis construction, range clipping, ellipsoid slice ranges, and voxel traversal helpers. The utilities barrel re-exports the module and related types.
Circle, rectangle, sphere, and region fill
packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts, packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts, packages/tools/src/tools/segmentation/strategies/fillCircle.ts, packages/tools/src/tools/segmentation/strategies/fillRectangle.ts, packages/tools/src/tools/segmentation/strategies/fillSphere.ts, packages/tools/src/tools/segmentation/strategies/utils/obliqueIntegerFill.ts
fillCircle, fillRectangle, and fillSphere now normalize view vectors, use voxel-center or ordered-corner geometry, and apply plane-based bounds or membership checks for oblique views. Rectangle corner ordering, oblique fill descriptors, and region fill wiring are updated accordingly.
Tests and documentation
packages/core/test/utilities/*, packages/tools/src/tools/segmentation/strategies/__tests__/*, packages/docs/docs/behaviour/*, packages/docs/docs/concepts/cornerstone-tools/segmentation/planar-fill-iteration.md, packages/docs/docs/migration-guides/5x/*, packages/docs/sidebars.js
Tests cover the new geometry utilities, oblique integer iterator, and oblique fill wiring, while the docs sidebar, concept page, behaviour page, and migration notes describe planar fill iteration and oblique voxel behavior.

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

Suggested reviewers: sedghi

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
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.
Title check ✅ Passed The title is concise, uses the semantic-release format, and accurately describes the main change: improving brush shapes for rotated images.
Description check ✅ Passed The description is complete and relevant. It includes context, issue reference, detailed changes, expected results, automated and manual testing instructions, tested environment details, and completed…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 4

🧹 Nitpick comments (3)
packages/tools/src/tools/segmentation/strategies/fillCircle.ts (1)

71-76: 💤 Low value

Corner ordering inconsistency with destructuring at line 314.

The return order is [topRight, topLeft, bottomRight, bottomLeft] but the destructuring at line 314 expects [topLeft, bottomRight, bottomLeft, topRight]. This mismatch will cause incorrect axis calculations when the corners are consumed.

However, looking at the usage at line 265, strokeCornersWorld is only used to compute boundsIJK via getBoundingBoxAroundShapeIJK, which is order-agnostic. The cornersInWorld used in createPointInEllipse comes from the canvas-derived corners at lines 223-226, not from createCircleCornersForCenter. So this ordering doesn't affect the actual shape test.

Consider aligning the return order for consistency
   return [
-    topRight as Types.Point3,
-    topLeft as Types.Point3,
-    bottomRight as Types.Point3,
-    bottomLeft as Types.Point3,
+    topLeft as Types.Point3,
+    bottomRight as Types.Point3,
+    bottomLeft as Types.Point3,
+    topRight as Types.Point3,
   ];
🤖 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/segmentation/strategies/fillCircle.ts` around lines
71 - 76, The return statement in the function (around lines 71-76) returns
corners in the order [topRight, topLeft, bottomRight, bottomLeft], but the
destructuring at line 314 expects them in the order [topLeft, bottomRight,
bottomLeft, topRight]. Reorder the return statement to match the expected
destructuring order at line 314. Change the return array to put topLeft first,
then bottomRight, then bottomLeft, then topRight to maintain consistency with
how the corners are consumed elsewhere in the code.
packages/tools/src/tools/segmentation/strategies/fillSphere.ts (1)

14-14: ⚡ Quick win

Unused import: getSphereBoundsInfoFromViewport.

The import is still present but the function is no longer used after the bounds computation was changed to use direct IJK calculation.

Remove unused import
-import { getSphereBoundsInfoFromViewport } from '../../../utilities/getSphereBoundsInfo';
🤖 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/segmentation/strategies/fillSphere.ts` at line 14,
Remove the unused import statement for getSphereBoundsInfoFromViewport from the
imports at the top of the fillSphere.ts file. This function is no longer needed
since the bounds computation has been changed to use direct IJK calculation
instead of relying on the getSphereBoundsInfoFromViewport utility function.
packages/tools/src/tools/segmentation/strategies/fillRectangle.ts (1)

151-160: 💤 Low value

Full projectedSpacing used as thickness may be too permissive.

The comment says "Using full projected spacing gives more stable voxel occupancy," but using the full spacing rather than half-spacing as a tolerance means voxels up to one full voxel away from the plane are included. This could include voxels from adjacent slices in thin-slice scenarios.

For consistency with fillCircle.ts which uses spacingInNormal / 2 for planeTolerance, consider whether this intentional difference is desired.

🤖 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/segmentation/strategies/fillRectangle.ts` around
lines 151 - 160, The thickness variable is set to the full projectedSpacing
value, which may be overly permissive and include voxels from adjacent slices in
thin-slice scenarios. For consistency with fillCircle.ts which uses
spacingInNormal divided by 2 for planeTolerance, evaluate whether the thickness
assignment should be changed to use projectedSpacing divided by 2 instead of the
full value. Review the intention and adjust if the full spacing is not
intentionally required for this particular case.
🤖 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/src/tools/segmentation/strategies/fillCircle.ts`:
- Around line 330-338: The spacingInNormal calculation in the fillCircle
strategy currently assumes viewNormal is always available when calling
getSpacingInNormalDirection, but viewNormal is optional and may be undefined.
Refactor the conditional logic to explicitly check if viewNormal is defined
before attempting to call getSpacingInNormalDirection with the cast to
Types.Point3. If viewNormal is undefined, provide an appropriate fallback that
does not assume Z-axis spacing is suitable for all orientations. Additionally,
reconsider whether the current condition (spacing && direction) is sufficient,
as getSpacingInNormalDirection might handle cases where only one of these
parameters is defined.
- Around line 427-441: The ellipse equation test does not check the
strokePredicate when a point falls outside the static ellipse boundary (when
xTerm + yTerm > 1), unlike the sphere case which checks strokePredicate before
its sphere test. To fix this, after the ellipse equation check at line 434 (the
condition checking if xTerm + yTerm <= 1), add a check for the strokePredicate
similar to how it is handled in the sphere case. This will ensure that
stroke-based fills work correctly for ellipses during brush strokes by
evaluating the strokePredicate for points outside the static ellipse.

In `@packages/tools/src/tools/segmentation/strategies/fillRectangle.ts`:
- Around line 120-135: The axis construction in the fillRectangle function
assumes that after angular sorting, p0, p1, and p3 form adjacent edges from a
corner, but there is no validation that this assumption holds. If the sorting
produces a different order like diagonal pairs, then axisU and axisV would not
be orthogonal and the normal calculation would be incorrect. After obtaining
orderedPoints, validate adjacency by comparing distances from p0 to each other
point—the two shortest distances should correspond to p1 and p3 (the adjacent
edges), while the longest should be to p2 (the diagonal). If this condition is
not met, reorder the points so that p1 and p3 are correctly identified as the
adjacent corner points before constructing the axes and normal.

In `@packages/tools/src/tools/segmentation/strategies/fillSphere.ts`:
- Around line 97-112: The boundsIJK variable is computed without clamping to
valid image dimensions, unlike the fillRectangle.ts strategy. After calculating
the boundsIJK array using centerIJK and radiusIJK values, clamp each dimension's
min and max bounds to the range [0, dims[i]-1] for each of the three dimensions
I, J, and K, where dims are the image dimensions. This should be done before
assigning the clamped bounds to operationData.isInObjectBoundsIJK to prevent
out-of-bounds array access errors.

---

Nitpick comments:
In `@packages/tools/src/tools/segmentation/strategies/fillCircle.ts`:
- Around line 71-76: The return statement in the function (around lines 71-76)
returns corners in the order [topRight, topLeft, bottomRight, bottomLeft], but
the destructuring at line 314 expects them in the order [topLeft, bottomRight,
bottomLeft, topRight]. Reorder the return statement to match the expected
destructuring order at line 314. Change the return array to put topLeft first,
then bottomRight, then bottomLeft, then topRight to maintain consistency with
how the corners are consumed elsewhere in the code.

In `@packages/tools/src/tools/segmentation/strategies/fillRectangle.ts`:
- Around line 151-160: The thickness variable is set to the full
projectedSpacing value, which may be overly permissive and include voxels from
adjacent slices in thin-slice scenarios. For consistency with fillCircle.ts
which uses spacingInNormal divided by 2 for planeTolerance, evaluate whether the
thickness assignment should be changed to use projectedSpacing divided by 2
instead of the full value. Review the intention and adjust if the full spacing
is not intentionally required for this particular case.

In `@packages/tools/src/tools/segmentation/strategies/fillSphere.ts`:
- Line 14: Remove the unused import statement for
getSphereBoundsInfoFromViewport from the imports at the top of the fillSphere.ts
file. This function is no longer needed since the bounds computation has been
changed to use direct IJK calculation instead of relying on the
getSphereBoundsInfoFromViewport utility function.
🪄 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: d759a66a-da29-4877-a2bd-3a73b4936c03

📥 Commits

Reviewing files that changed from the base of the PR and between 2769fcf and 756ba0a.

📒 Files selected for processing (3)
  • packages/tools/src/tools/segmentation/strategies/fillCircle.ts
  • packages/tools/src/tools/segmentation/strategies/fillRectangle.ts
  • packages/tools/src/tools/segmentation/strategies/fillSphere.ts

Comment thread packages/tools/src/tools/segmentation/strategies/fillCircle.ts
Comment thread packages/tools/src/tools/segmentation/strategies/fillCircle.ts
Comment thread packages/tools/src/tools/segmentation/strategies/fillRectangle.ts
Comment thread packages/tools/src/tools/segmentation/strategies/fillSphere.ts Outdated

@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.

I'm needing to make more exhaustive changes to correct a number of edge cases/flaws in how this is currently working. Will see if I can get this fixed this week still.

@sen-trenser

Copy link
Copy Markdown

I'm needing to make more exhaustive changes to correct a number of edge cases/flaws in how this is currently working. Will see if I can get this fixed this week still.

@wayfarer3130 Could you please let us know what changes and edge cases are planned? It would help us determine which of the above AI review comments to address with priority.

Thanks!

@wayfarer3130

Copy link
Copy Markdown
Collaborator

I'm needing to make more exhaustive changes to correct a number of edge cases/flaws in how this is currently working. Will see if I can get this fixed this week still.

@wayfarer3130 Could you please let us know what changes and edge cases are planned? It would help us determine which of the above AI review comments to address with priority.

Thanks!

The underlying problem for this PR and several others is a general handling of the oblique cut of the display versus the underlying data, and the differences between a planar oblique cut and a volume in a specific view. Without fixing that, it is hard to figure out when the correct voxels get filled and whether the computed area/setup is correct for the new display area.

Basically you have a transform view => world => integer image coordinates, but what you want is view => view voxel coordinates => integer image coordinates so that you can iterate over view voxel coordinates where the z coordinate is orthogonal to the view plane, and filling the x,y integer voxel coordinates for a specific integer z coordinate fills exactly 1 oblique plane without doubling up on any two voxels. That is a difficult setup to create, although I think I have a handle on it now. That coordinate iterator/space then needs to be used consistently.

There is also one pair of optimizations that is definitely worth considering, which is to use a view voxel bounding box and view voxel range functions that return a list of pairs of entry/exit points into the area so that the inclusion test is just done with entry/exit pairs, and not by testing every instance in the area. The fill function is then a simple integer index range fill in view voxel coordinates, with the oblique fill just being a particular instance of the volume fill. That looks something like:

for(const z in viewVoxelBounds.getZBounds()) {
  const yBound = viewVoxelBounds.getYBoundForZ(z); 
  for(const y in yBound) {
     for(const x in viewVoxelBounds.getXBounds(y,z)) {
        fillFunction(x,y,z)
     }
  }
}

@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: 2

🤖 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/core/src/utilities/iterateOverPlane.ts`:
- Around line 77-106: Reject non-positive subPixelResolution in iterateOverPlane
before computing stepU, stepV, and stepW. Add a fail-fast check near the option
destructuring in iterateOverPlane so callers cannot pass 0 or negative values,
since those values break the sampling loop behavior. Use the existing
iterateOverPlane function and its subPixelResolution option as the place to
validate and throw an error before any spacing-derived steps are calculated.

In
`@packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts`:
- Around line 26-30: The geometry helper in getSubPixelSpacingAndXYDirections
should not fall through and return nothing when viewport.getImageData() is
unavailable, since that breaks the promised { spacing, xDir, yDir } contract.
Update the function to either throw an explicit error at this guard or change
its return type to optional and propagate that contract through every caller
that destructures the result. Make the fix at the
getSubPixelSpacingAndXYDirections boundary so callers never receive an
unexpected undefined.
🪄 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: 48f475f0-0289-47dc-ab26-f6cc4214e082

📥 Commits

Reviewing files that changed from the base of the PR and between e93f3d0 and bdba761.

📒 Files selected for processing (9)
  • packages/core/src/utilities/getInPlaneSpacingAndXYDirections.ts
  • packages/core/src/utilities/index.ts
  • packages/core/src/utilities/iterateOverPlane.ts
  • packages/core/test/utilities/getInPlaneSpacingAndXYDirections.jest.js
  • packages/core/test/utilities/iterateOverPlane.jest.js
  • packages/docs/docs/concepts/cornerstone-tools/segmentation/planar-fill-iteration.md
  • packages/docs/docs/migration-guides/5x/1-migration-notes.md
  • packages/docs/sidebars.js
  • packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts
✅ Files skipped from review due to trivial changes (3)
  • packages/docs/sidebars.js
  • packages/core/test/utilities/getInPlaneSpacingAndXYDirections.jest.js
  • packages/docs/docs/concepts/cornerstone-tools/segmentation/planar-fill-iteration.md

Comment thread packages/core/src/utilities/iterateOverPlane.ts Outdated
Comment thread packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts Outdated

@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: 4

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

972-1051: 🚀 Performance & Scalability | 🔵 Trivial

Verify call frequency of createObliqueIntegerBasis for interactive brush strokes.

The drift-bounded search in choosePrimitiveIntegerNormal scales the denominator search (needed = ceil(1/sinThreshold)+1) with volume extent — for large volumes this can run into the hundreds/low-thousands of iterations. If createObliqueIntegerBasis is rebuilt on every mouse-move while dragging a brush (rather than once per interaction/plane-normal change), this repeated search could add per-frame latency in oblique views.

Worth confirming the call site caches the basis while viewPlaneNormal/viewUp/volume geometry are unchanged during a single brush stroke.

🤖 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/obliqueIntegerIterator.ts` around lines 972 -
1051, `createObliqueIntegerBasis` is doing an expensive normal search via
`choosePrimitiveIntegerNormal`, so verify it is not rebuilt on every mouse-move
during a brush drag. Update the interactive brush call site to cache and reuse
the `ObliqueIntegerBasis` for the duration of a stroke, only recomputing when
`viewPlaneNormal`, `viewUp`, `viewRight`, or volume geometry changes. Use the
`createObliqueIntegerBasis` entry point and the brush interaction handler to
locate the caching logic.
🤖 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/core/src/utilities/obliqueIntegerIterator.ts`:
- Around line 477-486: `applyMatrix3` is multiplying the matrix as if the sliced
row vectors were columns, so it computes the transpose result instead of the
intended row-major transform. Update the logic in `applyMatrix3` to use each row
from `m` as a row dot product with `v`, so `orientBasisToViewUp` evaluates
`A`/`B` in the correct world-space direction for rotated or oblique volumes.

In `@packages/docs/docs/behaviour/obliqueVoxels.md`:
- Around line 50-52: The inline code span in the obliqueVoxels documentation has
a trailing space after w =, which triggers the markdown lint warning. Update the
affected sentence in the documentation so the inline code for the w assignment
is tightly written without any extra whitespace, keeping the wording and the
surrounding explanation unchanged.
- Around line 34-39: Add language tags to the fenced example blocks in
obliqueVoxels.md so markdownlint stops failing; update each affected fence near
the oblique voxel examples to declare a language such as text, including the
blocks around the equations and any other fenced snippets noted in the review.
Use the existing documentation examples in the same section to locate the fences
and ensure every triple-backtick block has a valid language tag.

In
`@packages/tools/src/tools/segmentation/strategies/utils/obliqueIntegerFill.ts`:
- Around line 89-103: The spacing math in worldLengthPerLatticeStep is incorrect
because the three lattice-axis contributions are being summed first and then
multiplied by spacing per world component, which distorts the result for rotated
volumes with anisotropic spacing. Update worldLengthPerLatticeStep so each term
in the world vector is scaled by its own axis spacing inside the iVec/jVec/kVec
contributions, keeping the logic in obliqueIntegerFill aligned with the intended
uScale/vScale calculation and the brush sizing behavior.

---

Nitpick comments:
In `@packages/core/src/utilities/obliqueIntegerIterator.ts`:
- Around line 972-1051: `createObliqueIntegerBasis` is doing an expensive normal
search via `choosePrimitiveIntegerNormal`, so verify it is not rebuilt on every
mouse-move during a brush drag. Update the interactive brush call site to cache
and reuse the `ObliqueIntegerBasis` for the duration of a stroke, only
recomputing when `viewPlaneNormal`, `viewUp`, `viewRight`, or volume geometry
changes. Use the `createObliqueIntegerBasis` entry point and the brush
interaction handler to locate the caching logic.
🪄 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: 13173eb3-4792-4fda-b2bf-1146fdcd38a8

📥 Commits

Reviewing files that changed from the base of the PR and between bdba761 and fe4c0d9.

📒 Files selected for processing (14)
  • packages/core/src/utilities/index.ts
  • packages/core/src/utilities/obliqueIntegerIterator.ts
  • packages/core/test/utilities/obliqueIntegerIterator.jest.js
  • packages/docs/docs/behaviour/index.md
  • packages/docs/docs/behaviour/obliqueVoxels.md
  • packages/docs/docs/migration-guides/5x/3-oblique-integer-voxels.md
  • packages/docs/sidebars.js
  • packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts
  • packages/tools/src/tools/segmentation/strategies/__tests__/obliqueIntegerFill.spec.ts
  • packages/tools/src/tools/segmentation/strategies/compositions/regionFill.ts
  • packages/tools/src/tools/segmentation/strategies/fillCircle.ts
  • packages/tools/src/tools/segmentation/strategies/fillRectangle.ts
  • packages/tools/src/tools/segmentation/strategies/fillSphere.ts
  • packages/tools/src/tools/segmentation/strategies/utils/obliqueIntegerFill.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/docs/docs/migration-guides/5x/3-oblique-integer-voxels.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/tools/src/tools/segmentation/strategies/fillRectangle.ts
  • packages/tools/src/tools/segmentation/strategies/fillSphere.ts

Comment on lines +477 to +486
function applyMatrix3(m: mat3 | number[], v: Point3): Point3 {
const iVec = m.slice(0, 3) as Point3;
const jVec = m.slice(3, 6) as Point3;
const kVec = m.slice(6, 9) as Point3;
return [
iVec[0] * v[0] + jVec[0] * v[1] + kVec[0] * v[2],
iVec[1] * v[0] + jVec[1] * v[1] + kVec[1] * v[2],
iVec[2] * v[0] + jVec[2] * v[1] + kVec[2] * v[2],
] as Point3;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the oblique iterator test suite exercises orientBasisToViewUp / applyMatrix3
# with a non-identity direction matrix (rotated volume).
fd 'obliqueIntegerIterator.jest.js' | xargs -I{} rg -n -C3 'orientBasisToViewUp|applyMatrix3|direction' {}

Repository: cornerstonejs/cornerstone3D

Length of output: 3620


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant helper implementations and matrix construction.
FILE='packages/core/src/utilities/obliqueIntegerIterator.ts'

echo '--- applyMatrix3 and nearby helpers ---'
sed -n '450,560p' "$FILE"

echo
echo '--- voxelToWorld matrix construction and indexNormalCovectorFromWorld usage ---'
sed -n '1000,1055p' "$FILE"

echo
echo '--- locate mat3 helpers/imports ---'
rg -n 'from "@?gl-matrix"|mat3|applyMatrix3|indexNormalCovectorFromWorld|orientBasisToViewUp|voxelToWorld' "$FILE"

Repository: cornerstonejs/cornerstone3D

Length of output: 5884


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE='packages/core/src/utilities/obliqueIntegerIterator.ts'

echo '--- indexNormalCovectorFromWorld ---'
sed -n '180,250p' "$FILE"

echo
echo '--- createObliqueIntegerBasis tests around direction handling ---'
sed -n '340,560p' "$FILE"

echo
echo '--- rotation probe for applyMatrix3 vs row-major M·v ---'
python3 - <<'PY'
import math

# Row-major 3x3 matrix with columns = direction axes * spacing
direction = [
    0, -1, 0,
    1,  0, 0,
    0,  0, 1,
]
spacing = [1, 1, 1]

iVec = direction[0:3]
jVec = direction[3:6]
kVec = direction[6:9]
M = [
    iVec[0]*spacing[0], jVec[0]*spacing[1], kVec[0]*spacing[2],
    iVec[1]*spacing[0], jVec[1]*spacing[1], kVec[1]*spacing[2],
    iVec[2]*spacing[0], jVec[2]*spacing[1], kVec[2]*spacing[2],
]

v = [1, 0, 0]

# Intended row-major M·v
mv = [
    M[0]*v[0] + M[1]*v[1] + M[2]*v[2],
    M[3]*v[0] + M[4]*v[1] + M[5]*v[2],
    M[6]*v[0] + M[7]*v[1] + M[8]*v[2],
]

# Current implementation: row slices combined by component => M^T·v
mtv = [
    M[0]*v[0] + M[3]*v[1] + M[6]*v[2],
    M[1]*v[0] + M[4]*v[1] + M[7]*v[2],
    M[2]*v[0] + M[5]*v[1] + M[8]*v[2],
]

print("M =", M)
print("M·v =", mv)
print("M^T·v =", mtv)
PY

Repository: cornerstonejs/cornerstone3D

Length of output: 9669


applyMatrix3 uses the matrix rows as columns. m.slice(0,3)/(3,6)/(6,9) are the row vectors of the row-major voxel-to-world matrix, but this formula computes Mᵀ·v instead of M·v. That makes orientBasisToViewUp score A/B in the wrong world-space direction for rotated/oblique volumes; switch to row dot products here.

🤖 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/obliqueIntegerIterator.ts` around lines 477 -
486, `applyMatrix3` is multiplying the matrix as if the sliced row vectors were
columns, so it computes the transpose result instead of the intended row-major
transform. Update the logic in `applyMatrix3` to use each row from `m` as a row
dot product with `v`, so `orientBasisToViewUp` evaluates `A`/`B` in the correct
world-space direction for rotated or oblique volumes.

Comment thread packages/docs/docs/behaviour/obliqueVoxels.md Outdated
Comment thread packages/docs/docs/behaviour/obliqueVoxels.md Outdated
Comment thread packages/tools/src/tools/segmentation/strategies/utils/obliqueIntegerFill.ts Outdated
@sen-trenser

Copy link
Copy Markdown

Hi @wayfarer3130 ,
Some playwright tests are failing after the Oblique offset calculations changes.
Please let us know if you are updating the playwright tests or the calculation logic to fix this.

Thanks!

@wayfarer3130

Copy link
Copy Markdown
Collaborator

Temporary tracking branch

I've pushed an updated copy of this branch to my own fork rather than to this PR's head:

wayfarer3130/cornerstone3Dfix-improved-brush-shape-for-rotated-images

This is a temporary tracking branch only — a convenience copy so the merged state can be built and tested. It is not a replacement for this PR, and nothing on arul-trenser/cornerstone3D was modified; this PR's head branch is untouched.

What that branch contains on top of this PR's head:

  1. Merge of origin/main (at 5.8.2, 7034957b10). One conflict, in packages/docs/docs/migration-guides/5x/1-migration-notes.md — both sides appended a new section to the end of the guide, so it was a purely additive text collision. Both sections are kept, upstream's touch-action: none note first and this branch's in-plane voxel iteration note last, matching history order.
  2. One cleanup commit removing two imports left unused by the oblique fill rewrite: getSphereBoundsInfoFromViewport in fillSphere.ts (bounds now come from the sphere radius in IJK) and the vec3 runtime import in obliqueIntegerIterator.ts.

Verification on the merged state: prettier clean; 183 unit tests passing (packages/core/test/utilities plus the tools strategy and spatial specs, including the new obliqueIntegerIterator, iterateOverPlane, getInPlaneSpacingAndXYDirections, obliqueIntegerFill and fillRectangle suites); packages/core typechecks clean. The packages/tools typecheck errors are pre-existing stale-dist resolution artifacts (they include untouched symbols such as growCutLog and replaceCurrentMemo) and clear after rebuilding core.

Note that I deliberately did not revert the prettier reformatting in packages/tools/src/utilities/spatial/. Those files as they stand on main fail this repo's own .prettierrc (prettier 3.6.2, trailingComma: "es5"), while the versions on this branch pass — so the reformatting here is correct and reverting it would introduce lint failures.

Feel free to pull from that branch or ignore it; it carries no changes intended for review beyond the merge and the two-line import cleanup.

@sen-trenser

Copy link
Copy Markdown

Temporary tracking branch

I've pushed an updated copy of this branch to my own fork rather than to this PR's head:

wayfarer3130/cornerstone3Dfix-improved-brush-shape-for-rotated-images
...
Feel free to pull from that branch or ignore it; it carries no changes intended for review beyond the merge and the two-line import cleanup.

Thank you for the updates and for merging the latest changes from the head.

The following Playwright tests are currently failing in this MR:

  1. tests/labelmapsegmentationtools.spec.ts:508:7 › Basic manual labelmap Segmentation tools › should render and allow usage of sephere scissor
  2. tests/genericViewport/genericLabelmapOverlapPlayground.spec.ts:138:7 › Labelmap Overlap Playground - Next › should render overlapping labelmaps on stack and orthographic views
  3. tests/genericViewport/genericLabelmapSegmentationTools.spec.ts:275:7 › Labelmap Segmentation Tools - Next (GPU) › should paint with sphere brush (next GPU)
  4. tests/genericViewport/genericLabelmapSegmentationTools.spec.ts:343:7 › Labelmap Segmentation Tools - Next (CPU) › should paint with sphere brush (next CPU)
  5. tests/genericViewport/genericLabelmapSliceRenderingTools.spec.ts:143:7 › Labelmap Slice Rendering Tools - Next › should paint with sphere brush using useSliceRendering
  6. tests/genericViewport/genericStackLabelmapSegmentation.spec.ts:76:7 › Stack Labelmap Segmentation - Next (GPU) › should paint a brush stroke (next GPU)

Could you also let us know whether you plan to address them or would prefer us to work on fixing these tests?

Additionally, could you please confirm whether any further actions are required to proceed with merging this MR, and indicate who will be responsible for each outstanding item?

We would also appreciate your confirmation as to whether the changes in this MR will be ready for merge after the failing tests have been resolved.

Thanks!

wayfarer3130 and others added 5 commits September 10, 2026 13:59
…ape-for-rotated-images

Resolves three conflicts:

- packages/core/src/utilities/index.ts - both sides added an export; keep
  both.
- packages/docs/docs/migration-guides/5x/1-migration-notes.md - both sides
  appended a new section; keep both.
- packages/tools/src/utilities/math/polyline/getSubPixelSpacingAndXYDirections.ts
  - both sides rewrote the oblique spacing path. Keep this branch's version
  for now; the next commit moves it onto the shared core utility.

main now carries the shared oblique-capable voxel iterator of cornerstonejs#2893
(packages/core/src/utilities/voxelSlab), which does the same job as this
branch's obliqueIntegerIterator. The next commit removes the duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ator

This branch added its own oblique voxel iterator, and cornerstonejs#2893 then landed the
shared one on main. The two solve the same problem, so this removes the
duplicate and moves the brush fills onto the shared design.

The brush fills now describe a brush as a `VoxelSlabShape` anchored on the view
plane and enumerate its voxels with `csUtils.voxelSlab.iterateVoxelsInShape`:

- a circle or ellipse brush is `createEllipseShape`, flat, with the depth from
  the view slab thickness;
- a sphere brush is `createCircleShape` with a depth radius, which reports the
  thickness it needs and ignores the view slab;
- a rectangle brush is `createRectangleShape`, flat, one voxel deep, on the
  plane the drawn corners define rather than on the camera plane.

A brush is therefore measured by exactly the rule that an area annotation of
the same shape reports, and no fill carries a depth tolerance of its own. The
tolerance was the defect: too small a value left holes in an oblique sheet, too
large a value bled the fill into the neighbouring slices, and no single value is
right for every orientation.

Adds `createUnionShape` to core, which is the one piece the shared design
lacked. A brush stroke paints the union of one disc per sample, and a single
shape cannot describe that. The union merges its members' runs into a disjoint
ascending sequence, so the iterator visits a shared voxel once - a fill that
writes a voxel twice records two undo entries for it. Cost is per row times the
member count, where a per-voxel predicate over the same members costs the member
count for every voxel of the bounding box.

Removed, all of it unreleased and specific to the removed design:

- `csUtils.obliqueIntegerIterator`, `csUtils.iterateOverPlane` and
  `csUtils.getInPlaneSpacingAndXYDirections`, with their tests.
- `strategies/utils/obliqueIntegerFill.ts` and its test.
- The `behaviour/` docs directory and the oblique integer voxel migration guide.

`getSubPixelSpacingAndXYDirections` goes back to main's version. This branch had
refactored it onto `getInPlaneSpacingAndXYDirections`, whose oblique path used
`getSpacingInNormalDirection`; main resolves the same case with
`getEffectiveSpacingAlongDirection`, which is the measure that answers "how far
to step to cross one voxel". One utility for that question, not two.

`fillSphere` keeps main's stroke-aware fallback bounds, which this branch had
replaced with a single-centre box that clips a drag. The bounds now only serve
the fallback path, but a regression there is still a regression.

`sampleAreaAnnotationVoxels` exports its index bounds helper as
`getShapeIndexBounds`, so the annotation side and the brush side bound their
iteration with one implementation.

Tests: `createUnionShape` is checked against the brute-force reference
implementation of Rule M at oblique angles from 1 to 89 degrees, with a
no-duplicates assertion throughout. The brush fills assert that a thin view
paints one layer, that a thick slab paints every layer, that a sphere is
complete and contained at every orientation from 0 to 90 degrees, and that every
painted voxel satisfies Rule M's depth test against the drawn plane - which is
the bleed the bounding-box walk produced.

Ref: cornerstonejs#2651
Ref: cornerstonejs#2889

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tationTools

Adds the controls that a reviewer needs to test a brush on a rotated plane:

- Crosshairs on the middle mouse button. The pan tool keeps Ctrl + left click,
  and the "Crosshairs" toggle gives the middle button back to the pan tool.
- Slice navigation on the mouse wheel.
- An "Axial Oblique Angle" slider, which tilts the axial plane about the world
  X axis by an exact angle. The rotation keeps the focal point of the first
  camera and recomputes the position, because setCamera with a viewPlaneNormal
  alone turns the camera about its position and moves the focal point out of
  the volume.
- A "Reset Cameras" button.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module header repeated the slab thickness rules and the area semantics
of a thick-slab fill, which planar-fill-iteration.md already gives. Replace
the two sections with a reference to that page, and keep a short list of the
wrapping that the file adds over the shared shapes and the shared iterator.

Trim the function comments to the caveat that each function owns: the
unprojected centres of a sphere stroke, the copy that the undo memo needs,
the overlap step of a stroke, and the thickness that the bounds and the
iterator must share.

No change to the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tools half of Rule F reached the branch in 136539b without the core
half, so the branch did not compile. Add `getFillHalfWidth` and the
`membershipHalfWidth` option, finish the rename of `slabThicknessWorld` to
`viewThicknessWorld` in fillCircle.ts, and document Rule F.

Rule M gives a half width of `(T + T_v) / 2`, which is `T_v` for a fill of
one voxel of depth. That slab holds two layers of voxels, and the user drew
one layer. Rule F gives `max(F, T_v) / 2` instead.

The depth of `T_v` is exact. A slab of thickness `T_v` is a standard digital
plane, because `T_v` is the L1 length of the index-space normal. Such a slab
holds every voxel that the plane passes through, and it shares no voxel with
the fill one `T_v` away. Consecutive fills therefore tile the volume.

Measurements over a disc of 8 mm, against the iterator:

| case                     | overlap at T_v | overlap at L2 | coverage |
| ------------------------ | -------------- | ------------- | -------- |
| axis aligned, 1 mm       | 0 of 197       | 0 of 197      | 100%     |
| oblique 30 deg, 1 mm     | 0 of 273       | 74 of 273     | 100%     |
| oblique 45 deg, 1 mm     | 0 of 139       | 0 of 139      | 100%     |
| double oblique, 1 mm     | 0 of 335       | 135 of 335    | 100%     |
| double oblique, CT       | 0 of 385       | 85 of 385     | 100%     |

A viewport steps by `getSpacingInNormalDirection`, which is the L2 measure.
L2 is shorter than L1 for an oblique normal, so two consecutive slices fall
inside one digital plane and both slices show the same fill. That step is a
separate defect, and this commit does not change it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sen-trenser

Copy link
Copy Markdown

@wayfarer3130 Thank you for updating the PR.
Could you please let us know if there is any next steps for this PR to unblock it from merging?

Thanks!

wayfarer3130 and others added 2 commits September 14, 2026 10:00
Add `rendering.sliceStepMeasure` to the configuration. The value `'l2'` is
the default, and `'l2'` keeps the historic behaviour exactly. No test and no
viewport changes unless a caller sets `'l1'`.

The two measures give the distance between two adjacent slices:

- `'l2'`, from `getSpacingInNormalDirection`: `sqrt( Σᵢ (n · aᵢ · sᵢ)² )`.
- `'l1'`, from `getVoxelThicknessAlongNormal`: `Σᵢ |n · aᵢ| · sᵢ`, which is
  how far one voxel reaches along the normal.

The two measures are equal when the normal is parallel to a voxel axis, so
an acquisition-orientation view behaves the same either way. The L2 value is
the smaller one for an oblique normal, so a viewport steps less than the
width of one voxel, and two adjacent oblique slices show some of the same
voxels. One brush stroke then appears on the next slice and on the previous
slice. Issue cornerstonejs#2912 gives the measurements.

Both slice paths read the flag, and the two paths agree:

- `getTargetVolumeAndSpacingInNormalDir`, which the volume viewport scrolls
  through.
- `planarSliceBasis`, which the planar GenericViewport uses.

The labelmapSegmentationTools example gets a "Slice step: voxel width (L1)"
toggle, next to the "Axial Oblique Angle" slider, to demonstrate the two
measures. Set an angle, paint a stroke, then step one slice and back. The
toggle calls `scroll(0)` on each viewport, which moves no slice and rounds
the focal point onto the new step, so the measure takes effect at once and
the current position is kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iagnostics

The "Axial Oblique Angle" slider set the focal point to the centre of the
volume. That centre does not sit on the grid of slice positions of the new
normal, so a brush fill wrote a plane that lies between two slice positions.
The slice that the user painted on looked solid, and each neighbouring slice
showed a part of the same plane as a set of horizontal lines.

`setAxialObliqueAngle` now calls `scroll(0)`. That call moves no slice, and
it rounds the focal point onto the nearest slice position. A fill then
writes one plane that one slice shows completely, and that no other slice
shows. Issue cornerstonejs#2912 asks for an offset rule that removes the need for this
call.

Add two diagnostics, both off by default, and a toolbar toggle that turns
the two on:

- `cs3d.core.utilities.getTargetVolumeAndSpacingInNormalDir` reports the
  measure, the L1 value, the L2 value and the value the viewport uses. It
  reports an oblique viewport only, and only when the numbers change,
  because the function runs on every render.
- `cs3d.tools.tools.segmentation.brushVoxelSlab` reports the depth spread of
  the voxels that one fill writes. A fill of one digital plane gives a
  `depthSpanInVoxels` below 1.

Both diagnostics compute nothing when the level is above debug. Add
`getLevel` to the `Logger` type for that test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wayfarer3130

Copy link
Copy Markdown
Collaborator

How to test that a fill writes exactly one plane

The branch now fills with Rule F, and the labelmapSegmentationTools example has the controls that show the result. This comment gives the procedure.

What Rule F is

Rule M gives a half width of (T + T_v) / 2, which is T_v for a fill of one voxel of depth. That slab holds two layers of voxels, and the user drew one layer. Rule F gives max(F, T_v) / 2 instead, where F is the depth that the fill covers.

The depth of T_v is exact, and it is not a compromise. T_v is the L1 length of the index-space normal, so a slab of thickness T_v is a standard digital plane. Such a slab holds every voxel that the plane passes through, and it shares no voxel with the fill one T_v away. Consecutive fills therefore tile the volume, with no hole and no overlap.

The procedure

  1. Run the labelmapSegmentationTools example.
  2. Turn on "Slice step: voxel width (L1)". A viewport then steps by T_v, which is the thickness of one digital plane.
  3. Press "Reset Cameras", and set "Axial Oblique Angle" to 45 degrees.
  4. Paint one circle in the centre of the axial viewport.
  5. Step one slice forward, and then two slices backward.

The fill fills the painted slice completely. The slice before and the slice after show nothing.

Turn "Slice step: voxel width (L1)" off and repeat the procedure to see the historic behaviour: each neighbouring slice then shows a part of the fill.

The numbers

Turn on "Debug logs: slice step and brush fill" for the measurements. Both diagnostics are off by default, and both compute nothing when the level is above debug.

cs3d.tools.tools.segmentation.brushVoxelSlab reports one line for each fill. The line has this form:

brush fill painted=<count> T_v=<value> halfWidth=<value> halfWidth/T_v=<value>
  depthSpanInVoxels=<value> distinctLayers=<count> ONE PLANE
  • depthSpanInVoxels below 1 means the fill wrote one digital plane. This is the value to check, and the line ends with ONE PLANE or MORE THAN ONE PLANE for the same test.
  • halfWidth/T_v is 0.5 for a fill of one voxel of depth, which is Rule F.

cs3d.core.utilities.getTargetVolumeAndSpacingInNormalDir reports one line for each change of the step, for an oblique viewport only. The line has this form:

slice step [<viewportId>] measure=<l1|l2> used=<value> l1=<value> l2=<value>
  l1/l2=<value> fromSlabThickness=<true|false>

l1/l2 is the factor by which the historic step is too small. The factor is 1.00 for an acquisition orientation, it is 1.41 at 45 degrees about one axis, and it reaches 1.73 for a double oblique view. Issue #2912 gives the measured values.

Two limits of the current branch

The slice step. The fill writes one digital plane, and the planes are T_v apart. A viewport steps by getSpacingInNormalDirection, which is the L2 measure, and L2 is shorter than L1 for an oblique normal. Two adjacent slices therefore fall inside one digital plane without the flag. The flag rendering.sliceStepMeasure is experimental, and the flag takes the value 'l2' by default, so this branch changes no default behaviour. Issue #2912 holds the measurements and the proposed rule.

A thick slab. getSlabThickness returns a half thickness, and fillCircle passes that value as the full depth, so a fill in a thick-slab view writes half of the depth that the viewport shows. This is not new in this branch: Rule M had the same error, with a half width of (F_half + T_v) / 2. The default path is unaffected, because an orthographic viewport returns 0.05 mm and max(0.05, T_v) resolves to T_v.

A thick slab has a second problem. max(F, T_v) / 2 tiles only when F is a multiple of T_v. With F of 6 mm over voxels of 1 mm, a fill at layer 12 writes the layers 10 to 14, and a fill at layer 18 writes the layers 16 to 20. No fill writes layer 15.

I propose to correct both thick-slab problems in a separate pull request, because a brush in a thick-slab view is a different case from the oblique defect that this pull request fixes. Tell me if you prefer both in this one.

🤖 Generated with Claude Code

wayfarer3130 and others added 5 commits September 14, 2026 11:42
A brush fill selected the voxels whose depth fell in an open interval. An
open interval drops both of its boundaries, so a voxel centre that lands
exactly on a boundary belonged to neither of two consecutive fills. No fill
wrote that voxel, at any plane position.

A voxel centre lands on a boundary when the depths of the centres are
commensurate with the slab. A rotation of 45 degrees about one voxel axis is
the common case, because the depths are then multiples of `T_v / 2`.

A measurement over a volume of 25 x 25 x 25 voxels, over every plane
position, gives the count of voxels that no fill writes:

| case                            | open interval | half-open interval |
| ------------------------------- | ------------- | ------------------ |
| axis aligned, 1 mm              | 0             | 0                  |
| oblique 30 degrees, 1 mm        | 0             | 0                  |
| oblique 45 degrees, 1 mm        | 7800 of 15625 | 0                  |
| oblique 60 degrees, 1 mm        | 0             | 0                  |
| oblique 45 degrees, anisotropic | 0             | 0                  |

Rule F now uses a half-open interval, and the low boundary is the closed
end. Each voxel then belongs to exactly one of two consecutive fills, and
consecutive fills tile the volume at every orientation.

The epsilon moves the interval instead of narrowing it, so the interval keeps
a width of exactly `2 * halfWidth`. A narrower interval would leave a gap
between two consecutive fills.

The half-open interval also fixes the count of layers of a thick fill. A
half-open interval of width `F` holds exactly `F / T_v` layers, whatever the
position of the plane between two layers. The open interval gave one layer
more or one layer less by that position, so a view of 6 mm over voxels of
1 mm wrote 5 layers or 6 layers. The test now expects 6.

Rule M and Rule D keep the open interval, and this commit changes no
measurement. `depthInterval` defaults to `'open'`, and only the brush fill
passes `'half-open'`. Rule M needs the open interval, because the widened
slab of Rule M must report both layers for a plane midway between two
layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A circle brush read `viewport.getSlabThickness()` and passed that value as
the depth of the fill. `getSlabThickness` does not return a depth on the
volume viewport. `setOrientationOfClippingPlanes` puts the two clipping
planes at `focalPoint ± slabThickness`, so the depth the viewport shows is
twice the value. A fill in a thick-slab view therefore wrote half of the
depth that the user saw. A view of 20 mm wrote 10 mm.

`BaseVolumeViewport.getReferencePlaneThickness` already made the conversion
for an annotation, and the conversion was private, so the brush repeated the
error instead of sharing the correct code.

Add `utilities.getViewSlabDepth` to core, which takes a full depth and
reports "no slab" as undefined. `getReferencePlaneThickness` now calls it,
so one definition serves both.

Add `utilities.getViewSlabDepthOfViewport` to the tools package, which
selects the render path, because only one of the two paths needs the
doubling:

- A volume viewport stores a half thickness, so the helper doubles it.
- A generic planar viewport uses `vtkImageResliceMapper`, where the field is
  already a full thickness, so the helper passes it through.

A stack viewport has no slab API, and the helper reports undefined, so the
fill falls back to one voxel along the normal. A viewport with no slab
returns the rendering minimum, which also reports undefined.

This affects a thick-slab view only. An orthographic viewport with no slab
resolves to `max(0.1, T_v)`, which is `T_v` for any real voxel, so the
default path does not change. The tests cover the conversion, and the
existing fill tests pass a depth directly and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The half-open interval belongs to Rule F, and a sphere brush does not use
Rule F. A shape that carries its own depth reports that depth through
`getRequiredThickness`, and `buildBrushFill` then takes the half width from
Rule M. The previous commit gave every fill the half-open interval, so a
sphere took the half width of Rule M with the interval of Rule F, and the
sphere wrote the layer on its far boundary as well.

The depth interval now follows the same branch as the half width:

- A flat brush, which is a circle, an ellipse or a rectangle, takes Rule F
  and the half-open interval.
- A shape with its own depth, which is a sphere, takes Rule M and the open
  interval.

This restores the voxels that a sphere brush wrote before the half-open
commit, exactly. A Playwright run of the sphere brush tests gives the same
count of differing pixels with this commit as with the open interval on
every fill, which is what a change of no effect looks like against a
baseline from another machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 and others added 6 commits September 14, 2026 14:26
The review of this branch found the same explanation written in several
places, and several comments that record why this branch exists.

- Keep the `sliceStepMeasure` explanation in `Cornerstone3DConfig` only.
  The two call sites and `init.ts` now point at that option.
- Keep one link to issue 2912, in `Cornerstone3DConfig`. A reader of the
  other five sites reaches a closed issue and learns nothing.
- Keep the Rule M and Rule F rationale in `slabMembership.ts` only.
  `indexSpaceSlab.ts` and `iterateVoxelsInShape.ts` now point at
  `getFillHalfWidth`. The short "Measurement code must leave this unset"
  warnings stay, because each one states a real constraint.
- Remove the paragraph in `fillCircle.ts` that repeats the documentation
  of `getViewSlabDepth`, and the sentences that describe the old
  bounding-box walk.
- Rewrite the telegraphic comments in `fillRectangle.ts`, and remove the
  two comments that restate the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`createSphereBrushFill` took one `radiusWorld`, which `fillSphere.ts`
computed as `distance(points[0], points[1]) / 2`. That value has two
defects.

The value does not divide by the aspect ratio, and `xRadius` and
`yRadius` both do. A viewport whose width and height differ stretches
the two in-plane axes, so the brush painted a different size than the
cursor showed.

The value also drops `xRadius`, which comes from `points[2]` and
`points[3]`. A brush with two different radii became a sphere.

`createSphereBrushFill` now takes `xRadius`, `yRadius` and `viewUp`, and
builds an ellipsoid with `createEllipseShape`. The two radii are equal on
a square viewport, which makes the shape a sphere. The depth semi-axis is
the larger of the two radii, because the normal is not drawn and carries
no stretch.

This changes the voxels that the sphere brush writes. The four Playwright
baselines for the sphere brush need a new image.

The commit also renames the `brushVoxelSlab` logger. The name was
`cs3d.tools.tools.segmentation.brushVoxelSlab`, because `toolsLog` is
already `cs3d.tools`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The brush fills now walk the brush shape itself, and not the
axis-aligned bounding box around the shape. The boundary voxels change,
so four baselines need a new image.

- `genericLabelmapSegmentationTools.spec.ts/sphereBrush.png`
- `genericLabelmapSegmentationTools.spec.ts/cpu-sphereBrush.png`
- `genericLabelmapSliceRenderingTools.spec.ts/sphereBrush.png`
- `genericLabelmapOverlapPlayground.spec.ts/viewport.png`

Each difference is at the rim of the painted region only. The position
and the size of the region do not change.

This machine gives the same pixel counts as the CI runner for these four
tests, and for the fifth failure that this commit does not address.

`genericStackLabelmapSegmentation.spec.ts:76` still fails. That test
reads a baseline that the legacy `circularBrush.spec.ts` owns, and
`compareAgainstSharedBaseline` writes a shared baseline only when the
file is absent. The legacy test still passes against that same image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`regionFill` paints through the voxel slab iterator, and three other
paths test one point at a time through `VoxelManager.forEach`:
`crossLayerErase`, `labelmapOverlap` and `determineSegmentIndex`. Those
three read `operationData.isInObject`, which still described the old
world-space ball over the axis-aligned bounding box.

On an oblique plane the two regions differ. Another layer then loses the
labels that the brush never painted, and keeps the labels that a
thick-slab fill must remove.

`createBrushFillPredicate` builds a point test from the fill itself, and
`applyBrushFill` records the fill, the point test and the bounds
together. The three brush strategies call `applyBrushFill`, so the fill
and `isInObject` can no longer describe two different regions. A new
test walks the bounds of three fills and asserts that the point test and
the iterator select the same voxels.

The commit also fixes three defects that the same review found.

`createPointInEllipse` added half a voxel to the index before it called
`transformIndexToWorld`. `indexToWorld` returns the voxel centre
already, so the test was half a voxel out on the two branches of
`VoxelManager.forEach` that pass no world point.

`createPointInEllipse` tested `zDist <= planeTolerance`, which flattens
the shape into one plane. `fillSphere` reuses that function, so the
sphere's fallback test became a disc. The function now takes an optional
`depthRadius`, and a caller that supplies one gets an ellipsoid.

`forEachBrushFillVoxel` spread the whole per-voxel depth array into
`Math.max`. A thick-slab fill of about 150000 voxels threw a
`RangeError`, and an empty fill reported `NaN`. The minimum and the
maximum now accumulate in the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`genericStackLabelmapSegmentation.spec.ts:76` reads the baseline that
the legacy `circularBrush.spec.ts` owns, and it reads that baseline with
the default `maxDiffPixelRatio` of 0. The brush fills now walk the brush
shape itself, which moves 16 pixels at the rim of the painted stroke, so
the test failed.

`compareAgainstSharedBaseline` writes a shared baseline only when the
file is absent, so `--update-snapshots` cannot rewrite this image. The
new image is the `-actual.png` of run 34885115875, which is the image
that the CI runner produced.

The legacy owner of the baseline keeps passing. That test reads the same
image with `maxDiffPixelRatio: 0.06`, and 16 pixels of about 1000000 is
far below that limit.

The eight `-actual.png` files of the four attempts of the CI run are
byte-identical, and this machine renders the same image byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stack segmentation tests allowed a difference of 6 percent, and the
dynamic threshold tests allowed 10 percent. A fill defect that moves a
whole layer of voxels stays below those limits, so the tests could not
find it.

Each new limit comes from a measurement. This machine renders the same
image as the CI runner for these tests, so the difference that remains
is the true one:

- `circularBrush` 0 pixels, `circleScissor` 0 pixels, and
  `dynamicThreshold` Initial Highlight 0 pixels. Those two files now use
  the default limit of 0.
- `circularEraser1` 3 pixels (0.000011), `circularEraser2` 9 pixels
  (0.000034), `sphereBrush` 32 pixels (0.000122), `dynamicThreshold`
  150 pixels (0.000572), `rectangleScissor` 213 pixels (0.000813). Each
  of those files now uses 0.002, which is about 2.5 times the largest
  measurement.

The per-pixel threshold drops from 0.01 to the default 0.005 in every
file of this group.

`tests/labelmapsegmentationtools.spec.ts` keeps `threshold: 0.01` and
`maxDiffPixelRatio: 0.01`. Those tests draw a 1024x1024 volume viewport,
and this machine differs from the baseline by 0.34 to 0.84 percent. That
limit absorbs the difference between one graphics driver and another, and
a tighter limit would fail on a machine that is not the CI runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 and others added 3 commits September 15, 2026 10:03
Two baselines were stale, and the CI run of e314d72 showed both.

`genericStackLabelmapSegmentation.spec.ts:113` reads `cpu-brush.png`.
That test never ran on CI before now: it shares a file with the test at
line 76, line 76 failed, and Playwright reported the rest of the file as
"did not run". Line 76 passes now, so line 113 runs and shows that its
baseline is 6074 pixels out of date. The new image is the `-actual.png`
of run 34974028868. The painted stroke in that image is correct.

`compat-circularBrushSegment1.png` is no longer necessary. Compatibility
mode now renders the circular brush byte for byte the same as the main
baseline, so the fork holds a duplicate that is 5 pixels out of date.
`resolveCompatScreenshotPath` falls back to the main baseline when the
fork is absent, which is what that function documents for a test that
does not diverge.

Neither difference comes from this branch. A checkout of the source of
1116203 gives the same 6074 pixels and the same 5 pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isSegmentationOverlayCompatible` chose between the stack rule and the
volume rule by a test of whether the viewport has a `getAllVolumeIds`
method. That test was correct while `BaseVolumeViewport` was the only
class with the method.

A `PlanarViewport` has the method for a stack and for a volume, and it
returns an empty list for a stack. Every stack-backed `PlanarViewport`
therefore took the volume rule. That rule compares the frame of
reference of the viewport against the frame of reference of the
labelmap, and a `PlanarViewport` reports a synthetic value of the form
`planarNext-viewport-<id>`, which no labelmap can match. The function
returned false, and `internalAddSegmentationRepresentation` dropped the
representation with a log line and no other sign.

The `stackLabelmapSegmentation` example shows the result. In
compatibility mode the second viewport holds no representation, so the
sphere brush appears to paint nothing. The labelmap is correct: the
voxel counts of the painted images are the same in both modes.

A `PlanarViewport` now answers which of the two it shows. Every other
class keeps the old test, because a `BaseVolumeViewport` is a volume
viewport before `setVolumes`, and `getImageIds` throws on one until an
actor exists.

The defect is older than this branch. A checkout of the source of
1116203 gives the same failure. The tolerance of 0.06 in
`sphereBrush.spec.ts` hid it, because the missing overlay is 2.1 percent
of the image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`buildIndexSpaceSlab` and `iterateVoxelsInShape` took two options that a
caller had to keep consistent: `membershipHalfWidth`, a half width the
caller computed for itself, and `depthInterval`, the matching interval.
Nothing held the two together, and the documentation described who may
set them ("Measurement code must leave this unset") instead of what they
do.

One option replaces both. `depthCoverage` names the behaviour:

- `'overlapping'`, the default, selects every voxel whose box the slab
  reaches. The slab widens by half a voxel each side, and a plane halfway
  between two layers selects both.
- `'centerInside'` selects only the voxels whose centre the slab
  contains, and never gets thinner than one voxel. Consecutive slabs tile
  the normal, so each voxel belongs to exactly one.

The half width and the interval both follow from the flag, through
`getSlabHalfWidth` and `isSlabDepthLowInclusive`. A caller passes one
thickness and picks a rule, and the pair that lost the voxels on a slab
boundary - the narrow half width with an open interval - can no longer
be expressed.

A single thickness is enough for both rules: for every input,
`getFillHalfWidth(d, Tv)` equals `max(resolveReferencePlaneThickness(d,
Tv), Tv) / 2`, so the two rules read the same resolved thickness and
differ only in the formula.

`BrushVoxelSlabFill` now carries `thicknessWorld` and `depthCoverage`
instead of a computed half width, and the three places that need the
half width read it from the same function.

No behaviour changes. The 17 legacy and compatibility segmentation
snapshots and 418 unit tests pass without a new baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wayfarer3130
wayfarer3130 merged commit c57f949 into cornerstonejs:main Sep 16, 2026
17 checks passed
wayfarer3130 added a commit to arul-trenser/cornerstone3D that referenced this pull request Sep 16, 2026
…th-contour-for-oblique-data

PR cornerstonejs#2743 and this branch both export the private index bounds helper of
`sampleAreaAnnotationVoxels.ts`, under two different names. PR cornerstonejs#2743 calls the
helper `getShapeIndexBounds`, and `strategies/utils/brushVoxelSlab.ts` on main
already imports that name. This branch called the same helper
`getAreaAnnotationIndexBounds`.

The merge keeps `getShapeIndexBounds`, and `LabelmapBaseTool.ts` now imports
that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 added a commit to arul-trenser/cornerstone3D that referenced this pull request Sep 16, 2026
PR cornerstonejs#2743 separated two rules that this branch wrote before the separation
existed. Rule M measures, and Rule F fills. `getFillHalfWidth` in core states
the rule, and the brush fills in `strategies/utils/brushVoxelSlab.ts` follow it.

The contour to labelmap conversion in `LabelmapBaseTool.ts` is a fill, but it
called `iterateVoxelsInShape` without a `depthCoverage`. The iterator defaults
to `'overlapping'`, which is Rule M, and Rule M gives a half width of one full
voxel on each side of the contour plane.

Two defects follow:

- A contour that falls midway between two voxel layers writes both layers. The
  user drew one layer.
- Two contours on neighbouring frames both write the layer between them, so
  each of the two records an undo entry for that layer.

The conversion now passes `depthCoverage: 'centerInside'`, which is Rule F, and
`getSlabHalfWidth` with the same coverage gives the index bounds. Rule F takes
only the voxels whose centre the slab holds, so a thin oblique contour writes
the single frame that the view shows, and contours on consecutive frames tile
the volume.

The comment above the fill claimed the Rule F behaviour before this change,
and the code did not do it.

Also documents the conversion in `planar-fill-iteration.md`, which PR cornerstonejs#2743
added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 added a commit to arul-trenser/cornerstone3D that referenced this pull request Sep 16, 2026
…xamples

PR cornerstonejs#2743 added `setAxialObliqueAngle` to the `labelmapSegmentationTools`
example, and this branch added the same function to the
`labelmapEditWithContourAutomatic` example. The two copies were identical,
except for one comment.

`utils/demo/helpers/camera/createObliqueAngleController.ts` now holds the one
copy, and both examples use it. The controller captures the camera of a
viewport one time, and `setAngle` turns that captured camera to an exact
angle. The angle is absolute, so the same value always gives the same plane.

Each example keeps its own slider, its own title and its own reset, because
the two examples present the control differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 added a commit to arul-trenser/cornerstone3D that referenced this pull request Sep 16, 2026
The fill had no test. The fill was written inside
`LabelmapBaseTool.viewportContoursToLabelmap`, which needs a viewport, a tool
group and segmentation state, so no unit test could reach the fill.

`strategies/utils/contourVoxelSlab.ts` now holds the fill, as
`iterateContourFillVoxels`. This follows `strategies/utils/brushVoxelSlab.ts`,
which PR cornerstonejs#2743 added for the same reason. `LabelmapBaseTool.ts` calls the new
function and keeps its behaviour.

The test covers Rule F, which is the rule that the fill must follow:

- A contour on a layer of voxel centres fills one layer, and fills the 9 x 9
  voxels that the contour encloses.
- A contour midway between two layers fills one layer, and not both. Rule M
  fills both, and that is the defect.
- Two contours on consecutive frames share no voxel, and cover both layers.
- An oblique contour spans more than one layer, writes no voxel two times,
  keeps every voxel centre within half a voxel of the contour plane, and
  leaves no hole along the normal or in the plane.
- A contour of two points fills nothing.
- A volume with a spacing of [0.5, 0.5, 3] and an origin of [-40, -30, -20]
  fills one layer, so the fill reads the geometry and assumes no unit grid.

Three of these tests fail when `CONTOUR_FILL_COVERAGE` returns to
`'overlapping'`, which is Rule M.

`iterateContourFillVoxels` yields the index buffer that `iterateVoxelsInShape`
reuses, and the documentation of the function states this. The test copies the
index inside the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wayfarer3130 added a commit that referenced this pull request Sep 16, 2026
…n oblique datasets (#2842)

* fix: labelMapEditWithContour is not woring for oblique data

* fix: review comments

* fix(tools): convert a contour to a labelmap with the voxel slab iterator

`viewportContoursToLabelmap` walked the index bounding box of the contour
and tested every voxel with `isPointInsidePolyline3D`. The projection of
that test needs one shared X, Y or Z value across the contour points, and
an oblique contour has none, so the conversion raised an error. This
branch added an oblique path to `projectTo2D` and to
`isPointInsidePolyline3D` to supply that projection.

Commit cd7f279 (#2893) merged a shared voxel iterator into main that
does the same work, and does it for every orientation. The conversion now
builds a `createPolylineShape` from the contour and fills that shape with
`iterateVoxelsInShape`. The iterator emits exact integer runs, so the fill
tests no voxel at all, and it visits no voxel off the contour plane.

The oblique code of this branch is therefore no longer necessary, and this
commit deletes it. `packages/tools/src/utilities/math/polyline/` returns to
the state of main: `projectTo2D` loses the oblique basis, the
`OBLIQUE_PROJECTION_INDEX` sentinel, `projectPointTo2D`,
`isObliqueProjection` and `isDegenerateObliqueBasis`;
`isPointInsidePolyline3D` loses the `viewPlaneNormal`, `viewUp` and
`precomputedProjection` options. `viewportContoursToLabelmap` was the only
caller of all of that code.

The depth rule changes on an oblique plane. The branch used
`getSpacingInNormalDirection / 2`, an L2 distance. The iterator uses
`getVoxelThicknessAlongNormal`, the L1 support width of the voxel box. At
45 degrees in a 1 mm grid the two values are 0.707 mm and 1.414 mm. The L1
width is the width that fills one oblique frame without a gap.

`getAnnotationIndexBounds` of `sampleAreaAnnotationVoxels.ts` becomes the
exported `getAreaAnnotationIndexBounds`, and `margin` takes a default of 0.
The fill and the statistics now walk one index box, and not two.

Verified against the code that this commit replaces:

- On an axis aligned plane with anisotropic spacing, the new fill and the
  old bounding box walk select the same 177 voxels.
- On oblique planes at 0, 15, 30, 45, 60, 75 and 89 degrees, the new fill
  equals a brute force reference that applies Rule M to every voxel of the
  volume. The sets are identical, they hold no duplicate, and no voxel lies
  further from the plane than one voxel thickness.

Jest passes: 56 tools suites with 790 tests, and 43 core suites with 757
tests. The Playwright snapshots did not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(examples): tilt the axial plane of the contour to labelmap example

No example could produce an oblique plane, so no example could show the
defect that this pull request fixes. `labelmapEditWithContourAutomatic`
holds three orthogonal viewports, it adds no tool that rotates a camera,
and the sibling example `labelmapEditWithContour` is the same.

This commit adds an "Axial Oblique Angle" slider to
`labelmapEditWithContourAutomatic`. The slider rotates the axial viewport
about the world X axis by an exact angle, from 0 to 60 degrees. The
rotation keeps the focal point of the first camera, so the plane turns
about the centre of the volume, and `scroll(0)` then rounds the focal
point onto the nearest slice position.

The instructions of the example described a preview workflow and a set of
mouse bindings that this example does not hold. The instructions now
describe what the example does: a drag draws a closed contour, and the
tool converts that contour when the contour closes.

Verified in the browser with Playwright, at an angle of 30 degrees:

- The slider turns the view plane normal from (0, 0, -1) to
  (0, 0.5, -0.866), which is 30 degrees exactly.
- A drag over the labelmap raises `ANNOTATION_ADDED`,
  `ANNOTATION_COMPLETED`, `SEGMENTATION_DATA_MODIFIED` and
  `ANNOTATION_REMOVED`, and the console shows no error.
- The screenshot before the drag and the screenshot after the drag differ
  over the area of the contour, and the labelmap changes there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): fill a contour to labelmap with Rule F, not Rule M

PR #2743 separated two rules that this branch wrote before the separation
existed. Rule M measures, and Rule F fills. `getFillHalfWidth` in core states
the rule, and the brush fills in `strategies/utils/brushVoxelSlab.ts` follow it.

The contour to labelmap conversion in `LabelmapBaseTool.ts` is a fill, but it
called `iterateVoxelsInShape` without a `depthCoverage`. The iterator defaults
to `'overlapping'`, which is Rule M, and Rule M gives a half width of one full
voxel on each side of the contour plane.

Two defects follow:

- A contour that falls midway between two voxel layers writes both layers. The
  user drew one layer.
- Two contours on neighbouring frames both write the layer between them, so
  each of the two records an undo entry for that layer.

The conversion now passes `depthCoverage: 'centerInside'`, which is Rule F, and
`getSlabHalfWidth` with the same coverage gives the index bounds. Rule F takes
only the voxels whose centre the slab holds, so a thin oblique contour writes
the single frame that the view shows, and contours on consecutive frames tile
the volume.

The comment above the fill claimed the Rule F behaviour before this change,
and the code did not do it.

Also documents the conversion in `planar-fill-iteration.md`, which PR #2743
added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(examples): share one oblique camera controller between the examples

PR #2743 added `setAxialObliqueAngle` to the `labelmapSegmentationTools`
example, and this branch added the same function to the
`labelmapEditWithContourAutomatic` example. The two copies were identical,
except for one comment.

`utils/demo/helpers/camera/createObliqueAngleController.ts` now holds the one
copy, and both examples use it. The controller captures the camera of a
viewport one time, and `setAngle` turns that captured camera to an exact
angle. The angle is absolute, so the same value always gives the same plane.

Each example keeps its own slider, its own title and its own reset, because
the two examples present the control differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(tools): cover the contour to labelmap fill

The fill had no test. The fill was written inside
`LabelmapBaseTool.viewportContoursToLabelmap`, which needs a viewport, a tool
group and segmentation state, so no unit test could reach the fill.

`strategies/utils/contourVoxelSlab.ts` now holds the fill, as
`iterateContourFillVoxels`. This follows `strategies/utils/brushVoxelSlab.ts`,
which PR #2743 added for the same reason. `LabelmapBaseTool.ts` calls the new
function and keeps its behaviour.

The test covers Rule F, which is the rule that the fill must follow:

- A contour on a layer of voxel centres fills one layer, and fills the 9 x 9
  voxels that the contour encloses.
- A contour midway between two layers fills one layer, and not both. Rule M
  fills both, and that is the defect.
- Two contours on consecutive frames share no voxel, and cover both layers.
- An oblique contour spans more than one layer, writes no voxel two times,
  keeps every voxel centre within half a voxel of the contour plane, and
  leaves no hole along the normal or in the plane.
- A contour of two points fills nothing.
- A volume with a spacing of [0.5, 0.5, 3] and an origin of [-40, -30, -20]
  fills one layer, so the fill reads the geometry and assumes no unit grid.

Three of these tests fail when `CONTOUR_FILL_COVERAGE` returns to
`'overlapping'`, which is Rule M.

`iterateContourFillVoxels` yields the index buffer that `iterateVoxelsInShape`
reuses, and the documentation of the function states this. The test copies the
index inside the loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

[Bug] Brush is not working properly for rotated viewports

3 participants