diff --git a/packages/tools/src/utilities/contourSegmentation/addPolylinesToSegmentation.ts b/packages/tools/src/utilities/contourSegmentation/addPolylinesToSegmentation.ts index ff8c50580f..f3af2a9f2f 100644 --- a/packages/tools/src/utilities/contourSegmentation/addPolylinesToSegmentation.ts +++ b/packages/tools/src/utilities/contourSegmentation/addPolylinesToSegmentation.ts @@ -16,7 +16,11 @@ import type { Types } from '@cornerstonejs/core'; import { utilities } from '@cornerstonejs/core'; import { addAnnotation } from '../../stateManagement'; +import { addChildAnnotation } from '../../stateManagement/annotation/annotationState'; import type { PolylineInfoWorld } from './polylineInfoTypes'; +import type { ContourSegmentationAnnotation } from '../../types'; +import updateContourPolyline from '../contours/updateContourPolyline'; +import { ContourWindingDirection } from '../../types/ContourAnnotation'; const DEFAULT_CONTOUR_SEG_TOOLNAME = 'PlanarFreehandContourSegmentationTool'; @@ -27,41 +31,97 @@ export default function addPolylinesToSegmentation( polylinesInfo: PolylineInfoWorld[], segmentIndex: number ) { - polylinesInfo.forEach(({ polyline, viewReference }) => { + polylinesInfo.forEach(({ polyline, viewReference, holePolylines }) => { if (polyline.length < 3) { return; } - const contourSegmentationAnnotation = { - annotationUID: utilities.uuidv4(), - data: { - contour: { - closed: true, - polyline, - }, - segmentation: { - segmentationId, - segmentIndex, - }, - handles: {}, - }, - handles: {}, - highlighted: false, - autoGenerated: false, - invalidated: false, - isLocked: false, - isVisible: true, - metadata: { - toolName: DEFAULT_CONTOUR_SEG_TOOLNAME, - ...viewReference, - }, - }; + const parentAnnotation = createContourSegmentationAnnotation( + segmentationId, + segmentIndex, + viewReference + ); - addAnnotation(contourSegmentationAnnotation, viewport.element); + addAnnotation(parentAnnotation, viewport.element); + + const parentPolylineCanvas = polyline.map((point) => + viewport.worldToCanvas(point) + ); + + updateContourPolyline( + parentAnnotation, + { + points: parentPolylineCanvas, + closed: true, + targetWindingDirection: ContourWindingDirection.Clockwise, + }, + viewport + ); const currentSet = annotationUIDsMap?.get(segmentIndex) || new Set(); - currentSet.add(contourSegmentationAnnotation.annotationUID); + currentSet.add(parentAnnotation.annotationUID); annotationUIDsMap.set(segmentIndex, currentSet); + + holePolylines?.forEach((holePolyline) => { + if (holePolyline.length < 3) { + return; + } + + const holeAnnotation = createContourSegmentationAnnotation( + segmentationId, + segmentIndex, + viewReference + ); + + addAnnotation(holeAnnotation, viewport.element); + addChildAnnotation(parentAnnotation, holeAnnotation); + + const holePolylineCanvas = holePolyline.map((point) => + viewport.worldToCanvas(point) + ); + + updateContourPolyline( + holeAnnotation, + { + points: holePolylineCanvas, + closed: true, + targetWindingDirection: ContourWindingDirection.CounterClockwise, + }, + viewport + ); + }); }); return annotationUIDsMap; } + +function createContourSegmentationAnnotation( + segmentationId: string, + segmentIndex: number, + viewReference: Types.ViewReference +): ContourSegmentationAnnotation { + return { + annotationUID: utilities.uuidv4() as string, + data: { + contour: { + closed: true, + polyline: [], + }, + segmentation: { + segmentationId, + segmentIndex, + }, + handles: {}, + cachedStats: {}, + }, + handles: {}, + highlighted: false, + autoGenerated: false, + invalidated: false, + isLocked: false, + isVisible: true, + metadata: { + toolName: DEFAULT_CONTOUR_SEG_TOOLNAME, + ...viewReference, + }, + }; +} diff --git a/packages/tools/src/utilities/contourSegmentation/logicalOperators.ts b/packages/tools/src/utilities/contourSegmentation/logicalOperators.ts index f239176b26..16ad491f91 100644 --- a/packages/tools/src/utilities/contourSegmentation/logicalOperators.ts +++ b/packages/tools/src/utilities/contourSegmentation/logicalOperators.ts @@ -26,7 +26,11 @@ */ import type { Types } from '@cornerstonejs/core'; -import { getAnnotation, removeAnnotation } from '../../stateManagement'; +import { + getAnnotation, + removeAnnotation, + getChildAnnotations, +} from '../../stateManagement'; import type { ContourSegmentationData, ContourSegmentationAnnotation, @@ -87,9 +91,20 @@ function getPolylinesInfoWorld( annotationUID ) as ContourSegmentationAnnotation; const { polyline } = annotation.data.contour; + + const childAnnotations = getChildAnnotations(annotation); + const holePolylines = + childAnnotations.length > 0 + ? childAnnotations.map( + (child) => + (child as ContourSegmentationAnnotation).data.contour.polyline + ) + : undefined; + polylinesInfo.push({ polyline, viewReference: getViewReferenceFromAnnotation(annotation), + ...(holePolylines ? { holePolylines } : {}), }); } return polylinesInfo; @@ -134,18 +149,32 @@ function extractPolylinesInCanvasSpace( } const polyLinesInfoCanvas1 = polyLinesInfoWorld1.map( - ({ polyline, viewReference }) => { + ({ polyline, viewReference, holePolylines }) => { return { polyline: convertContourPolylineToCanvasSpace(polyline, viewport), viewReference, + ...(holePolylines?.length + ? { + holePolylines: holePolylines.map((hole) => + convertContourPolylineToCanvasSpace(hole, viewport) + ), + } + : {}), }; } ); const polyLinesInfoCanvas2 = polyLinesInfoWorld2.map( - ({ polyline, viewReference }) => { + ({ polyline, viewReference, holePolylines }) => { return { polyline: convertContourPolylineToCanvasSpace(polyline, viewport), viewReference, + ...(holePolylines?.length + ? { + holePolylines: holePolylines.map((hole) => + convertContourPolylineToCanvasSpace(hole, viewport) + ), + } + : {}), }; } ); @@ -263,12 +292,19 @@ function applyLogicalOperation( break; } // Convert merged polylines back to world space using their associated viewReference - const polyLinesWorld = polylinesMerged.map(({ polyline, viewReference }) => { - return { + const polyLinesWorld: PolylineInfoWorld[] = polylinesMerged.map( + ({ polyline, viewReference, holePolylines }) => ({ polyline: convertContourPolylineToWorld(polyline, viewport), viewReference, - }; - }); + ...(holePolylines?.length + ? { + holePolylines: holePolylines.map((hole) => + convertContourPolylineToWorld(hole, viewport) + ), + } + : {}), + }) + ); const resultSegment = options; const segmentation = getSegmentation(resultSegment.segmentationId); diff --git a/packages/tools/src/utilities/contourSegmentation/polylineInfoTypes.ts b/packages/tools/src/utilities/contourSegmentation/polylineInfoTypes.ts index b975015250..1c7a503df4 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineInfoTypes.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineInfoTypes.ts @@ -3,9 +3,13 @@ import type { Types } from '@cornerstonejs/core'; export type PolylineInfoWorld = { polyline: Types.Point3[]; viewReference: Types.ViewReference; + /** Hole polylines in world space (child contours with opposite winding). */ + holePolylines?: Types.Point3[][]; }; export type PolylineInfoCanvas = { polyline: Types.Point2[]; viewReference: Types.ViewReference; + /** Hole polylines in canvas space (child contours with opposite winding). */ + holePolylines?: Types.Point2[][]; }; diff --git a/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts b/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts index 4e69845cb9..3888cef0b2 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts @@ -31,7 +31,9 @@ export function intersectPolylinesSets( continue; } const intersection = checkIntersection(polyA.polyline, polyB.polyline); - if (intersection.hasIntersection && !intersection.isContourHole) { + if (intersection.isContourHole) { + result.push({ ...polyA }); + } else if (intersection.hasIntersection) { const intersectionRegions = cleanupPolylines( intersectPolylines(polyA.polyline, polyB.polyline) ); @@ -43,6 +45,9 @@ export function intersectPolylinesSets( }); }); } + } else if (intersection.isTargetInsideSource) { + // polyB is entirely inside polyA — the intersection is polyB itself + result.push({ ...polyB }); } } } diff --git a/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts b/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts index 6e8e8fccd6..ae5c16f067 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts @@ -7,6 +7,7 @@ import { removeDuplicatePoints, } from './sharedOperations'; import arePolylinesIdentical from '../math/polyline/arePolylinesIdentical'; +import containsPoints from '../math/polyline/containsPoints'; import type { PolylineInfoCanvas } from './polylineInfoTypes'; import type { ContourSegmentationAnnotation } from '../../types'; import { getViewReferenceFromAnnotation } from './getViewReferenceFromAnnotation'; @@ -58,19 +59,57 @@ export function subtractPolylineSets( polylineB.polyline ) ); + const existingHoles = currentPolyline.holePolylines ?? []; for (const subtractedPolyline of subtractedPolylines) { const cleaned = removeDuplicatePoints(subtractedPolyline); if (cleaned.length >= 3) { + const holesStillInside = existingHoles.filter((hole) => + containsPoints(cleaned, hole) + ); newPolylines.push({ polyline: cleaned, viewReference: currentPolyline.viewReference, + ...(holesStillInside.length > 0 + ? { holePolylines: holesStillInside } + : {}), }); } } + } else if (intersection.isTargetInsideSource) { + const existingHoles = currentPolyline.holePolylines ?? []; + const isInsideExistingHole = existingHoles.some((hole) => + containsPoints(hole, polylineB.polyline) + ); + if (isInsideExistingHole) { + // It's already in a hole, so subtracting it changes nothing. + newPolylines.push({ + polyline: currentPolyline.polyline, + viewReference: currentPolyline.viewReference, + ...(currentPolyline.holePolylines + ? { holePolylines: currentPolyline.holePolylines } + : {}), + }); + } else { + // Subtrahend is fully inside the minuend — cut a hole (no edge crossings). + newPolylines.push({ + polyline: currentPolyline.polyline, + viewReference: currentPolyline.viewReference, + holePolylines: [ + ...(currentPolyline.holePolylines ?? []), + polylineB.polyline, + ], + }); + } + } else if (intersection.isContourHole) { + // Minuend is fully inside the subtrahend — removed entirely. + continue; } else { newPolylines.push({ polyline: currentPolyline.polyline, viewReference: currentPolyline.viewReference, + ...(currentPolyline.holePolylines + ? { holePolylines: currentPolyline.holePolylines } + : {}), }); } } diff --git a/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts b/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts index 8c51e996dc..39af4823c6 100644 --- a/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts +++ b/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts @@ -69,6 +69,7 @@ export function checkIntersection( ): { hasIntersection: boolean; isContourHole: boolean; + isTargetInsideSource: boolean; } { const sourceAABB = math.polyline.getAABB(sourcePolyline); const targetAABB = math.polyline.getAABB(targetPolyline); @@ -76,7 +77,11 @@ export function checkIntersection( const aabbIntersect = math.aabb.intersectAABB(sourceAABB, targetAABB); if (!aabbIntersect) { - return { hasIntersection: false, isContourHole: false }; + return { + hasIntersection: false, + isContourHole: false, + isTargetInsideSource: false, + }; } const lineSegmentsIntersect = math.polyline.intersectPolyline( @@ -89,9 +94,15 @@ export function checkIntersection( !lineSegmentsIntersect && math.polyline.containsPoints(targetPolyline, sourcePolyline); + // Target is fully contained inside source (no edge crossings) + const isTargetInsideSource = + !lineSegmentsIntersect && + !isContourHole && + math.polyline.containsPoints(sourcePolyline, targetPolyline); + const hasIntersection = lineSegmentsIntersect || isContourHole; - return { hasIntersection, isContourHole }; + return { hasIntersection, isContourHole, isTargetInsideSource }; } /**