All files / tools/src/utilities/contourSegmentation clipperBooleanOps.ts

49.36% Statements 39/79
21.42% Branches 9/42
75% Functions 9/12
46.57% Lines 34/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220                                          128x                           128x               7x 7x 7x 1337x   7x       7x 7x 7x 1330x   7x                                                         7x 7x 7x       7x 7x 7x         7x 7x     7x 7x 7x                     7x     7x           7x 1330x                               7x 7x 7x                                                                                                                     7x       7x 7x                 7x    
/**
 * 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, ClipType> = {
  [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);
    Iif (child.isHole) {
      // Outer-rings nested inside this hole become their own top-level polygons.
      collectOuters(child, out);
      continue;
    }
    const outerPoly = child.poly;
    Iif (!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);
      }
    }
    Iif (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[] {
  Eif (subjects.length === 0) {
    Eif (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);
}
 
/**
 * Decompose a single, possibly self-intersecting, polyline into simple
 * (non-self-intersecting) rings.
 *
 * A figure-eight comes back as two rings that touch at the crossing point;
 * a simple polygon comes back unchanged (as a single ring). This is the
 * EvenOdd self-union Clipper performs when given a subject and no clip — it
 * resolves the self-intersection into the smallest set of simple rings whose
 * even-odd fill equals the original.
 *
 * Holes (rings Clipper marks as interior under even-odd) are returned flat
 * alongside outers; the caller decides how to re-associate them.
 */
export function splitSelfIntersections(
  polyline: Types.Point2[]
): PolygonWithHoles[] {
  Iif (polyline.length < 3) {
    return [];
  }
 
  const tree = new PolyTreeD();
  Clipper.booleanOpDWithPolyTree(
    ClipType.Union,
    [point2ToPathD(polyline)],
    null,
    tree,
    FillRule.EvenOdd,
    PRECISION
  );
 
  return polyTreeToPolygons(tree);
}