Skip to content

Commit f2c3947

Browse files
grootstebozewolfclaude
authored andcommitted
N-SS (MVP): arc-aware noding through the core org.locationtech.jts.noding framework
Cherry-pick of the N-SS core slice (originally feature/sfa-curve-N-SS @ 2ab3c15) onto the extension-points branch. Reconstructed as a single commit because the target lacks the curved arc helpers a literal cherry-pick would patch against; the MVP excludes the superseded shadow ArcNoder and carries only the core-integrated path plus its dependency closure. Core seam (non-behavioral; full core noding suite + 10k-iteration overlay noding stress test stay green): - SegmentNodeList.createSplitEdge / createSplitEdgePts: private -> protected, so a curve-aware node list can build arc-bearing split edges and reuse the validated chord slicing. - NodedSegmentString: extract node-list construction into a protected createNodeList() factory (default new SegmentNodeList(this)). jts-curved (uses the oracle-pinned CircularArcs primitives): - CircularArcs: closed-form arc length and arc/arc, arc/segment intersection. - ArcSegmentString: arc-math / intersection helper (intersectPieces, midOnArc). - CurvedSegmentString extends NodedSegmentString, backed by arc chord endpoints as the core Coordinate[] (core segment i == arc i) with the arc mids alongside; overrides createNodeList(). - CurvedSegmentNodeList extends SegmentNodeList, overriding createSplitEdge to emit CurvedSegmentString substrings whose sub-arcs are recomputed on the original circle (mid at the sub-arc angular midpoint). - CurvedSegmentIntersector implements the generic SegmentIntersector, computing true arc/arc and arc/segment crossings (no LineIntersector) and recording them via addIntersection(Coordinate,int). Driven by the stock core SimpleNoder, which offers every segment pair, so the arc-bulge envelope problem that makes MCIndexNoder unsafe for arcs does not arise. Verified (CurvedNoderTest): two intersecting circles node at the true crossings into arc-preserving substrings; a semicircle cut by a chord at two points reconstructs pi*r; disjoint arcs pass through unchanged; node points match an independent densified brute-force intersection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UP8vRDiu5WomqAqXf5dsP
1 parent 5b89669 commit f2c3947

8 files changed

Lines changed: 777 additions & 3 deletions

File tree

modules/core/src/main/java/org/locationtech/jts/noding/NodedSegmentString.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,22 @@ public static void getNodedSubstrings(Collection segStrings, Collection resultEd
6666
}
6767
}
6868

69-
private SegmentNodeList nodeList = new SegmentNodeList(this);
69+
private SegmentNodeList nodeList = createNodeList();
7070
private Coordinate[] pts;
7171
private Object data;
7272

73+
/**
74+
* Creates the {@link SegmentNodeList} backing this segment string. Subclasses
75+
* (e.g. an arc-aware segment string) may override this to supply a node list
76+
* that builds curve-preserving split edges. Called during construction, so it
77+
* must not depend on subclass fields not yet initialised.
78+
*
79+
* @return the node list for this segment string
80+
*/
81+
protected SegmentNodeList createNodeList() {
82+
return new SegmentNodeList(this);
83+
}
84+
7385
/**
7486
* Creates a instance from a list of vertices and optional data object.
7587
*

modules/core/src/main/java/org/locationtech/jts/noding/SegmentNodeList.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ private void checkSplitEdgesCorrectness(List splitEdges)
224224
* (and including) the two intersections.
225225
* The label for the new edge is the same as the label for the parent edge.
226226
*/
227-
private SegmentString createSplitEdge(SegmentNode ei0, SegmentNode ei1)
227+
protected SegmentString createSplitEdge(SegmentNode ei0, SegmentNode ei1)
228228
{
229229
Coordinate[] pts = createSplitEdgePts(ei0, ei1);
230230
return new NodedSegmentString(pts, edge.getData());
@@ -240,7 +240,7 @@ private SegmentString createSplitEdge(SegmentNode ei0, SegmentNode ei1)
240240
* @param ei1 the end node of the split edge
241241
* @return the points for the split edge
242242
*/
243-
private Coordinate[] createSplitEdgePts(SegmentNode ei0, SegmentNode ei1) {
243+
protected Coordinate[] createSplitEdgePts(SegmentNode ei0, SegmentNode ei1) {
244244
//Debug.println("\ncreateSplitEdge"); Debug.print(ei0); Debug.print(ei1);
245245
int npts = ei1.segmentIndex - ei0.segmentIndex + 2;
246246

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Copyright (c) 2026 grootstebozewolf
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
7+
* The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
8+
* and the Eclipse Distribution License is available at
9+
*
10+
* http://www.eclipse.org/org/documents/edl-v10.php.
11+
*/
12+
package org.locationtech.jts.geom.curved;
13+
14+
import java.util.ArrayList;
15+
import java.util.Comparator;
16+
import java.util.HashMap;
17+
import java.util.List;
18+
import java.util.Map;
19+
20+
import org.locationtech.jts.geom.Coordinate;
21+
import org.locationtech.jts.geom.CoordinateSequence;
22+
import org.locationtech.jts.geom.impl.CoordinateArraySequence;
23+
24+
/**
25+
* An arc-aware analogue of {@code NodedSegmentString} for arc-aware noding (N-SS,
26+
* JTS #1195). It carries a control-point sequence read as consecutive arc pieces
27+
* {@code (p[2i], p[2i+1], p[2i+2])} (a collinear triple is a straight chord) plus
28+
* an opaque {@code data} context, records intersection nodes per arc piece, and
29+
* splits itself at those nodes into noded sub-strings.
30+
* <p>
31+
* Splitting is arc-aware: an arc cut at a crossing yields sub-arcs on the same
32+
* circle (each mid point recomputed at the sub-arc's angular midpoint), and a
33+
* sub-string spans the original arc joints (which are not nodes), breaking only
34+
* at the recorded crossings — the same rule as linear {@code NodedSegmentString}.
35+
* The crossings come from the oracle-pinned {@link CircularArcs} primitives.
36+
*/
37+
public final class ArcSegmentString {
38+
39+
private static final double EPS = 1e-9;
40+
41+
private final CoordinateSequence pts;
42+
private final Object data;
43+
private final Map<Integer, List<double[]>> nodes = new HashMap<Integer, List<double[]>>();
44+
45+
public ArcSegmentString(CoordinateSequence pts, Object data) {
46+
this.pts = pts;
47+
this.data = data;
48+
}
49+
50+
public CoordinateSequence getCoordinateSequence() { return pts; }
51+
public Object getData() { return data; }
52+
53+
/** Number of arc pieces (consecutive control-point triples). */
54+
public int numArcs() { return Math.max(0, (pts.size() - 1) / 2); }
55+
56+
/** The {@code i}-th arc piece as {sx,sy,mx,my,ex,ey}. */
57+
double[] arc(int i) {
58+
int b = 2 * i;
59+
return new double[]{ pts.getX(b), pts.getY(b), pts.getX(b+1), pts.getY(b+1), pts.getX(b+2), pts.getY(b+2) };
60+
}
61+
62+
/**
63+
* Records an intersection node on arc piece {@code arcIndex}. A point coincident
64+
* with the arc's endpoints (a joint, not an interior crossing) or with an already
65+
* recorded node is ignored, so no zero-length sub-arc is produced.
66+
*/
67+
void addNode(int arcIndex, double x, double y) {
68+
double[] a = arc(arcIndex);
69+
if (near(x, y, a[0], a[1]) || near(x, y, a[4], a[5])) return; // at an endpoint joint
70+
List<double[]> list = nodes.get(arcIndex);
71+
if (list == null) { list = new ArrayList<double[]>(); nodes.put(arcIndex, list); }
72+
for (double[] q : list) if (near(x, y, q[0], q[1])) return; // duplicate
73+
list.add(new double[]{ x, y });
74+
}
75+
76+
/** True iff any node has been recorded on any arc. */
77+
boolean hasNodes() { return !nodes.isEmpty(); }
78+
79+
/**
80+
* Splits this string at its recorded nodes into noded sub-strings, each carrying
81+
* the same {@code data}. With no nodes, returns this string unchanged.
82+
*/
83+
public List<ArcSegmentString> getNodedSubstrings() {
84+
List<ArcSegmentString> out = new ArrayList<ArcSegmentString>();
85+
if (!hasNodes()) { out.add(this); return out; }
86+
List<Coordinate> cur = new ArrayList<Coordinate>();
87+
int nA = numArcs();
88+
for (int i = 0; i < nA; i++) {
89+
double[] a = arc(i);
90+
double[] c = circle(a); // null if collinear (chord)
91+
List<double[]> breaks = breakPoints(a, c, nodes.get(i));
92+
for (int j = 0; j + 1 < breaks.size(); j++) {
93+
double[] p = breaks.get(j), q = breaks.get(j + 1);
94+
double mx, my;
95+
if (c != null) { double[] m = arcMid(c, p, q); mx = m[0]; my = m[1]; }
96+
else { mx = 0.5 * (p[0] + q[0]); my = 0.5 * (p[1] + q[1]); }
97+
if (cur.isEmpty()) cur.add(new Coordinate(p[0], p[1]));
98+
cur.add(new Coordinate(mx, my));
99+
cur.add(new Coordinate(q[0], q[1]));
100+
boolean endIsNode = (j + 1 < breaks.size() - 1); // q is an interior crossing
101+
if (endIsNode) { out.add(build(cur)); cur = new ArrayList<Coordinate>(); }
102+
}
103+
}
104+
if (!cur.isEmpty()) out.add(build(cur));
105+
return out;
106+
}
107+
108+
/** Ordered break points along one arc: start, interior nodes (by sweep), end. */
109+
private static List<double[]> breakPoints(double[] a, double[] c, List<double[]> ns) {
110+
List<double[]> bp = new ArrayList<double[]>();
111+
bp.add(new double[]{ a[0], a[1] });
112+
if (ns != null && !ns.isEmpty()) {
113+
List<double[]> sorted = new ArrayList<double[]>(ns);
114+
if (c != null) {
115+
final double[] cc = c;
116+
sorted.sort(new Comparator<double[]>() {
117+
public int compare(double[] p, double[] q) {
118+
return Double.compare(sweepFrac(cc, p), sweepFrac(cc, q));
119+
}
120+
});
121+
} else {
122+
final double[] aa = a;
123+
sorted.sort(new Comparator<double[]>() {
124+
public int compare(double[] p, double[] q) {
125+
return Double.compare(d2(aa[0],aa[1],p[0],p[1]), d2(aa[0],aa[1],q[0],q[1]));
126+
}
127+
});
128+
}
129+
bp.addAll(sorted);
130+
}
131+
bp.add(new double[]{ a[4], a[5] });
132+
return bp;
133+
}
134+
135+
private ArcSegmentString build(List<Coordinate> coords) {
136+
return new ArcSegmentString(new CoordinateArraySequence(coords.toArray(new Coordinate[0])), data);
137+
}
138+
139+
// ---- intersection of two arc/chord pieces (delegates to the oracle-pinned primitives) ----
140+
141+
static double[][] intersectPieces(double[] a, double[] b) {
142+
boolean aArc = isArc(a), bArc = isArc(b);
143+
if (aArc && bArc) {
144+
return CircularArcs.intersectArc(a[0],a[1],a[2],a[3],a[4],a[5], b[0],b[1],b[2],b[3],b[4],b[5]);
145+
}
146+
if (aArc) return CircularArcs.intersectSegment(a[0],a[1],a[2],a[3],a[4],a[5], b[0],b[1], b[4],b[5]);
147+
if (bArc) return CircularArcs.intersectSegment(b[0],b[1],b[2],b[3],b[4],b[5], a[0],a[1], a[4],a[5]);
148+
double[] p = segSeg(a[0],a[1],a[4],a[5], b[0],b[1],b[4],b[5]);
149+
return (p == null) ? new double[0][] : new double[][]{ p };
150+
}
151+
152+
// ---- geometry helpers ----
153+
154+
static boolean isArc(double[] p) {
155+
return 2 * (p[0]*(p[3]-p[5]) + p[2]*(p[5]-p[1]) + p[4]*(p[1]-p[3])) != 0.0;
156+
}
157+
158+
/**
159+
* Mid control point for the sub-arc of {@code arc} from {@code (px,py)} to
160+
* {@code (qx,qy)} (both on the arc), recomputed on the arc's circle at the
161+
* sub-arc's angular midpoint. Collinear (chord) pieces return the chord midpoint.
162+
*/
163+
static double[] midOnArc(double[] arc, double px, double py, double qx, double qy) {
164+
double[] c = circle(arc);
165+
if (c == null) return new double[]{ 0.5*(px+qx), 0.5*(py+qy) };
166+
return arcMid(c, new double[]{ px, py }, new double[]{ qx, qy });
167+
}
168+
169+
/** {cx, cy, r, a0, signedSweep} of the arc, or null if collinear/degenerate. */
170+
private static double[] circle(double[] p) {
171+
double sx=p[0],sy=p[1],mx=p[2],my=p[3],ex=p[4],ey=p[5];
172+
double d = 2 * (sx*(my-ey) + mx*(ey-sy) + ex*(sy-my));
173+
if (d == 0.0) return null;
174+
double s2=sx*sx+sy*sy, m2=mx*mx+my*my, e2=ex*ex+ey*ey;
175+
double cx=(s2*(my-ey)+m2*(ey-sy)+e2*(sy-my))/d;
176+
double cy=(s2*(ex-mx)+m2*(sx-ex)+e2*(mx-sx))/d;
177+
double r=Math.hypot(sx-cx,sy-cy);
178+
if (!Double.isFinite(r) || r==0.0) return null;
179+
double a0=Math.atan2(sy-cy,sx-cx), am=Math.atan2(my-cy,mx-cx), ae=Math.atan2(ey-cy,ex-cx);
180+
boolean ccw = d > 0;
181+
double theta = directedSweep(a0,am,ccw) + directedSweep(am,ae,ccw);
182+
return new double[]{ cx, cy, r, a0, ccw ? theta : -theta };
183+
}
184+
185+
private static double sweepFrac(double[] c, double[] p) {
186+
boolean ccw = c[4] >= 0;
187+
return directedSweep(c[3], Math.atan2(p[1]-c[1], p[0]-c[0]), ccw);
188+
}
189+
190+
/** Point on the circle at the angular midpoint of the sub-arc from p to q (in the arc's direction). */
191+
private static double[] arcMid(double[] c, double[] p, double[] q) {
192+
boolean ccw = c[4] >= 0;
193+
double ap = Math.atan2(p[1]-c[1], p[0]-c[0]);
194+
double sw = directedSweep(ap, Math.atan2(q[1]-c[1], q[0]-c[0]), ccw);
195+
double mid = ap + (ccw ? 1 : -1) * sw / 2;
196+
return new double[]{ c[0] + c[2]*Math.cos(mid), c[1] + c[2]*Math.sin(mid) };
197+
}
198+
199+
private static double directedSweep(double from, double to, boolean ccw) {
200+
double t = ccw ? (to - from) : (from - to);
201+
t %= 2 * Math.PI;
202+
if (t < 0) t += 2 * Math.PI;
203+
return t;
204+
}
205+
206+
private static double[] segSeg(double x1,double y1,double x2,double y2, double x3,double y3,double x4,double y4) {
207+
double d = (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3);
208+
if (Math.abs(d) < 1e-12) return null;
209+
double t = ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / d;
210+
double u = ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / d;
211+
if (t < -1e-9 || t > 1+1e-9 || u < -1e-9 || u > 1+1e-9) return null;
212+
return new double[]{ x1 + t*(x2-x1), y1 + t*(y2-y1) };
213+
}
214+
215+
private static boolean near(double x1,double y1,double x2,double y2) { return Math.hypot(x1-x2,y1-y2) <= EPS; }
216+
private static double d2(double x1,double y1,double x2,double y2) { double dx=x1-x2,dy=y1-y2; return dx*dx+dy*dy; }
217+
}

0 commit comments

Comments
 (0)