diff --git a/bun.lock b/bun.lock index 44c83871ec..ddd14566db 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "root", @@ -119,7 +120,7 @@ }, "packages/adapters": { "name": "@cornerstonejs/adapters", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", @@ -128,17 +129,17 @@ "ndarray": "1.0.19", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, }, "packages/ai": { "name": "@cornerstonejs/ai", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@babel/runtime-corejs2": "7.26.10", "buffer": "6.0.3", @@ -150,17 +151,17 @@ "onnxruntime-web": "1.17.1", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, }, "packages/core": { "name": "@cornerstonejs/core", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@kitware/vtk.js": "34.15.1", "comlink": "4.4.2", @@ -170,7 +171,7 @@ }, "packages/dicomImageLoader": { "name": "@cornerstonejs/dicom-image-loader", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@cornerstonejs/codec-charls": "1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", @@ -183,73 +184,74 @@ "uuid": "9.0.1", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", "dicom-parser": "1.8.21", }, }, "packages/labelmap-interpolation": { "name": "@cornerstonejs/labelmap-interpolation", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@itk-wasm/morphological-contour-interpolation": "1.1.0", "itk-wasm": "1.0.0-b.165", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", "@kitware/vtk.js": "34.15.1", }, }, "packages/nifti-volume-loader": { "name": "@cornerstonejs/nifti-volume-loader", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "nifti-reader-js": "0.6.9", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", }, }, "packages/polymorphic-segmentation": { "name": "@cornerstonejs/polymorphic-segmentation", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@icr/polyseg-wasm": "0.4.0", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", - "@cornerstonejs/tools": "4.22.7", + "@cornerstonejs/core": "4.22.10", + "@cornerstonejs/tools": "4.22.10", "@kitware/vtk.js": "34.15.1", }, }, "packages/tools": { "name": "@cornerstonejs/tools", - "version": "4.22.7", + "version": "4.22.10", "dependencies": { "@types/offscreencanvas": "2019.7.3", + "clipper2-ts": "2.0.1", "comlink": "4.4.2", "lodash.get": "4.4.2", }, "devDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", "canvas": "3.2.0", }, "peerDependencies": { - "@cornerstonejs/core": "4.22.7", + "@cornerstonejs/core": "4.22.10", "@kitware/vtk.js": "34.15.1", "@types/d3-array": "3.2.1", "@types/d3-interpolate": "3.0.4", @@ -1487,6 +1489,8 @@ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + "clipper2-ts": ["clipper2-ts@2.0.1", "", {}, "sha512-raIgNMpYN/PFYk/T9iRzaG99rf5nlXBWL1FyAdR3wjEYSzJy+pPfolLH5bCdRqCFQqn/eYUsF1jb3cQEMxmWGw=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], diff --git a/packages/tools/package.json b/packages/tools/package.json index 0b72856b65..8e276c1485 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -101,6 +101,7 @@ }, "dependencies": { "@types/offscreencanvas": "2019.7.3", + "clipper2-ts": "2.0.1", "comlink": "4.4.2", "lodash.get": "4.4.2" }, diff --git a/packages/tools/src/utilities/contourSegmentation/SEMANTICS.md b/packages/tools/src/utilities/contourSegmentation/SEMANTICS.md new file mode 100644 index 0000000000..c37544caec --- /dev/null +++ b/packages/tools/src/utilities/contourSegmentation/SEMANTICS.md @@ -0,0 +1,734 @@ +# Contour Segmentation Operation Semantics + +This document defines the exact behavior of every contour-segmentation editing +operation in `@cornerstonejs/tools`. It is the spec the boolean-op pipeline +(`clipperBooleanOps.ts` + `polylineSetOps.ts` + the four `polylineXxx.ts` +wrappers + the interactive-edit paths in `sharedOperations.ts` and +`mergeMultipleAnnotations.ts`) must obey. + +Where this document and the implementation disagree, the document is right and +the code is a bug. Where this document and the tests disagree, treat as a bug +in this document and update it. + +--- + +## 1. Model + +### 1.1 Polygon-with-holes + +A polygon is a simple closed outer ring plus zero or more simple closed hole +rings: + +``` +type Polygon = { + outer: Point2[]; // simple closed polyline, CW, ≥ 3 vertices + holes?: Point2[][]; // each simple closed, CCW, fully inside outer, + // pairwise non-overlapping +}; +``` + +A **point is inside a polygon** iff it is inside `outer` AND outside every hole. +The "solid area" of a polygon is the set of points inside it under this rule. + +### 1.2 Segment + +A segment is a labeled collection of polygons within a segmentation, keyed by +`segmentIndex`. Inside one segment, on a single view reference, polygons +**must not have overlapping solid areas** — if two pieces touch or overlap +in their solid regions, they should have been unioned already. The pipeline +is responsible for enforcing this. + +**Polygons may nest spatially without limit.** A polygon B sitting entirely +inside a hole of polygon A is a top-level polygon in the segment, treated +exactly as if it were anywhere else. B is independently editable, can have +its own holes, and those holes can contain further top-level polygons, and +so on. There is no "depth limit" and no parent-child relationship at the +data layer — nesting is purely a spatial coincidence. + +Consequence: the segment is a flat list of polygons-with-holes (per §1.1). +"Same-segment polygon inside a hole" is a spatial observation, not a +structural one. Clipper's `PolyTreeD` is used internally to compute the +nesting at op-output time; we then flatten it back to the segment's +flat-list representation. + +### 1.3 View reference + +Identifies a slice / frame-of-reference. Operations only combine polygons +sharing the same view reference. Polygons on different view references pass +through untouched, even when their canvas-space coordinates would otherwise +coincide (different slices of a volume). + +### 1.4 Segment independence + +Segments do not interact. Drawing, editing, or running a Combine Contour +operation on segment N **never** touches segment M. Two segments may claim +overlapping space — a pixel can be both "lung" and "tumor" — and the pipeline +makes no attempt to enforce mutual exclusivity. + +--- + +## 2. Universal invariants + +These hold for every operation, cursor- or toolbar-driven. + +| # | Invariant | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I1 | No cross-view interaction. | +| I2 | Holes are not solid. A point in a hole is OUTSIDE the polygon. | +| I3 | Both sides' holes are respected. Holes in either input subtract from that input's solid before any boolean op. | +| I4 | Identical inputs collapse algebraically: `A−A=∅`, `A⊕A=∅`, `A∪A=A`, `A∩A=A`. | +| I5 | Disjoint inputs do not interact: `∪={A,B}`, `−=A`, `∩=∅`, `⊕={A,B}`. | +| I6 | Output windings are normalized: outers CW, holes CCW. | +| I7 | Annotation identity is not preserved through any boolean op — output polygons are fresh annotations; inputs are removed. | +| I8 | Empty inputs short-circuit. `∅ op X` and `X op ∅` follow the algebra; Clipper is not invoked. | +| I9 | Outputs have pairwise non-overlapping solid areas within a segment per §1.2 — Clipper's `PolyTreeD` guarantees this. | +| I10 | Spatial nesting is unbounded. A polygon may sit inside another's hole, which itself sits inside a third's hole, etc., with no limit. Each remains a top-level polygon in the segment's flat list. | + +--- + +## 3. Cursor interactions (basic, no holes) + +Each cursor gesture has a **fixed polarity**. The user cannot accidentally +cross polarities mid-stroke. + +### 3.1 Shift + drag = REMOVE + +Polarity: **always subtractive**. Total segment area `≤` original total +segment area. Never grows any contour. + +The cursor may start **anywhere** — inside an existing polygon's solid, +inside one of its holes, or completely outside any polygon. The starting +position does NOT change the polarity; this is purely a subtractive gesture. + +Mechanic: the stroke is collected as a closed polygon, then subtracted from +every existing polygon in the same segment that it overlaps. Polygons that +are not overlapped pass through unchanged. + +Mapped op: `segment := segment − {stroke}`. + +| Stroke vs single existing polygon T | Result | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| Stroke fully outside T | No-op | +| Stroke fully inside T's solid (no edge crossings, no hole touched) | New hole carved in T, hole shape = stroke outline | +| Stroke fully inside an existing hole of T | No-op (already empty) | +| Stroke crosses T's outer edge | T shrinks; the bitten-off piece is discarded | +| Stroke straddles a hole edge of T | Hole grows into the solid; T's outer unchanged in that region | +| Stroke fully covers T (and all its holes) | T removed entirely | +| Stroke overlaps multiple polygons in the segment | Each is independently subtracted; some may vanish, others shrink, others gain holes | + +**Hole effects under REMOVE.** Subtracting solid is the operation that changes +hole topology, in any of these ways: + +- **New hole created** — a stroke entirely inside solid carves a hole. +- **Existing hole grows** — a stroke that removes solid bordering a hole + enlarges the hole. +- **Two holes merge** — a stroke that removes the solid bridge between two + existing holes turns them into one larger hole. +- **Hole merges with the exterior (hole "breaks out")** — a stroke that + carves a channel from a hole's edge through to T's outer boundary makes + the hole no longer a hole: it becomes part of T's outer concavity. T's + hole count drops by one and T's outer ring becomes more complex. +- **Polygon disappears** — if the subtraction removes all remaining solid. + +### 3.2 Normal drag (cursor starts OFF any existing contour edge) = ADD + +Polarity: **always additive**. Total segment area `≥` original total +segment area. Never shrinks any contour. + +The cursor may start **anywhere except on an existing contour edge** — +starting on an edge invokes the edit-drag path (§3.3) instead. Valid +starting positions include: + +- Inside an existing polygon's solid area +- Inside an existing polygon's hole +- Completely outside any polygon (in empty space) + +The starting position does NOT change the polarity; this is purely an +additive gesture. + +Mechanic: the stroke is collected as a NEW closed polygon. That new polygon +is then unioned with every existing polygon in the same segment that it +touches. Polygons that are not touched pass through unchanged; if the +stroke touches none, it remains as a fresh disjoint polygon in the segment. + +Mapped op: `segment := segment ∪ {stroke}`. + +| Stroke vs single existing polygon T | Result | +| --------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Stroke fully outside T (and not touching any other polygon) | New disjoint polygon added to segment | +| Stroke fully inside T's solid | No-op (already covered) | +| Stroke fully inside a hole of T, touching the hole boundary | Hole shrinks; if stroke fully covers the hole, hole disappears | +| Stroke fully inside a hole of T, not touching the hole boundary | New top-level polygon added, nested in the hole (per §1.2) | +| Stroke crosses T's outer edge | T grows to include the stroke | +| Stroke straddles a hole edge of T | Hole shrinks in the source direction | +| Stroke fully covers T | T replaced by stroke outline (or unioned with anything T was overlapping) | +| Stroke overlaps multiple polygons in the segment | Those polygons union with the stroke into one polygon | +| Stroke bridges a gap between two polygons | The two polygons merge into one through the bridge | + +**Hole effects under ADD.** Adding solid only shrinks holes — it can never +grow, create, or merge them with each other. Specifically: + +- **Existing hole shrinks** — a stroke that fills part of a hole reduces + the hole's area; T's outer is unchanged. +- **Existing hole disappears** — a stroke that fully covers a hole removes + it. The hole is gone; T's hole count drops by one. +- **Hole "absorbed" into outer** — if the filling stroke also extends out + of the hole into T's solid, the hole is filled via the connection; same + net effect as "disappears." +- **New nested polygon instead of shrinking** — a stroke that lands inside + a hole without touching the hole's boundary becomes a separate top-level + polygon nested in the hole (per §1.2). T's hole is unchanged. +- **Holes never merge with each other under ADD.** Merging holes requires + removing the solid between them — that is REMOVE territory. + +### 3.3 Click + drag on contour edge = LOCAL EDIT + +Polarity: **direct deformation**. NOT a boolean op. May add or remove area +depending on the direction the handle moves. + +This path does not invoke the Clipper pipeline unless rule 3.4 triggers. + +- Expanding (handle pulled outward): area added locally to the polygon's outer. +- Contracting (handle pushed inward): area removed locally. +- Holes are unaffected unless the edit reaches a hole boundary, at which point + 3.4 applies. + +### 3.4 Edit drag with topology change = MERGE or SPLIT (never XOR) + +When an edit causes the polygon to self-intersect, or causes the edited +polygon to overlap a sibling polygon in the same segment, the result is the +**UNION** of all touching pieces. + +| Topology event | Result | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Outer ring pinches into a figure-eight | Polygon splits into two polygons (one outer ring becomes two) | +| Edited polygon's outer now overlaps sibling B (same segment) | New polygon = (edited polygon) ∪ B; sibling B removed | +| Edited polygon's outer touches one of its own holes | Hole opens to the boundary; that hole disappears, replaced by an indentation in the outer | +| Edited polygon's outer crosses a polygon in a different segment | No interaction (segment independence) | + +The user explicitly does not want XOR here: an edit overlapping an existing +polygon means "merge these", not "punch out their intersection." + +--- + +## 4. Cursor interactions involving existing holes + +Extending §3 to the case where the target polygon already has holes. + +### 4.1 Shift + drag (subtract) with target holes + +| Stroke location | Result | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stroke fully inside an existing hole, no nested same-segment polygon present | No-op (subtracting from empty space) | +| Stroke fully inside an existing hole AND overlapping a same-segment polygon B nested there | Subtract from B per §3.1; surrounding polygon T unchanged | +| Stroke fully inside T's solid (no hole touched) | New hole carved (§3.1 baseline) | +| Stroke fully inside T's solid AND encompassing an entire existing hole | New larger hole, merging the old one | +| Stroke straddles a hole edge and surrounding solid | Hole grows in the source direction (parts of solid bordering the hole become hole) | +| Stroke straddles T's outer AND an interior hole | Outer shrinks where stroke extends outside; hole grows where stroke extends into solid bordering the hole; the part of the stroke inside the hole is a no-op (assuming no nested polygon there) | +| Stroke fully covers T (outer + all holes) | T removed entirely | + +### 4.2 Normal drag (union) with target holes + +Recall (§1.2): a same-segment polygon that ends up nested inside a hole is a +top-level polygon, not a child of the surrounding polygon. The stroke can +therefore land "inside" a hole without modifying the polygon that owns the +hole. + +| Stroke location | Result | +| ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stroke fully inside an existing hole, not touching the hole boundary or any nested same-segment polygon | New top-level polygon added to segment, spatially nested inside the hole. Surrounding polygon T unchanged. | +| Stroke fully inside an existing hole AND overlapping a same-segment polygon B nested in that hole | Union with B per §3.2; T unchanged | +| Stroke straddles a hole edge | Hole shape is reshaped: where the stroke extends out of the hole into solid, the hole boundary is rewritten to follow the stroke. Effectively the hole "shrinks" in that direction. | +| Stroke fully covers an existing hole AND extends into T's solid | Hole disappears (filled in via the connection through T's solid) | +| Stroke fully inside T's solid | No-op | +| Stroke fully outside T (disjoint) | New disjoint polygon added (§3.2 baseline) | +| Stroke straddles T's outer AND a hole | Outer grows where stroke extends outward; hole reshaped where stroke crosses the hole boundary | +| Stroke fully covers T (outer + all holes) | Result = stroke (T replaced) | + +### 4.3 Edit drag with target holes (3.4 extended) + +| Topology event | Result | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| Outer edit ring crosses into a hole and back out | Hole opens, becomes an indentation in the outer | +| Outer edit ring completely engulfs a hole | Hole survives, just sits further inside | +| Hole annotation itself is edited (rare — usually not user-exposed) | Same rules recursively, with the hole's outer playing the role of "outer" | + +--- + +## 5. Combine Contour (explicit segment-to-segment ops) + +The toolbar operations between two whole segments A and B. Either may contain +multiple polygons; each polygon may have holes. + +For each polygon in A and each in B, on a matching view reference, Clipper +runs once per view-reference group with A as subject and B as clip. + +### 5.1 Union: `A := A ∪ B` + +> Result is every region covered by EITHER A or B. +> A hole survives only where the opposite side is also not solid. + +**Hole effects:** same polarity as §3.2 (ADD). Holes in A or B can only +**shrink** or **disappear** under Union — wherever the opposite side is +solid, that part of the hole gets filled. Two holes do not merge with each +other under Union. (Same logic as the cursor ADD rule: filling solid never +opens or enlarges empty space.) + +### 5.2 Subtract: `A := A − B` + +> Result is every region of A that is NOT covered by B. +> A's solid is reduced wherever B is solid; A's existing holes are +> independently unaffected by overlaps with B's holes (subtracting empty +> space from empty space is a no-op). + +**Hole effects:** same polarity as §3.1 (REMOVE). Subtraction can: + +- create new holes in A (where B sits fully inside A's solid), +- enlarge existing A holes (where B extends into solid bordering a hole), +- merge two A holes into one (where B removes the bridge of solid between + them), and +- merge an A hole with the exterior — the hole "breaks out" when B carves + a channel from the hole's edge through to A's outer boundary, making + that hole stop being a hole and become part of A's outer concavity. + +B's holes are irrelevant to whether B subtracts at a point: B's "solid" +(per §1.1) is what subtracts. A point inside one of B's holes is not part +of B's solid, so B does not subtract there. + +### 5.3 Intersect: `A := A ∩ B` + +> Result is every region covered by BOTH A and B. +> Holes from either side are subtracted from the intersection. + +### 5.4 XOR: `A := A ⊕ B` + +> Result is every region covered by EXACTLY ONE of A or B. +> Equivalent to `(A ∪ B) − (A ∩ B)`. + +--- + +## 6. Exhaustive case matrix + +Let A and B be single polygons-with-holes on the same view reference. +`A_o` = outer of A, `A_h` = hole set of A; likewise for B. + +### Case A — Disjoint (`A_o ∩ B_o = ∅`) + +| Op | Result | +| --- | -------------------- | +| ∪ | {A, B} (two outputs) | +| − | {A} | +| ∩ | ∅ | +| ⊕ | {A, B} | + +### Case B — Edge-crossing (`A_o` and `B_o` boundaries intersect) + +| Op | Result | +| --- | --------------------------------------------------------------------------------------------------------------------------- | +| ∪ | Single merged outer; holes from A or B survive only where they remain entirely outside the opposite side's solid | +| − | A_o clipped by B_o; A's holes that lie fully outside B are preserved; A's holes that intersect B_o's solid grow accordingly | +| ∩ | Overlap region of A_o and B_o, minus any A or B hole intersecting that region | +| ⊕ | Symmetric difference: (A − B) ∪ (B − A) | + +### Case C — A_o fully contains B_o, no edge crossings (B inside A's solid) + +| Op | Result | +| --- | ------------------------------------------------ | +| ∪ | A unchanged (B already covered) | +| − | A with B carved as a new hole | +| ∩ | B (any A_h overlapping B subtracted from result) | +| ⊕ | A with B as a hole (same as `−` in this case) | + +### Case D — B_o fully contains A_o (symmetric to C) + +| Op | Result | +| --- | ------------------------------------------------ | +| ∪ | B unchanged | +| − | ∅ (A removed) | +| ∩ | A (any B_h overlapping A subtracted from result) | +| ⊕ | B with A as a hole | + +### Case E — B_o is fully inside a hole of A (B in A's empty region) + +Per §1.2, B remains a top-level polygon in the result and may itself carry +holes containing further top-level polygons recursively. + +| Op | Result | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| ∪ | A's outer unchanged; A's hole still present; B (with its own holes if any) added as a separate top-level polygon spatially nested in the hole | +| − | A unchanged (subtracting from empty space) | +| ∩ | ∅ (no solid overlap) | +| ⊕ | Same as ∪ (since ∩ is empty) | + +### Case F — A_o fully inside a hole of B (symmetric to E) + +| Op | Result | +| --- | ---------------------------------------------- | +| ∪ | B with A as nested island in the relevant hole | +| − | A unchanged | +| ∩ | ∅ | +| ⊕ | Same as ∪ | + +### Case G — Identical (same outer, same holes) + +| Op | Result | +| --- | ------ | +| ∪ | A | +| − | ∅ | +| ∩ | A | +| ⊕ | ∅ | + +### Case H — Same outer, different holes + +| Op | Result | +| --- | -------------------------------------------------------------------------- | +| ∪ | outer = A_o; holes = pointwise intersection of A_h and B_h | +| − | outer = A_o; solid = A_solid − B_solid (often a thin region; may be empty) | +| ∩ | outer = A_o; holes = pointwise union of A_h and B_h | +| ⊕ | symmetric difference of A_h and B_h (within shared outer) | + +### Case I — Two disjoint polygons in A versus one B that bridges them + +| Op | Result | +| --- | ------------------------------------------------------- | +| ∪ | Single merged polygon spanning both A pieces and B | +| − | Each A piece independently clipped by B | +| ∩ | The two overlap regions (one per A piece) | +| ⊕ | Holes/disjoint regions where exactly one side was solid | + +### Case J — Arbitrarily nested polygons (hole inside an island inside a hole inside an island …) + +Per §1.2, each "island inside a hole" is a separate top-level polygon. The +output of every op is a flat list of polygons-with-holes; nesting depth +shows up only as spatial coincidence. A polygon never has a hole-inside-a- +hole in its own data structure — that structure decomposes into separate +polygons. + +Concretely, a four-level nest "A contains hole H₁ contains island B contains +hole H₂ contains island C" comes out of any op as three flat top-level +polygons in the segment: {A with hole H₁, B with hole H₂, C}. Their spatial +nesting is implicit in the geometry. + +--- + +## 7. Suggested unit tests + +### 7.1 `test/clipperBooleanOps.spec.ts` — pure geometry + +```ts +import { + applyBoolean, + BooleanOp, + type PolygonWithHoles, +} from '../clipperBooleanOps'; + +const square = (x: number, y: number, size: number): [number, number][] => [ + [x, y], + [x + size, y], + [x + size, y + size], + [x, y + size], +]; +const poly = ( + outer: [number, number][], + ...holes: [number, number][][] +): PolygonWithHoles => ({ outer, holes: holes.length ? holes : undefined }); + +describe('applyBoolean', () => { + describe('Union', () => { + it('disjoint polygons → two outputs', () => { + const r = applyBoolean( + [poly(square(0, 0, 10))], + [poly(square(20, 0, 10))], + BooleanOp.Union + ); + expect(r).toHaveLength(2); + }); + it('edge-crossing → single merged polygon', () => { + const r = applyBoolean( + [poly(square(0, 0, 10))], + [poly(square(5, 0, 10))], + BooleanOp.Union + ); + expect(r).toHaveLength(1); + }); + it('A contains B → A unchanged (no extra hole)', () => { + const r = applyBoolean( + [poly(square(0, 0, 100))], + [poly(square(40, 40, 20))], + BooleanOp.Union + ); + expect(r).toHaveLength(1); + expect(r[0].holes ?? []).toHaveLength(0); + }); + it('B inside a hole of A → result has nested island polygon', () => { + const r = applyBoolean( + [poly(square(0, 0, 100), square(20, 20, 60))], + [poly(square(40, 40, 20))], + BooleanOp.Union + ); + // Expect 2 polygons: outer "donut" A, plus the B island sitting inside the hole. + expect(r).toHaveLength(2); + }); + it('three-deep nesting (A hole H1 island B hole H2 island C) flattens to 3 top-level polygons', () => { + const A = poly(square(0, 0, 100), square(10, 10, 80)); + const B = poly(square(20, 20, 60), square(30, 30, 40)); + const C = poly(square(40, 40, 20)); + const r = applyBoolean([A], [B, C], BooleanOp.Union); + expect(r).toHaveLength(3); + }); + it('A === B → A', () => { + const r = applyBoolean( + [poly(square(0, 0, 10))], + [poly(square(0, 0, 10))], + BooleanOp.Union + ); + expect(r).toHaveLength(1); + }); + it('same outer, different holes → outer preserved, holes = intersection of hole sets', () => { + const r = applyBoolean( + [poly(square(0, 0, 100), square(10, 10, 30))], + [poly(square(0, 0, 100), square(60, 60, 30))], + BooleanOp.Union + ); + // Holes don't intersect → output has no holes + expect(r[0].holes ?? []).toHaveLength(0); + }); + it('B fills part of an A hole → A hole shrinks; outer unchanged', () => { + /* ... */ + }); + it('B fully covers an A hole (and extends into A solid) → A hole disappears', () => { + /* ... */ + }); + it('Two A polygons + B bridging them → polygons merge into one', () => { + /* ... */ + }); + it('Two holes in A do NOT merge under Union (Union cannot bridge empty regions)', () => { + /* ... */ + }); + }); + + describe('Difference', () => { + it('disjoint → A', () => { + /* ... */ + }); + it('edge-crossing → A clipped', () => { + /* ... */ + }); + it('A contains B → A with B as new hole', () => { + const r = applyBoolean( + [poly(square(0, 0, 100))], + [poly(square(40, 40, 20))], + BooleanOp.Difference + ); + expect(r).toHaveLength(1); + expect(r[0].holes).toHaveLength(1); + }); + it('B contains A → empty', () => { + const r = applyBoolean( + [poly(square(40, 40, 20))], + [poly(square(0, 0, 100))], + BooleanOp.Difference + ); + expect(r).toHaveLength(0); + }); + it('B inside hole of A → A unchanged', () => { + const a = poly(square(0, 0, 100), square(20, 20, 60)); + const r = applyBoolean( + [a], + [poly(square(40, 40, 20))], + BooleanOp.Difference + ); + expect(r).toHaveLength(1); + expect(r[0].holes).toHaveLength(1); + }); + it('A === B → empty', () => { + /* ... */ + }); + it('B straddles A hole boundary → hole grows', () => { + /* ... */ + }); + it('B bridges two A holes → A ends with one merged hole', () => { + /* ... */ + }); + it('B carves a channel from A hole to A outer → hole merges with exterior; result has no hole', () => { + // A = 100x100 square with a 20x20 hole at (40,40). B = a thin strip + // from (40,0) to (60,100) — connects the hole to A's top edge. + // Expected: result is a single polygon with no hole and a concave + // outer ring (a "C" shape). + /* ... */ + }); + it('B fully inside A solid AND enclosing an existing A hole → existing hole gets absorbed into the new larger hole', () => { + /* ... */ + }); + }); + + describe('Intersection', () => { + it('disjoint → empty', () => { + /* ... */ + }); + it('edge-crossing → overlap region', () => { + /* ... */ + }); + it('A contains B → B', () => { + const r = applyBoolean( + [poly(square(0, 0, 100))], + [poly(square(40, 40, 20))], + BooleanOp.Intersection + ); + expect(r).toHaveLength(1); + // result outer ~= B's outer + }); + it('B inside hole of A → empty', () => { + /* ... */ + }); + it('A hole overlaps B → hole subtracted from result', () => { + /* ... */ + }); + }); + + describe('XOR', () => { + it('disjoint → both', () => { + /* ... */ + }); + it('A contains B → A with B as hole', () => { + /* ... */ + }); + it('A === B → empty', () => { + /* ... */ + }); + it('same outer different holes → XOR of holes', () => { + /* ... */ + }); + }); + + describe('Edge guarantees', () => { + it('empty subject + Union → clips returned', () => { + /* ... */ + }); + it('empty clip + Difference → subjects returned', () => { + /* ... */ + }); + it('polygon with <3 vertices is dropped', () => { + /* ... */ + }); + it('result outer winding is CW', () => { + /* ... */ + }); + it('result hole winding is CCW', () => { + /* ... */ + }); + }); +}); +``` + +### 7.2 `test/polylineSetOps.spec.ts` — view-reference grouping + +```ts +describe('runBooleanOpByView', () => { + it('different view references never interact (subtract)', () => { + const a = [{ polyline: square(0, 0, 10), viewReference: V1 }]; + const b = [{ polyline: square(0, 0, 10), viewReference: V2 }]; + const r = runBooleanOpByView(a, b, BooleanOp.Difference); + expect(r).toEqual(a); + }); + it('union: B-only view-references pass through', () => { + /* ... */ + }); + it('intersect: A-only view-references drop', () => { + /* ... */ + }); + it('subtract: A-only view-references pass through', () => { + /* ... */ + }); + it('holes survive canvas round-trip', () => { + /* ... */ + }); +}); +``` + +### 7.3 `test/cursorInteractions.spec.ts` — §3 / §4 (integration) + +These need a real viewport + annotation state, so they are heavier: + +```ts +describe('Shift+drag (subtract polarity)', () => { + it('stroke fully outside target → no-op', () => { + /* ... */ + }); + it('stroke fully inside solid → new hole carved', () => { + /* ... */ + }); + it('stroke fully inside an existing hole → no-op', () => { + /* ... */ + }); + it('stroke straddling target outer → target shrinks', () => { + /* ... */ + }); + it('stroke straddling a hole edge → hole grows', () => { + /* ... */ + }); + it('stroke covering entire target → target removed', () => { + /* ... */ + }); +}); + +describe('Normal drag (union polarity)', () => { + it('stroke disjoint → new polygon added', () => { + /* ... */ + }); + it('stroke inside solid → no-op', () => { + /* ... */ + }); + it('stroke inside a hole → hole shrinks', () => { + /* ... */ + }); + it('stroke straddling target outer → target grows', () => { + /* ... */ + }); + it('stroke bridging two polygons → they merge', () => { + /* ... */ + }); +}); + +describe('Edit drag (topology change)', () => { + it('outer self-intersection (figure-eight) → polygon splits', () => { + /* ... */ + }); + it('edit overlapping sibling polygon → polygons merge (no XOR)', () => { + /* ... */ + }); + it('outer touches one of its own holes → hole opens into indentation', () => { + /* ... */ + }); + it('edit crossing another segment → no interaction', () => { + /* ... */ + }); +}); +``` + +### 7.4 `test/combineContour.spec.ts` — §5 (toolbar ops) + +One test per cell of the §6 matrix per operation. ~40 cases total. + +--- + +## 8. Implementation notes + +The current pipeline implements §5 and §6 by calling +`Clipper.booleanOpDWithPolyTree` with `FillRule.EvenOdd` once per +view-reference group. EvenOdd means: a point is solid iff an odd number of +paths wind around it. Feeding outers and holes into the same path set under +EvenOdd makes Clipper treat the nested hole as an empty cutout regardless of +its winding direction. The result `PolyTreeD` flattens into our +`PolygonWithHoles[]` per §1.1 via `polyTreeToPolygons`. + +For §3 / §4 (cursor), the same machinery runs with: + +- Subject = the target polygon (plus all its existing holes) +- Clip = the cursor stroke (single polygon, no holes) +- Operation = Difference (shift+drag) or Union (normal drag) + +§3.3 / §3.4 are implemented elsewhere — direct polyline edit of the outer +ring, with §3.4 triggering when a self-intersection is detected post-edit. +That detection currently lives in the freehand tool, not the boolean-op +pipeline. 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/clipperBooleanOps.ts b/packages/tools/src/utilities/contourSegmentation/clipperBooleanOps.ts new file mode 100644 index 0000000000..efa65dc9ca --- /dev/null +++ b/packages/tools/src/utilities/contourSegmentation/clipperBooleanOps.ts @@ -0,0 +1,186 @@ +/** + * Thin wrapper around clipper2-ts that operates on cornerstone3D Point2 polygons + * with optional holes, returning hole-aware results via Clipper's PolyTreeD. + * + * All input/output polygons are in canvas space. View references and segmentation + * metadata are NOT handled here — callers must group by view reference before + * invoking these functions. + */ + +import type { Types } from '@cornerstonejs/core'; +import { + Clipper, + ClipType, + FillRule, + PolyTreeD, + type PathD, + type PathsD, + type PolyPathD, +} from 'clipper2-ts'; + +/** Decimal places preserved by Clipper. Canvas pixel coords don't need more. */ +const PRECISION = 4; + +export type PolygonWithHoles = { + outer: Types.Point2[]; + holes?: Types.Point2[][]; +}; + +export enum BooleanOp { + Union, + Difference, + Intersection, + Xor, +} + +const opToClipType: Record = { + [BooleanOp.Union]: ClipType.Union, + [BooleanOp.Difference]: ClipType.Difference, + [BooleanOp.Intersection]: ClipType.Intersection, + [BooleanOp.Xor]: ClipType.Xor, +}; + +function point2ToPathD(polyline: Types.Point2[]): PathD { + const len = polyline.length; + const out: PathD = new Array(len); + for (let i = 0; i < len; i++) { + out[i] = { x: polyline[i][0], y: polyline[i][1] }; + } + return out; +} + +function pathDToPoint2(path: PathD): Types.Point2[] { + const len = path.length; + const out: Types.Point2[] = new Array(len); + for (let i = 0; i < len; i++) { + out[i] = [path[i].x, path[i].y]; + } + return out; +} + +function polygonsToPathsD(polygons: PolygonWithHoles[]): PathsD { + const paths: PathsD = []; + for (const p of polygons) { + if (p.outer.length >= 3) { + paths.push(point2ToPathD(p.outer)); + } + if (p.holes) { + for (const hole of p.holes) { + if (hole.length >= 3) { + paths.push(point2ToPathD(hole)); + } + } + } + } + return paths; +} + +/** + * Walk a PolyTreeD into a flat list of PolygonWithHoles. + * + * In Clipper's PolyTree, the root's direct children are outer rings (isHole=false). + * Each outer ring's children are holes (isHole=true). A hole can in turn contain + * nested outer rings (separate polygons sitting inside that hole) — those become + * top-level entries in our flat output too. + */ +function polyTreeToPolygons(tree: PolyTreeD): PolygonWithHoles[] { + const out: PolygonWithHoles[] = []; + collectOuters(tree, out); + return out; +} + +function collectOuters(node: PolyPathD, out: PolygonWithHoles[]): void { + for (let i = 0; i < node.count; i++) { + const child = node.child(i); + if (child.isHole) { + // Outer-rings nested inside this hole become their own top-level polygons. + collectOuters(child, out); + continue; + } + const outerPoly = child.poly; + if (!outerPoly || outerPoly.length < 3) { + continue; + } + const polygon: PolygonWithHoles = { outer: pathDToPoint2(outerPoly) }; + const holes: Types.Point2[][] = []; + for (let j = 0; j < child.count; j++) { + const grand = child.child(j); + if (grand.isHole) { + const holePoly = grand.poly; + if (holePoly && holePoly.length >= 3) { + holes.push(pathDToPoint2(holePoly)); + } + // Descend further: outer rings nested inside this hole are separate polygons. + collectOuters(grand, out); + } + } + if (holes.length > 0) { + polygon.holes = holes; + } + out.push(polygon); + } +} + +/** Defensive clone so the caller can mutate the result without aliasing inputs. */ +function clonePolygons(polygons: PolygonWithHoles[]): PolygonWithHoles[] { + return polygons.map((p) => ({ + outer: p.outer.map((pt) => [pt[0], pt[1]] as Types.Point2), + holes: p.holes?.map((h) => h.map((pt) => [pt[0], pt[1]] as Types.Point2)), + })); +} + +/** + * Apply a boolean operation between two sets of polygons-with-holes, returning + * the resulting polygons with hole topology preserved. + * + * Empty-input shortcuts avoid invoking Clipper for trivial cases. + */ +export function applyBoolean( + subjects: PolygonWithHoles[], + clips: PolygonWithHoles[], + op: BooleanOp +): PolygonWithHoles[] { + if (subjects.length === 0) { + if (op === BooleanOp.Union || op === BooleanOp.Xor) { + return clonePolygons(clips); + } + return []; + } + if (clips.length === 0) { + if (op === BooleanOp.Intersection) { + return []; + } + // Union, Difference, Xor with no clip all return the subject unchanged. + return clonePolygons(subjects); + } + + const subjectPaths = polygonsToPathsD(subjects); + const clipPaths = polygonsToPathsD(clips); + + if (subjectPaths.length === 0) { + if (op === BooleanOp.Union || op === BooleanOp.Xor) { + return clonePolygons(clips); + } + return []; + } + if (clipPaths.length === 0) { + if (op === BooleanOp.Intersection) { + return []; + } + return clonePolygons(subjects); + } + + const tree = new PolyTreeD(); + // EvenOdd treats overlapping rings as creating holes regardless of winding, + // which lets us hand Clipper the outer-and-hole paths in any order. + Clipper.booleanOpDWithPolyTree( + opToClipType[op], + subjectPaths, + clipPaths, + tree, + FillRule.EvenOdd, + PRECISION + ); + + return polyTreeToPolygons(tree); +} 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/mergeMultipleAnnotations.ts b/packages/tools/src/utilities/contourSegmentation/mergeMultipleAnnotations.ts index f18382f173..1b046a4efb 100644 --- a/packages/tools/src/utilities/contourSegmentation/mergeMultipleAnnotations.ts +++ b/packages/tools/src/utilities/contourSegmentation/mergeMultipleAnnotations.ts @@ -9,7 +9,6 @@ import { removeAnnotation, getChildAnnotations, addChildAnnotation, - clearParentAnnotation, } from '../../stateManagement/annotation/annotationState'; import { addContourSegmentationAnnotation } from './addContourSegmentationAnnotation'; import { removeContourSegmentationAnnotation } from './removeContourSegmentationAnnotation'; @@ -17,6 +16,11 @@ import { triggerAnnotationModified } from '../../stateManagement/annotation/help import triggerAnnotationRenderForViewportIds from '../triggerAnnotationRenderForViewportIds'; import { getViewportIdsWithToolToRender } from '../viewportFilters'; import { hasToolByName, hasTool } from '../../store/addTool'; +import { + applyBoolean, + BooleanOp, + type PolygonWithHoles, +} from './clipperBooleanOps'; /** * Default tool name for contour segmentation operations. @@ -103,83 +107,83 @@ function processSequentialIntersections( }> ): void { const { element } = viewport; - const allAnnotationsToRemove = [sourceAnnotation]; - const allResultPolylines: Types.Point2[][] = []; - const allHoles: Array<{ - annotation: ContourSegmentationAnnotation; - polyline: Types.Point2[]; - }> = []; - // Collect all holes from target annotations - mergeOperations.forEach(({ targetAnnotation }) => { - const holes = getContourHolesData(viewport, targetAnnotation); - allHoles.push(...holes); - allAnnotationsToRemove.push(targetAnnotation); - }); + // Build subject polygons (one per target outer) carrying their hole polylines + // so Clipper can preserve holes across the boolean op. + const subjects: PolygonWithHoles[] = mergeOperations.map( + ({ targetAnnotation, targetPolyline }) => { + const holes = getContourHolesData(viewport, targetAnnotation).map( + (h) => h.polyline + ); + return { + outer: targetPolyline, + holes: holes.length ? holes : undefined, + }; + } + ); + const clips: PolygonWithHoles[] = [{ outer: sourcePolyline }]; - // Determine operation type based on whether source start point is inside any target polyline + // Decide op: source-start-inside-any-target signals an additive intent (union); + // otherwise this is a cut-out (difference). const sourceStartPoint = sourcePolyline[0]; const shouldMerge = mergeOperations.some(({ targetPolyline }) => math.polyline.containsPoint(targetPolyline, sourceStartPoint) ); + const op = shouldMerge ? BooleanOp.Union : BooleanOp.Difference; - if (shouldMerge) { - // Merge all polylines together - let resultPolyline = sourcePolyline; - mergeOperations.forEach(({ targetPolyline }) => { - resultPolyline = math.polyline.mergePolylines( - resultPolyline, - targetPolyline - ); - }); - allResultPolylines.push(resultPolyline); - } else { - // Subtract source from each target - mergeOperations.forEach(({ targetPolyline }) => { - const subtractedPolylines = math.polyline.subtractPolylines( - targetPolyline, - sourcePolyline - ); - allResultPolylines.push(...subtractedPolylines); - }); - } + const resultPolygons = applyBoolean(subjects, clips, op); + + // Collect every annotation we're about to discard: source, all targets, and + // every existing hole (geometry is replaced wholesale). + const allHoleAnnotations: ContourSegmentationAnnotation[] = []; + const allAnnotationsToRemove: ContourSegmentationAnnotation[] = [ + sourceAnnotation, + ]; + mergeOperations.forEach(({ targetAnnotation }) => { + allAnnotationsToRemove.push(targetAnnotation); + getContourHolesData(viewport, targetAnnotation).forEach((h) => + allHoleAnnotations.push(h.annotation) + ); + }); - // Remove all original annotations - allAnnotationsToRemove.forEach((annotation) => { + [...allAnnotationsToRemove, ...allHoleAnnotations].forEach((annotation) => { removeAnnotation(annotation.annotationUID); removeContourSegmentationAnnotation(annotation); }); - // Detach holes from old annotations - allHoles.forEach((holeData) => clearParentAnnotation(holeData.annotation)); - - // Create new annotations from result polylines + // Rebuild annotations from clipper output (outer + holes per polygon). const baseAnnotation = mergeOperations[0].targetAnnotation; - const newAnnotations: ContourSegmentationAnnotation[] = []; - allResultPolylines.forEach((polyline) => { - if (!polyline || polyline.length < 3) { - console.warn( - 'Skipping creation of new annotation due to invalid polyline:', - polyline - ); + resultPolygons.forEach((polygon) => { + if (polygon.outer.length < 3) { return; } - - const newAnnotation = createNewAnnotationFromPolyline( + const parent = createNewAnnotationFromPolyline( viewport, baseAnnotation, - polyline + polygon.outer, + ContourWindingDirection.Clockwise ); - addAnnotation(newAnnotation, element); - addContourSegmentationAnnotation(newAnnotation); - triggerAnnotationModified(newAnnotation, viewport.element); - newAnnotations.push(newAnnotation); + addAnnotation(parent, element); + addContourSegmentationAnnotation(parent); + triggerAnnotationModified(parent, element); + + polygon.holes?.forEach((holePolyline) => { + if (holePolyline.length < 3) { + return; + } + const hole = createNewAnnotationFromPolyline( + viewport, + baseAnnotation, + holePolyline, + ContourWindingDirection.CounterClockwise + ); + addAnnotation(hole, element); + addChildAnnotation(parent, hole); + triggerAnnotationModified(hole, element); + }); }); - // Reassign holes to new annotations - reassignHolesToNewAnnotations(viewport, allHoles, newAnnotations); - updateViewportsForAnnotations(viewport, allAnnotationsToRemove); } @@ -189,7 +193,8 @@ function processSequentialIntersections( function createNewAnnotationFromPolyline( viewport: Types.IViewport, baseAnnotation: ContourSegmentationAnnotation, - polyline: Types.Point2[] + polyline: Types.Point2[], + windingDirection: ContourWindingDirection = ContourWindingDirection.Clockwise ): ContourSegmentationAnnotation { const startPointWorld = viewport.canvasToWorld(polyline[0]); const endPointWorld = viewport.canvasToWorld(polyline[polyline.length - 1]); @@ -233,7 +238,7 @@ function createNewAnnotationFromPolyline( { points: polyline, closed: true, - targetWindingDirection: ContourWindingDirection.Clockwise, + targetWindingDirection: windingDirection, }, viewport ); @@ -241,33 +246,6 @@ function createNewAnnotationFromPolyline( return newAnnotation; } -/** - * Reassigns holes to new annotations based on containment. - */ -function reassignHolesToNewAnnotations( - viewport: Types.IViewport, - holes: Array<{ - annotation: ContourSegmentationAnnotation; - polyline: Types.Point2[]; - }>, - newAnnotations: ContourSegmentationAnnotation[] -): void { - holes.forEach((holeData) => { - // Find which new annotation should contain this hole - const parentAnnotation = newAnnotations.find((annotation) => { - const parentPolyline = convertContourPolylineToCanvasSpace( - annotation.data.contour.polyline, - viewport - ); - return math.polyline.containsPoints(parentPolyline, holeData.polyline); - }); - - if (parentAnnotation) { - addChildAnnotation(parentAnnotation, holeData.annotation); - } - }); -} - /** * Helper function to get hole data from an annotation. */ 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..4b9481743f 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineIntersect.ts @@ -1,50 +1,20 @@ import type { PolylineInfoCanvas } from './polylineInfoTypes'; -import { checkIntersection, cleanupPolylines } from './sharedOperations'; -import { intersectPolylines } from '../math/polyline'; -import arePolylinesIdentical from '../math/polyline/arePolylinesIdentical'; -import { areViewReferencesEqual } from './areViewReferencesEqual'; +import { runBooleanOpByView } from './polylineSetOps'; +import { BooleanOp } from './clipperBooleanOps'; /** - * Performs the intersection operation on two sets of polylines. - * Returns polylines that are present in both sets (by polyline and viewReference), - * or the intersected regions if the polylines overlap. + * Intersect two sets of polylines. Returns polygons (with holes) representing + * overlapping regions between the two sets, grouped by view reference. * - * @param set1 The first set of PolylineInfoCanvas - * @param set2 The second set of PolylineInfoCanvas - * @returns Array of PolylineInfoCanvas representing the intersection + * Clipper handles all spatial relationships uniformly: + * - Disjoint polygons → empty result + * - Edge crossings → intersection region + * - One polygon fully inside another → the inner polygon + * - Holes in either input are subtracted from the intersection naturally */ export function intersectPolylinesSets( set1: PolylineInfoCanvas[], set2: PolylineInfoCanvas[] ): PolylineInfoCanvas[] { - if (!set1.length || !set2.length) { - return []; - } - const result: PolylineInfoCanvas[] = []; - for (const polyA of set1) { - for (const polyB of set2) { - if (!areViewReferencesEqual(polyA.viewReference, polyB.viewReference)) { - continue; // Skip if view references are not equal - } - if (arePolylinesIdentical(polyA.polyline, polyB.polyline)) { - result.push({ ...polyA }); - continue; - } - const intersection = checkIntersection(polyA.polyline, polyB.polyline); - if (intersection.hasIntersection && !intersection.isContourHole) { - const intersectionRegions = cleanupPolylines( - intersectPolylines(polyA.polyline, polyB.polyline) - ); - if (intersectionRegions && intersectionRegions.length > 0) { - intersectionRegions.forEach((region) => { - result.push({ - polyline: region, - viewReference: polyA.viewReference, - }); - }); - } - } - } - } - return result; + return runBooleanOpByView(set1, set2, BooleanOp.Intersection); } diff --git a/packages/tools/src/utilities/contourSegmentation/polylineSetOps.ts b/packages/tools/src/utilities/contourSegmentation/polylineSetOps.ts new file mode 100644 index 0000000000..7b31ca5e7f --- /dev/null +++ b/packages/tools/src/utilities/contourSegmentation/polylineSetOps.ts @@ -0,0 +1,111 @@ +/** + * Bridges PolylineInfoCanvas (canvas-space polylines + viewReference + holes) + * to the pure-geometry clipperBooleanOps. Groups polylines by viewReference, + * runs Clipper once per group, then flattens back to PolylineInfoCanvas[]. + * + * View references must match exactly — operations between polylines on + * different slices/views never combine. + */ + +import type { PolylineInfoCanvas } from './polylineInfoTypes'; +import { areViewReferencesEqual } from './areViewReferencesEqual'; +import { + applyBoolean, + BooleanOp, + type PolygonWithHoles, +} from './clipperBooleanOps'; +import type { Types } from '@cornerstonejs/core'; + +type Group = { + viewReference: PolylineInfoCanvas['viewReference']; + polygons: PolygonWithHoles[]; +}; + +function toPolygon(info: PolylineInfoCanvas): PolygonWithHoles { + return { + outer: info.polyline, + holes: info.holePolylines?.length ? info.holePolylines : undefined, + }; +} + +function groupByViewReference(set: PolylineInfoCanvas[]): Group[] { + const groups: Group[] = []; + for (const info of set) { + if (info.polyline.length < 3) { + continue; + } + const existing = groups.find((g) => + areViewReferencesEqual(g.viewReference, info.viewReference) + ); + if (existing) { + existing.polygons.push(toPolygon(info)); + } else { + groups.push({ + viewReference: info.viewReference, + polygons: [toPolygon(info)], + }); + } + } + return groups; +} + +function flatten( + result: PolygonWithHoles[], + viewReference: PolylineInfoCanvas['viewReference'] +): PolylineInfoCanvas[] { + return result.map((p) => ({ + polyline: p.outer, + viewReference, + ...(p.holes?.length ? { holePolylines: p.holes } : {}), + })); +} + +/** + * Run `op` between two PolylineInfoCanvas sets, grouping by view reference so + * polygons on different views never interact. + * + * - For commutative ops (Union, Xor): subject-only and clip-only view groups + * are passed through unchanged. + * - For Difference: subject groups without a matching clip group pass through; + * clip-only groups are dropped (nothing to subtract from). + * - For Intersection: only groups present on both sides contribute. + */ +export function runBooleanOpByView( + setA: PolylineInfoCanvas[], + setB: PolylineInfoCanvas[], + op: BooleanOp +): PolylineInfoCanvas[] { + const aGroups = groupByViewReference(setA); + const bGroups = groupByViewReference(setB); + + const out: PolylineInfoCanvas[] = []; + const matchedB = new Set(); + + for (const aGroup of aGroups) { + const bGroup = bGroups.find((g) => + areViewReferencesEqual(g.viewReference, aGroup.viewReference) + ); + if (bGroup) { + matchedB.add(bGroup); + const result = applyBoolean(aGroup.polygons, bGroup.polygons, op); + out.push(...flatten(result, aGroup.viewReference)); + } else if (op === BooleanOp.Intersection) { + // No matching clip → empty. + } else { + // Union / Xor / Difference with empty clip: subjects unchanged. + out.push(...flatten(aGroup.polygons, aGroup.viewReference)); + } + } + + // B-only groups: only meaningful for Union and Xor. + if (op === BooleanOp.Union || op === BooleanOp.Xor) { + for (const bGroup of bGroups) { + if (matchedB.has(bGroup)) { + continue; + } + out.push(...flatten(bGroup.polygons, bGroup.viewReference)); + } + } + + return out; +} diff --git a/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts b/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts index 6e8e8fccd6..8d51146110 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineSubtract.ts @@ -1,89 +1,25 @@ import type { Types } from '@cornerstonejs/core'; -import * as math from '../math'; -import { - checkIntersection, - cleanupPolylines, - convertContourPolylineToCanvasSpace, - removeDuplicatePoints, -} from './sharedOperations'; -import arePolylinesIdentical from '../math/polyline/arePolylinesIdentical'; import type { PolylineInfoCanvas } from './polylineInfoTypes'; import type { ContourSegmentationAnnotation } from '../../types'; +import { convertContourPolylineToCanvasSpace } from './sharedOperations'; import { getViewReferenceFromAnnotation } from './getViewReferenceFromAnnotation'; -import { areViewReferencesEqual } from './areViewReferencesEqual'; +import { runBooleanOpByView } from './polylineSetOps'; +import { BooleanOp } from './clipperBooleanOps'; /** - * Subtracts polylines in set2 from set1, returning only those in set1 that are not present in set2. - * Comparison is done by polyline and viewReference. - * - * @param polylinesSetA The minuend set of PolylineInfoCanvas - * @param polylinesSetB The subtrahend set of PolylineInfoCanvas - * @returns Array of PolylineInfoCanvas in set1 but not in set2 + * Subtract polylines in `polylinesSetB` from `polylinesSetA`. Holes are + * preserved through the operation (Clipper handles edge crossings, full + * containment, and hole-vs-subtrahend interactions uniformly). */ export function subtractPolylineSets( polylinesSetA: PolylineInfoCanvas[], polylinesSetB: PolylineInfoCanvas[] ): PolylineInfoCanvas[] { - const result: PolylineInfoCanvas[] = []; - for (let i = 0; i < polylinesSetA.length; i++) { - let currentPolylines = [polylinesSetA[i]]; - for (let j = 0; j < polylinesSetB.length; j++) { - const polylineB = polylinesSetB[j]; - const newPolylines: PolylineInfoCanvas[] = []; - for (const currentPolyline of currentPolylines) { - if ( - !areViewReferencesEqual( - currentPolyline.viewReference, - polylineB.viewReference - ) - ) { - // If viewReference does not match, keep the polyline for further checks - newPolylines.push(currentPolyline); - continue; - } - if ( - arePolylinesIdentical(currentPolyline.polyline, polylineB.polyline) - ) { - // Polyline is identical, so it is subtracted (not added to newPolylines) - continue; - } - const intersection = checkIntersection( - currentPolyline.polyline, - polylineB.polyline - ); - if (intersection.hasIntersection && !intersection.isContourHole) { - const subtractedPolylines = cleanupPolylines( - math.polyline.subtractPolylines( - currentPolyline.polyline, - polylineB.polyline - ) - ); - for (const subtractedPolyline of subtractedPolylines) { - const cleaned = removeDuplicatePoints(subtractedPolyline); - if (cleaned.length >= 3) { - newPolylines.push({ - polyline: cleaned, - viewReference: currentPolyline.viewReference, - }); - } - } - } else { - newPolylines.push({ - polyline: currentPolyline.polyline, - viewReference: currentPolyline.viewReference, - }); - } - } - currentPolylines = newPolylines; - } - result.push(...currentPolylines); - } - return result; + return runBooleanOpByView(polylinesSetA, polylinesSetB, BooleanOp.Difference); } /** - * Subtracts multiple sets of polylines from a base set progressively. - * Each set is subtracted from the accumulated result from previous operations. + * Progressively subtract multiple sets from a base set. */ export function subtractMultiplePolylineSets( basePolylineSet: PolylineInfoCanvas[], @@ -92,35 +28,36 @@ export function subtractMultiplePolylineSets( if (subtractorSets.length === 0) { return [...basePolylineSet]; } - let result = [...basePolylineSet]; - for (let i = 0; i < subtractorSets.length; i++) { - result = subtractPolylineSets(result, subtractorSets[i]); + let result: PolylineInfoCanvas[] = basePolylineSet; + for (const subtractor of subtractorSets) { + result = subtractPolylineSets(result, subtractor); } return result; } /** - * Subtracts polylines from annotations by extracting their polylines and subtracting intersecting ones. - * This is a convenience function that works directly with annotation data. + * Convenience: subtract by annotation. Carries each annotation's child (hole) + * annotations into the operation so the subtraction respects existing holes. */ export function subtractAnnotationPolylines( baseAnnotations: ContourSegmentationAnnotation[], subtractorAnnotations: ContourSegmentationAnnotation[], viewport: Types.IViewport ): PolylineInfoCanvas[] { - const basePolylines = baseAnnotations.map((annotation) => ({ - polyline: convertContourPolylineToCanvasSpace( - annotation.data.contour.polyline, - viewport - ), - viewReference: getViewReferenceFromAnnotation(annotation), - })); - const subtractorPolylines = subtractorAnnotations.map((annotation) => ({ - polyline: convertContourPolylineToCanvasSpace( - annotation.data.contour.polyline, - viewport - ), - viewReference: getViewReferenceFromAnnotation(annotation), - })); - return subtractPolylineSets(basePolylines, subtractorPolylines); + const toInfo = ( + annotation: ContourSegmentationAnnotation + ): PolylineInfoCanvas => { + const info: PolylineInfoCanvas = { + polyline: convertContourPolylineToCanvasSpace( + annotation.data.contour.polyline, + viewport + ), + viewReference: getViewReferenceFromAnnotation(annotation), + }; + return info; + }; + return subtractPolylineSets( + baseAnnotations.map(toInfo), + subtractorAnnotations.map(toInfo) + ); } diff --git a/packages/tools/src/utilities/contourSegmentation/polylineUnify.ts b/packages/tools/src/utilities/contourSegmentation/polylineUnify.ts index caeaa31058..ef6609f9bb 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineUnify.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineUnify.ts @@ -1,91 +1,25 @@ import type { Types } from '@cornerstonejs/core'; -import * as math from '../math'; -import { - checkIntersection, - convertContourPolylineToCanvasSpace, -} from './sharedOperations'; -import arePolylinesIdentical from '../math/polyline/arePolylinesIdentical'; import type { PolylineInfoCanvas } from './polylineInfoTypes'; import type { ContourSegmentationAnnotation } from '../../types'; +import { convertContourPolylineToCanvasSpace } from './sharedOperations'; import { getViewReferenceFromAnnotation } from './getViewReferenceFromAnnotation'; -import { areViewReferencesEqual } from './areViewReferencesEqual'; +import { runBooleanOpByView } from './polylineSetOps'; +import { BooleanOp } from './clipperBooleanOps'; /** - * Unifies two sets of polylines by merging unique polylines from both sets. - * If a polyline from set B is not present in set A (by polyline and viewReference), - * it is added to the result. The result contains all unique polylines from both sets. - * - * @param polylinesSetA The first set of PolylineInfoCanvas - * @param polylinesSetB The second set of PolylineInfoCanvas - * @returns Array of unique PolylineInfoCanvas from both sets + * Union two sets of polylines. Overlapping polygons in the same view reference + * are merged; disjoint polygons pass through unchanged. Holes in either input + * are preserved if they are not covered by the other input. */ export function unifyPolylineSets( polylinesSetA: PolylineInfoCanvas[], polylinesSetB: PolylineInfoCanvas[] ): PolylineInfoCanvas[] { - const result: PolylineInfoCanvas[] = []; - const processedFromA = new Set(); - const processedFromB = new Set(); - for (let i = 0; i < polylinesSetA.length; i++) { - if (processedFromA.has(i)) { - continue; - } - const polylineA = polylinesSetA[i]; - let merged = false; - for (let j = 0; j < polylinesSetB.length; j++) { - if (processedFromB.has(j)) { - continue; - } - const polylineB = polylinesSetB[j]; - if ( - !areViewReferencesEqual( - polylineA.viewReference, - polylineB.viewReference - ) - ) { - continue; // Skip if view references are not equal - } - if (arePolylinesIdentical(polylineA.polyline, polylineB.polyline)) { - result.push(polylineA); - processedFromA.add(i); - processedFromB.add(j); - merged = true; - break; - } - const intersection = checkIntersection( - polylineA.polyline, - polylineB.polyline - ); - if (intersection.hasIntersection && !intersection.isContourHole) { - const mergedPolyline = math.polyline.mergePolylines( - polylineA.polyline, - polylineB.polyline - ); - result.push({ - polyline: mergedPolyline, - viewReference: polylineA.viewReference, - }); - processedFromA.add(i); - processedFromB.add(j); - merged = true; - break; - } - } - if (!merged) { - result.push(polylineA); - processedFromA.add(i); - } - } - for (let j = 0; j < polylinesSetB.length; j++) { - if (!processedFromB.has(j)) { - result.push(polylinesSetB[j]); - } - } - return result; + return runBooleanOpByView(polylinesSetA, polylinesSetB, BooleanOp.Union); } /** - * Unifies multiple sets of polylines by progressively merging them. + * Progressively union multiple sets. */ export function unifyMultiplePolylineSets( polylineSets: PolylineInfoCanvas[][] @@ -96,7 +30,7 @@ export function unifyMultiplePolylineSets( if (polylineSets.length === 1) { return [...polylineSets[0]]; } - let result = [...polylineSets[0]]; + let result: PolylineInfoCanvas[] = polylineSets[0]; for (let i = 1; i < polylineSets.length; i++) { result = unifyPolylineSets(result, polylineSets[i]); } @@ -104,26 +38,24 @@ export function unifyMultiplePolylineSets( } /** - * Unifies polylines from annotations by extracting their polylines and merging intersecting ones. + * Convenience: union by annotation. */ export function unifyAnnotationPolylines( annotationsSetA: ContourSegmentationAnnotation[], annotationsSetB: ContourSegmentationAnnotation[], viewport: Types.IViewport ): PolylineInfoCanvas[] { - const polylinesSetA = annotationsSetA.map((annotation) => ({ - polyline: convertContourPolylineToCanvasSpace( - annotation.data.contour.polyline, - viewport - ), - viewReference: getViewReferenceFromAnnotation(annotation), - })); - const polylinesSetB = annotationsSetB.map((annotation) => ({ + const toInfo = ( + annotation: ContourSegmentationAnnotation + ): PolylineInfoCanvas => ({ polyline: convertContourPolylineToCanvasSpace( annotation.data.contour.polyline, viewport ), viewReference: getViewReferenceFromAnnotation(annotation), - })); - return unifyPolylineSets(polylinesSetA, polylinesSetB); + }); + return unifyPolylineSets( + annotationsSetA.map(toInfo), + annotationsSetB.map(toInfo) + ); } diff --git a/packages/tools/src/utilities/contourSegmentation/polylineXor.ts b/packages/tools/src/utilities/contourSegmentation/polylineXor.ts index 00b36045e5..669933872e 100644 --- a/packages/tools/src/utilities/contourSegmentation/polylineXor.ts +++ b/packages/tools/src/utilities/contourSegmentation/polylineXor.ts @@ -1,66 +1,15 @@ -import type { Types } from '@cornerstonejs/core'; -import { cleanupPolylines } from './sharedOperations'; -import arePolylinesIdentical from '../math/polyline/arePolylinesIdentical'; -import { subtractPolylineSets } from './polylineSubtract'; import type { PolylineInfoCanvas } from './polylineInfoTypes'; -import { areViewReferencesEqual } from './areViewReferencesEqual'; +import { runBooleanOpByView } from './polylineSetOps'; +import { BooleanOp } from './clipperBooleanOps'; /** - * Performs the XOR (exclusive or) operation on two sets of polylines. - * Returns polylines that are in one set or the other, but not both (by polyline and viewReference). - * If both sets are identical, returns an empty array. - * - * @param polylinesSetA The first set of PolylineInfoCanvas - * @param polylinesSetB The second set of PolylineInfoCanvas - * @returns Array of PolylineInfoCanvas that are unique to each set + * XOR (symmetric difference) of two sets of polylines: areas covered by + * exactly one of the two sets. Clipper computes this in a single pass; we + * no longer compose subtract twice. */ export function xorPolylinesSets( polylinesSetA: PolylineInfoCanvas[], polylinesSetB: PolylineInfoCanvas[] ): PolylineInfoCanvas[] { - if (!polylinesSetA.length && !polylinesSetB.length) { - return []; - } - if (!polylinesSetA.length) { - return polylinesSetB; - } - if (!polylinesSetB.length) { - return polylinesSetA; - } - if (polylinesSetA.length === polylinesSetB.length) { - let allIdentical = true; - for (let i = 0; i < polylinesSetA.length; i++) { - let foundMatch = false; - for (let j = 0; j < polylinesSetB.length; j++) { - if ( - !areViewReferencesEqual( - polylinesSetA[i].viewReference, - polylinesSetB[j].viewReference - ) - ) { - continue; // Skip if view references are not equal - } - if ( - arePolylinesIdentical( - polylinesSetA[i].polyline, - polylinesSetB[j].polyline - ) - ) { - foundMatch = true; - break; - } - } - if (!foundMatch) { - allIdentical = false; - break; - } - } - if (allIdentical) { - return []; - } - } - const aMinusB = subtractPolylineSets(polylinesSetA, polylinesSetB); - const bMinusA = subtractPolylineSets(polylinesSetB, polylinesSetA); - const xorResult = [...aMinusB, ...bMinusA]; - return xorResult; + return runBooleanOpByView(polylinesSetA, polylinesSetB, BooleanOp.Xor); } diff --git a/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts b/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts index 8c51e996dc..ba4bb180b3 100644 --- a/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts +++ b/packages/tools/src/utilities/contourSegmentation/sharedOperations.ts @@ -9,7 +9,6 @@ import { removeAnnotation, getChildAnnotations, addChildAnnotation, - clearParentAnnotation, } from '../../stateManagement/annotation/annotationState'; import { addContourSegmentationAnnotation } from './addContourSegmentationAnnotation'; import { removeContourSegmentationAnnotation } from './removeContourSegmentationAnnotation'; @@ -17,6 +16,11 @@ import { triggerAnnotationModified } from '../../stateManagement/annotation/help import triggerAnnotationRenderForViewportIds from '../triggerAnnotationRenderForViewportIds'; import { getViewportIdsWithToolToRender } from '../viewportFilters'; import { hasToolByName } from '../../store/addTool'; +import { + applyBoolean, + BooleanOp, + type PolygonWithHoles, +} from './clipperBooleanOps'; const TOLERANCE = 1e-10; // Very small tolerance for floating point comparison @@ -69,6 +73,7 @@ export function checkIntersection( ): { hasIntersection: boolean; isContourHole: boolean; + isTargetInsideSource: boolean; } { const sourceAABB = math.polyline.getAABB(sourcePolyline); const targetAABB = math.polyline.getAABB(targetPolyline); @@ -76,7 +81,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 +98,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 }; } /** @@ -154,7 +169,10 @@ export function createPolylineHole( } /** - * Combines a source polyline with a target polyline using merge or subtract operations. + * Combines a source polyline with a target polyline. The source-start-point's + * relation to the target's outer ring drives merge (union) vs subtract (difference). + * Existing target holes flow through Clipper, so a source that intersects a hole + * adjusts the hole geometry correctly instead of being silently preserved. */ export function combinePolylines( viewport: Types.IViewport, @@ -163,7 +181,6 @@ export function combinePolylines( sourceAnnotation: ContourSegmentationAnnotation, sourcePolyline: Types.Point2[] ): void { - // Check if the necessary tool for creating new combined contours is registered. if (!hasToolByName(DEFAULT_CONTOUR_SEG_TOOL_NAME)) { console.warn( `${DEFAULT_CONTOUR_SEG_TOOL_NAME} is not registered in cornerstone. Cannot combine polylines.` @@ -172,118 +189,71 @@ export function combinePolylines( } const sourceStartPoint = sourcePolyline[0]; - // Determine if the operation should be a merge (union) or subtraction const mergePolylines = math.polyline.containsPoint( targetPolyline, sourceStartPoint ); const contourHolesData = getContourHolesData(viewport, targetAnnotation); - const unassignedContourHolesSet = new Set(contourHolesData); - const reassignedContourHolesMap = new Map< - Types.Point2[], - typeof contourHolesData - >(); - - /** Helper to assign a hole to a new parent polyline. */ - const assignHoleToPolyline = ( - parentPolyline: Types.Point2[], - holeData: (typeof contourHolesData)[0] - ) => { - let holes = reassignedContourHolesMap.get(parentPolyline); - if (!holes) { - holes = []; - reassignedContourHolesMap.set(parentPolyline, holes); - } - holes.push(holeData); - unassignedContourHolesSet.delete(holeData); - }; - - const newPolylines: Types.Point2[][] = []; - - if (mergePolylines) { - const mergedPolyline = math.polyline.mergePolylines( - targetPolyline, - sourcePolyline - ); - newPolylines.push(mergedPolyline); - - // When merging, all existing holes remain inside the new merged contour - Array.from(unassignedContourHolesSet.keys()).forEach((holeData) => - assignHoleToPolyline(mergedPolyline, holeData) - ); - } else { - // Subtract polylines - const subtractedPolylines = math.polyline.subtractPolylines( - targetPolyline, - sourcePolyline - ); - - subtractedPolylines.forEach((newPolyline) => { - newPolylines.push(newPolyline); - // Reassign existing holes to the new polyline(s) if they are contained within - Array.from(unassignedContourHolesSet.keys()).forEach((holeData) => { - const containsHole = math.polyline.containsPoints( - newPolyline, - holeData.polyline - ); - if (containsHole) { - assignHoleToPolyline(newPolyline, holeData); - } - }); - }); - } + const subjects: PolygonWithHoles[] = [ + { + outer: targetPolyline, + holes: contourHolesData.length + ? contourHolesData.map((h) => h.polyline) + : undefined, + }, + ]; + const clips: PolygonWithHoles[] = [{ outer: sourcePolyline }]; - // Detach holes from the old targetAnnotation before it's deleted - Array.from(reassignedContourHolesMap.values()).forEach( - (contourHolesDataArray) => - contourHolesDataArray.forEach((contourHoleData) => - clearParentAnnotation(contourHoleData.annotation) - ) + const resultPolygons = applyBoolean( + subjects, + clips, + mergePolylines ? BooleanOp.Union : BooleanOp.Difference ); const { element } = viewport; - const { metadata, data } = targetAnnotation; - const { handles, segmentation } = data; - const { textBox } = handles; - - // Remove original annotations - removeAnnotation(sourceAnnotation.annotationUID); - removeAnnotation(targetAnnotation.annotationUID); - removeContourSegmentationAnnotation(sourceAnnotation); - removeContourSegmentationAnnotation(targetAnnotation); - // Create new annotations from result polylines - const newAnnotations: ContourSegmentationAnnotation[] = []; + // Replace source, target, and all of target's hole annotations with the + // freshly computed geometry — hole annotations may differ if source intersected them. + const annotationsToRemove: ContourSegmentationAnnotation[] = [ + sourceAnnotation, + targetAnnotation, + ...contourHolesData.map((h) => h.annotation), + ]; + annotationsToRemove.forEach((annotation) => { + removeAnnotation(annotation.annotationUID); + removeContourSegmentationAnnotation(annotation); + }); - for (let i = 0; i < newPolylines.length; i++) { - const polyline = newPolylines[i]; - if (!polyline || polyline.length < 3) { - console.warn( - 'Skipping creation of new annotation due to invalid polyline:', - polyline - ); - continue; + resultPolygons.forEach((polygon) => { + if (polygon.outer.length < 3) { + return; } - - const newAnnotation = createNewAnnotationFromPolyline( + const parent = createNewAnnotationFromPolyline( viewport, targetAnnotation, - polyline + polygon.outer, + ContourWindingDirection.Clockwise ); + addAnnotation(parent, element); + addContourSegmentationAnnotation(parent); + triggerAnnotationModified(parent, element); - addAnnotation(newAnnotation, element); - addContourSegmentationAnnotation(newAnnotation); - triggerAnnotationModified(newAnnotation, viewport.element); - newAnnotations.push(newAnnotation); - - // Add re-assigned holes as children to this new annotation - reassignedContourHolesMap - .get(polyline) - ?.forEach((holeData) => - addChildAnnotation(newAnnotation, holeData.annotation) + polygon.holes?.forEach((holePolyline) => { + if (holePolyline.length < 3) { + return; + } + const hole = createNewAnnotationFromPolyline( + viewport, + targetAnnotation, + holePolyline, + ContourWindingDirection.CounterClockwise ); - } + addAnnotation(hole, element); + addChildAnnotation(parent, hole); + triggerAnnotationModified(hole, element); + }); + }); updateViewportsForAnnotations(viewport, [targetAnnotation, sourceAnnotation]); } @@ -294,7 +264,8 @@ export function combinePolylines( export function createNewAnnotationFromPolyline( viewport: Types.IViewport, templateAnnotation: ContourSegmentationAnnotation, - polyline: Types.Point2[] + polyline: Types.Point2[], + windingDirection: ContourWindingDirection = ContourWindingDirection.Clockwise ): ContourSegmentationAnnotation { const startPointWorld = viewport.canvasToWorld(polyline[0]); const endPointWorld = viewport.canvasToWorld(polyline[polyline.length - 1]); @@ -338,7 +309,7 @@ export function createNewAnnotationFromPolyline( { points: polyline, closed: true, - targetWindingDirection: ContourWindingDirection.Clockwise, + targetWindingDirection: windingDirection, }, viewport ); diff --git a/yarn.lock b/yarn.lock index 957854b37a..fec4244b00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5618,6 +5618,11 @@ cli-width@^4.1.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== +clipper2-ts@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/clipper2-ts/-/clipper2-ts-2.0.1.tgz#f696089d9b463f9e58fa3bb4abdd2fa51e5df32d" + integrity sha512-raIgNMpYN/PFYk/T9iRzaG99rf5nlXBWL1FyAdR3wjEYSzJy+pPfolLH5bCdRqCFQqn/eYUsF1jb3cQEMxmWGw== + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"