diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java new file mode 100644 index 0000000000..8e0927bf23 --- /dev/null +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcel.java @@ -0,0 +1,972 @@ +/* + * 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.Collections; +import java.util.Comparator; +import java.util.List; + +import org.locationtech.jts.algorithm.Orientation; +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.Polygon; +import org.locationtech.jts.geom.curve.CircularArcDensifier; +import org.locationtech.jts.geom.curve.CircularString; +import org.locationtech.jts.geom.curve.CurvePolygon; +import org.locationtech.jts.geom.curve.MultiSurface; + +/** + * Package-private curve DCEL. Half-edges, twins, next/prev, incident + * face. Members stay {@link CurveSegmentString} (an arc stays an + * arc). Built from the P2.1–P2.5.2 node set plus already-named + * MIXED / shared-edge ends. Cycle order is the Faces left-most / + * next-outgoing walk, persisted as links. + *

+ * PLG / COV eat this structure. Not a noder, not OverlayNG-for-circles, + * not a public API, not another Geometry assembler. + * {@link CurveSegmentFaces} may assemble rings from the bounded + * faces; that is a consumer, not this product. + *

+ * Generic walk-on-{@code nodes==null} is unsafe: a MIXED abort can + * hide a real crossing ({@code HALF_DISC × HALF_CROSSING_UPPER}). + * That pair stamps {@link #MIXED_HIDES_CROSSING}. A coincident + * leave-angle is snap-rounding ({@link #TANGENT_LEAVE_ANGLE}). + * Pinch / kiss / holed Geometry-level stay {@code null}. Densify + * is never a noder. Not P2.5.5. + */ +final class CurveSegmentDcel { + + /** Named stamp: coincident leave-angle. Snap-rounding, not a walk. */ + static final String TANGENT_LEAVE_ANGLE = "P2.5.4 tangent leave-angle"; + + /** + * Named stamp: {@code nodes==null} because a collinear pair aborted, + * and another pair still has a discrete crossing. Not a noder. + */ + static final String MIXED_HIDES_CROSSING = + "MIXED nodes==null hides a crossing"; + + /** + * Near-tangent window for an arc leave. Chord coincidence is + * {@link #compareLeave} only: subtracted-vector atan2 can collapse + * distinct direction points (locationtech #1224). An arc centre + * from a rebuilt circumcircle can put a theoretically-on-axis + * tangent in either adjacent quadrant, so the N=3 stamp still + * needs this window. + */ + private static final double ANGLE_EPS = 1.0e-8; + + private static String missReason; + + private final List halves; + private final List faces; + private final List vertices; + private final double eps; + + private CurveSegmentDcel(List halves, List faces, + List vertices, double eps) { + this.halves = halves; + this.faces = faces; + this.vertices = vertices; + this.eps = eps; + } + + /** + * Why the last {@link #of(Geometry[])} / string-group call + * returned {@code null}, or {@code null} when a DCEL was + * produced. Package-private -- not a public API. + */ + static String missReason() { + return missReason; + } + + /** + * Arrangement DCEL of N hole-free circular / compound shells, or + * {@code null}. Sewn at discrete nodes, or at a named MIXED + * interval with no hidden crossing. {@link #missReason()} names + * a stamp when the walk would need snap-rounding or a noder. + */ + static CurveSegmentDcel of(Geometry[] geoms) { + missReason = null; + if (geoms == null || geoms.length < 2) return null; + List> groups = + new ArrayList>(geoms.length); + boolean miss = false; + for (int i = 0; i < geoms.length && !miss; i++) { + if (geoms[i] == null || geoms[i].isEmpty() || hasHole(geoms[i])) { + miss = true; + } + else { + List s = CurveSegmentString.of(geoms[i]); + if (s == null) { + miss = true; + } + else { + groups.add(s); + } + } + } + if (miss) return null; + return of(groups, scaleOf(geoms)); + } + + /** + * Arrangement DCEL of N string collections. Same sew as + * {@link #of(Geometry[])}; no Geometry hole check. + */ + static CurveSegmentDcel of(List> groups, + double scale) { + missReason = null; + if (groups == null || groups.size() < 2) return null; + double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); + Coordinate[] nodes = CurveSegmentNoder.nodes(groups, scale); + if (nodes == null) { + if (hidesCrossing(groups, scale, eps)) { + missReason = MIXED_HIDES_CROSSING; + return null; + } + if (!hasNamedInterval(groups, scale)) { + return null; + } + } + return build(groups, nodes, scale, eps); + } + + List halves() { + return halves; + } + + List faces() { + return faces; + } + + List boundedFaces() { + List out = new ArrayList(); + for (int i = 0; i < faces.size(); i++) { + if (faces.get(i).bounded) { + out.add(faces.get(i)); + } + } + return out; + } + + List vertices() { + return vertices; + } + + double eps() { + return eps; + } + + private static CurveSegmentDcel build(List> groups, + Coordinate[] nodes, double scale, double eps) { + List pool = new ArrayList(); + addCanon(pool, nodes, eps); + addEdgeEnds(pool, groups, scale, eps); + addStringEnds(pool, groups, eps); + + List pieces = splitAll(groups, pool, eps); + if (pieces == null || pieces.isEmpty()) return null; + pieces = mergeCoincident(pieces, scale, eps); + List halves = buildHalves(pieces, pool, eps); + if (halves == null || halves.isEmpty()) return null; + + List verts = indexByStart(halves, pool, eps); + if (verts == null) return null; + if (hasCoincidentLeave(verts)) { + missReason = TANGENT_LEAVE_ANGLE; + return null; + } + if (!linkCycles(verts, halves)) return null; + + List faces = assignFaces(halves, eps); + if (faces == null || faces.isEmpty()) return null; + return new CurveSegmentDcel(halves, faces, verts, eps); + } + + /** + * A MIXED abort ({@link CurveSegmentString#intersect} {@code null}) + * hid a discrete hit that is not an end of a named shared edge. + * That pair needs a noder, not a generic {@code nodes==null} walk. + */ + private static boolean hidesCrossing(List> groups, + double scale, double eps) { + List namedEnds = new ArrayList(); + collectNamedEnds(groups, scale, namedEnds, eps); + boolean hidden = false; + for (int i = 0; i < groups.size() && !hidden; i++) { + for (int j = i + 1; j < groups.size() && !hidden; j++) { + List a = groups.get(i); + List b = groups.get(j); + if (a == null || b == null) { + continue; + } + for (int p = 0; p < a.size() && !hidden; p++) { + for (int q = 0; q < b.size() && !hidden; q++) { + Coordinate[] xs = CurveSegmentString.intersect(a.get(p), + b.get(q), scale); + if (xs != null) { + hidden = hasUnnamedHit(xs, namedEnds, eps); + } + } + } + } + } + return hidden; + } + + private static void collectNamedEnds(List> groups, + double scale, List namedEnds, double eps) { + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size(); k++) { + CurveSegmentString e = edges.get(k); + canon(e.getStart(), namedEnds, eps); + canon(e.getEnd(), namedEnds, eps); + } + } + } + } + } + + private static boolean hasUnnamedHit(Coordinate[] xs, + List namedEnds, double eps) { + boolean hit = false; + for (int k = 0; k < xs.length && !hit; k++) { + if (!nearPool(xs[k], namedEnds, eps)) { + hit = true; + } + } + return hit; + } + + private static boolean hasNamedInterval(List> groups, + double scale) { + boolean found = false; + for (int i = 0; i < groups.size() && !found; i++) { + for (int j = i + 1; j < groups.size() && !found; j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size() && !found; k++) { + if (!edges.get(k).isDegenerate()) { + found = true; + } + } + } + } + } + return found; + } + + private static boolean linkCycles(List verts, List halves) { + boolean miss = false; + for (int i = 0; i < verts.size() && !miss; i++) { + List out = verts.get(i).out; + if (out.isEmpty()) { + continue; + } + for (int j = 0; j < out.size(); j++) { + Half back = out.get(j); + // Leave-sorted outgoing is CCW (endpoint quadrant, then + // orientation — not atan2 of subtracted deltas). The + // outgoing after `back` in that list is a right turn; the + // one before is a left turn (interior on the left, CCW face). + int left = (j - 1 + out.size()) % out.size(); + Half nxt = out.get(left); + back.twin.next = nxt; + } + } + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + if (h.next == null) { + miss = true; + } + else { + h.next.prev = h; + } + } + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + if (h.twin == null || h.twin.twin != h) { + miss = true; + } + else if (h.next == null || h.prev == null) { + miss = true; + } + else if (h.next.prev != h || h.prev.next != h) { + miss = true; + } + } + return !miss; + } + + private static List assignFaces(List halves, double eps) { + List faces = new ArrayList(); + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half start = halves.get(i); + if (start.face == null) { + Face face = walkFace(start, eps); + if (face == null) { + miss = true; + } + else { + faces.add(face); + } + } + } + return miss ? null : faces; + } + + private static Face walkFace(Half start, double eps) { + Face face = new Face(); + Half cur = start; + int guard = 0; + boolean closed = false; + boolean miss = false; + while (guard++ < 256 && !closed && !miss) { + if (cur.face != null) { + miss = true; + } + else { + cur.face = face; + face.halves.add(cur); + if (cur.next == start) { + closed = true; + } + else if (cur.next == null) { + miss = true; + } + else { + cur = cur.next; + } + } + } + if (miss || !closed || face.halves.isEmpty()) return null; + face.signedArea = signedArea(face.halves); + face.bounded = face.signedArea > eps * eps; + return face; + } + + private static List splitAll( + List> groups, List pool, + double eps) { + List out = new ArrayList(); + boolean miss = false; + for (int g = 0; g < groups.size() && !miss; g++) { + List strings = groups.get(g); + if (strings == null) { + miss = true; + } + else { + for (int i = 0; i < strings.size() && !miss; i++) { + List parts = split(strings.get(i), pool, eps); + if (parts == null) { + miss = true; + } + else { + out.addAll(parts); + } + } + } + } + return miss ? null : out; + } + + private static List split(CurveSegmentString s, + List pool, double eps) { + if (s == null) return null; + List cuts = new ArrayList(); + TwoNodeClip.Edge e = s.asEdge(); + for (int i = 0; i < pool.size(); i++) { + Coordinate p = pool.get(i); + if (onString(s, p, eps)) { + cuts.add(new Cut(e.param(p), p)); + } + } + if (cuts.size() < 2) { + List one = new ArrayList(1); + if (!s.isDegenerate()) { + one.add(s); + } + return one; + } + Collections.sort(cuts, Cut.BY_T); + List uniq = new ArrayList(); + for (int i = 0; i < cuts.size(); i++) { + Cut c = cuts.get(i); + if (uniq.isEmpty() + || uniq.get(uniq.size() - 1).pt.distance(c.pt) > eps) { + uniq.add(c); + } + } + List out = new ArrayList(); + for (int i = 0; i + 1 < uniq.size(); i++) { + Coordinate a = uniq.get(i).pt; + Coordinate b = uniq.get(i + 1).pt; + if (a.distance(b) > eps) { + out.add(sub(s, a, b)); + } + } + return out; + } + + private static CurveSegmentString sub(CurveSegmentString s, Coordinate from, + Coordinate to) { + if (!s.isArc()) { + return CurveSegmentString.segment(from, to); + } + Coordinate mid = TwoNodeClip.midOnSweep(from, to, s.asEdge()); + return CurveSegmentString.arc(from, mid, to); + } + + /** + * True when {@code p} lies on this string. The residual stays in + * R²: compare {@code dx²+dy²} to {@code R²} (arc) or to the + * projected chord point (chord) with tolerance {@code eps²}. + * The arc compare floors at 1 ulp of the two squares so a + * representable on-circle node is not rejected. Not + * {@code hypot}, not a length {@code eps} on {@code d²}, + * not a sagitta quotient. Package-private so the extreme-sagitta + * pin can call it; not a public API. + */ + static boolean onString(CurveSegmentString s, Coordinate p, + double eps) { + TwoNodeClip.Edge e = s.asEdge(); + double eps2 = eps * eps; + if (s.isArc()) { + double dx = p.x - e.circle[0]; + double dy = p.y - e.circle[1]; + double r = e.circle[2]; + double d2 = dx * dx + dy * dy; + double r2 = r * r; + double tol2 = Math.max(eps2, Math.ulp(d2) + Math.ulp(r2)); + if (Math.abs(d2 - r2) > tol2) { + return false; + } + return TwoNodeClip.isOnSweep(p, e.circle, e.a, e.mid, e.b); + } + double t = TwoNodeClip.parameter(e.a, e.b, p); + double dx = p.x - (e.a.x + t * (e.b.x - e.a.x)); + double dy = p.y - (e.a.y + t * (e.b.y - e.a.y)); + return dx * dx + dy * dy <= eps2; + } + + private static List mergeCoincident( + List pieces, double scale, double eps) { + List out = new ArrayList(); + for (int i = 0; i < pieces.size(); i++) { + CurveSegmentString p = pieces.get(i); + if (!p.isDegenerate() && !containsPiece(out, p, scale, eps)) { + out.add(p); + } + } + return out; + } + + private static boolean containsPiece(List out, + CurveSegmentString p, double scale, double eps) { + boolean seen = false; + for (int i = 0; i < out.size() && !seen; i++) { + if (samePiece(out.get(i), p, scale, eps)) { + seen = true; + } + } + return seen; + } + + private static boolean samePiece(CurveSegmentString p, CurveSegmentString q, + double scale, double eps) { + boolean ends = (p.getStart().distance(q.getStart()) <= eps + && p.getEnd().distance(q.getEnd()) <= eps) + || (p.getStart().distance(q.getEnd()) <= eps + && p.getEnd().distance(q.getStart()) <= eps); + if (!ends) return false; + if (p.isArc() != q.isArc()) return false; + if (!p.isArc()) return true; + return CurveSegmentString.sameCircle(p, q, scale); + } + + private static List buildHalves(List pieces, + List pool, double eps) { + List halves = new ArrayList(); + boolean miss = false; + for (int i = 0; i < pieces.size() && !miss; i++) { + CurveSegmentString p = pieces.get(i); + Coordinate a = canon(p.getStart(), pool, eps); + Coordinate b = canon(p.getEnd(), pool, eps); + if (a.distance(b) <= eps) { + continue; + } + CurveSegmentString fwd = sub(p, a, b); + CurveSegmentString rev = reverse(fwd); + if (fwd.isDegenerate() || rev.isDegenerate()) { + miss = true; + } + else { + Half hf = new Half(a, b, fwd); + Half hr = new Half(b, a, rev); + hf.twin = hr; + hr.twin = hf; + halves.add(hf); + halves.add(hr); + } + } + return miss ? null : halves; + } + + private static CurveSegmentString reverse(CurveSegmentString s) { + if (!s.isArc()) { + return CurveSegmentString.segment(s.getEnd(), s.getStart()); + } + return CurveSegmentString.arc(s.getEnd(), s.getMid(), s.getStart()); + } + + private static List indexByStart(List halves, + List pool, double eps) { + List verts = new ArrayList(); + for (int i = 0; i < pool.size(); i++) { + verts.add(new Vertex(pool.get(i))); + } + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + Half h = halves.get(i); + Vertex v = vertexAt(verts, h.origin, eps); + if (v == null) { + miss = true; + } + else { + v.out.add(h); + h.originVertex = v; + } + } + if (miss) return null; + List used = new ArrayList(); + for (int i = 0; i < verts.size(); i++) { + Vertex v = verts.get(i); + if (!v.out.isEmpty()) { + Collections.sort(v.out, Half.BY_ANGLE); + used.add(v); + } + } + return used; + } + + /** + * Two different pieces leaving in the same direction are a + * tangent. Distinct direction points stay distinct (locationtech + * #1224); the quadrant comes from the endpoints, not FP deltas + * (#1226). Ordering a true tie is snap-rounding (P2.5.4). Stamp + * and stop. + */ + private static boolean hasCoincidentLeave(List verts) { + boolean hit = false; + for (int i = 0; i < verts.size() && !hit; i++) { + List out = verts.get(i).out; + for (int j = 0; j < out.size() && !hit; j++) { + Half a = out.get(j); + Half b = out.get((j + 1) % out.size()); + if (a != b && leavesCoincide(a, b)) { + hit = true; + } + } + } + return hit; + } + + private static double signedArea(List members) { + double signed = 0.0; + for (int i = 0; i < members.size(); i++) { + CurveSegmentString s = members.get(i).member; + if (s.isArc()) { + signed += CircularArcDensifier.arcAreaContribution( + s.getStart(), s.getMid(), s.getEnd()); + } + else { + Coordinate a = s.getStart(); + Coordinate b = s.getEnd(); + signed += 0.5 * (a.x * b.y - b.x * a.y); + } + } + return signed; + } + + private static void addEdgeEnds(List pool, + List> groups, double scale, double eps) { + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + List edges = CurveSegmentNoder.edges( + groups.get(i), groups.get(j), scale); + if (edges != null) { + for (int k = 0; k < edges.size(); k++) { + CurveSegmentString e = edges.get(k); + canon(e.getStart(), pool, eps); + canon(e.getEnd(), pool, eps); + } + } + } + } + } + + private static void addStringEnds(List pool, + List> groups, double eps) { + for (int g = 0; g < groups.size(); g++) { + List strings = groups.get(g); + if (strings != null) { + for (int i = 0; i < strings.size(); i++) { + CurveSegmentString s = strings.get(i); + canon(s.getStart(), pool, eps); + canon(s.getEnd(), pool, eps); + } + } + } + } + + private static void addCanon(List pool, Coordinate[] xs, + double eps) { + if (xs == null) return; + for (int i = 0; i < xs.length; i++) { + canon(xs[i], pool, eps); + } + } + + private static Coordinate canon(Coordinate p, List pool, + double eps) { + Coordinate found = null; + for (int i = 0; i < pool.size() && found == null; i++) { + if (pool.get(i).distance(p) <= eps) { + found = pool.get(i); + } + } + if (found != null) return found; + Coordinate n = new Coordinate(p); + pool.add(n); + return n; + } + + private static boolean nearPool(Coordinate p, List pool, + double eps) { + boolean found = false; + for (int i = 0; i < pool.size() && !found; i++) { + if (pool.get(i).distance(p) <= eps) { + found = true; + } + } + return found; + } + + private static Vertex vertexAt(List verts, Coordinate p, double eps) { + Vertex found = null; + for (int i = 0; i < verts.size() && found == null; i++) { + if (verts.get(i).pt.distance(p) <= eps) { + found = verts.get(i); + } + } + return found; + } + + /** + * CCW order of leave directions around a shared origin. Same + * walk as locationtech {@code HalfEdge.compareAngularDirection} + * after #1224 / #1226, kept here: this DCEL does not use core + * {@code HalfEdge} or {@code Quadrant}. Direction-point equality + * first (not subtracted-vector {@code ==}), then quadrant from + * the endpoints, then {@link Orientation#index}. + */ + static int compareLeave(Half a, Half b) { + if (a.leaveDir.equals2D(b.leaveDir)) { + return 0; + } + int qa = leaveQuadrant(a); + int qb = leaveQuadrant(b); + if (qa > qb) { + return 1; + } + if (qa < qb) { + return -1; + } + return Orientation.index(b.origin, b.leaveDir, a.leaveDir); + } + + /** + * True same leave, or an arc near-tangent. Distinct chord + * direction points that {@link #compareLeave} separates are + * not a stamp, even when atan2 of the deltas collapsed. + */ + static boolean leavesCoincide(Half a, Half b) { + if (compareLeave(a, b) == 0) { + return true; + } + if (!a.member.isArc() && !b.member.isArc()) { + return false; + } + return angleDiff(leaveAngle(a), leaveAngle(b)) < ANGLE_EPS; + } + + private static double leaveAngle(Half h) { + return Math.atan2(h.leaveDir.y - h.origin.y, h.leaveDir.x - h.origin.x); + } + + private static double angleDiff(double a, double b) { + double d = Math.abs(a - b); + if (d > Math.PI) { + d = TwoNodeClip.TWO_PI - d; + } + return d; + } + + /** + * Quadrant of the leave ray from the endpoints, not from + * subtracted {@code dx}/{@code dy}. A chord uses origin→dest. + * An arc uses origin vs centre so the tangent quadrant does + * not go through {@code (origin - centre)}. + */ + private static int leaveQuadrant(Half h) { + if (!h.member.isArc()) { + return quadrant(h.origin, h.dest); + } + TwoNodeClip.Edge e = h.member.asEdge(); + Coordinate c = new Coordinate(e.circle[0], e.circle[1]); + if (sweepCcw(e)) { + return quadrantBits(c.y >= h.origin.y, h.origin.x >= c.x); + } + return quadrantBits(h.origin.y >= c.y, c.x >= h.origin.x); + } + + private static int quadrant(Coordinate o, Coordinate d) { + return quadrantBits(d.x >= o.x, d.y >= o.y); + } + + private static int quadrantBits(boolean xNonNeg, boolean yNonNeg) { + if (xNonNeg) { + return yNonNeg ? 0 : 3; + } + return yNonNeg ? 1 : 2; + } + + /** + * Point that names the leave direction. A chord uses dest. An + * arc uses the tangent at the start (radius rotated 90°), not + * the chord to dest. + */ + private static Coordinate leaveDir(CurveSegmentString s, + Coordinate origin, Coordinate dest) { + if (!s.isArc()) { + return dest; + } + TwoNodeClip.Edge e = s.asEdge(); + double rx = origin.x - e.circle[0]; + double ry = origin.y - e.circle[1]; + if (sweepCcw(e)) { + return new Coordinate(origin.x - ry, origin.y + rx); + } + return new Coordinate(origin.x + ry, origin.y - rx); + } + + private static boolean sweepCcw(TwoNodeClip.Edge e) { + double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); + double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); + double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); + return TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); + } + + private static boolean hasHole(Geometry g) { + Geometry geom = unwrap(g); + if (geom instanceof CurvePolygon) { + return ((CurvePolygon) geom).getNumInteriorRing() > 0; + } + if (geom instanceof Polygon) { + return ((Polygon) geom).getNumInteriorRing() > 0; + } + return false; + } + + private static Geometry unwrap(Geometry g) { + if (g == null || g.isEmpty()) return null; + if (g instanceof MultiSurface) { + if (g.getNumGeometries() != 1) return null; + return unwrap(g.getGeometryN(0)); + } + return g; + } + + private static double scaleOf(Geometry[] geoms) { + double s = 1.0; + if (geoms == null) return s; + for (int i = 0; i < geoms.length; i++) { + if (geoms[i] == null || geoms[i].isEmpty()) { + continue; + } + double w = Math.max(geoms[i].getEnvelopeInternal().getWidth(), + geoms[i].getEnvelopeInternal().getHeight()); + if (w > s) { + s = w; + } + } + return s; + } + + private static final class Cut { + static final Comparator BY_T = new Comparator() { + public int compare(Cut a, Cut b) { + return Double.compare(a.t, b.t); + } + }; + final double t; + final Coordinate pt; + Cut(double t, Coordinate pt) { + this.t = t; + this.pt = pt; + } + } + + /** + * Directed half-edge. Twin is the reverse. {@link #next} / + * {@link #prev} walk the left face. Member is a + * {@link CurveSegmentString}. + */ + static final class Half { + static final Comparator BY_ANGLE = new Comparator() { + public int compare(Half a, Half b) { + return compareLeave(a, b); + } + }; + final Coordinate origin; + final Coordinate dest; + final CurveSegmentString member; + final Coordinate leaveDir; + Half twin; + Half next; + Half prev; + Face face; + Vertex originVertex; + + Half(Coordinate origin, Coordinate dest, CurveSegmentString member) { + this.origin = origin; + this.dest = dest; + this.member = member; + this.leaveDir = leaveDir(member, origin, dest); + } + + Coordinate origin() { + return origin; + } + + Coordinate dest() { + return dest; + } + + Half twin() { + return twin; + } + + Half next() { + return next; + } + + Half prev() { + return prev; + } + + Face face() { + return face; + } + + CurveSegmentString member() { + return member; + } + + boolean isArc() { + return member.isArc(); + } + + LineString toLine(GeometryFactory f) { + if (!member.isArc()) { + return f.createLineString(new Coordinate[] { + new Coordinate(origin), new Coordinate(dest) + }); + } + return TwoNodeClip.arc(origin, member.getMid(), dest, f); + } + } + + /** + * Left face of a next-cycle. Bounded when the walk has positive + * signed area (CCW). The complementary outer (union) ring is + * bounded too; {@link CurveSegmentFaces} drops it when assembling + * Geometry. Unbounded is the clockwise exterior. + */ + static final class Face { + final List halves = new ArrayList(); + double signedArea; + boolean bounded; + + Half edge() { + return halves.isEmpty() ? null : halves.get(0); + } + + boolean isBounded() { + return bounded; + } + + int edgeCount() { + return halves.size(); + } + + double signedArea() { + return signedArea; + } + + List toLines(GeometryFactory f) { + List members = new ArrayList(halves.size()); + boolean miss = false; + for (int i = 0; i < halves.size() && !miss; i++) { + LineString ls = halves.get(i).toLine(f); + if (ls == null) { + miss = true; + } + else { + members.add(ls); + } + } + return miss ? null : members; + } + } + + static final class Vertex { + final Coordinate pt; + final List out = new ArrayList(); + + Vertex(Coordinate pt) { + this.pt = pt; + } + + Coordinate coordinate() { + return pt; + } + + List outgoing() { + return out; + } + + int degree() { + return out.size(); + } + } +} diff --git a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java index 64decbc095..1021d0ee95 100644 --- a/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java +++ b/modules/curve/src/main/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentFaces.java @@ -12,50 +12,39 @@ package org.locationtech.jts.operation.overlayng.curve; import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; 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.Polygon; -import org.locationtech.jts.geom.curve.CircularArcDensifier; -import org.locationtech.jts.geom.curve.CircularString; import org.locationtech.jts.geom.curve.CurveGeometryFactory; import org.locationtech.jts.geom.curve.CurvePolygon; import org.locationtech.jts.geom.curve.MultiSurface; import org.locationtech.jts.operation.overlayng.OverlayNG; /** - * Faces of an N-shell arrangement. Package-private -- not a public - * API, not an N-ary overlay, not a noder. OverlayNG stays binary; - * CAP / CUP / SUB / XOR of a pair among the N stay on the existing - * kits. This rung splits the P2.5.2 node set (and P2.2 shared-edge - * ends) into pieces and walks left-most / next-outgoing. + * Geometry assemble of an N-shell arrangement. Package-private -- + * not a public API, not an N-ary overlay, not a noder, not a DCEL. + * The arrangement structure is {@link CurveSegmentDcel} (half-edges, + * twins, next/prev, face pointers, {@link CurveSegmentString} + * members). This class walks those bounded faces into + * {@link Geometry}. OverlayNG stays binary; CAP / CUP / SUB / XOR + * of a pair among the N stay on the existing kits. *

- * N=2 recovers the pair-kit faces: a discrete crossing walks the - * 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 (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 + * N=2 recovers the pair-kit faces when the DCEL sews, or falls + * back to the kits when it does not (0-node containment, + * same-circle special case, MIXED-hides-crossing). Pinch / holed + * Geometry-level stays {@code null}. A coincident leave-angle 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 - * {@code SegmentString} -- stamp and stop. Densify is never a - * noder. Not P2.5.5. + * Densify is never a noder. Not P2.5.5. */ final class CurveSegmentFaces { /** Named stamp: coincident leave-angle. Snap-rounding, not a walk. */ - static final String TANGENT_LEAVE_ANGLE = "P2.5.4 tangent leave-angle"; - - private static final double ANGLE_EPS = 1.0e-8; + static final String TANGENT_LEAVE_ANGLE = + CurveSegmentDcel.TANGENT_LEAVE_ANGLE; private static String missReason; @@ -72,9 +61,9 @@ static String missReason() { /** * Bounded faces of N hole-free circular / compound shells, or - * {@code null}. N=2 is the pair-kit rings. N≥3 is the walk, or - * {@code null} when the walk would need snap-rounding - * ({@link #missReason()} names the stamp). + * {@code null}. N=2 is the pair-kit rings when the DCEL cannot + * sew. N≥3 is the DCEL, or {@code null} when the walk would + * need snap-rounding ({@link #missReason()} names the stamp). */ static Geometry faces(Geometry[] geoms) { missReason = null; @@ -108,7 +97,7 @@ static Geometry faces(Geometry[] geoms) { } /** - * Bounded faces of N string collections. Same walk as + * Bounded faces of N string collections. Same assemble as * {@link #faces(Geometry[])}; no pair-kit fallback (no Geometry * overlay to recover). */ @@ -120,17 +109,15 @@ private static Geometry faces(List> groups, double scale, GeometryFactory f, Geometry[] geoms) { missReason = null; if (groups == null || groups.size() < 2) return null; - Coordinate[] nodes = CurveSegmentNoder.nodes(groups, scale); - if (nodes == null) { - return geoms != null && geoms.length == 2 - ? pairKitFaces(geoms[0], geoms[1]) - : null; - } - Geometry walked = walk(groups, nodes, scale, f); - if (walked != null) { - missReason = null; - return walked; + CurveSegmentDcel dcel = CurveSegmentDcel.of(groups, scale); + if (dcel != null) { + Geometry walked = assemble(dcel, scale, f); + if (walked != null) { + missReason = null; + return walked; + } } + missReason = CurveSegmentDcel.missReason(); if (geoms != null && geoms.length == 2) { Geometry kit = pairKitFaces(geoms[0], geoms[1]); if (kit != null) { @@ -157,44 +144,25 @@ static Geometry pairKitFaces(Geometry a, Geometry b) { return toGeometry(faces, TwoNodeClip.curveFactory(a)); } - private static Geometry walk(List> groups, - Coordinate[] nodes, double scale, GeometryFactory f) { + private static Geometry assemble(CurveSegmentDcel dcel, double scale, + GeometryFactory f) { double eps = Math.max(TwoNodeClip.PROPER_CROSS_FRAC * scale, 1.0e-12); - List pool = new ArrayList(); - addCanon(pool, nodes, eps); - addEdgeEnds(pool, groups, scale, eps); - addStringEnds(pool, groups, eps); - - List pieces = splitAll(groups, pool, scale, eps); - if (pieces == null || pieces.isEmpty()) return null; - pieces = mergeCoincident(pieces, scale, eps); - List halves = buildHalves(pieces, pool, eps); - if (halves == null) return null; - - List verts = indexByStart(halves, pool, eps); - if (verts == null) return null; - if (hasCoincidentLeave(verts)) { - missReason = TANGENT_LEAVE_ANGLE; - return null; - } - List faces = new ArrayList(); + List cells = dcel.boundedFaces(); boolean miss = false; - for (int i = 0; i < halves.size() && !miss; i++) { - Half start = halves.get(i); - if (!start.used) { - List members = walkRing(start, eps, f); - if (members == null) { + for (int i = 0; i < cells.size() && !miss; i++) { + CurveSegmentDcel.Face cell = cells.get(i); + List members = cell.toLines(f); + if (members == null) { + miss = true; + } + else if (Math.abs(cell.signedArea()) > eps * eps) { + Polygon face = TwoNodeClip.closeRing(members, f, eps); + if (face == null) { miss = true; } - else if (Math.abs(signedArea(members)) > eps * eps) { - Polygon face = TwoNodeClip.closeRing(members, f, eps); - if (face == null) { - miss = true; - } - else { - faces.add(face); - } + else { + faces.add(face); } } } @@ -226,300 +194,6 @@ private static void dropUnion(List faces, double eps) { } } - private static List splitAll( - List> groups, List pool, - double scale, double eps) { - List out = new ArrayList(); - boolean miss = false; - for (int g = 0; g < groups.size() && !miss; g++) { - List strings = groups.get(g); - if (strings == null) { - miss = true; - } - else { - for (int i = 0; i < strings.size() && !miss; i++) { - List parts = split(strings.get(i), pool, eps); - if (parts == null) { - miss = true; - } - else { - out.addAll(parts); - } - } - } - } - return miss ? null : out; - } - - private static List split(CurveSegmentString s, - List pool, double eps) { - if (s == null) return null; - List cuts = new ArrayList(); - TwoNodeClip.Edge e = s.asEdge(); - for (int i = 0; i < pool.size(); i++) { - Coordinate p = pool.get(i); - if (onString(s, p, eps)) { - cuts.add(new Cut(e.param(p), p)); - } - } - if (cuts.size() < 2) { - List one = new ArrayList(1); - if (!s.isDegenerate()) { - one.add(s); - } - return one; - } - Collections.sort(cuts, Cut.BY_T); - List uniq = new ArrayList(); - for (int i = 0; i < cuts.size(); i++) { - Cut c = cuts.get(i); - if (uniq.isEmpty() - || uniq.get(uniq.size() - 1).pt.distance(c.pt) > eps) { - uniq.add(c); - } - } - List out = new ArrayList(); - for (int i = 0; i + 1 < uniq.size(); i++) { - Coordinate a = uniq.get(i).pt; - Coordinate b = uniq.get(i + 1).pt; - if (a.distance(b) > eps) { - out.add(sub(s, a, b)); - } - } - return out; - } - - private static CurveSegmentString sub(CurveSegmentString s, Coordinate from, - Coordinate to) { - if (!s.isArc()) { - return CurveSegmentString.segment(from, to); - } - Coordinate mid = TwoNodeClip.midOnSweep(from, to, s.asEdge()); - return CurveSegmentString.arc(from, mid, to); - } - - private static boolean onString(CurveSegmentString s, Coordinate p, - double eps) { - TwoNodeClip.Edge e = s.asEdge(); - if (s.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 List mergeCoincident( - List pieces, double scale, double eps) { - List out = new ArrayList(); - for (int i = 0; i < pieces.size(); i++) { - CurveSegmentString p = pieces.get(i); - if (!p.isDegenerate() && !containsPiece(out, p, scale, eps)) { - out.add(p); - } - } - return out; - } - - private static boolean containsPiece(List out, - CurveSegmentString p, double scale, double eps) { - boolean seen = false; - for (int i = 0; i < out.size() && !seen; i++) { - if (samePiece(out.get(i), p, scale, eps)) { - seen = true; - } - } - return seen; - } - - private static boolean samePiece(CurveSegmentString p, CurveSegmentString q, - double scale, double eps) { - boolean ends = (p.getStart().distance(q.getStart()) <= eps - && p.getEnd().distance(q.getEnd()) <= eps) - || (p.getStart().distance(q.getEnd()) <= eps - && p.getEnd().distance(q.getStart()) <= eps); - if (!ends) return false; - if (p.isArc() != q.isArc()) return false; - if (!p.isArc()) return true; - return CurveSegmentString.sameCircle(p, q, scale); - } - - private static List buildHalves(List pieces, - List pool, double eps) { - List halves = new ArrayList(); - boolean miss = false; - for (int i = 0; i < pieces.size() && !miss; i++) { - CurveSegmentString p = pieces.get(i); - Coordinate a = canon(p.getStart(), pool, eps); - Coordinate b = canon(p.getEnd(), pool, eps); - if (a.distance(b) <= eps) { - continue; - } - CurveSegmentString fwd = sub(p, a, b); - CurveSegmentString rev = reverse(fwd); - if (fwd.isDegenerate() || rev.isDegenerate()) { - miss = true; - } - else { - Half hf = new Half(a, b, fwd); - Half hr = new Half(b, a, rev); - hf.rev = hr; - hr.rev = hf; - halves.add(hf); - halves.add(hr); - } - } - return miss ? null : halves; - } - - private static CurveSegmentString reverse(CurveSegmentString s) { - if (!s.isArc()) { - return CurveSegmentString.segment(s.getEnd(), s.getStart()); - } - return CurveSegmentString.arc(s.getEnd(), s.getMid(), s.getStart()); - } - - private static List indexByStart(List halves, - List pool, double eps) { - List verts = new ArrayList(); - for (int i = 0; i < pool.size(); i++) { - verts.add(new Vertex(pool.get(i))); - } - boolean miss = false; - for (int i = 0; i < halves.size() && !miss; i++) { - Half h = halves.get(i); - Vertex v = vertexAt(verts, h.from, eps); - if (v == null) { - miss = true; - } - else { - v.out.add(h); - } - } - if (miss) return null; - for (int i = 0; i < verts.size(); i++) { - List out = verts.get(i).out; - Collections.sort(out, Half.BY_ANGLE); - for (int j = 0; j < out.size(); j++) { - out.get(j).originOut = out; - } - } - return verts; - } - - /** - * Two different pieces leaving at the same angle are a tangent. - * Ordering them is snap-rounding (P2.5.4). Stamp and stop. - */ - private static boolean hasCoincidentLeave(List verts) { - boolean hit = false; - for (int i = 0; i < verts.size() && !hit; i++) { - List out = verts.get(i).out; - for (int j = 0; j < out.size() && !hit; j++) { - Half a = out.get(j); - Half b = out.get((j + 1) % out.size()); - if (a != b && angleDiff(a.leaveAngle, b.leaveAngle) < ANGLE_EPS) { - hit = true; - } - } - } - return hit; - } - - private static List walkRing(Half start, double eps, - GeometryFactory f) { - List members = new ArrayList(); - Half cur = start; - int guard = 0; - boolean closed = false; - boolean miss = false; - while (guard++ < 256 && !closed && !miss) { - if (cur.used) { - miss = true; - } - else { - cur.used = true; - LineString ls = toLine(cur, f); - if (ls == null) { - miss = true; - } - else { - members.add(ls); - Half next = nextAfter(cur.rev); - if (next == null) { - miss = true; - } - else if (next == start) { - closed = true; - } - else { - cur = next; - } - } - } - } - if (miss || !closed || members.isEmpty()) return null; - Coordinate a = members.get(0).getCoordinateN(0); - LineString last = members.get(members.size() - 1); - Coordinate b = last.getCoordinateN(last.getNumPoints() - 1); - if (a.distance(b) > eps) return null; - return members; - } - - /** - * Left-most: the next half after {@code back} in CCW leave-angle - * order at {@code back.from}. - */ - private static Half nextAfter(Half back) { - // Re-find the vertex outgoing by scanning the reverse's neighbours - // is awkward; store the sorted list on the half at index time. - List out = back.originOut; - if (out == null || out.isEmpty()) return null; - int at = -1; - for (int i = 0; i < out.size(); i++) { - if (out.get(i) == back) { - at = i; - } - } - if (at < 0) return null; - return out.get((at + 1) % out.size()); - } - - private static LineString toLine(Half h, GeometryFactory f) { - CurveSegmentString s = h.piece; - if (!s.isArc()) { - return f.createLineString(new Coordinate[] { - new Coordinate(h.from), new Coordinate(h.to) - }); - } - return TwoNodeClip.arc(h.from, s.getMid(), h.to, f); - } - - private static double signedArea(List members) { - double signed = 0.0; - for (int i = 0; i < members.size(); i++) { - LineString m = members.get(i); - if (m instanceof CircularString) { - Coordinate[] pts = m.getCoordinates(); - for (int k = 0; k + 2 < pts.length; k += 2) { - signed += CircularArcDensifier.arcAreaContribution( - pts[k], pts[k + 1], pts[k + 2]); - } - } - else { - Coordinate[] pts = m.getCoordinates(); - for (int k = 0; k < pts.length - 1; k++) { - signed += 0.5 * (pts[k].x * pts[k + 1].y - - pts[k + 1].x * pts[k].y); - } - } - } - return signed; - } - private static Geometry exactOverlay(Geometry a, Geometry b, int opCode) { Geometry g = CircularDiscOverlay.overlay(a, b, opCode); if (g != null) return g; @@ -550,92 +224,6 @@ private static Geometry toGeometry(List faces, GeometryFactory f) { return new MultiSurface(faces.toArray(new Polygon[0]), f); } - private static void addEdgeEnds(List pool, - List> groups, double scale, double eps) { - for (int i = 0; i < groups.size(); i++) { - for (int j = i + 1; j < groups.size(); j++) { - List edges = CurveSegmentNoder.edges( - groups.get(i), groups.get(j), scale); - if (edges != null) { - for (int k = 0; k < edges.size(); k++) { - CurveSegmentString e = edges.get(k); - canon(e.getStart(), pool, eps); - canon(e.getEnd(), pool, eps); - } - } - } - } - } - - private static void addStringEnds(List pool, - List> groups, double eps) { - for (int g = 0; g < groups.size(); g++) { - List strings = groups.get(g); - if (strings != null) { - for (int i = 0; i < strings.size(); i++) { - CurveSegmentString s = strings.get(i); - canon(s.getStart(), pool, eps); - canon(s.getEnd(), pool, eps); - } - } - } - } - - private static void addCanon(List pool, Coordinate[] xs, - double eps) { - if (xs == null) return; - for (int i = 0; i < xs.length; i++) { - canon(xs[i], pool, eps); - } - } - - private static Coordinate canon(Coordinate p, List pool, - double eps) { - Coordinate found = null; - for (int i = 0; i < pool.size() && found == null; i++) { - if (pool.get(i).distance(p) <= eps) { - found = pool.get(i); - } - } - if (found != null) return found; - Coordinate n = new Coordinate(p); - pool.add(n); - return n; - } - - private static Vertex vertexAt(List verts, Coordinate p, double eps) { - Vertex found = null; - for (int i = 0; i < verts.size() && found == null; i++) { - if (verts.get(i).pt.distance(p) <= eps) { - found = verts.get(i); - } - } - return found; - } - - private static double leaveAngle(CurveSegmentString s) { - Coordinate from = s.getStart(); - if (!s.isArc()) { - return Math.atan2(s.getEnd().y - from.y, s.getEnd().x - from.x); - } - TwoNodeClip.Edge e = s.asEdge(); - double rx = from.x - e.circle[0]; - double ry = from.y - e.circle[1]; - double a0 = Math.atan2(e.a.y - e.circle[1], e.a.x - e.circle[0]); - double aM = Math.atan2(e.mid.y - e.circle[1], e.mid.x - e.circle[0]); - double a1 = Math.atan2(e.b.y - e.circle[1], e.b.x - e.circle[0]); - boolean ccw = TwoNodeClip.normPos(aM - a0) < TwoNodeClip.normPos(a1 - a0); - return ccw ? Math.atan2(rx, -ry) : Math.atan2(-rx, ry); - } - - private static double angleDiff(double a, double b) { - double d = Math.abs(a - b); - if (d > Math.PI) { - d = TwoNodeClip.TWO_PI - d; - } - return d; - } - private static boolean hasHole(Geometry g) { Geometry geom = unwrap(g); if (geom instanceof CurvePolygon) { @@ -671,47 +259,4 @@ private static double scaleOf(Geometry[] geoms) { } return s; } - - private static final class Cut { - static final Comparator BY_T = new Comparator() { - public int compare(Cut a, Cut b) { - return Double.compare(a.t, b.t); - } - }; - final double t; - final Coordinate pt; - Cut(double t, Coordinate pt) { - this.t = t; - this.pt = pt; - } - } - - private static final class Half { - static final Comparator BY_ANGLE = new Comparator() { - public int compare(Half a, Half b) { - return Double.compare(a.leaveAngle, b.leaveAngle); - } - }; - final Coordinate from; - final Coordinate to; - final CurveSegmentString piece; - final double leaveAngle; - Half rev; - List originOut; - boolean used; - Half(Coordinate from, Coordinate to, CurveSegmentString piece) { - this.from = from; - this.to = to; - this.piece = piece; - this.leaveAngle = leaveAngle(piece); - } - } - - private static final class Vertex { - final Coordinate pt; - final List out = new ArrayList(); - Vertex(Coordinate pt) { - this.pt = pt; - } - } } 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 71dc777bf4..3280d87075 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 @@ -34,7 +34,8 @@ * 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 of a MIXED pair is {@link MixedOverlapOverlay} (the - * named interval as a shared edge). Face assemble of the N-set + * named interval as a shared edge). The arrangement structure of + * the N-set is {@link CurveSegmentDcel}. Face Geometry assemble * is {@link CurveSegmentFaces}. Densify is never a noder. */ final class CurveSegmentNoder { diff --git a/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java new file mode 100644 index 0000000000..cbb7855300 --- /dev/null +++ b/modules/curve/src/test/java/org/locationtech/jts/operation/overlayng/curve/CurveSegmentDcelTest.java @@ -0,0 +1,480 @@ +/* + * 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.Arrays; +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curve.CurveGeometryFactory; +import org.locationtech.jts.io.curve.CurveWKTReader; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * P2.5.7 curve DCEL. Pins half-edge / twin / next / prev / incident + * face on arrangements this stack already names: two-disc crossing, + * MIXED shared-edge, STADIUM_FOUR N=3 (nine bounded faces). Stamps + * coincident leave-angle and MIXED-hides-crossing. Not a noder, + * not OverlayNG-for-circles, not a Geometry assembler. + */ +public class CurveSegmentDcelTest extends GeometryTestCase { + + private static final String CIRCLE_5 = + "CURVEPOLYGON (CIRCULARSTRING (-5 0, 0 5, 5 0, 0 -5, -5 0))"; + private static final String CIRCLE_CROSSING = + "CURVEPOLYGON (CIRCULARSTRING (2 0, 7 5, 12 0, 7 -5, 2 0))"; + private static final String HALF_DISC = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 0, 0 5, 5 0), (5 0, -5 0)))"; + private static final String HALF_HANGING = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-5 8, 0 3, 5 8), (5 8, -5 8)))"; + private static final String STADIUM_FOUR = + "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 STADIUM_ODD = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 4, 0 5, 1 4), (1 4, 1 -1), CIRCULARSTRING (1 -1, 0 -2, -1 -1), (-1 -1, -1 4)))"; + private static final String ON_DIAMETER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (-1 1, 0 2, 1 1), (1 1, 1 0), (1 0, -1 0), (-1 0, -1 1)))"; + private static final String HALF_CROSSING_UPPER = + "CURVEPOLYGON (COMPOUNDCURVE (CIRCULARSTRING (2 0, 7 5, 12 0), (12 0, 2 0)))"; + private static final String CIRCLE_INT_TAN = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 2 3, 5 0, 2 -3, -1 0))"; + private static final String UNIT_DISC = + "CURVEPOLYGON (CIRCULARSTRING (-1 0, 0 1, 1 0, 0 -1, -1 0))"; + private static final String UNIT_DISC_TOUCH = + "CURVEPOLYGON (CIRCULARSTRING (1 0, 2 1, 3 0, 2 -1, 1 0))"; + 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_X = + "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))"; + + private static final double EXACT = 1.0e-12; + private static final double SQRT_12_75 = Math.sqrt(12.75); + private static final double HALF = 12.5 * Math.PI; + private static final double LENS = 50.0 * Math.acos(0.8) - 24.0; + private static final double FOUR_CAP = 25.0 * Math.asin(0.2) + 2.0 * Math.sqrt(6.0); + private static final double N3_TRIPLE = 2.0 * FOUR_CAP - 16.0; + private static final double N3_LENS_SIDE = 0.5 * (LENS - N3_TRIPLE); + private static final double N3_AC_NOT_B = 16.0 - FOUR_CAP; + private static final double N3_BC_NOT_A = 12.0 + 0.5 * Math.PI - FOUR_CAP; + private static final double N3_C_BOTTOM = 2.0 + 0.5 * Math.PI; + private static final double N3_A_EAR = 0.5 * (HALF - LENS - N3_AC_NOT_B); + private static final double N3_B_REST = HALF - LENS - N3_BC_NOT_A; + private static final double N3_UNION = 25.5 * Math.PI - LENS + 2.0; + + public static void main(String[] args) { + TestRunner.run(CurveSegmentDcelTest.class); + } + + public CurveSegmentDcelTest(String name) { super(name); } + + private static Geometry readCurve(String wkt) throws Exception { + return new CurveWKTReader(new CurveGeometryFactory()).read(wkt); + } + + /** + * R1.5 two-disc: sewn at the radical-axis pair. Twins reverse the + * same arc; next/prev close; every half has a left face. Three + * bounded cells (lens + two crescents) plus the exterior. + */ + public void testTwoDiscCrossingPinsTwinsAndCycles() throws Exception { + Geometry a = readCurve(CIRCLE_5); + Geometry b = readCurve(CIRCLE_CROSSING); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b }); + assertNotNull("R1.5 DCEL", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("three bounded faces", 3, dcel.boundedFaces().size()); + assertTrue("plus the unbounded exterior", dcel.faces().size() >= 4); + + CurveSegmentDcel.Half alongA = findHalfNear(dcel, 3.5, SQRT_12_75, + 5.0, 0.0); + assertNotNull("half from the upper node along CIRCLE_5", alongA); + assertTrue("member stays an arc", alongA.isArc()); + assertTwinReverses(alongA); + assertEquals("upper node is degree 4", 4, + vertexAt(dcel, 3.5, SQRT_12_75).degree()); + assertEquals("lower node is degree 4", 4, + vertexAt(dcel, 3.5, -SQRT_12_75).degree()); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b)); + CurveSegmentDcel viaStrings = CurveSegmentDcel.of(groups, 12.0); + assertNotNull(viaStrings); + assertLinks(viaStrings); + assertEquals(3, viaStrings.boundedFaces().size()); + } + + /** + * H-SHELL-N-MIXED: nodes stay null (the overlap is an interval). + * The named diameter is a shared edge. T-junctions at (±1, 0) + * are degree 3. Inner + bite are the bounded cells. + */ + public void testMixedSharedEdgePinsTwinsAndCycles() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry on = readCurve(ON_DIAMETER); + assertNull("MIXED nodes stay null", CurveSegmentNoder.nodes(half, on)); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { half, on }); + assertNotNull("MIXED DCEL from the named interval", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("inner + bite", 2, dcel.boundedFaces().size()); + assertHasBoundedArea(dcel, 2.0 + 0.5 * Math.PI); + assertHasBoundedArea(dcel, HALF - 2.0 - 0.5 * Math.PI); + + CurveSegmentDcel.Vertex left = vertexAt(dcel, -1.0, 0.0); + CurveSegmentDcel.Vertex right = vertexAt(dcel, 1.0, 0.0); + assertEquals("T-junction at (-1 0)", 3, left.degree()); + assertEquals("T-junction at (1 0)", 3, right.degree()); + + CurveSegmentDcel.Half shared = findChordHalf(dcel, -1.0, 0.0, 1.0, 0.0); + assertNotNull("shared diameter half", shared); + assertFalse("shared run stays a chord", shared.isArc()); + assertTwinReverses(shared); + assertTrue("twin faces differ across the shared edge", + shared.face() != shared.twin().face()); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_FOUR. Faces already names + * nine bounded cells. The DCEL is that walk with twins and + * cycle links, not another Geometry assembler. + */ + public void testStadiumFourN3PinsNineFaces() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_HANGING); + Geometry c = readCurve(STADIUM_FOUR); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b, c }); + assertNotNull("N=3 DCEL", dcel); + assertNull(CurveSegmentDcel.missReason()); + assertLinks(dcel); + assertEquals("nine bounded faces", 9, dcel.boundedFaces().size()); + assertHasBoundedArea(dcel, N3_TRIPLE); + assertHasBoundedArea(dcel, N3_LENS_SIDE); + assertHasBoundedArea(dcel, N3_AC_NOT_B); + assertHasBoundedArea(dcel, N3_BC_NOT_A); + assertHasBoundedArea(dcel, N3_C_BOTTOM); + assertHasBoundedArea(dcel, N3_A_EAR); + assertHasBoundedArea(dcel, N3_B_REST); + double bounded = 0.0; + List cells = dcel.boundedFaces(); + for (int i = 0; i < cells.size(); i++) { + bounded += cells.get(i).signedArea(); + } + assertEquals("bounded cells fill the union", N3_UNION, bounded, 1.0e-8); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b), + CurveSegmentString.of(c)); + CurveSegmentDcel viaStrings = CurveSegmentDcel.of(groups, 16.0); + assertNotNull(viaStrings); + assertLinks(viaStrings); + assertEquals(9, viaStrings.boundedFaces().size()); + } + + /** + * HALF_DISC × STADIUM_FOUR is a sewn 4-node pair. Members stay + * arc or chord; twins and cycles close. + */ + public void testStadiumFourPairPinsTwins() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry stadium = readCurve(STADIUM_FOUR); + CurveSegmentDcel dcel = CurveSegmentDcel.of( + new Geometry[] { half, stadium }); + assertNotNull(dcel); + assertLinks(dcel); + assertTrue("CAP + XOR cells", dcel.boundedFaces().size() >= 3); + boolean sawArc = false; + boolean sawChord = false; + List halves = dcel.halves(); + for (int i = 0; i < halves.size(); i++) { + if (halves.get(i).isArc()) { + sawArc = true; + } + else { + sawChord = true; + } + } + assertTrue("arc member survives", sawArc); + assertTrue("chord member survives", sawChord); + } + + /** + * HALF_DISC × HALF_HANGING × STADIUM_ODD: coincident leave-angle + * at the tangent. Snap-rounding, not a HotPixel. Named stamp. + */ + public void testTangentLeaveAngleStampsNull() throws Exception { + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { + readCurve(HALF_DISC), readCurve(HALF_HANGING), + readCurve(STADIUM_ODD) }); + assertNull("N≥3 near-tangent is P2.5.4, not a DCEL", dcel); + assertEquals("snap-rounding: coincident leave-angle", + CurveSegmentDcel.TANGENT_LEAVE_ANGLE, + CurveSegmentDcel.missReason()); + } + + /** + * Locationtech #1224 / #1226 on this walk, not on core + * HalfEdge / Quadrant. Subtracted leave-vectors can make + * (1 1)→(0 0.5) and (1 1)→(0 0.49999999999999994) look equal + * under atan2; endpoint quadrant + orientation keeps them + * distinct and antisymmetric. Not a TANGENT stamp. + */ + public void testLeaveAngleCompareRobust() { + Coordinate o = new Coordinate(1, 1); + CurveSegmentDcel.Half upper = chordHalf(o, 0, 0.5); + CurveSegmentDcel.Half lower = chordHalf(o, 0, 0.49999999999999994); + CurveSegmentDcel.Half north = chordHalf(o, 0, 1); + assertTrue("edges with distinct direction points must not compare equal", + CurveSegmentDcel.compareLeave(upper, lower) != 0); + assertTrue("leave comparison must be antisymmetric", + CurveSegmentDcel.compareLeave(upper, lower) + == -CurveSegmentDcel.compareLeave(lower, upper)); + assertTrue("north is a different leave", + CurveSegmentDcel.compareLeave(upper, north) != 0); + assertTrue("distinct direction points are not a TANGENT stamp", + !CurveSegmentDcel.leavesCoincide(upper, lower)); + assertEquals("same-ray leave is coincident", + 0, CurveSegmentDcel.compareLeave(upper, chordHalf(o, -1, 0))); + } + + /** + * HALF_DISC and STADIUM_ODD leave (0 5) on the same east + * tangent. The robust compare still ties, so the N=3 walk + * keeps the TANGENT_LEAVE_ANGLE stamp. + */ + public void testArcLeaveTangentStillCoincident() { + Coordinate o = new Coordinate(0, 5); + double s = Math.sqrt(0.5); + CurveSegmentString disc = CurveSegmentString.arc(o, + new Coordinate(5 * s, 5 * s), new Coordinate(5, 0)); + CurveSegmentString cap = CurveSegmentString.arc(o, + new Coordinate(s, 4 + s), new Coordinate(1, 4)); + CurveSegmentDcel.Half a = new CurveSegmentDcel.Half(o, disc.getEnd(), + disc); + CurveSegmentDcel.Half b = new CurveSegmentDcel.Half(o, cap.getEnd(), + cap); + assertTrue("both pieces stay arcs", a.isArc() && b.isArc()); + assertTrue("same leave tangent at (0 5)", + CurveSegmentDcel.leavesCoincide(a, b)); + } + + /** + * HALF_DISC × HALF_CROSSING_UPPER: collinear diameters abort the + * node set and hide the arc–arc lens nodes. Generic + * {@code nodes==null} walk is unsafe. Named stamp, not a noder. + */ + public void testMixedHidesCrossingStampsNull() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_CROSSING_UPPER); + assertNull("collinear pair aborts the node set", + CurveSegmentNoder.nodes(a, b)); + List edges = CurveSegmentNoder.edges(a, b); + assertNotNull(edges); + assertTrue("noder still names the diameter overlap", !edges.isEmpty()); + CurveSegmentDcel dcel = CurveSegmentDcel.of(new Geometry[] { a, b }); + assertNull("do not sew a DCEL over a hidden crossing", dcel); + assertEquals(CurveSegmentDcel.MIXED_HIDES_CROSSING, + CurveSegmentDcel.missReason()); + } + + /** + * onString stays in R². A flat (colinear) triple is a chord + * whose sagitta residual is 0. A high-sagitta arc accepts an + * on-circle point by {@code |dx²+dy² − R²| ≤ eps²}, not + * {@code hypot(d) − R} and not a sagitta quotient. + * leaveAngle / compareLeave are untouched. + */ + public void testOnStringExtremeSagittasStayInR2() { + double eps = 1.0e-9; + double eps2 = eps * eps; + + CurveSegmentString flat = CurveSegmentString.arc( + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(2, 0)); + assertFalse("flat sagitta is a chord", flat.isArc()); + Coordinate flatMid = new Coordinate(1, 0); + assertTrue("flat sagitta → 0: midpoint is on the chord", + CurveSegmentDcel.onString(flat, flatMid, eps)); + double flatDx = flatMid.x - 1.0; + double flatDy = flatMid.y - 0.0; + assertEquals("flat sagitta residual is 0 in R²", + 0.0, flatDx * flatDx + flatDy * flatDy, 0.0); + assertTrue("on-chord within eps²", + CurveSegmentDcel.onString(flat, new Coordinate(1, 0.5 * eps), eps)); + assertFalse("off-chord by more than eps", + CurveSegmentDcel.onString(flat, new Coordinate(1, 2.0 * eps), eps)); + + CurveSegmentString high = CurveSegmentString.arc( + new Coordinate(1, 0), new Coordinate(-1, 0), + new Coordinate(0.6, 0.8)); + assertTrue("high-sagitta stays an arc", high.isArc()); + TwoNodeClip.Edge e = high.asEdge(); + double r2 = e.circle[2] * e.circle[2]; + Coordinate apex = high.getMid(); + double dx = apex.x - e.circle[0]; + double dy = apex.y - e.circle[1]; + assertTrue("high-sagitta mid residual is in eps² of R²", + Math.abs(dx * dx + dy * dy - r2) <= eps2); + assertTrue("high-sagitta mid is onString", + CurveSegmentDcel.onString(high, apex, eps)); + assertTrue("high-sagitta end is onString", + CurveSegmentDcel.onString(high, high.getEnd(), eps)); + assertFalse("centre is not on the high-sagitta arc", + CurveSegmentDcel.onString(high, + new Coordinate(e.circle[0], e.circle[1]), eps)); + } + + /** + * Pinch / kiss / holed Geometry-level stay null. Not a face, + * not a DCEL. No invented noder. + */ + public void testPinchKissHoledStayNull() throws Exception { + assertNull("H-ANNULUS-TANGENT: pinch is not a DCEL", + CurveSegmentDcel.of(new Geometry[] { + readCurve(CIRCLE_5), readCurve(CIRCLE_INT_TAN) })); + assertNull("TOUCH-ext: kiss is not a DCEL", + CurveSegmentDcel.of(new Geometry[] { + readCurve(UNIT_DISC), readCurve(UNIT_DISC_TOUCH) })); + assertNull("H-SHELL-HOLE-X: holed Geometry-level stays null", + CurveSegmentDcel.of(new Geometry[] { + readCurve(HALF_HOLED), readCurve(HOLE_X) })); + } + + private static void assertLinks(CurveSegmentDcel dcel) { + assertNotNull(dcel); + List halves = dcel.halves(); + assertTrue("has half-edges", !halves.isEmpty()); + double eps = dcel.eps(); + for (int i = 0; i < halves.size(); i++) { + CurveSegmentDcel.Half h = halves.get(i); + assertNotNull("twin", h.twin()); + assertSame("twin.twin", h, h.twin().twin()); + assertNotNull("next", h.next()); + assertNotNull("prev", h.prev()); + assertSame("next.prev", h, h.next().prev()); + assertSame("prev.next", h, h.prev().next()); + assertNotNull("incident face", h.face()); + assertNotNull("member", h.member()); + assertTrue("twin dest is origin", + h.origin().distance(h.twin().dest()) <= eps); + assertTrue("twin origin is dest", + h.dest().distance(h.twin().origin()) <= eps); + assertCycleCloses(h, halves.size()); + } + } + + private static void assertCycleCloses(CurveSegmentDcel.Half start, int guard) { + CurveSegmentDcel.Half cur = start; + int n = 0; + boolean closed = false; + while (n++ < guard && !closed) { + cur = cur.next(); + if (cur == start) { + closed = true; + } + } + assertTrue("next-cycle closes", closed); + cur = start; + n = 0; + closed = false; + while (n++ < guard && !closed) { + cur = cur.prev(); + if (cur == start) { + closed = true; + } + } + assertTrue("prev-cycle closes", closed); + } + + private static void assertTwinReverses(CurveSegmentDcel.Half h) { + assertNotNull(h); + CurveSegmentDcel.Half t = h.twin(); + assertNotNull(t); + assertSame(h, t.twin()); + assertEquals(h.isArc(), t.isArc()); + if (h.isArc()) { + assertEquals(h.member().getMid().x, t.member().getMid().x, EXACT); + assertEquals(h.member().getMid().y, t.member().getMid().y, EXACT); + } + } + + private static void assertHasBoundedArea(CurveSegmentDcel dcel, double area) { + boolean found = false; + List cells = dcel.boundedFaces(); + for (int i = 0; i < cells.size() && !found; i++) { + if (Math.abs(cells.get(i).signedArea() - area) <= 1.0e-8) { + found = true; + } + } + assertTrue("missing bounded area " + area, found); + } + + private static CurveSegmentDcel.Vertex vertexAt(CurveSegmentDcel dcel, + double x, double y) { + Coordinate want = new Coordinate(x, y); + CurveSegmentDcel.Vertex found = null; + List verts = dcel.vertices(); + for (int i = 0; i < verts.size() && found == null; i++) { + if (verts.get(i).coordinate().distance(want) <= 1.0e-8) { + found = verts.get(i); + } + } + assertNotNull("missing vertex (" + x + " " + y + ")", found); + return found; + } + + private static CurveSegmentDcel.Half chordHalf(Coordinate origin, + double x, double y) { + Coordinate dest = new Coordinate(x, y); + return new CurveSegmentDcel.Half(origin, dest, + CurveSegmentString.segment(origin, dest)); + } + + private static CurveSegmentDcel.Half findHalfNear(CurveSegmentDcel dcel, + double x0, double y0, double x1, double y1) { + Coordinate a = new Coordinate(x0, y0); + Coordinate b = new Coordinate(x1, y1); + CurveSegmentDcel.Half found = null; + List halves = dcel.halves(); + for (int i = 0; i < halves.size() && found == null; i++) { + CurveSegmentDcel.Half h = halves.get(i); + if (h.origin().distance(a) <= 1.0e-8 + && h.dest().distance(b) <= 1.0e-6) { + found = h; + } + } + return found; + } + + private static CurveSegmentDcel.Half findChordHalf(CurveSegmentDcel dcel, + double x0, double y0, double x1, double y1) { + Coordinate p = new Coordinate(x0, y0); + Coordinate q = new Coordinate(x1, y1); + CurveSegmentDcel.Half found = null; + List halves = dcel.halves(); + for (int i = 0; i < halves.size() && found == null; i++) { + CurveSegmentDcel.Half h = halves.get(i); + if (h.isArc()) { + continue; + } + boolean ends = h.origin().distance(p) <= EXACT + && h.dest().distance(q) <= EXACT + || h.origin().distance(q) <= EXACT + && h.dest().distance(p) <= EXACT; + if (ends) { + found = h; + } + } + return found; + } +} 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 b506840bd1..4cef40a3b6 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 @@ -35,6 +35,10 @@ * each unordered pair. P2.5.3 walks the faces of that node set. * P2.5.4 is near-tangent robustness: a coincident leave-angle * stamps {@link CurveSegmentFaces#TANGENT_LEAVE_ANGLE}. + * P2.5.7 is the package-private curve DCEL + * ({@link CurveSegmentDcel}): half-edges, twins, next/prev, face + * pointers on {@link CurveSegmentString} members. Face Geometry + * assemble stays {@link CurveSegmentFaces}. * MIXED overlay walks the named diameter as a shared edge * ({@link MixedOverlapOverlay}). Not N-SS, not a core {@code Noder}. */ @@ -565,9 +569,10 @@ public void testN3HalfHangingStadiumFaces() throws Exception { /** * HALF_DISC × HALF_HANGING × STADIUM_ODD: crossings (±1, 0) plus - * the tangent at (0, 5). Two pieces leave at the same angle - * ({@code ANGLE_EPS = 1e-8}). Ordering them is snap-rounding - * (P2.5.4). Named stamp, not a HotPixel, not a bare null. + * the tangent at (0, 5). Two pieces leave in the same direction + * (endpoint quadrant + orientation, not atan2 of deltas). + * Ordering them is snap-rounding (P2.5.4). Named stamp, not a + * HotPixel, not a bare null. */ public void testN3TangentStampsNull() throws Exception { Geometry faces = CurveSegmentFaces.faces(new Geometry[] {