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 @@ -37,6 +37,8 @@
* 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),
* {@link MixedOverlapOverlay} (collinear overlap as a shared edge:
* {@code H-SHELL-N-MIXED} is a bite, not a punch),
* or a two-node walk vs a disc or plain polygon via
* {@link TwoNodeClip}. A 0-node mixed shell vs a circular disc
* ({@code CC-NEST-ANNULUS}) is not a punch. A miss is {@code null}.
Expand Down Expand Up @@ -74,6 +76,10 @@ static Geometry overlay(Geometry a, Geometry b, int opCode) {
if (halves != null) {
return halves;
}
Geometry mixed = MixedOverlapOverlay.overlay(shellA, shellB, opCode, a);
if (mixed != null) {
return mixed;
}
return TwoShellClip.overlay(shellA, shellB, opCode, a);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@
* same rings {@link TwoShellClip} / {@link NSpanClip} /
* {@link CircularDiscOverlay} already assemble (CAP + XOR). A
* 0-node containment or a same-circle special case falls back to
* those kits. MIXED / pinch / holed Geometry-level stays
* {@code null} -- hole rings are walked as strings, as in P2.3 /
* P2.4. A coincident leave-angle at a node (near-tangent) is
* those kits. MIXED (collinear overlap) recovers the pair-kit
* faces once {@link MixedOverlapOverlay} certifies the shared
* edge (CAP + XOR = inner + bite). Pinch / holed Geometry-level
* stays {@code null} -- hole rings are walked as strings, as in
* P2.3 / P2.4. A coincident leave-angle at a node (near-tangent) is
* snap-rounding (P2.5.4): {@code faces} returns {@code null} and
* {@link #missReason()} names {@link #TANGENT_LEAVE_ANGLE}.
* Ordering those leaves needs HotPixel / ScaledNoder / core
Expand Down Expand Up @@ -141,8 +143,10 @@ private static Geometry faces(List<List<CurveSegmentString>> groups,

/**
* Pair-kit CAP + XOR components. Empty CAP is not a miss. A kit
* that cannot certify (MIXED, pinch, lineal) is {@code null}.
* Does not call {@link OverlayNGCurve} -- that would densify.
* that cannot certify (pinch, lineal) is {@code null}. MIXED
* collinear overlap is {@link MixedOverlapOverlay} (shared
* edge, not a discrete node pair). Does not call
* {@link OverlayNGCurve} -- that would densify.
*/
static Geometry pairKitFaces(Geometry a, Geometry b) {
Geometry cap = exactOverlay(a, b, OverlayNG.INTERSECTION);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
* stays {@code null} and {@link #edges} names the interval. A MIXED
* or pinch pair inside an N-set adds no point. A tangent pinch
* (TOUCH-ext, H-ANNULUS-TANGENT) is a zero-length edge, not a face.
* Overlay still goes through the existing kits. Face assemble of
* the N-set is {@link CurveSegmentFaces}. Densify is never a noder.
* Overlay of a MIXED pair is {@link MixedOverlapOverlay} (the
* named interval as a shared edge). Face assemble of the N-set
* is {@link CurveSegmentFaces}. Densify is never a noder.
*/
final class CurveSegmentNoder {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
/*
* 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.List;

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.curve.CircularString;
import org.locationtech.jts.geom.curve.CurvePolygon;
import org.locationtech.jts.operation.overlayng.OverlayNG;

/**
* H-SHELL-N-MIXED: two hole-free CompoundCurve shells whose only
* named interaction is a collinear overlap (an interval, not a
* discrete node set). {@link CurveSegmentNoder#edges} already
* names that run; {@link CurveSegmentNoder#nodes} stays
* {@code null}. This kit walks each shell from the run's ends
* and treats the overlap walk as a shared edge, not a node pair.
* <p>
* A rest walk that sits entirely inside the other shell is the
* inner; the other rest is the outer. CAP / CUP are those
* shells. SUB / XOR splice the two rests into a bite (the
* overlap is on the outer, so this is not a punch). A rest that
* weaves in and out (collinear overlap plus a hidden crossing)
* stays {@code null} -- that pair is not this cell. Nested or
* crossing half-discs stay on {@link HalfDiscOverlay}. Pinch /
* TOUCH-ext / R-LL are not this kit. Package-private -- not a
* noder, not a public API, not snap-rounding.
*/
final class MixedOverlapOverlay {

private MixedOverlapOverlay() { }

static Geometry overlay(CurvePolygon a, CurvePolygon b, int opCode,
Geometry first) {
if (a == null || b == null) return null;
if (CurveSegmentNoder.nodes(a, b) != null) return null;
List<CurveSegmentString> edges = CurveSegmentNoder.edges(a, b);
CurveSegmentString run = singleChord(edges);
if (run == null) return null;

List<TwoNodeClip.Edge> edgesA = TwoNodeClip.flatten(a);
List<TwoNodeClip.Edge> edgesB = TwoNodeClip.flatten(b);
if (edgesA == null || edgesB == null) return null;
double scale = Math.max(
Math.max(a.getEnvelopeInternal().getWidth(),
a.getEnvelopeInternal().getHeight()),
Math.max(b.getEnvelopeInternal().getWidth(),
b.getEnvelopeInternal().getHeight()));
double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12);

Coordinate u = run.getStart();
Coordinate v = run.getEnd();
GeometryFactory f = TwoNodeClip.curveFactory(first);
List<LineString> aRest = restWalk(edgesA, u, v, run, scale, eps, f);
List<LineString> bRest = restWalk(edgesB, u, v, run, scale, eps, f);
if (aRest == null || bRest == null) return null;

int aSide = sideOfWalk(aRest, b);
int bSide = sideOfWalk(bRest, a);
if (aSide == TwoNodeClip.MIXED || bSide == TwoNodeClip.MIXED
|| aSide == bSide) {
return null;
}
boolean aOuter = aSide == TwoNodeClip.OUT;
List<LineString> outerRest = aOuter ? aRest : bRest;
List<LineString> innerRest = aOuter ? bRest : aRest;
CurvePolygon outer = aOuter ? a : b;
CurvePolygon inner = aOuter ? b : a;

if (opCode == OverlayNG.INTERSECTION) {
return inner.copy();
}
if (opCode == OverlayNG.UNION) {
return outer.copy();
}
Geometry bite = TwoNodeClip.ring(outerRest, innerRest, u, v, f, scale);
if (bite == null) return null;
if (opCode == OverlayNG.DIFFERENCE) {
return aOuter ? bite : f.createEmpty(2);
}
if (opCode == OverlayNG.SYMDIFFERENCE) {
return bite;
}
return null;
}

/**
* The one non-degenerate collinear run, or {@code null}. An arc
* overlap is a same-circle sweep, not this cell. A pinch is not
* an interval.
*/
private static CurveSegmentString singleChord(List<CurveSegmentString> edges) {
if (edges == null || edges.isEmpty()) return null;
CurveSegmentString run = null;
boolean many = false;
for (int i = 0; i < edges.size(); i++) {
CurveSegmentString e = edges.get(i);
if (!e.isDegenerate()) {
if (run != null) {
many = true;
}
else {
run = e;
}
}
}
if (many || run == null || run.isArc()) return null;
return run;
}

/**
* The shell walk between the overlap ends that is <em>not</em>
* the named run. The other walk is the shared edge itself.
*/
private static List<LineString> restWalk(List<TwoNodeClip.Edge> edges,
Coordinate u, Coordinate v, CurveSegmentString run, double scale,
double eps, GeometryFactory f) {
TwoNodeClip.Node nU = nodeOn(edges, u, scale);
TwoNodeClip.Node nV = nodeOn(edges, v, scale);
if (nU == null || nV == null) return null;
List<LineString> uv = TwoNodeClip.walkEdges(edges, nU, nV, f);
List<LineString> vu = TwoNodeClip.walkEdges(edges, nV, nU, f);
if (uv == null || vu == null) return null;
boolean uvRun = isOverlapWalk(uv, run, eps);
boolean vuRun = isOverlapWalk(vu, run, eps);
if (uvRun == vuRun) return null;
return uvRun ? vu : uv;
}

private static boolean isOverlapWalk(List<LineString> walk,
CurveSegmentString run, double eps) {
if (walk == null || walk.size() != 1) return false;
LineString ls = walk.get(0);
if (ls instanceof CircularString) return false;
Coordinate[] c = ls.getCoordinates();
if (c.length < 2) return false;
if (Math.abs(ls.getLength() - run.length()) > eps) return false;
return sameEnds(c[0], c[c.length - 1], run.getStart(), run.getEnd(),
eps);
}

/**
* Every off-boundary sample of the walk must agree. A weave
* (in and out) is MIXED -- not a containment bite.
*/
private static int sideOfWalk(List<LineString> walk, CurvePolygon other) {
if (walk == null || walk.isEmpty()) return TwoNodeClip.MIXED;
int side = TwoNodeClip.MIXED;
boolean saw = false;
boolean mixed = false;
for (int i = 0; i < walk.size() && !mixed; i++) {
LineString m = walk.get(i);
if (m instanceof CircularString && m.getNumPoints() >= 3) {
Coordinate[] pts = m.getCoordinates();
for (int k = 1; k + 1 < pts.length && !mixed; k += 2) {
mixed = !acceptSample(pts[k], other, side, saw);
if (!mixed) {
int loc = TwoNodeClip.locateInShell(pts[k], other);
if (loc != TwoNodeClip.MIXED) {
side = loc;
saw = true;
}
}
}
}
else {
Coordinate[] c = m.getCoordinates();
if (c.length >= 2) {
Coordinate mid = new Coordinate(0.5 * (c[0].x + c[c.length - 1].x),
0.5 * (c[0].y + c[c.length - 1].y));
mixed = !acceptSample(mid, other, side, saw);
if (!mixed) {
int loc = TwoNodeClip.locateInShell(mid, other);
if (loc != TwoNodeClip.MIXED) {
side = loc;
saw = true;
}
}
}
}
}
if (mixed || !saw) return TwoNodeClip.MIXED;
return side;
}

/**
* {@code false} when this off-boundary sample disagrees with the
* side already seen.
*/
private static boolean acceptSample(Coordinate p, CurvePolygon other,
int side, boolean saw) {
int loc = TwoNodeClip.locateInShell(p, other);
if (loc == TwoNodeClip.MIXED) return true;
return !saw || loc == side;
}

private static TwoNodeClip.Node nodeOn(List<TwoNodeClip.Edge> edges,
Coordinate p, double scale) {
double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12);
TwoNodeClip.Node found = null;
for (int i = 0; i < edges.size(); i++) {
TwoNodeClip.Edge e = edges.get(i);
if (edgeHolds(e, p, eps) && found == null) {
found = new TwoNodeClip.Node(i, e.param(p), p);
}
}
return found;
}

private static boolean edgeHolds(TwoNodeClip.Edge e, Coordinate p,
double eps) {
if (e.isArc) {
double d = Math.hypot(p.x - e.circle[0], p.y - e.circle[1]);
if (Math.abs(d - e.circle[2]) > eps) {
return false;
}
return TwoNodeClip.isOnSweep(p, e.circle, e.a, e.mid, e.b);
}
double t = TwoNodeClip.parameter(e.a, e.b, p);
Coordinate q = new Coordinate(e.a.x + t * (e.b.x - e.a.x),
e.a.y + t * (e.b.y - e.a.y));
return p.distance(q) <= eps;
}

private static boolean sameEnds(Coordinate a0, Coordinate a1,
Coordinate b0, Coordinate b1, double eps) {
return a0.distance(b0) <= eps && a1.distance(b1) <= eps
|| a0.distance(b1) <= eps && a1.distance(b0) <= eps;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@
* 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,
* assemble the hole faces. A collinear overlap of two
* hole-free CompoundCurve shells ({@code H-SHELL-N-MIXED})
* is a shared-edge walk: CAP / CUP are the inner / outer
* shells, SUB / XOR the bite (not a punch). 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 All @@ -137,7 +140,8 @@
* walk (hits, ring / member walk, CAP / CUP / SUB / XOR). Even-n
* assemble is {@code NSpanClip}. R1.7 dispatch is
* {@code CompoundCurveShellOverlay} (hole / bite-vs-hole /
* two-hole / half-disc / two-shell / vs disc or polygon). R-LL and R-AA
* two-hole / half-disc / mixed-overlap / 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
Expand Down Expand Up @@ -243,7 +247,9 @@ public OverlayNGCurve(Geometry a, Geometry b) {
* a same-outer
* hole-inside pair, a different-outer hole composed from a
* certified outer clip, a straddling hole whose new edge is a
* subset of the other shell (a bite, not a punch), and an even
* subset of the other shell (a bite, not a punch), a collinear
* overlap of two hole-free CompoundCurve shells walked as a
* shared edge ({@code H-SHELL-N-MIXED}), and an even
* 4+ line–circle cut of a disc
* by a plain polygon. In
* the R1 case the <em>answer</em> is exact even though the <em>decision</em>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
* NSpan, so two crossings plus a touch assemble like even-n.
* Pair hits go through {@link CurveSegmentString}; collinear overlap
* stays {@code null} from {@code intersect} (the noder names that
* interval as an edge). Face assemble is not a noder.
* interval as an edge; {@link MixedOverlapOverlay} walks it).
* Face assemble is not a noder.
*/
final class TwoShellClip {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,12 @@ public void testHShellComplementaryHalfDiscsAreTheDisc() throws Exception {
twoHoles.getArea(), EXACT);
Geometry onDiameter = readCurve(
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))");
// Collinear overlap is not a discrete node set; no cheap closed
// form without a noder.
assertNull("H-SHELL-N-MIXED: collinear overlap stays refused",
CompoundCurveShellOverlay.overlay(upper, onDiameter, OverlayNG.INTERSECTION));
OverlayNGCurve mixedCap = new OverlayNGCurve(upper, onDiameter);
Geometry mixed = mixedCap.getResult(OverlayNG.INTERSECTION);
assertFalse("H-SHELL-N-MIXED CAP is exact (not the chordsaw)",
mixedCap.isApproximate());
assertEquals("inner on-diameter", 2.0 + 0.5 * Math.PI, mixed.getArea(),
EXACT);
Geometry stadiumNest = readCurve(
"CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 -1, -2 0, -1 1), (-1 1, 1 1), CIRCULARSTRING (1 1, 2 0, 1 -1), (1 -1, -1 -1)))");
Geometry circle5 = readCurve(CIRCLE_5);
Expand Down
Loading
Loading