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[] {