From 7a3a4fbda811eee0346e099cef0e89291590dc7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 08:23:45 +0000 Subject: [PATCH] feat: P2.3 bite vs hole on the hole-straddle walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H-SHELL-HOLE-CROSS is the first face walk: even-n hole ∩ other shares the clip edge, and new edge ⊂ other.shell is a bite, not a punched hole. Overlay splices that bite or punches the leftover. H-SHELL-HOLE-X stays a named miss. No noder in jts-core. Co-authored-by: Jeroen Bloemscheer --- .../operation/overlayng/curve/BiteVsHole.java | 588 ++++++++++++++++++ .../curve/CompoundCurveShellOverlay.java | 13 +- .../overlayng/curve/CurveSegmentNoder.java | 3 +- .../overlayng/curve/CurveSegmentString.java | 3 +- .../curve/DifferentOuterHoleOverlay.java | 8 +- .../overlayng/curve/OverlayNGCurve.java | 16 +- .../curve/CircularArcOverlayTest.java | 15 +- .../curve/CompoundCurveShellOverlayTest.java | 82 ++- .../curve/CurveSegmentStringTest.java | 28 +- .../curve/OverlayNGCurvePerfGateTest.java | 20 + .../curve/OverlayNGCurveRatchetTest.java | 15 +- 11 files changed, 752 insertions(+), 39 deletions(-) create mode 100644 modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java new file mode 100644 index 0000000000..a4379c9140 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/BiteVsHole.java @@ -0,0 +1,588 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.overlayng.curve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.locate.SimplePointInAreaLocator; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Location; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; +import org.locationtech.jts.operation.overlayng.OverlayNG; + +/** + * First face walk: bite versus hole. One predicate -- if the new + * edge of {@code hole ∩ other} is a subset of the other shell, it + * is a bite, not an interior punch ({@code H-SHELL-HOLE-CROSS}). + *

+ * The noder already names the two hole–shell nodes. This rung walks + * those into the clip edge on the other shell. Overlay then splices + * the bite (SUB / XOR face) or punches the leftover hole. Two holes + * ({@code H-SHELL-HOLE-X}) stay {@code null}. A pair this walk + * cannot certify keeps the named miss. Not a noder, not N-SS. + */ +final class BiteVsHole { + + static final int BITE = 1; + static final int HOLE = -1; + static final int MISS = 0; + + private BiteVsHole() { } + + /** + * Exact overlay, or {@code null} if the walk cannot certify bite + * versus hole. Same-outer and strictly-inside / strictly-outside + * different-outer cells stay on those kits. + */ + static Geometry overlay(Geometry a, Geometry b, int opCode) { + Walk walk = walk(a, b); + if (walk == null || walk.kind != BITE) return null; + GeometryFactory f = TwoNodeClip.curveFactory(a); + CurvePolygon outerHoled = new CurvePolygon( + walk.holed.getExteriorCurve(), null, f); + Geometry firstOuter = walk.holedFirst ? outerHoled : walk.solid; + Geometry secondOuter = walk.holedFirst ? walk.solid : outerHoled; + if (opCode == OverlayNG.INTERSECTION) { + Geometry cap = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.INTERSECTION); + return splice(cap, walk.holeIn, walk.p, walk.q, f, walk.scale); + } + if (opCode == OverlayNG.UNION) { + Geometry cup = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.UNION); + return punch(cup, walk.leftover, f); + } + if (opCode == OverlayNG.DIFFERENCE) { + Geometry sub = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.DIFFERENCE); + if (walk.holedFirst) { + return splice(sub, walk.holeOut, walk.p, walk.q, f, walk.scale); + } + return join(sub, walk.bite, f); + } + if (opCode == OverlayNG.SYMDIFFERENCE) { + Geometry abOuters = CompoundCurveShellOverlay.overlay( + firstOuter, secondOuter, OverlayNG.DIFFERENCE); + Geometry baOuters = CompoundCurveShellOverlay.overlay( + secondOuter, firstOuter, OverlayNG.DIFFERENCE); + Geometry ab = walk.holedFirst + ? splice(abOuters, walk.holeOut, walk.p, walk.q, f, walk.scale) + : join(abOuters, walk.bite, f); + Geometry ba = walk.holedFirst + ? join(baOuters, walk.bite, f) + : splice(baOuters, walk.holeOut, walk.p, walk.q, f, walk.scale); + return join(ab, ba, f); + } + return null; + } + + /** + * {@link #BITE} when the new edge ⊂ other.shell, {@link #HOLE} + * when the walk says punch, {@link #MISS} when the predicate + * cannot be certified. + */ + static int decide(Geometry a, Geometry b) { + Walk walk = walk(a, b); + return walk == null ? MISS : walk.kind; + } + + /** + * The certified clip edge, or {@code null}. A chord on the other + * shell; not a face. + */ + static CurveSegmentString clipEdge(Geometry a, Geometry b) { + Walk walk = walk(a, b); + if (walk == null || walk.kind != BITE) return null; + if (walk.newEdge == null || walk.newEdge.size() != 1) return null; + LineString e = walk.newEdge.get(0); + if (e instanceof CircularString) return null; + Coordinate[] c = e.getCoordinates(); + if (c.length < 2) return null; + return CurveSegmentString.segment(c[0], c[c.length - 1]); + } + + private static Walk walk(Geometry a, Geometry b) { + CurvePolygon ca = SameOuterHoleOverlay.mixedShell(a); + CurvePolygon cb = SameOuterHoleOverlay.mixedShell(b); + if (ca == null || cb == null) return null; + int ha = ca.getNumInteriorRing(); + int hb = cb.getNumInteriorRing(); + CurvePolygon holed = null; + CurvePolygon solid = null; + boolean holedFirst = false; + if (ha == 1 && hb == 0) { + holed = ca; + solid = cb; + holedFirst = true; + } + else if (ha == 0 && hb == 1) { + holed = cb; + solid = ca; + holedFirst = false; + } + if (holed == null || solid == null) return null; + if (holed.getExteriorCurve().equalsExact(solid.getExteriorCurve())) { + return null; + } + LineString hole = SameOuterHoleOverlay.plainHole(holed); + if (hole == null) return null; + GeometryFactory f = TwoNodeClip.curveFactory(a); + CurvePolygon outerHoled = new CurvePolygon( + holed.getExteriorCurve(), null, f); + if (!SameOuterHoleOverlay.holeInsideShell(holed, outerHoled)) { + return null; + } + + List shell = TwoNodeClip.flatten(solid); + if (shell == null) return null; + Coordinate[] ring = hole.getCoordinates(); + List nodes = TwoNodeClip.nodesVsPolygon(shell, ring); + if (nodes == null || nodes.size() != 2) return null; + double scale = scaleOf(holed, solid); + if (nodes.get(0).pt.distance(nodes.get(1).pt) + < TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + + List holeStr = CurveSegmentString.of(hole); + List shellStr = CurveSegmentString.of(solid); + if (holeStr == null || shellStr == null) return null; + Coordinate[] named = CurveSegmentNoder.nodes(holeStr, shellStr, scale); + if (named == null || named.length != 2) return null; + + TwoNodeClip.Node n0 = nodes.get(0); + TwoNodeClip.Node n1 = nodes.get(1); + List pq = TwoNodeClip.walkRing(ring, n0.pt, n1.pt); + List qp = TwoNodeClip.walkRing(ring, n1.pt, n0.pt); + if (pq == null || qp == null) return null; + int pqSide = sideOfShell(pq, solid); + int qpSide = sideOfShell(qp, solid); + List holeIn = null; + List holeOut = null; + if (pqSide == TwoNodeClip.IN && qpSide == TwoNodeClip.OUT) { + holeIn = pq; + holeOut = qp; + } + else if (qpSide == TwoNodeClip.IN && pqSide == TwoNodeClip.OUT) { + holeIn = qp; + holeOut = pq; + } + if (holeIn == null || holeOut == null) return null; + + List s01 = TwoNodeClip.walkEdges(shell, n0, n1, f); + List s10 = TwoNodeClip.walkEdges(shell, n1, n0, f); + if (s01 == null || s10 == null) return null; + List edge01 = clipEdgeOnShell(s01, solid, hole, scale); + List edge10 = clipEdgeOnShell(s10, solid, hole, scale); + List newEdge = null; + if (edge01 != null && edge10 == null) { + newEdge = edge01; + } + else if (edge10 != null && edge01 == null) { + newEdge = edge10; + } + if (newEdge == null) return null; + if (lengthOf(newEdge) <= TwoNodeClip.PROPER_CROSS_FRAC * scale) { + return null; + } + + Coordinate p = holeIn.get(0); + Coordinate q = holeIn.get(holeIn.size() - 1); + List oriented = directed(newEdge, q, p, scale, f); + if (oriented == null) return null; + + Geometry bite = closePlain(holeIn, oriented, f, scale); + Geometry leftover = closePlain(holeOut, oriented, f, scale); + if (bite == null || leftover == null) return null; + Walk w = new Walk(); + w.kind = BITE; + w.holed = holed; + w.solid = solid; + w.holedFirst = holedFirst; + w.holeIn = holeIn; + w.holeOut = holeOut; + w.newEdge = oriented; + w.p = p; + w.q = q; + w.bite = bite; + w.leftover = leftover; + w.scale = scale; + return w; + } + + /** + * The shell walk whose step into the other interior lands in the + * hole. That walk is the new edge, and it is a subset of the other + * shell by construction. No such walk is a named miss. + */ + private static List clipEdgeOnShell(List walk, + CurvePolygon other, LineString hole, double scale) { + if (walk == null || walk.isEmpty()) return null; + Coordinate step = stepInside(walk, other, scale); + if (step == null) return null; + if (!inPlainRing(step, hole)) return null; + return walk; + } + + private static Coordinate stepInside(List walk, + CurvePolygon other, double scale) { + LineString m = walk.get(0); + Coordinate a = m.getCoordinateN(0); + Coordinate b = m.getCoordinateN(m.getNumPoints() - 1); + Coordinate mid; + if (m instanceof CircularString && m.getNumPoints() >= 3) { + mid = m.getCoordinateN(1); + } + else { + mid = new Coordinate(0.5 * (a.x + b.x), 0.5 * (a.y + b.y)); + } + double dx = b.x - a.x; + double dy = b.y - a.y; + double len = Math.hypot(dx, dy); + if (len <= 1.0e-12) return null; + double eps = Math.max(1.0e-6, 1.0e-4 * scale); + Coordinate left = new Coordinate( + mid.x - dy / len * eps, mid.y + dx / len * eps); + Coordinate right = new Coordinate( + mid.x + dy / len * eps, mid.y - dx / len * eps); + int sl = TwoNodeClip.locateInShell(left, other); + int sr = TwoNodeClip.locateInShell(right, other); + if (sl == TwoNodeClip.IN && sr != TwoNodeClip.IN) return left; + if (sr == TwoNodeClip.IN && sl != TwoNodeClip.IN) return right; + return null; + } + + private static int sideOfShell(List path, CurvePolygon shell) { + if (path == null || path.size() < 2) return TwoNodeClip.MIXED; + Coordinate sample; + if (path.size() >= 3) { + sample = path.get(path.size() / 2); + } + else { + sample = new Coordinate(0.5 * (path.get(0).x + path.get(1).x), + 0.5 * (path.get(0).y + path.get(1).y)); + } + return TwoNodeClip.locateInShell(sample, shell); + } + + /** + * Replace the shared clip edge on {@code face} with {@code via} + * (the hole walk). The new edge ⊂ face.shell here -- a bite, not + * a punch. A face that does not carry that edge stays {@code null}. + */ + private static Geometry splice(Geometry face, List via, + Coordinate p, Coordinate q, GeometryFactory f, double scale) { + if (face == null || via == null) return null; + if (face.isEmpty()) return face; + if (face.getNumGeometries() != 1) return null; + Geometry g = face.getGeometryN(0); + if (!(g instanceof CurvePolygon)) return null; + CurvePolygon cp = (CurvePolygon) g; + if (cp.getNumInteriorRing() > 0) return null; + List edges = TwoNodeClip.flatten(cp); + if (edges == null) return null; + int hit = indexChord(edges, p, q); + if (hit < 0) return null; + TwoNodeClip.Edge e = edges.get(hit); + double tP = TwoNodeClip.parameter(e.a, e.b, p); + double tQ = TwoNodeClip.parameter(e.a, e.b, q); + Coordinate first = tP <= tQ ? p : q; + Coordinate second = tP <= tQ ? q : p; + List repl = directedPath(via, first, second, scale); + if (repl == null) return null; + List members = new ArrayList(); + boolean ok = true; + for (int i = 0; i < edges.size() && ok; i++) { + if (i == hit) { + addChord(members, e.a, first, f, scale); + addPath(members, repl, f); + addChord(members, second, e.b, f, scale); + } + else { + LineString piece = toLine(edges.get(i), f); + if (piece == null) { + ok = false; + } + else { + members.add(piece); + } + } + } + if (!ok) return null; + return TwoNodeClip.closeRing(members, f, + Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12)); + } + + private static int indexChord(List edges, + Coordinate p, Coordinate q) { + int found = -1; + boolean two = false; + for (int i = 0; i < edges.size() && !two; i++) { + TwoNodeClip.Edge e = edges.get(i); + if (!e.isArc && onChord(p, e.a, e.b) && onChord(q, e.a, e.b)) { + if (found >= 0) { + two = true; + } + else { + found = i; + } + } + } + return two ? -1 : found; + } + + private static boolean onChord(Coordinate p, Coordinate a, Coordinate b) { + double t = TwoNodeClip.parameter(a, b, p); + Coordinate q = new Coordinate( + a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)); + return p.distance(q) <= 1.0e-12; + } + + private static void addChord(List dest, Coordinate from, + Coordinate to, GeometryFactory f, double scale) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + if (from.distance(to) <= eps) return; + dest.add(f.createLineString(new Coordinate[] { + new Coordinate(from), new Coordinate(to) + })); + } + + private static void addPath(List dest, List path, + GeometryFactory f) { + if (path == null || path.size() < 2) return; + dest.add(f.createLineString(copy(path))); + } + + private static List directedPath(List path, + Coordinate from, Coordinate to, double scale) { + if (path == null || path.size() < 2) return null; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate a = path.get(0); + Coordinate b = path.get(path.size() - 1); + if (a.distance(from) <= eps && b.distance(to) <= eps) return path; + if (a.distance(to) <= eps && b.distance(from) <= eps) { + return reverse(path); + } + return null; + } + + private static List directed(List parts, + Coordinate from, Coordinate to, double scale, GeometryFactory f) { + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate start = startOf(parts); + Coordinate end = endOf(parts); + if (start.distance(from) <= eps && end.distance(to) <= eps) { + return parts; + } + if (start.distance(to) <= eps && end.distance(from) <= eps) { + return reverseMembers(parts); + } + return null; + } + + private static Geometry closePlain(List along, + List back, GeometryFactory f, double scale) { + if (along == null || back == null || along.size() < 2) return null; + List oriented = directed(back, along.get(along.size() - 1), + along.get(0), scale, f); + if (oriented == null) return null; + List ring = new ArrayList(); + addUniquePts(ring, along); + addUniquePts(ring, coordsOf(oriented)); + if (ring.size() < 3) return null; + if (!ring.get(0).equals2D(ring.get(ring.size() - 1))) { + ring.add(new Coordinate(ring.get(0))); + } + try { + Geometry poly = f.createPolygon(f.createLinearRing(copy(ring))); + if (poly.getArea() <= 0.0) return null; + return poly; + } + catch (RuntimeException ex) { + return null; + } + } + + private static Geometry punch(Geometry solid, Geometry hole, GeometryFactory f) { + if (solid == null || hole == null) return null; + if (solid.isEmpty()) return solid; + if (solid.getNumGeometries() != 1) return null; + Geometry g = solid.getGeometryN(0); + LineString ring = asHoleRing(hole, f); + if (ring == null) return null; + if (g instanceof CurvePolygon) { + CurvePolygon cp = (CurvePolygon) g; + if (cp.getNumInteriorRing() > 0) return null; + return new CurvePolygon(cp.getExteriorCurve(), + new LineString[] { ring }, f); + } + if (TwoNodeClip.isPlainPolygon(g)) { + try { + return f.createPolygon(((Polygon) g).getExteriorRing(), + new LinearRing[] { f.createLinearRing(ring.getCoordinates()) }); + } + catch (RuntimeException ex) { + return null; + } + } + return null; + } + + private static LineString asHoleRing(Geometry hole, GeometryFactory f) { + if (hole instanceof Polygon) { + return ((Polygon) hole).getExteriorRing(); + } + if (hole instanceof LineString && ((LineString) hole).isClosed()) { + return (LineString) hole; + } + return null; + } + + private static Geometry join(Geometry a, Geometry extra, GeometryFactory f) { + if (extra == null) return null; + if (a == null || a.isEmpty()) return extra; + if (extra.isEmpty()) return a; + List faces = new ArrayList(); + if (!addFaces(faces, a) || !addFaces(faces, extra)) return null; + if (faces.isEmpty()) return null; + if (faces.size() == 1) return faces.get(0); + return new MultiSurface(faces.toArray(new Polygon[0]), f); + } + + private static boolean addFaces(List dest, Geometry g) { + boolean ok = true; + for (int i = 0; i < g.getNumGeometries() && ok; i++) { + Geometry p = g.getGeometryN(i); + if (p instanceof Polygon && !p.isEmpty()) { + dest.add((Polygon) p); + } + else { + ok = false; + } + } + return ok; + } + + private static boolean inPlainRing(Coordinate p, LineString hole) { + try { + Polygon poly = hole.getFactory().createPolygon( + hole.getFactory().createLinearRing(hole.getCoordinates())); + return SimplePointInAreaLocator.locate(p, poly) == Location.INTERIOR; + } + catch (RuntimeException ex) { + return false; + } + } + + private static LineString toLine(TwoNodeClip.Edge e, GeometryFactory f) { + if (e.isArc) { + return TwoNodeClip.arc(e.a, e.mid, e.b, f); + } + return f.createLineString(new Coordinate[] { + new Coordinate(e.a), new Coordinate(e.b) + }); + } + + private static double lengthOf(List parts) { + double len = 0.0; + for (int i = 0; i < parts.size(); i++) { + len += parts.get(i).getLength(); + } + return len; + } + + private static Coordinate startOf(List parts) { + return parts.get(0).getCoordinateN(0); + } + + private static Coordinate endOf(List parts) { + LineString last = parts.get(parts.size() - 1); + return last.getCoordinateN(last.getNumPoints() - 1); + } + + private static List reverseMembers(List parts) { + List out = new ArrayList(parts.size()); + for (int i = parts.size() - 1; i >= 0; i--) { + out.add((LineString) parts.get(i).reverse()); + } + return out; + } + + private static List reverse(List path) { + List out = new ArrayList(path.size()); + for (int i = path.size() - 1; i >= 0; i--) { + out.add(path.get(i)); + } + return out; + } + + private static List coordsOf(List parts) { + List out = new ArrayList(); + for (int i = 0; i < parts.size(); i++) { + Coordinate[] c = parts.get(i).getCoordinates(); + for (int k = 0; k < c.length; k++) { + out.add(c[k]); + } + } + return out; + } + + private static void addUniquePts(List dest, + List src) { + if (src == null) return; + for (int i = 0; i < src.size(); i++) { + Coordinate p = src.get(i); + if (dest.isEmpty() || !dest.get(dest.size() - 1).equals2D(p)) { + dest.add(new Coordinate(p)); + } + } + } + + private static Coordinate[] copy(List path) { + Coordinate[] c = new Coordinate[path.size()]; + for (int i = 0; i < path.size(); i++) { + c[i] = new Coordinate(path.get(i)); + } + return c; + } + + private static double scaleOf(Geometry a, Geometry b) { + double wa = Math.max(a.getEnvelopeInternal().getWidth(), + a.getEnvelopeInternal().getHeight()); + double wb = Math.max(b.getEnvelopeInternal().getWidth(), + b.getEnvelopeInternal().getHeight()); + return Math.max(Math.max(wa, wb), 1.0); + } + + private static final class Walk { + int kind; + CurvePolygon holed; + CurvePolygon solid; + boolean holedFirst; + List holeIn; + List holeOut; + List newEdge; + Coordinate p; + Coordinate q; + Geometry bite; + Geometry leftover; + double scale; + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java index 26f94dbce2..01b877e9d7 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlay.java @@ -33,10 +33,11 @@ * {@link SameOuterHoleOverlay}, {@link DifferentOuterHoleOverlay}, * {@link HalfDiscOverlay} (complementary / sectors / collinear), * {@link TwoShellClip} (0 / 1 / 2 / even-n / odd-n with a tangent - * as a degenerate NSpan), or a two-node walk vs a disc or plain - * polygon via {@link TwoNodeClip}. A hole that straddles the - * other shell, or two holes that cross, stay {@code null} (bite - * / noder, not a kit). A 0-node mixed shell vs a circular disc + * as a degenerate NSpan), {@link BiteVsHole} (straddling hole: + * new edge ⊂ other.shell is a bite, not a punch), or a two-node + * walk vs a disc or plain polygon via {@link TwoNodeClip}. Two + * holes that cross stay {@code null} ({@code H-SHELL-HOLE-X}). + * A 0-node mixed shell vs a circular disc * ({@code CC-NEST-ANNULUS}) is not a punch. A miss is {@code null}. */ final class CompoundCurveShellOverlay { @@ -57,6 +58,10 @@ static Geometry overlay(Geometry a, Geometry b, int opCode) { if (differentHole != null) { return differentHole; } + Geometry bite = BiteVsHole.overlay(a, b, opCode); + if (bite != null) { + return bite; + } CurvePolygon shellA = compoundCurveShell(a); CurvePolygon shellB = compoundCurveShell(b); if (shellA != null && shellB != null) { diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java index a42559d1bc..6aafbbd653 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentNoder.java @@ -86,7 +86,8 @@ static Coordinate[] nodes(List a, /** * Shared runs of a circular pair. Empty is no interval and no - * pinch. {@code null} is a hole (P2.3 / P2.4) or a pair the + * pinch. {@code null} is a holed pair (the hole ring is walked + * as strings by {@link BiteVsHole}, not here) or a pair the * strings cannot name. Does not assemble a face. */ static List edges(Geometry a, Geometry b) { diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java index f59c06c3bf..fb03aecb53 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentString.java @@ -128,7 +128,8 @@ boolean isDegenerate() { /** * Exterior pieces of a hole-free circular / compound / plain ring, * or the pieces of a lineal CircularString / CompoundCurve / - * LineString (R-LL). Holes stay {@code null} (P2.3 / P2.4). + * LineString (R-LL). A holed CurvePolygon stays {@code null}; + * pass the hole ring itself as a LineString. Two holes are P2.4. */ static List of(Geometry g) { Geometry geom = unwrap(g); diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/DifferentOuterHoleOverlay.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/DifferentOuterHoleOverlay.java index 5fc5a036df..3a3d08a8c5 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/DifferentOuterHoleOverlay.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/DifferentOuterHoleOverlay.java @@ -32,8 +32,9 @@ * and if it is strictly outside ignore it for CAP (keep it on the * holed side). A hole that meets or crosses the CAP shares the * clip edge: subtracting hole ∩ other is a bite, not an interior - * punch ({@code H-SHELL-HOLE-CROSS}). Two holes that cross - * ({@code H-SHELL-HOLE-X}) are a noder. Both stay {@code null}. + * punch ({@code H-SHELL-HOLE-CROSS}). That pair is + * {@link BiteVsHole}. Two holes that cross + * ({@code H-SHELL-HOLE-X}) stay {@code null}. */ final class DifferentOuterHoleOverlay { @@ -173,7 +174,8 @@ else if (loc == TwoNodeClip.IN) { } } // Both sides of the CAP: the hole crosses the other shell. - // hole ∩ CAP shares the clip edge (H-SHELL-HOLE-CROSS). + // hole ∩ CAP shares the clip edge -- BiteVsHole decides + // bite versus hole. This kit stays MIXED. if (mixed || (sawIn && sawOut)) return TwoNodeClip.MIXED; if (sawIn) return TwoNodeClip.IN; if (sawOut) return TwoNodeClip.OUT; diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java index fa7e5dea91..af94622f48 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurve.java @@ -106,9 +106,10 @@ * is the holed / unholed / hole polygon. A different-outer hole * whose outers already clip composes: hole strictly inside the * outer CAP is punched, hole strictly outside is ignored on - * CAP. A hole that meets or crosses the other outer shares - * the clip edge (a bite, not an interior punch). Two holes - * that cross are a noder. Collinear overlap, mixed labels, + * CAP. A hole that crosses the other outer shares the clip + * edge: if that new edge is a subset of the other shell it + * is a bite, not an interior punch. Two holes that cross + * stay a named miss. Collinear overlap, mixed labels, * or a line-only shell return {@code null} without paying * this path. *

  • R-LL -- one operand is a {@link org.locationtech.jts.geom.curve.CircularString} @@ -133,8 +134,9 @@ * R1.5–R1.7 share package-private {@code TwoNodeClip} for the two-node * walk (hits, ring / member walk, CAP / CUP / SUB / XOR). Even-n * assemble is {@code NSpanClip}. R1.7 dispatch is - * {@code CompoundCurveShellOverlay} (hole / half-disc / two-shell / - * vs disc or polygon). R-LL and R-AA reuse the same intersection + * {@code CompoundCurveShellOverlay} (hole / bite-vs-hole / + * half-disc / two-shell / vs disc or polygon). R-LL and R-AA + * reuse the same intersection * primitives. Each rung keeps its own shape dispatch. * The distinction in R0/R1 is the one that matters: an exact answer chosen by a * tolerance-bounded decision is still exact, but the decision can be wrong for @@ -238,7 +240,9 @@ public OverlayNGCurve(Geometry a, Geometry b) { * whose only non-alternation is a tangent (degenerate NSpan), * a same-outer * hole-inside pair, a different-outer hole composed from a - * certified outer clip, and an even 4+ line–circle cut of a disc + * certified outer clip, a straddling hole whose new edge is a + * subset of the other shell (a bite, not a punch), and an even + * 4+ line–circle cut of a disc * by a plain polygon. In * the R1 case the answer is exact even though the decision * to return it was made on densified copies. diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java index 7f01fafaf8..2478e3b7b7 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CircularArcOverlayTest.java @@ -286,8 +286,9 @@ public void testHFourArealCutsAreNNodeAssemble() throws Exception { * with the touch as a zero-length span. A same-outer hole-inside * pair is the holed cell. A different-outer hole composes when * it sits strictly inside or outside a certified outer CAP. - * Collinear overlap, mixed labels, and a hole that meets or - * crosses the other outer stay refused. + * Collinear overlap, mixed labels, and a hole that meets the + * other diameter stay refused. A hole that straddles the other + * shell is a bite when the new edge ⊂ that shell. */ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception { Geometry upper = readCurve(HALF_UPPER); @@ -378,10 +379,12 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception { CompoundCurveShellOverlay.overlay(holed, right, OverlayNG.INTERSECTION)); Geometry straddle = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); - // The hole crosses the other outer, not the other hole. hole ∩ - // other shares the clip edge, so a punch would guess a bite. - assertNull("H-SHELL-HOLE-CROSS: hole straddles the other shell", - CompoundCurveShellOverlay.overlay(straddle, right, OverlayNG.INTERSECTION)); + OverlayNGCurve crossCap = new OverlayNGCurve(straddle, right); + Geometry bite = crossCap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-HOLE-CROSS: new edge ⊂ other.shell is a bite", + crossCap.isApproximate()); + assertEquals("Q1 minus the right half-rectangle", 6.25 * Math.PI - 1.0, + bite.getArea(), EXACT); Geometry holeX = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"); // Two holes that cross each other is a noder, not a kit. diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java index 1d36353383..1dd9a550e7 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CompoundCurveShellOverlayTest.java @@ -12,6 +12,7 @@ package org.locationtech.jts.operation.overlayng.curve; import org.locationtech.jts.algorithm.distance.DiscreteHausdorffDistance; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.geom.curve.CircularString; @@ -36,11 +37,12 @@ * hole-inside pair is the holed cell. A different-outer hole * composes when it sits strictly inside or outside a certified * outer CAP. A hole that straddles the other shell shares the - * clip edge, so subtracting hole ∩ other is a bite, not an - * interior punch. Two holes that cross are a noder. A - * three-point LineString is not an arc. Collinear overlap, - * mixed labels, and those named hole misses stay {@code null} - * so OverlayNGCurve can take R2 without paying this path first. + * clip edge: if that new edge is a subset of the other shell + * it is a bite, not an interior punch. Two holes that cross + * are a noder. A three-point LineString is not an arc. + * Collinear overlap, mixed labels, and the two-hole miss stay + * {@code null} so OverlayNGCurve can take R2 without paying + * this path first. */ public class CompoundCurveShellOverlayTest extends GeometryTestCase { @@ -602,6 +604,69 @@ public void testDifferentOuterHoleComplementaryIsDiscMinusHole() assertEquals("disc minus the rectangle", DISC - 1.0, both.getArea(), EXACT); } + /** + * H-SHELL-HOLE-CROSS: the hole straddles the other shell. Even-n + * hole ∩ other shares the clip edge (0,1)–(0,2) on the vertical + * diameter. That new edge ⊂ other.shell, so the walk says bite, + * not a punched hole. + */ + public void testHoleStraddleIsABiteNotAHole() throws Exception { + Geometry straddle = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); + Geometry right = readCurve(HALF_RIGHT); + assertEquals("new edge ⊂ other.shell is a bite", BiteVsHole.BITE, + BiteVsHole.decide(straddle, right)); + CurveSegmentString clip = BiteVsHole.clipEdge(straddle, right); + assertNotNull("clip edge is the shared diameter run", clip); + assertFalse(clip.isArc()); + assertFalse(clip.isDegenerate()); + assertEquals(1.0, clip.length(), EXACT); + assertTrue("clip edge (0 1)–(0 2)", + (clip.getStart().distance(new Coordinate(0, 1)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 2)) <= EXACT) + || (clip.getStart().distance(new Coordinate(0, 2)) <= EXACT + && clip.getEnd().distance(new Coordinate(0, 1)) <= EXACT)); + + OverlayNGCurve cap = new OverlayNGCurve(straddle, right); + Geometry q = cap.getResult(OverlayNG.INTERSECTION); + assertFalse("H-SHELL-HOLE-CROSS CAP is exact (bite, not hole)", + cap.isApproximate()); + assertEquals("Q1 minus the right half-rectangle", 6.25 * Math.PI - 1.0, + q.getArea(), EXACT); + assertEquals("bite is a shell, not an interior ring", 0, + ((CurvePolygon) q).getNumInteriorRing()); + assertArcAndLineShell(q); + assertParity(straddle, right, OverlayNG.INTERSECTION, q); + + OverlayNGCurve cup = new OverlayNGCurve(straddle, right); + Geometry u = cup.getResult(OverlayNG.UNION); + assertFalse("H-SHELL-HOLE-CROSS CUP is exact", cup.isApproximate()); + assertEquals("three-quarter minus the leftover hole", + 18.75 * Math.PI - 1.0, u.getArea(), EXACT); + assertEquals("leftover is a hole (new edge not on the CUP shell)", + 1, ((CurvePolygon) u).getNumInteriorRing()); + + OverlayNGCurve sub = new OverlayNGCurve(straddle, right); + Geometry ears = sub.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS SUB is exact", sub.isApproximate()); + assertEquals("Q2 minus the left half-rectangle", 6.25 * Math.PI - 1.0, + ears.getArea(), EXACT); + assertEquals("SUB bite is a shell", 0, + ((CurvePolygon) ears).getNumInteriorRing()); + + OverlayNGCurve rev = new OverlayNGCurve(right, straddle); + Geometry other = rev.getResult(OverlayNG.DIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS reverse SUB is exact", + rev.isApproximate()); + assertEquals("Q4 plus the right half-rectangle", 6.25 * Math.PI + 1.0, + other.getArea(), EXACT); + + OverlayNGCurve xor = new OverlayNGCurve(straddle, right); + Geometry x = xor.getResult(OverlayNG.SYMDIFFERENCE); + assertFalse("H-SHELL-HOLE-CROSS XOR is exact", xor.isApproximate()); + assertEquals("both bites", 12.5 * Math.PI, x.getArea(), EXACT); + } + public void testNotThisCellReturnsNull() throws Exception { Geometry half = readCurve(HALF_DISC); Geometry disc = readCurve(CIRCLE_5); @@ -612,19 +677,12 @@ public void testNotThisCellReturnsNull() throws Exception { Geometry right = readCurve(HALF_RIGHT); Geometry onDiameter = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"); - Geometry straddle = readCurve( - "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); assertNull("two discs stay on R1.5", CompoundCurveShellOverlay.overlay(disc, other, OverlayNG.INTERSECTION)); assertNull("plain vs plain", CompoundCurveShellOverlay.overlay(square, square, OverlayNG.UNION)); assertNull("H-SHELL-HOLE-OUTER: hole meets the other diameter", CompoundCurveShellOverlay.overlay(holed, right, OverlayNG.INTERSECTION)); - // The hole crosses the other outer (the vertical diameter), not - // the other hole. hole ∩ other shares that clip edge, so a punch - // would guess a bite versus a hole. - assertNull("H-SHELL-HOLE-CROSS: hole straddles the other shell", - CompoundCurveShellOverlay.overlay(straddle, right, OverlayNG.INTERSECTION)); Geometry holeX = readCurve( "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0.5 0.5, 1.5 0.5, 1.5 1.5, 0.5 1.5, 0.5 0.5))"); // Two holes that cross each other is a noder, not a kit. diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java index 0df4f9050b..52d95bfa16 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentStringTest.java @@ -17,6 +17,7 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.geom.curve.CurvePolygon; import org.locationtech.jts.io.curve.CurveWKTReader; import junit.textui.TestRunner; @@ -25,7 +26,8 @@ /** * P2.1 nodes plus P2.2 overlap-as-edge. {@link CurveSegmentString} * is the unit; {@link CurveSegmentNoder} emits the discrete node - * set or a shared run as an edge (interval). No faces. Not N-SS. + * set or a shared run as an edge (interval). P2.3 walks a hole + * ring as strings into one face decision (bite vs hole). Not N-SS. */ public class CurveSegmentStringTest extends GeometryTestCase { @@ -385,20 +387,38 @@ public void testRllCollinearOverlapIsAnEdge() throws Exception { assertEquals(8.0, run.length(), EXACT); } - public void testPinchAndHolesStayNamedMiss() throws Exception { + public void testHoleStraddleNodesAreTheClipPair() throws Exception { Geometry right = readCurve(HALF_RIGHT); Geometry straddle = readCurve(HOLE_STRADDLE); - assertNull("H-SHELL-HOLE-CROSS: holes are P2.3", + assertNull("H-SHELL-HOLE-CROSS: Geometry pair still has a hole", CurveSegmentNoder.nodes(straddle, right)); - assertNull("H-SHELL-HOLE-CROSS: edges stay null", + assertNull("H-SHELL-HOLE-CROSS: Geometry edges stay on the outers", CurveSegmentNoder.edges(straddle, right)); + CurvePolygon cp = (CurvePolygon) straddle; + List hole = CurveSegmentString.of( + cp.getInteriorCurveN(0)); + List shell = CurveSegmentString.of(right); + Coordinate[] nodes = CurveSegmentNoder.nodes(hole, shell, 10.0); + assertEquals("H-SHELL-HOLE-CROSS: even-n on the hole ring", 2, + nodes.length); + assertHas(nodes, 0.0, 1.0); + assertHas(nodes, 0.0, 2.0); + assertEquals("new edge ⊂ other.shell ⇒ bite", BiteVsHole.BITE, + BiteVsHole.decide(straddle, right)); + CurveSegmentString clip = BiteVsHole.clipEdge(straddle, right); + assertNotNull(clip); + assertEquals(1.0, clip.length(), EXACT); + assertTrue(sameEnds(clip, 0.0, 1.0, 0.0, 2.0)); + Geometry holed = readCurve(HALF_HOLED); Geometry holeX = readCurve(HOLE_X); assertNull("H-SHELL-HOLE-X: two holes are P2.4", CurveSegmentNoder.nodes(holed, holeX)); assertNull("H-SHELL-HOLE-X: edges stay null", CurveSegmentNoder.edges(holed, holeX)); + assertEquals("H-SHELL-HOLE-X: walk does not invent a face", + BiteVsHole.MISS, BiteVsHole.decide(holed, holeX)); } public void testNoderDoesNotAssembleFaces() throws Exception { diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java index 19060ec160..1182ed430e 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurvePerfGateTest.java @@ -560,6 +560,26 @@ public void testDifferentOuterHoleLensCapNotSlowerThanChord() () -> chordOverlay(a, b, OverlayNGCurve.INTERSECTION)); } + public void testHoleStraddleBiteCapNotSlowerThanChord() throws Exception { + Geometry a = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); + Geometry b = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (0 -5, 5 0, 0 5), (0 5, 0 -5)))"); + assertLaserNotSlower("hole-straddle bite CAP", + () -> OverlayNGCurve.intersection(a, b), + () -> chordOverlay(a, b, OverlayNGCurve.INTERSECTION)); + } + + public void testHoleStraddleBiteSubNotSlowerThanChord() throws Exception { + Geometry a = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"); + Geometry b = readCurve( + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (0 -5, 5 0, 0 5), (0 5, 0 -5)))"); + assertLaserNotSlower("hole-straddle bite SUB", + () -> OverlayNGCurve.difference(a, b), + () -> chordOverlay(a, b, OverlayNGCurve.DIFFERENCE)); + } + public void testFourCutCapNotSlowerThanChord() throws Exception { Geometry a = readCurve(CIRCLE_5); Geometry b = readCurve("POLYGON ((-8 -1, 8 -1, 8 1, -8 1, -8 -1))"); diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurveRatchetTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurveRatchetTest.java index 0f378403bf..e768034bd8 100644 --- a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurveRatchetTest.java +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/OverlayNGCurveRatchetTest.java @@ -51,8 +51,9 @@ * Complementary half-discs are 0EEE. Perpendicular same-circle * half-discs, a two-node two-shell clip, collinear same-side halves, * nested halves, and a 1-node touch are exact. A four-cut two-shell - * n-span, a same-outer hole-inside pair, and a different-outer hole - * that sits strictly inside or outside a certified outer CAP are + * n-span, a same-outer hole-inside pair, a different-outer hole + * that sits strictly inside or outside a certified outer CAP, and + * a straddling hole whose new edge ⊂ the other shell (a bite) are * exact. A four-cut disc vs a band is EEEE. */ public class OverlayNGCurveRatchetTest extends GeometryTestCase { @@ -107,6 +108,8 @@ public class OverlayNGCurveRatchetTest extends GeometryTestCase { "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, 0 -2, 1 -1), (1 -1, 1 6), CIRCULARSTRING (1 6, 0 7, -1 6), (-1 6, -1 -1)))"; private static final String HALF_HOLED = "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (0 1, 1 1, 1 2, 0 2, 0 1))"; + private static final String HOLE_STRADDLE = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)), (-1 1, 1 1, 1 2, -1 2, -1 1))"; private static final int[] OPS = { OverlayNGCurve.INTERSECTION, OverlayNGCurve.UNION, OverlayNGCurve.DIFFERENCE, OverlayNGCurve.SYMDIFFERENCE }; @@ -310,6 +313,14 @@ public void testMatrix_fourCut() throws Exception { assertRow("four-cut disc ∩ band", CIRCLE_5, BAND_FOUR, "EEEE"); } + public void testMatrix_holeStraddleBite() throws Exception { + assertRow("hole-straddle bite", HOLE_STRADDLE, HALF_RIGHT, "EEEE"); + } + + public void testMatrix_holeStraddleBiteReverse() throws Exception { + assertRow("hole-straddle bite reverse", HALF_RIGHT, HOLE_STRADDLE, "EEEE"); + } + // -- the disjoint CUP/XOR result, not just its exactness ----------------- /**