Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@
* 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}).
* A hole that does not cross, but whose ring overlaps the other
* shell ({@code H-SHELL-HOLE-OUTER}: hole-edge ⊂ other.shell),
* is the same bite. {@link CurveSegmentNoder#edges} already names
* that shared run; there are no crossing nodes.
* <p>
* 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
* that cross ({@code H-SHELL-HOLE-X}) are {@link TwoHoleOverlay}.
* A pair this walk cannot certify keeps the named miss. Not a
* noder, not N-SS.
* The noder already names the two hole–shell nodes of a straddle.
* This rung walks those into the clip edge on the other shell, or
* walks the shared edge when the hole sits entirely in the solid.
* Overlay then splices the bite (SUB / XOR face) or punches the
* leftover hole. Two holes that cross ({@code H-SHELL-HOLE-X})
* are {@link TwoHoleOverlay}. A pair this walk cannot certify
* keeps the named miss. Not a noder, not N-SS.
*/
final class BiteVsHole {

Expand Down Expand Up @@ -68,6 +73,9 @@ static Geometry overlay(Geometry a, Geometry b, int opCode) {
if (opCode == OverlayNG.UNION) {
Geometry cup = CompoundCurveShellOverlay.overlay(
firstOuter, secondOuter, OverlayNG.UNION);
if (walk.leftover == null || walk.leftover.isEmpty()) {
return cup;
}
return punch(cup, walk.leftover, f);
}
if (opCode == OverlayNG.DIFFERENCE) {
Expand Down Expand Up @@ -154,20 +162,23 @@ else if (ha == 0 && hb == 1) {
List<TwoNodeClip.Edge> shell = TwoNodeClip.flatten(solid);
if (shell == null) return null;
Coordinate[] ring = hole.getCoordinates();
double scale = scaleOf(holed, solid);
List<CurveSegmentString> holeStr = CurveSegmentString.of(hole);
List<CurveSegmentString> 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;
if (named == null) {
return walkSharedEdge(holed, solid, hole, holedFirst, scale, f);
}

List<TwoNodeClip.Node> 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<CurveSegmentString> holeStr = CurveSegmentString.of(hole);
List<CurveSegmentString> 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<Coordinate> pq = TwoNodeClip.walkRing(ring, n0.pt, n1.pt);
Expand Down Expand Up @@ -228,6 +239,151 @@ else if (edge10 != null && edge01 == null) {
return w;
}

/**
* Hole-edge ⊂ other.shell, no crossing nodes. P2.2 already names
* the shared run. The hole sits entirely in the solid, so the
* shared edge is the clip and the rest of the ring is a bite.
* CUP is the outer union: the solid fills the hole.
*/
private static Walk walkSharedEdge(CurvePolygon holed, CurvePolygon solid,
LineString hole, boolean holedFirst, double scale, GeometryFactory f) {
List<CurveSegmentString> holeStr = CurveSegmentString.of(hole);
List<CurveSegmentString> shellStr = CurveSegmentString.of(solid);
if (holeStr == null || shellStr == null) return null;
List<CurveSegmentString> shared = CurveSegmentNoder.edges(holeStr,
shellStr, scale);
CurveSegmentString run = singleChord(shared, scale);
if (run == null) return null;
if (TwoNodeClip.locateInShell(run.getStart(), solid) != TwoNodeClip.MIXED) {
return null;
}
if (TwoNodeClip.locateInShell(run.getEnd(), solid) != TwoNodeClip.MIXED) {
return null;
}

Coordinate[] ring = hole.getCoordinates();
List<Coordinate> pq = TwoNodeClip.walkRing(ring, run.getStart(),
run.getEnd());
List<Coordinate> qp = TwoNodeClip.walkRing(ring, run.getEnd(),
run.getStart());
if (pq == null || qp == null) return null;
int pqSide = sideOfShell(pq, solid);
int qpSide = sideOfShell(qp, solid);
List<Coordinate> holeIn = null;
List<Coordinate> holeOut = null;
if (pqSide == TwoNodeClip.IN && isOnShellWalk(qp, run, scale)) {
holeIn = pq;
holeOut = qp;
}
else if (qpSide == TwoNodeClip.IN && isOnShellWalk(pq, run, scale)) {
holeIn = qp;
holeOut = pq;
}
if (holeIn == null || holeOut == null) return null;
if (!holeInteriorInSolid(ring, run, solid, scale)) return null;

Coordinate p = holeIn.get(0);
Coordinate q = holeIn.get(holeIn.size() - 1);
List<LineString> newEdge = new ArrayList<LineString>();
newEdge.add(f.createLineString(new Coordinate[] {
new Coordinate(run.getStart()), new Coordinate(run.getEnd())
}));
List<LineString> oriented = directed(newEdge, q, p, scale, f);
if (oriented == null) return null;
if (lengthOf(oriented) <= TwoNodeClip.PROPER_CROSS_FRAC * scale) {
return null;
}

Geometry bite = closePlain(holeIn, oriented, f, scale);
if (bite == 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 = f.createEmpty(2);
w.scale = scale;
return w;
}

private static CurveSegmentString singleChord(
List<CurveSegmentString> shared, double scale) {
if (shared == null) return null;
CurveSegmentString found = null;
boolean two = false;
for (int i = 0; i < shared.size() && !two; i++) {
CurveSegmentString e = shared.get(i);
if (e.isArc() || e.isDegenerate()) {
continue;
}
if (e.length() <= TwoNodeClip.PROPER_CROSS_FRAC * scale) {
continue;
}
if (found != null) {
two = true;
}
else {
found = e;
}
}
return two ? null : found;
}

private static boolean isOnShellWalk(List<Coordinate> path,
CurveSegmentString run, double scale) {
if (path == null || path.size() != 2) return false;
double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12);
return onRun(path.get(0), run, eps) && onRun(path.get(1), run, eps);
}

private static boolean holeInteriorInSolid(Coordinate[] ring,
CurveSegmentString run, CurvePolygon solid, double scale) {
if (ring == null || ring.length < 4) return false;
double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12);
int n = ring.length;
if (ring[0].equals2D(ring[n - 1])) {
n--;
}
if (n < 3) return false;
boolean ok = true;
for (int i = 0; i < n && ok; i++) {
if (onRun(ring[i], run, eps)) {
continue;
}
if (TwoNodeClip.locateInShell(ring[i], solid) != TwoNodeClip.IN) {
ok = false;
}
}
for (int i = 0; i < n && ok; i++) {
Coordinate a = ring[i];
Coordinate b = ring[(i + 1) % n];
if (onRun(a, run, eps) && onRun(b, run, eps)) {
continue;
}
Coordinate mid = new Coordinate(0.5 * (a.x + b.x), 0.5 * (a.y + b.y));
if (TwoNodeClip.locateInShell(mid, solid) != TwoNodeClip.IN) {
ok = false;
}
}
return ok;
}

private static boolean onRun(Coordinate p, CurveSegmentString run,
double eps) {
if (p.distance(run.getStart()) <= eps || p.distance(run.getEnd()) <= eps) {
return true;
}
double t = TwoNodeClip.parameter(run.getStart(), run.getEnd(), p);
if (t < -1.0e-12 || t > 1.0 + 1.0e-12) return false;
return onChord(p, run.getStart(), run.getEnd());
}

/**
* 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
Expand Down Expand Up @@ -424,6 +580,7 @@ private static Geometry closePlain(List<Coordinate> along,
private static Geometry punch(Geometry solid, Geometry hole, GeometryFactory f) {
if (solid == null || hole == null) return null;
if (solid.isEmpty()) return solid;
if (hole.isEmpty()) return solid;
if (solid.getNumGeometries() != 1) return null;
Geometry g = solid.getGeometryN(0);
LineString ring = asHoleRing(hole, f);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
* {@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), {@link BiteVsHole} (straddling hole:
* new edge ⊂ other.shell is a bite, not a punch),
* as a degenerate NSpan), {@link BiteVsHole} (straddling hole,
* or a hole whose ring overlaps the other shell: new edge ⊂
* other.shell is a bite, not a punch),
* {@link TwoHoleOverlay} (two holes that cross on the same outer),
* or a two-node walk vs a disc or plain polygon via
* {@link TwoNodeClip}. A 0-node mixed shell vs a circular disc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@
* outer CAP is punched, hole strictly outside is ignored on
* 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,
* is a bite, not an interior punch. A hole that does not
* cross but whose ring overlaps the other shell (hole-edge
* ⊂ other.shell) is the same bite. Two holes that cross
* assemble the hole faces. Collinear overlap, mixed labels,
* or a line-only shell return {@code null} without paying
* this path.</li>
* <li><b>R-LL</b> -- one operand is a {@link org.locationtech.jts.geom.curve.CircularString}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,12 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception {
assertEquals("small half minus hole", 4.5 * Math.PI - 1.0,
punched.getArea(), EXACT);

assertNull("H-SHELL-HOLE-OUTER: hole meets the other diameter",
CompoundCurveShellOverlay.overlay(holed, right, OverlayNG.INTERSECTION));
OverlayNGCurve diameterCap = new OverlayNGCurve(holed, right);
Geometry diameterBite = diameterCap.getResult(OverlayNG.INTERSECTION);
assertFalse("H-SHELL-HOLE-OUTER: hole meets the other diameter",
diameterCap.isApproximate());
assertEquals("Q1 minus the rectangle", 6.25 * Math.PI - 1.0,
diameterBite.getArea(), EXACT);
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))");
OverlayNGCurve crossCap = new OverlayNGCurve(straddle, right);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
* composes when it sits strictly inside or outside a certified
* outer CAP. A hole that straddles the other shell 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
* it is a bite, not an interior punch. A hole that does not
* cross but whose ring overlaps the other shell is the same
* bite. Two holes that cross
* on the same outer are the two-hole arrangement. A
* three-point LineString is not an arc. Collinear overlap
* and mixed labels stay {@code null} so OverlayNGCurve can
Expand Down Expand Up @@ -727,22 +729,89 @@ public void testTwoHolesThatCrossAreAnArrangement() throws Exception {
assertParity(holed, holeX, OverlayNG.SYMDIFFERENCE, x);
}

/**
* H-SHELL-HOLE-OUTER: the hole sits entirely in the other solid
* and its ring overlaps the other diameter. Not two crossing
* nodes -- P2.2 names (0 1)–(0 2) as an edge. Same bite
* predicate as HOLE-CROSS (hole-edge ⊂ other.shell). CUP is
* the three-quarter: the solid fills the hole.
*/
public void testHoleMeetsTheOtherDiameterIsABite() throws Exception {
Geometry holed = readCurve(HALF_HOLED);
Geometry right = readCurve(HALF_RIGHT);
assertEquals("hole-edge ⊂ other.shell is a bite", BiteVsHole.BITE,
BiteVsHole.decide(holed, right));
CurveSegmentString clip = BiteVsHole.clipEdge(holed, 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(holed, right);
Geometry q = cap.getResult(OverlayNG.INTERSECTION);
assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter CAP is exact",
cap.isApproximate());
assertEquals("Q1 minus the 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(holed, right, OverlayNG.INTERSECTION, q);

OverlayNGCurve cup = new OverlayNGCurve(holed, right);
Geometry u = cup.getResult(OverlayNG.UNION);
assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter CUP is exact",
cup.isApproximate());
assertEquals("three-quarter; the solid fills the hole",
18.75 * Math.PI, u.getArea(), EXACT);
assertEquals("CUP has no leftover hole", 0,
((CurvePolygon) u).getNumInteriorRing());
assertParity(holed, right, OverlayNG.UNION, u);

OverlayNGCurve sub = new OverlayNGCurve(holed, right);
Geometry ears = sub.getResult(OverlayNG.DIFFERENCE);
assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter SUB is exact",
sub.isApproximate());
assertEquals("Q2; the hole was entirely in the other",
6.25 * Math.PI, ears.getArea(), EXACT);
assertEquals("SUB is a shell", 0,
((CurvePolygon) ears).getNumInteriorRing());
assertParity(holed, right, OverlayNG.DIFFERENCE, ears);

OverlayNGCurve rev = new OverlayNGCurve(right, holed);
Geometry other = rev.getResult(OverlayNG.DIFFERENCE);
assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter reverse SUB is exact",
rev.isApproximate());
assertEquals("Q4 plus the rectangle", 6.25 * Math.PI + 1.0,
other.getArea(), EXACT);
assertParity(right, holed, OverlayNG.DIFFERENCE, other);

OverlayNGCurve xor = new OverlayNGCurve(holed, right);
Geometry x = xor.getResult(OverlayNG.SYMDIFFERENCE);
assertFalse("H-SHELL-HOLE-OUTER hole-on-diameter XOR is exact",
xor.isApproximate());
assertEquals("both ears plus the rectangle", 12.5 * Math.PI + 1.0,
x.getArea(), EXACT);
assertParity(holed, right, OverlayNG.SYMDIFFERENCE, x);
}

public void testNotThisCellReturnsNull() throws Exception {
Geometry half = readCurve(HALF_DISC);
Geometry disc = readCurve(CIRCLE_5);
Geometry other = readCurve(CIRCLE_CROSSING);
Geometry square = readCurve(SQUARE_CAP);
Geometry chords = readCurve(CHORD_SHELL);
Geometry holed = readCurve(HALF_HOLED);
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)))");
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));
// Collinear overlap is not a discrete node set; no cheap closed
// form without a noder.
assertNull("H-SHELL-N-MIXED: collinear overlap stays refused",
Expand Down
Loading
Loading