From b983f6a250fac62d9df9347f0bd5d3d55f62b731 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 09:05:27 +0000 Subject: [PATCH] feat: P2.5.2 N-string node set as all-pairs union Package-private CurveSegmentNoder.nodes accepts N geometries or N string collections and unions each unordered pair. N=2 delegates to the pair noder. MIXED, pinch, and holed pairs stay null and add no point. No faces, no jts-core noding change. Co-authored-by: Jeroen Bloemscheer --- .../overlayng/curve/CurveSegmentNoder.java | 102 +++++++++- .../curve/CurveSegmentStringTest.java | 183 +++++++++++++++++- 2 files changed, 278 insertions(+), 7 deletions(-) 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 42cc12accf..9503d31493 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 @@ -24,14 +24,17 @@ * Nodes and shared edges. Intersects {@link CurveSegmentString}s * and returns the discrete node set the kits already compute, or * {@code null}. A shared run is an {@linkplain #edges edge} - * (interval), not a pair of endpoints. Package-private -- not a - * core {@code Noder}, not N-SS, not a public API, not a face walker. + * (interval), not a pair of endpoints. N strings (or N geometries) + * are the unique union of each unordered pair; N=2 is that pair. + * Package-private -- not a core {@code Noder}, not N-SS, not a + * public API, not a face walker. *

* MIXED (collinear overlap) is not a discrete node set; {@link #nodes} - * stays {@code null} and {@link #edges} names the interval. - * A tangent pinch (TOUCH-ext, H-ANNULUS-TANGENT) is a zero-length - * edge, not a face. Overlay still goes through the existing kits. - * Densify is never a noder. + * stays {@code null} and {@link #edges} names the interval. A MIXED + * or pinch pair inside an N-set adds no point. A tangent pinch + * (TOUCH-ext, H-ANNULUS-TANGENT) is a zero-length edge, not a face. + * Overlay still goes through the existing kits. Densify is never + * a noder. */ final class CurveSegmentNoder { @@ -84,6 +87,77 @@ static Coordinate[] nodes(List a, return hits.toArray(new Coordinate[0]); } + /** + * Discrete nodes of N geometries: the unique union of each + * unordered pair's {@link #nodes(Geometry, Geometry)}. N=2 is + * that pair, bit-identical. A MIXED, pinch, or holed pair stays + * {@code null} and adds no point. Fewer than two operands, or + * every pair a miss, is {@code null}. Does not assemble a face. + */ + static Coordinate[] nodes(Geometry[] geoms) { + if (geoms == null || geoms.length < 2) return null; + if (geoms.length == 2) { + return nodes(geoms[0], geoms[1]); + } + List hits = new ArrayList(); + boolean any = false; + double scale = scaleOf(geoms); + for (int i = 0; i < geoms.length; i++) { + for (int j = i + 1; j < geoms.length; j++) { + Coordinate[] xs = nodes(geoms[i], geoms[j]); + if (xs != null) { + any = true; + addUnique(hits, xs, scale); + } + } + } + if (!any) return null; + return hits.toArray(new Coordinate[0]); + } + + /** + * Discrete nodes of N string collections: the unique union of + * each unordered pair's {@link #nodes(List, List, double)}. + * N=2 is that pair, bit-identical. A MIXED pair stays + * {@code null} and adds no point. + */ + static Coordinate[] nodes(List> groups, + double scale) { + if (groups == null || groups.size() < 2) return null; + if (groups.size() == 2) { + return nodes(groups.get(0), groups.get(1), scale); + } + List hits = new ArrayList(); + boolean any = false; + for (int i = 0; i < groups.size(); i++) { + for (int j = i + 1; j < groups.size(); j++) { + Coordinate[] xs = nodes(groups.get(i), groups.get(j), scale); + if (xs != null) { + any = true; + addUnique(hits, xs, scale); + } + } + } + if (!any) return null; + return hits.toArray(new Coordinate[0]); + } + + /** + * Discrete nodes of N strings. Each string is its own operand; + * the pair noder runs on each unordered pair of singletons. + */ + static Coordinate[] nodes(CurveSegmentString[] strings, double scale) { + if (strings == null || strings.length < 2) return null; + List> groups = + new ArrayList>(strings.length); + for (int i = 0; i < strings.length; i++) { + List one = new ArrayList(1); + one.add(strings[i]); + groups.add(one); + } + return nodes(groups, scale); + } + /** * Shared runs of a circular pair. Empty is no interval and no * pinch. {@code null} is a holed pair (the hole ring is walked @@ -189,4 +263,20 @@ private static double scaleOf(Geometry a, Geometry b) { b.getEnvelopeInternal().getHeight()); return Math.max(Math.max(wa, wb), 1.0); } + + 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; + } } 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 9f18018167..872522a33b 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 @@ -11,6 +11,7 @@ */ package org.locationtech.jts.operation.overlayng.curve; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -30,7 +31,8 @@ * ring as strings into one face decision (bite vs hole). P2.4 * walks two crossing hole rings into the hole faces. A hole * ring that overlaps the other shell (no crossing nodes) is - * the same P2.3 bite. Not N-SS. + * the same P2.3 bite. P2.5.2 is N strings: the unique union of + * each unordered pair. Not N-SS, not a face walker. */ public class CurveSegmentStringTest extends GeometryTestCase { @@ -480,6 +482,132 @@ public void testNoderDoesNotAssembleFaces() throws Exception { } } + /** + * N=2 is the pair noder. Kit-identical pins stay bit-identical. + */ + public void testN2IsBitIdenticalToPairNoder() throws Exception { + assertN2Same(CIRCLE_5, CIRCLE_CROSSING); + assertN2Same(CIRCLE_5, SQUARE_RIGHT); + assertN2Same(HALF_DISC, HALF_HANGING); + assertN2Same(CIRCLE_5, BAND_FOUR); + assertN2Same(HALF_DISC, STADIUM_FOUR); + assertN2Same(HALF_DISC, STADIUM_ODD); + assertN2Same(HALF_DISC, ON_DIAMETER); + assertN2Same(CIRCLE_5, CIRCLE_INT_TAN); + assertN2Same(UNIT_DISC, UNIT_DISC_TOUCH); + assertN2Same(HALF_HOLED, HOLE_X); + + List sa = Arrays.asList( + CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)), + CurveSegmentString.arc(c(5, 0), c(0, -5), c(-5, 0))); + List sb = Arrays.asList( + CurveSegmentString.arc(c(2, 0), c(7, 5), c(12, 0)), + CurveSegmentString.arc(c(12, 0), c(7, -5), c(2, 0))); + Coordinate[] pair = CurveSegmentNoder.nodes(sa, sb, 12.0); + List> groups = + Arrays.asList(sa, sb); + assertSamePoints("N=2 string groups", pair, + CurveSegmentNoder.nodes(groups, 12.0)); + } + + /** + * Three pairwise-crossing CompoundCurve shells. Pair nodes are + * already named (H-SHELL-2, H-SHELL-N, hanging vs stadium). The + * N-set is that union, no extras. + */ + public void testN3IsUnionOfPairSets() throws Exception { + Geometry a = readCurve(HALF_DISC); + Geometry b = readCurve(HALF_HANGING); + Geometry c = readCurve(STADIUM_FOUR); + Coordinate[] ab = CurveSegmentNoder.nodes(a, b); + Coordinate[] ac = CurveSegmentNoder.nodes(a, c); + Coordinate[] bc = CurveSegmentNoder.nodes(b, c); + assertNotNull("H-SHELL-2 pair", ab); + assertNotNull("H-SHELL-N pair", ac); + assertNotNull("hanging × stadium pair", bc); + Coordinate[] expect = unionNodes(ab, ac, bc); + Coordinate[] n3 = CurveSegmentNoder.nodes(new Geometry[] { a, b, c }); + assertSamePoints("N=3 union", expect, n3); + assertHas(n3, 3.0, 4.0); + assertHas(n3, -3.0, 4.0); + assertHas(n3, 1.0, SQRT_24); + assertHas(n3, -1.0, SQRT_24); + assertHas(n3, 1.0, 0.0); + assertHas(n3, -1.0, 0.0); + assertSamePoints("N=3 permutation", n3, + CurveSegmentNoder.nodes(new Geometry[] { c, a, b })); + + List> groups = Arrays.asList( + CurveSegmentString.of(a), CurveSegmentString.of(b), + CurveSegmentString.of(c)); + assertSamePoints("N=3 string groups", expect, + CurveSegmentNoder.nodes(groups, 16.0)); + + CurveSegmentString[] shells = new CurveSegmentString[] { + CurveSegmentString.arc(c(-5, 0), c(0, 5), c(5, 0)), + CurveSegmentString.arc(c(-5, 8), c(0, 3), c(5, 8)) }; + Coordinate[] hshell2 = TwoNodeClip.intersectCircles(0, 0, 5, 0, 8, 5); + assertSamePoints("N=2 strings is H-SHELL-2", hshell2, + CurveSegmentNoder.nodes(shells, 16.0)); + } + + /** + * A MIXED pair inside an N-set stays an edge. All-pairs must + * not invent a crossing there. The N-set is the other pairs. + */ + public void testN3MixedPairAddsNoFakeCrossing() throws Exception { + Geometry half = readCurve(HALF_DISC); + Geometry mixed = readCurve(ON_DIAMETER); + Geometry cross = readCurve(CIRCLE_CROSSING); + assertNull("H-SHELL-N-MIXED pair stays null", + CurveSegmentNoder.nodes(half, mixed)); + Coordinate[] named = CurveSegmentNoder.nodes(half, cross); + assertNotNull("half × crossing is a pair set", named); + Coordinate[] mc = CurveSegmentNoder.nodes(mixed, cross); + Coordinate[] n3 = CurveSegmentNoder.nodes( + new Geometry[] { half, mixed, cross }); + if (mc == null) { + assertSamePoints("MIXED pair contributes nothing", named, n3); + } + else { + assertSamePoints("MIXED pair contributes nothing", + unionNodes(named, mc), n3); + } + + Geometry disc = readCurve(CIRCLE_5); + Geometry tan = readCurve(CIRCLE_INT_TAN); + Geometry other = readCurve(CIRCLE_CROSSING); + assertNull("H-ANNULUS-TANGENT pair stays null", + CurveSegmentNoder.nodes(disc, tan)); + Coordinate[] r15 = CurveSegmentNoder.nodes(disc, other); + Coordinate[] tanCross = CurveSegmentNoder.nodes(tan, other); + Coordinate[] pinchN = CurveSegmentNoder.nodes( + new Geometry[] { disc, tan, other }); + assertSamePoints("pinch pair adds no node", + unionNodes(r15, tanCross), pinchN); + assertMissing(pinchN, 5.0, 0.0); + } + + /** + * Geometry-level holed pair stays null. Hole rings are walked + * as strings (P2.3 / P2.4), not invented here. + */ + public void testNHoledGeometryPairStaysNull() throws Exception { + Geometry holed = readCurve(HALF_HOLED); + Geometry holeX = readCurve(HOLE_X); + assertNull("H-SHELL-HOLE-X Geometry pair", + CurveSegmentNoder.nodes(holed, holeX)); + assertNull("N=2 holed is the same miss", + CurveSegmentNoder.nodes(new Geometry[] { holed, holeX })); + + Geometry disc = readCurve(CIRCLE_5); + Geometry cross = readCurve(CIRCLE_CROSSING); + Coordinate[] r15 = CurveSegmentNoder.nodes(disc, cross); + Coordinate[] n3 = CurveSegmentNoder.nodes( + new Geometry[] { holed, disc, cross }); + assertSamePoints("holed pairs add no Geometry-level node", r15, n3); + } + private static Coordinate c(double x, double y) { return new Coordinate(x, y); } @@ -518,6 +646,59 @@ private static CurveSegmentString findArc(List edges, return found; } + private void assertN2Same(String wa, String wb) throws Exception { + Geometry a = readCurve(wa); + Geometry b = readCurve(wb); + Coordinate[] pair = CurveSegmentNoder.nodes(a, b); + Coordinate[] n2 = CurveSegmentNoder.nodes(new Geometry[] { a, b }); + if (pair == null) { + assertNull("N=2 miss " + wa, n2); + } + else { + assertSamePoints("N=2 " + wa, pair, n2); + } + } + + private static Coordinate[] unionNodes(Coordinate[] a, Coordinate[] b) { + return unionNodes(a, b, null); + } + + private static Coordinate[] unionNodes(Coordinate[] a, Coordinate[] b, + Coordinate[] c) { + List hits = new ArrayList(); + addAll(hits, a); + addAll(hits, b); + addAll(hits, c); + return hits.toArray(new Coordinate[0]); + } + + private static void addAll(List hits, Coordinate[] xs) { + if (xs == null) return; + for (int k = 0; k < xs.length; k++) { + boolean seen = false; + for (int i = 0; i < hits.size() && !seen; i++) { + if (hits.get(i).x == xs[k].x && hits.get(i).y == xs[k].y) { + seen = true; + } + } + if (!seen) { + hits.add(xs[k]); + } + } + } + + private static void assertMissing(Coordinate[] nodes, double x, double y) { + assertNotNull("missing node set", nodes); + Coordinate want = new Coordinate(x, y); + boolean found = false; + for (int i = 0; i < nodes.length && !found; i++) { + if (nodes[i].distance(want) <= EXACT) { + found = true; + } + } + assertFalse("unexpected node (" + x + " " + y + ")", found); + } + private static void assertHas(Coordinate[] nodes, double x, double y) { assertNotNull("missing node set", nodes); Coordinate want = new Coordinate(x, y);