Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8788ef7
fix(contour-segmentation): handle containment cases in contour segmen…
GhadeerAlbattarni May 21, 2026
120b11b
fix(contour-segmentation): fix contour subtract when subtrahend is fu…
GhadeerAlbattarni May 21, 2026
58bb2f5
refactor: centralise containment detection in checkIntersection
GhadeerAlbattarni May 21, 2026
c4544a7
Merge remote-tracking branch 'upstream/main' into fix/contour-combine…
GhadeerAlbattarni May 21, 2026
bc166a0
undate comment and reorder functions in addPolylinesToSegmentation
GhadeerAlbattarni May 22, 2026
63522aa
Merge remote-tracking branch 'origin/main' into fix/contour-combine-e…
wayfarer3130 May 22, 2026
5c24a7c
fix: An attempt to look at some of hte fixes for intersections using
wayfarer3130 May 25, 2026
da64bcc
Add semantics definition for discussion
wayfarer3130 May 25, 2026
c8bf188
Updated intersect definition for freeform
wayfarer3130 Jun 23, 2026
5ef152b
Merge remote-tracking branch 'origin/main' into fix/clipper2-intersec…
wayfarer3130 Jun 23, 2026
28c2648
feat(demo): add button to reproduce labelmap removal crash (#2566)
theoc0702 Jun 23, 2026
fe5431f
Merge remote-tracking branch 'origin/main' into fix/clipper2-intersec…
wayfarer3130 Jun 24, 2026
41b91e1
Fix overlapping bugs
wayfarer3130 Jun 24, 2026
4beddb0
PR comments - two fixes
wayfarer3130 Jun 24, 2026
50d0396
audit fix
wayfarer3130 Jun 24, 2026
8effbc1
fix: PR comments
wayfarer3130 Jun 25, 2026
bbe7830
Merge remote-tracking branch 'origin/main' into fix/clipper2-intersec…
wayfarer3130 Jun 26, 2026
89f38a5
Merge branch 'main' into fix/clipper2-intersection-definition
sedghi Jun 30, 2026
b9a367b
Merge remote-tracking branch 'origin/main' into fix/clipper2-intersec…
jbocce Jul 1, 2026
f4685e8
Fix broken test.
jbocce Jul 2, 2026
93d74f0
Fix broken tests.
jbocce Jul 2, 2026
392ee1e
Fix broken tests.
jbocce Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
},
"dependencies": {
"@types/offscreencanvas": "2019.7.3",
"clipper2-ts": "2.0.1",
Comment thread
wayfarer3130 marked this conversation as resolved.
"comlink": "4.4.2",
"lodash.get": "4.4.2"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,158 +1,77 @@
import { eventTarget, triggerEvent, type Types } from '@cornerstonejs/core';
import type { ContourSegmentationAnnotation } from '../../../types/ContourSegmentationAnnotation';
import getViewportsForAnnotation from '../../../utilities/getViewportsForAnnotation';
import { getAllAnnotations } from '../../../stateManagement/annotation/annotationState';
import type {
AnnotationCompletedEventType,
ContourAnnotationCompletedEventDetail,
} from '../../../types/EventTypes';
import type { Annotation } from '../../../types';
import {
areSameSegment,
isContourSegmentationAnnotation,
} from '../../../utilities/contourSegmentation';
import isContourSegmentationAnnotation from '../../../utilities/contourSegmentation/isContourSegmentationAnnotation';
import { getToolGroupForViewport } from '../../../store/ToolGroupManager';
import { findAllIntersectingContours } from '../../../utilities/contourSegmentation/getIntersectingAnnotations';
import { processMultipleIntersections } from '../../../utilities/contourSegmentation/mergeMultipleAnnotations';
import {
convertContourPolylineToCanvasSpace,
createPolylineHole,
combinePolylines,
} from '../../../utilities/contourSegmentation/sharedOperations';
addContourStroke,
removeContourStroke,
} from '../../../utilities/contourSegmentation/applyContourStroke';
import { Events } from '../../../enums';

/**
* Default tool name for contour segmentation operations.
* This tool is used as the default when creating new combined/subtracted contours.
*/
const DEFAULT_CONTOUR_SEG_TOOL_NAME = 'PlanarFreehandContourSegmentationTool';

/**
* Event listener for the 'ANNOTATION_COMPLETED' event, specifically for contour segmentations.
* This function processes a newly completed contour segmentation. If the new contour
* intersects with existing contour segmentations on the same segment, it will
* either combine them or use the new contour to create holes in the existing ones.
* Now supports multiple intersections and merging multiple annotations.
* Event listener for ANNOTATION_COMPLETED on a contour-segmentation stroke.
*
* @param evt - The event object triggered when an annotation is completed.
* @returns A promise that resolves when the processing is complete.
* Polarity is read from `contourHoleProcessingEnabled` on the event detail:
* the freehand tool sets it when the configured modifier (default Shift) is
* held when the stroke starts. Per SEMANTICS.md:
* - shift held → §3.1 REMOVE (always subtract from segment)
* - shift unheld → §3.2 ADD (always union into segment)
*
* The polarity does NOT depend on stroke position, overlap with existing
* contours, or whether the stroke encloses anything. Same-plane same-segment
* polygons all participate in the boolean op per §5.0.
*/
export default async function contourSegmentationCompletedListener(
evt: AnnotationCompletedEventType
): Promise<void> {
const sourceAnnotation = evt.detail
.annotation as ContourSegmentationAnnotation;

// Ensure the completed annotation is a contour segmentation
if (!isContourSegmentationAnnotation(sourceAnnotation)) {
return;
}

const viewport = getViewport(sourceAnnotation);
const contourSegmentationAnnotations = getValidContourSegmentationAnnotations(
viewport,
sourceAnnotation
);

// If no other relevant contour segmentations exist, there's nothing to combine or make a hole in.
if (!contourSegmentationAnnotations.length) {
// we trigger the event here as here is the place where the source Annotation is not removed
triggerEvent(eventTarget, Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, {
element: viewport.element,
sourceAnnotation,
});
return;
}

const sourcePolyline = convertContourPolylineToCanvasSpace(
sourceAnnotation.data.contour.polyline,
viewport
);

// Find all intersecting contours instead of just one
const intersectingContours = findAllIntersectingContours(
viewport,
sourcePolyline,
contourSegmentationAnnotations
);

// If no intersecting contours are found, do nothing.
if (!intersectingContours.length) {
// we trigger the event here as here is the place where the source Annotation is not removed
triggerEvent(eventTarget, Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, {
element: viewport.element,
sourceAnnotation,
});

return;
}

// Handle multiple intersections
if (intersectingContours.length > 1) {
// Process multiple intersections using the new utility
processMultipleIntersections(
viewport,
sourceAnnotation,
sourcePolyline,
intersectingContours
);

return;
}

// Handle single intersection (backward compatibility)
const { targetAnnotation, targetPolyline, isContourHole } =
intersectingContours[0];
const { contourHoleProcessingEnabled = false } =
evt.detail as ContourAnnotationCompletedEventDetail;

if (isContourHole) {
// Check if hole processing is enabled for this specific event
const { contourHoleProcessingEnabled = false } =
evt.detail as ContourAnnotationCompletedEventDetail;

// Do not create holes when contourHoleProcessingEnabled is `false`
if (!contourHoleProcessingEnabled) {
return;
}

createPolylineHole(viewport, targetAnnotation, sourceAnnotation);
if (contourHoleProcessingEnabled) {
removeContourStroke(viewport, sourceAnnotation);
} else {
combinePolylines(
viewport,
targetAnnotation,
targetPolyline,
sourceAnnotation,
sourcePolyline
);
addContourStroke(viewport, sourceAnnotation);
}

triggerEvent(eventTarget, Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, {
element: viewport.element,
sourceAnnotation,
});
}

/**
* Checks if the 'PlanarFreehandContourSegmentationTool' is registered and
* configured (active or passive) for a given viewport.
*
* @param viewport - The viewport to check.
* @param silent - If true, suppresses console warnings. Defaults to false.
* @returns True if the tool is registered and configured, false otherwise.
*/
function isFreehandContourSegToolRegisteredForViewport(
viewport: Types.IViewport,
silent = false
): boolean {
const toolName = 'PlanarFreehandContourSegmentationTool';

const toolName = DEFAULT_CONTOUR_SEG_TOOL_NAME;
const toolGroup = getToolGroupForViewport(
viewport.id,
viewport.renderingEngineId
);

let errorMessage;

if (!toolGroup) {
errorMessage = `ToolGroup not found for viewport ${viewport.id}`;
} else if (!toolGroup.hasTool(toolName)) {
errorMessage = `Tool ${toolName} not added to ${toolGroup.id} toolGroup`;
} else if (!toolGroup.getToolOptions(toolName)) {
// getToolOptions returns undefined if the tool is not active or passive
errorMessage = `Tool ${toolName} must be in active/passive state in ${toolGroup.id} toolGroup`;
}

Expand All @@ -163,56 +82,10 @@ function isFreehandContourSegToolRegisteredForViewport(
return !errorMessage;
}

/**
* Retrieves a suitable viewport for processing the given annotation.
* It prioritizes viewports where the 'PlanarFreehandContourSegmentationTool'
* is registered. If no such viewport is found, it returns the first viewport
* associated with the annotation. This is because projecting polylines for hole
* creation might still be possible even if the full tool isn't registered for appending/removing contours.
*
* @param annotation - The annotation for which to find a viewport.
* @returns The most suitable `Types.IViewport` instance, or the first associated viewport.
*/
function getViewport(annotation: Annotation): Types.IViewport {
const viewports = getViewportsForAnnotation(annotation);
const viewportWithToolRegistered = viewports.find((viewport) =>
isFreehandContourSegToolRegisteredForViewport(viewport, true)
);

// Returns the first viewport even if freehand contour segmentation is not
// registered because it can be used to project the polyline to create holes.
// Another verification is done before appending/removing contours which is
// possible only when the tool is registered.
return viewportWithToolRegistered ?? viewports[0];
}

/**
* Retrieves all valid contour segmentation annotations that are:
* 1. Not the source annotation itself.
* 2. Contour segmentation annotations.
* 3. On the same segment as the source annotation.
* 4. Viewable in the given viewport (i.e., on the same image plane/slice).
*
* @param viewport - The viewport context.
* @param sourceAnnotation - The source contour segmentation annotation.
* @returns An array of `ContourSegmentationAnnotation` objects that meet the criteria.
*/
function getValidContourSegmentationAnnotations(
viewport: Types.IViewport,
sourceAnnotation: ContourSegmentationAnnotation
): ContourSegmentationAnnotation[] {
const { annotationUID: sourceAnnotationUID } = sourceAnnotation;

const allAnnotations = getAllAnnotations();
return allAnnotations.filter(
(targetAnnotation) =>
targetAnnotation.annotationUID &&
targetAnnotation.annotationUID !== sourceAnnotationUID &&
isContourSegmentationAnnotation(targetAnnotation) &&
areSameSegment(
targetAnnotation as ContourSegmentationAnnotation,
sourceAnnotation
) &&
viewport.isReferenceViewable(targetAnnotation.metadata) // Checks if annotation is on the same slice/orientation
) as ContourSegmentationAnnotation[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { resolveVectorToPeak } from './findOpenUShapedContourVectorToPeak';
import { polyline } from '../../../utilities/math';
import { removeAnnotation } from '../../../stateManagement/annotation/annotationState';
import { ContourWindingDirection } from '../../../types/ContourAnnotation';
import { bridgeSelfIntersectingPolyline } from '../../../utilities/contourSegmentation/bridgeWeaklyConnected';

const {
addCanvasPointsToArray,
Expand Down Expand Up @@ -163,9 +164,21 @@ function mouseDragDrawCallback(evt: EventTypes.InteractionEventType): void {
} else {
const crossingIndex = this.findCrossingIndexDuringCreate(evt);

if (crossingIndex !== undefined) {
// If we have crossed our drawing line, create a closed contour and then
// start an edit.
// Only treat a self-crossing as "close the contour" when it happens near
// the start of the stroke (the cross-back-to-close gesture). A crossing
// elsewhere is an intentional self-intersection — e.g. a figure-eight — so
// keep drawing and resolve it into a single contour at completion.
const crossingClosesContour =
crossingIndex !== undefined &&
pointsAreWithinCloseContourProximity(
canvasPoints[0],
canvasPoints[crossingIndex],
this.configuration.closeContourProximity
);

if (crossingClosesContour) {
// If we have crossed our drawing line near the start, create a closed
// contour and then start an edit.
this.applyCreateOnCross(evt, crossingIndex);
} else {
const numPointsAdded = addCanvasPointsToArray(
Expand Down Expand Up @@ -255,10 +268,15 @@ function completeDrawClosedContour(
// Remove last point which will be a duplicate now.
canvasPoints.pop();

const updatedPoints = shouldSmooth(this.configuration, annotation)
const smoothedPoints = shouldSmooth(this.configuration, annotation)
? getInterpolatedPoints(this.configuration, canvasPoints)
: canvasPoints;

// If the contour crosses itself (e.g. a figure-eight), stitch the lobes into
// a single weakly-simple contour rather than letting it stay self-intersecting
// (which renders/loads incorrectly). Simple contours are returned unchanged.
const updatedPoints = bridgeSelfIntersectingPolyline(smoothedPoints);

this.updateContourPolyline(
annotation,
{
Expand Down
Loading
Loading