fix: improved brush shape for rotated images - #2743
wayfarer3130 merged 33 commits into
Conversation
2c133c5 to
c9fd4f5
Compare
…ape-for-rotated-images
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughShared 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. ChangesPlanar spacing and fill iteration
Estimated code review effort: 5 (Critical) | ~120 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
packages/tools/src/tools/segmentation/strategies/fillCircle.ts (1)
71-76: 💤 Low valueCorner 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,
strokeCornersWorldis only used to computeboundsIJKviagetBoundingBoxAroundShapeIJK, which is order-agnostic. ThecornersInWorldused increatePointInEllipsecomes from the canvas-derived corners at lines 223-226, not fromcreateCircleCornersForCenter. 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 winUnused 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 valueFull
projectedSpacingused 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.tswhich usesspacingInNormal / 2forplaneTolerance, 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
📒 Files selected for processing (3)
packages/tools/src/tools/segmentation/strategies/fillCircle.tspackages/tools/src/tools/segmentation/strategies/fillRectangle.tspackages/tools/src/tools/segmentation/strategies/fillSphere.ts
wayfarer3130
left a comment
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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
📒 Files selected for processing (9)
packages/core/src/utilities/getInPlaneSpacingAndXYDirections.tspackages/core/src/utilities/index.tspackages/core/src/utilities/iterateOverPlane.tspackages/core/test/utilities/getInPlaneSpacingAndXYDirections.jest.jspackages/core/test/utilities/iterateOverPlane.jest.jspackages/docs/docs/concepts/cornerstone-tools/segmentation/planar-fill-iteration.mdpackages/docs/docs/migration-guides/5x/1-migration-notes.mdpackages/docs/sidebars.jspackages/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
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
packages/core/src/utilities/obliqueIntegerIterator.ts (1)
972-1051: 🚀 Performance & Scalability | 🔵 TrivialVerify call frequency of
createObliqueIntegerBasisfor interactive brush strokes.The drift-bounded search in
choosePrimitiveIntegerNormalscales the denominator search (needed = ceil(1/sinThreshold)+1) with volume extent — for large volumes this can run into the hundreds/low-thousands of iterations. IfcreateObliqueIntegerBasisis 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
📒 Files selected for processing (14)
packages/core/src/utilities/index.tspackages/core/src/utilities/obliqueIntegerIterator.tspackages/core/test/utilities/obliqueIntegerIterator.jest.jspackages/docs/docs/behaviour/index.mdpackages/docs/docs/behaviour/obliqueVoxels.mdpackages/docs/docs/migration-guides/5x/3-oblique-integer-voxels.mdpackages/docs/sidebars.jspackages/tools/src/tools/segmentation/strategies/BrushStrategy.tspackages/tools/src/tools/segmentation/strategies/__tests__/obliqueIntegerFill.spec.tspackages/tools/src/tools/segmentation/strategies/compositions/regionFill.tspackages/tools/src/tools/segmentation/strategies/fillCircle.tspackages/tools/src/tools/segmentation/strategies/fillRectangle.tspackages/tools/src/tools/segmentation/strategies/fillSphere.tspackages/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
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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)
PYRepository: 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.
…ape-for-rotated-images
…e-for-rotated-images
|
Hi @wayfarer3130 , Thanks! |
Temporary tracking branchI've pushed an updated copy of this branch to my own fork rather than to this PR's head:
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 What that branch contains on top of this PR's head:
Verification on the merged state: prettier clean; 183 unit tests passing ( Note that I deliberately did not revert the prettier reformatting in 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:
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! |
…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>
|
@wayfarer3130 Thank you for updating the PR. Thanks! |
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>
How to test that a fill writes exactly one planeThe branch now fills with Rule F, and the What Rule F isRule M gives a half width of The depth of The procedure
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 numbersTurn 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.
Two limits of the current branchThe slice step. The fill writes one digital plane, and the planes are A thick slab. A thick slab has a second problem. 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 |
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>
…ape-for-rotated-images
…ape-for-rotated-images
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>
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>
…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>
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>
…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>
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>
…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>
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:
inside the box. The fill therefore tests
O(N³)voxels to writeO(N²)voxels.
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
maincarries the shared oblique-capable voxel iterator of #2893. This pullrequest moves the brush fills onto that iterator.
Each brush is now a
VoxelSlabShapeon the view plane, andcsUtils.voxelSlab.iterateVoxelsInShapegives the voxels of that shape:createEllipseShape, flatcreateCircleShapewithdepthRadiuscreateRectangleShape, flatThe 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:createUnionShapeThe 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.
createUnionShapemerges the runs of its members into one disjointsequence in ascending order. Two results follow:
writes a voxel two times records two undo entries for that voxel.
same members costs the member count for every voxel of the bounding box.
New file in
tools:brushVoxelSlab.tsThis file holds the three shape builders, and the function that gives the
voxels to the
regionFillcomposition.regionFillkeeps the bounding box walkas 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.
orderRectangleCornersgives 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.
RectangleScissorsToolpassesviewPlaneNormaland
viewUpto the strategy, which the fill needs for a degenerate rectangle.Shared index bounds
sampleAreaAnnotationVoxelsexports its index bounds helper asgetShapeIndexBounds. The annotation code and the brush code now bound theiriteration with one implementation.
The files
The diff is 17 files, 2357 insertions and 186 deletions.
core/.../voxelSlab/shapes/createUnionShape.tscore/test/voxelSlabUnionShape.jest.jstools/.../strategies/utils/brushVoxelSlab.tstools/.../strategies/__tests__/brushVoxelSlab.spec.tstools/.../strategies/__tests__/fillRectangle.spec.tsdocs/.../segmentation/planar-fill-iteration.mdtools/.../strategies/fillCircle.tstools/.../strategies/fillSphere.tstools/.../strategies/fillRectangle.tstools/.../strategies/compositions/regionFill.tstools/.../strategies/BrushStrategy.tsbrushVoxelSlabFillfieldtools/.../segmentation/RectangleScissorsTool.tstools/src/utilities/sampleAreaAnnotationVoxels.tsgetShapeIndexBoundscore/.../voxelSlab/shapes/index.tsdocs/sidebars.jsdocs/.../migration-guides/5x/1-migration-notes.mdtools/examples/labelmapSegmentationTools/index.tsA 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.getInPlaneSpacingAndXYDirectionsandstrategies/utils/obliqueIntegerFill.ts. #2893 then merged the shared iteratorinto
main. The two solve the same problem, so a later commit on this branchremoves 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 fileappears in the diff:
getSubPixelSpacingAndXYDirectionsusesgetEffectiveSpacingAlongDirection, which measures how far to step to crossone voxel. The branch had sent that path through
getSpacingInNormalDirection, which answers a different question. Therepository keeps one utility for one question.
fillSpherefollow the stroke again. The branch hadreplaced those bounds with a box around one centre, and that box clips a drag.
Results
correct shape at every view orientation.
not from the camera.
every layer through the slab. An area computation over a full-thickness fill
must divide by the depth in voxels.
voxels in the neighbouring slices, and this fill does not.
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 --noEmitreports no error forpackages/coreand forpackages/tools.oxlintreports 0 errors.prettier --checkreports no change for everyfile that this pull request touches.
packages/core/test/voxelSlabUnionShape.jest.jstestscreateUnionShapeagainst 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.tsasserts that:
sphere, at every angle from 0 degrees to 90 degrees;
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
labelmapSegmentationToolsexample, so that areviewer 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:
The command opens
http://localhost:3000/, and shows an axial viewport, asagittal viewport and a coronal viewport of one CT volume.
The controls:
CrosshairsbuttonAxial Oblique AnglesliderReset CamerasbuttonThe steps:
Axial Oblique Angleslider to 45 degrees. The axial viewport nowshows an oblique plane, and the axial reference line in the sagittal
viewport is at 45 degrees.
CircularBrushin the first dropdown. Paint one stroke in the axialviewport.
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.
SphereBrush, and paint at the same angle. The paint must show acircle in all three viewports.
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.
Reset Cameras. Then rotate the planes with a circle handle of thecrosshairs 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:
width of the voxel box, which
getVoxelThicknessAlongNormalgives.getSpacingInNormalDirectiongives.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:
+1.517 mm. This is 0.505 of the voxel thickness along the normal, so the
paint is one layer of voxel centres.
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:
A note on the thickness that the fill uses
fillCirclereadsviewport.getSlabThickness(). An orthographic volumeviewport with no slab returns
RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS,which is 0.05 mm. That value is finite and greater than 0, so
resolveReferencePlaneThicknesskeeps it instead of the fallback of one voxel.The half width of Rule M is therefore
(0.05 + Tv) / 2, which is aboutTv / 2, and not theTvthat Rule M gives an annotation.The measurements above show that this half width still leaves no hole.
Tvisan L1 width and the slice step is an L2 width, and an L1 width is never smaller
than an L2 width, so
Tv / 2always covers half a slice step. A half width ofTvwould 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.
getSlabThicknessreturns a half thickness, so a thick slab fills half ofthe depth that the viewport shows.
Checklist
PR
semantic-release format and guidelines.
Code
etc.)
Public Documentation Updates
additions or removals.
Tested Environment
🤖 Generated with Claude Code