From 7ce2613a97723e245c9e7cc3800a964edb0d534a Mon Sep 17 00:00:00 2001 From: Jeroen Bloemscheer Date: Wed, 16 Sep 2026 09:12:59 +0200 Subject: [PATCH] Add SQL/MM CircularString (GeoTools-style LineString subclass) CircularString is a real JTS geometry based on locationtech/jts master. It stores chained 3-point control windows and composes ExactCircularArc for each window. Collinear windows degrade to a chord. length() and envelope stay exact; densify only via toLinear / getLinearized / linearize. WKTReader/WKTWriter support CIRCULARSTRING using control points. SegmentString is unchanged. No CompoundCurve, OverlayNG, or Year-2 Exact* types. Assisted-by: Cursor Agent Assisted-by: xAI Grok --- doc/JTS_Version_History.md | 1 + .../exactcurve/ExactCircularArc.java | 436 ++++++++++++++++ .../jts/algorithm/exactcurve/ExactCurve.java | 86 +++ .../algorithm/exactcurve/package-info.java | 30 ++ .../locationtech/jts/geom/CircularString.java | 493 ++++++++++++++++++ .../org/locationtech/jts/geom/Geometry.java | 2 + .../jts/geom/GeometryFactory.java | 33 ++ .../jts/geom/util/GeometryExtracter.java | 3 + .../org/locationtech/jts/io/WKTConstants.java | 1 + .../org/locationtech/jts/io/WKTReader.java | 24 +- .../org/locationtech/jts/io/WKTWriter.java | 21 + .../exactcurve/ExactCircularArcTest.java | 106 ++++ .../jts/geom/CircularStringShelfATest.java | 175 +++++++ .../jts/geom/CircularStringTest.java | 227 ++++++++ .../locationtech/jts/io/WKTReadWriteTest.java | 6 + .../oracle/circularstring-quarter.wkb-ndr.hex | 1 + .../oracle/circularstring-quarter.wkb-xdr.hex | 1 + .../oracle/circularstring-quarter.wkt | 1 + 18 files changed, 1646 insertions(+), 1 deletion(-) create mode 100644 modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArc.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCurve.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/package-info.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/geom/CircularString.java create mode 100644 modules/core/src/test/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArcTest.java create mode 100644 modules/core/src/test/java/org/locationtech/jts/geom/CircularStringShelfATest.java create mode 100644 modules/core/src/test/java/org/locationtech/jts/geom/CircularStringTest.java create mode 100644 modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-ndr.hex create mode 100644 modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-xdr.hex create mode 100644 modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkt diff --git a/doc/JTS_Version_History.md b/doc/JTS_Version_History.md index 4ad1f580ad..225a0bb167 100644 --- a/doc/JTS_Version_History.md +++ b/doc/JTS_Version_History.md @@ -26,6 +26,7 @@ Distributions for older JTS versions can be obtained at the * Add `CoverageCleaner` (#1126) * Add `MinimumBoundingTriangle` (#1160) * Add `DirectedHausdorffDistance` class (#1182) +* Add `CircularString` (ISO SQL/MM) with thin `ExactCurve` / `ExactCircularArc` and WKT support (#133) ### Functionality Improvements diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArc.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArc.java new file mode 100644 index 0000000000..a91650d2f0 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArc.java @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2026 Jeroen Bloemscheer. + * + * 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. + */ +/* + * AI Disclosure: This file was largely AI-generated. + * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence. + * The human contributor has reviewed and verified that the code is correct. + * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0 + * Assisted-by: Cursor Agent + * Assisted-by: xAI Grok + */ +package org.locationtech.jts.algorithm.exactcurve; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.Angle; +import org.locationtech.jts.algorithm.Orientation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; + +/** + * An exact circular-arc window defined by three control points + * (start, mid, end), matching GeoTools {@code CircularArc} and + * GEOS {@code CircularArc} / SQL/MM CIRCULARSTRING segments. + *

+ * Collinear or degenerate windows degrade to the control-point chord + * (start–mid–end), never a bogus huge circle. Length is the true + * arc length ({@code radius * sweep}) when circular, and the chord + * path length when collinear. {@link #isExact()} is {@code true} for + * both the circular and the exact-chord cases. + *

+ * Full-circle convention: {@code CIRCULARSTRING(s, m, s)} is a circle + * whose diameter is the segment {@code s–m} + * (center = midpoint of {@code s} and {@code m}, + * radius = {@code |s-m|/2}, sweep = 2π). + * + * @author Jeroen Bloemscheer + */ +public class ExactCircularArc implements ExactCurve { + + /** + * Default number of linearized segments per quadrant when no + * finite tolerance is supplied (JTS buffer default). + */ + public static final int DEFAULT_SEGMENTS_PER_QUADRANT = 8; + + /** + * Upper bound on segments per quadrant for a zero tolerance. + */ + public static final int MAX_SEGMENTS_PER_QUADRANT = 128; + + private static final double ANGLE_EPS = 1.0e-12; + + private final Coordinate p0; + private final Coordinate p1; + private final Coordinate p2; + + private final boolean collinear; + private final boolean fullCircle; + private final Coordinate center; + private final double radius; + private final double sweep; + private final boolean ccw; + + /** + * Creates an arc through {@code start}, {@code mid}, {@code end}. + * + * @param start the start control + * @param mid the on-arc control + * @param end the end control + */ + public ExactCircularArc(Coordinate start, Coordinate mid, Coordinate end) { + if (start == null || mid == null || end == null) { + throw new IllegalArgumentException("ExactCircularArc control points must not be null"); + } + this.p0 = start.copy(); + this.p1 = mid.copy(); + this.p2 = end.copy(); + + if (p0.equals2D(p2) && !p0.equals2D(p1)) { + this.fullCircle = true; + this.collinear = false; + this.center = new Coordinate( + p0.x + (p1.x - p0.x) / 2.0, + p0.y + (p1.y - p0.y) / 2.0); + this.radius = p0.distance(center); + this.sweep = Angle.PI_TIMES_2; + this.ccw = true; + return; + } + + this.fullCircle = false; + CircleFit fit = fitCircle(p0, p1, p2); + if (p0.equals2D(p1) && p1.equals2D(p2) + || Orientation.index(p0, p1, p2) == Orientation.COLLINEAR + || fit == null) { + this.collinear = true; + this.center = null; + this.radius = Double.POSITIVE_INFINITY; + this.sweep = 0.0; + this.ccw = false; + return; + } + + this.collinear = false; + this.center = fit.center; + this.radius = fit.radius; + double a0 = Math.atan2(p0.y - center.y, p0.x - center.x); + double a1 = Math.atan2(p1.y - center.y, p1.x - center.x); + double a2 = Math.atan2(p2.y - center.y, p2.x - center.x); + double ccwToEnd = Angle.normalizePositive(a2 - a0); + double ccwToMid = Angle.normalizePositive(a1 - a0); + boolean midOnCcw = ccwToMid > ANGLE_EPS && ccwToMid < ccwToEnd - ANGLE_EPS; + if (ccwToEnd <= ANGLE_EPS) { + this.sweep = 0.0; + this.ccw = true; + } + else if (midOnCcw || almostEqual(ccwToMid, ccwToEnd)) { + this.sweep = ccwToEnd; + this.ccw = true; + } + else { + this.sweep = Angle.PI_TIMES_2 - ccwToEnd; + this.ccw = false; + } + } + + private static CircleFit fitCircle(Coordinate p0, Coordinate p1, Coordinate p2) { + double ax = p0.x; + double ay = p0.y; + double bx = p1.x; + double by = p1.y; + double cx = p2.x; + double cy = p2.y; + double d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (!Double.isFinite(d) || d == 0.0) { + return null; + } + double a2 = ax * ax + ay * ay; + double b2 = bx * bx + by * by; + double c2 = cx * cx + cy * cy; + double ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d; + double uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d; + if (!Double.isFinite(ux) || !Double.isFinite(uy)) { + return null; + } + double r = Math.hypot(ax - ux, ay - uy); + if (!Double.isFinite(r) || r == 0.0) { + return null; + } + return new CircleFit(new Coordinate(ux, uy), r); + } + + /** + * Gets the start control point. + * + * @return a copy of the start control + */ + public Coordinate getStart() { + return p0.copy(); + } + + /** + * Gets the mid control point. + * + * @return a copy of the mid control + */ + public Coordinate getMid() { + return p1.copy(); + } + + /** + * Gets the end control point. + * + * @return a copy of the end control + */ + public Coordinate getEnd() { + return p2.copy(); + } + + /** + * Gets the circle center, or {@code null} if this window is a chord. + * + * @return the center, or {@code null} + */ + public Coordinate getCenter() { + return center == null ? null : center.copy(); + } + + /** + * Gets the circle radius, or {@link Double#POSITIVE_INFINITY} if collinear. + * + * @return the radius + */ + public double getRadius() { + return radius; + } + + /** + * Tests whether this window is a complete circle (start equals end, + * mid distinct). + * + * @return {@code true} if this is a full circle + */ + public boolean isFullCircle() { + return fullCircle; + } + + public boolean isCollinear() { + return collinear; + } + + /** + * Signed sweep angle in radians ({@code 0} if collinear). + * + * @return the sweep magnitude + */ + public double getSweep() { + return sweep; + } + + public boolean isExact() { + return true; + } + + public double length() { + if (collinear) { + return p0.distance(p1) + p1.distance(p2); + } + return radius * sweep; + } + + /** + * Alias of {@link #length()}. + * + * @return the exact length + */ + public double getLength() { + return length(); + } + + public Coordinate pointAt(double t) { + if (t <= 0.0) { + return p0.copy(); + } + if (t >= 1.0) { + return p2.copy(); + } + if (collinear || sweep <= ANGLE_EPS) { + return pointAtChord(t); + } + double dir = ccw ? 1.0 : -1.0; + double a0 = Math.atan2(p0.y - center.y, p0.x - center.x); + return pointOnCircle(a0 + dir * t * sweep); + } + + private Coordinate pointAtChord(double t) { + double d1 = p0.distance(p1); + double d2 = p1.distance(p2); + double tot = d1 + d2; + if (tot == 0.0) { + return p0.copy(); + } + double s = t * tot; + if (s <= d1) { + return interpolate(p0, p1, d1 == 0.0 ? 0.0 : s / d1); + } + return interpolate(p1, p2, d2 == 0.0 ? 0.0 : (s - d1) / d2); + } + + private static Coordinate interpolate(Coordinate a, Coordinate b, double f) { + return new Coordinate(a.x + f * (b.x - a.x), a.y + f * (b.y - a.y)); + } + + public Coordinate[] toLinear(double tolerance) { + List dest = new ArrayList(); + appendLinearized(dest, tolerance, true); + return dest.toArray(new Coordinate[dest.size()]); + } + + public void expandEnvelope(Envelope envelope) { + envelope.expandToInclude(p0); + envelope.expandToInclude(p1); + envelope.expandToInclude(p2); + if (collinear || center == null) { + return; + } + expandIfOnArc(envelope, 0.0); + expandIfOnArc(envelope, Angle.PI_OVER_2); + expandIfOnArc(envelope, Math.PI); + expandIfOnArc(envelope, -Angle.PI_OVER_2); + } + + private void expandIfOnArc(Envelope envelope, double theta) { + if (containsAngle(theta)) { + envelope.expandToInclude( + center.x + radius * Math.cos(theta), + center.y + radius * Math.sin(theta)); + } + } + + private boolean containsAngle(double theta) { + if (fullCircle) { + return true; + } + double a0 = Math.atan2(p0.y - center.y, p0.x - center.x); + double offset = ccw + ? Angle.normalizePositive(theta - a0) + : Angle.normalizePositive(a0 - theta); + return offset >= -ANGLE_EPS && offset <= sweep + ANGLE_EPS; + } + + /** + * Appends a linearized polyline for this window to {@code dest}. + * Densification is explicit: callers name the tolerance. + * Collinear windows emit the three control points (a chord path). + * + * @param dest destination list + * @param tolerance max distance from the true arc; {@code 0} uses the + * maximum segment count; non-finite uses + * {@link #DEFAULT_SEGMENTS_PER_QUADRANT} + * @param includeStart whether to emit the start control + */ + public void appendLinearized(List dest, double tolerance, boolean includeStart) { + if (includeStart) { + dest.add(p0.copy()); + } + if (collinear || sweep <= ANGLE_EPS) { + if (!p0.equals2D(p1)) { + dest.add(p1.copy()); + } + if (!p1.equals2D(p2) || dest.isEmpty()) { + dest.add(p2.copy()); + } + else if (includeStart && p0.equals2D(p2) && dest.size() == 1) { + dest.add(p2.copy()); + } + return; + } + + int nSeg = segmentsForTolerance(tolerance); + double dir = ccw ? 1.0 : -1.0; + double a0 = Math.atan2(p0.y - center.y, p0.x - center.x); + double a1 = Math.atan2(p1.y - center.y, p1.x - center.x); + double midOffset = ccw + ? Angle.normalizePositive(a1 - a0) + : Angle.normalizePositive(a0 - a1); + if (fullCircle && midOffset <= ANGLE_EPS) { + midOffset = Math.PI; + } + + boolean midAdded = p0.equals2D(p1); + for (int i = 1; i < nSeg; i++) { + double offset = sweep * i / nSeg; + if (!midAdded && offset >= midOffset - ANGLE_EPS) { + dest.add(p1.copy()); + midAdded = true; + if (Math.abs(offset - midOffset) <= ANGLE_EPS) { + continue; + } + } + dest.add(pointOnCircle(a0 + dir * offset)); + } + if (!midAdded && !p1.equals2D(p2)) { + dest.add(p1.copy()); + } + dest.add(p2.copy()); + } + + private int segmentsForTolerance(double tolerance) { + double quad = Angle.PI_OVER_2; + int perQuad; + if (tolerance == 0.0) { + perQuad = MAX_SEGMENTS_PER_QUADRANT; + } + else if (!Double.isFinite(tolerance) || tolerance < 0) { + perQuad = DEFAULT_SEGMENTS_PER_QUADRANT; + } + else { + double sagitta = radius * (1.0 - Math.cos(sweep / 2.0)); + if (tolerance >= sagitta) { + return 1; + } + double cosArg = 1.0 - tolerance / radius; + if (cosArg <= -1.0) { + perQuad = DEFAULT_SEGMENTS_PER_QUADRANT; + } + else { + double maxStep = 2.0 * Math.acos(Math.min(1.0, Math.max(-1.0, cosArg))); + if (!Double.isFinite(maxStep) || maxStep <= 0.0) { + perQuad = DEFAULT_SEGMENTS_PER_QUADRANT; + } + else { + int needed = (int) Math.ceil(sweep / maxStep); + int maxSeg = MAX_SEGMENTS_PER_QUADRANT * Math.max(1, (int) Math.ceil(sweep / quad)); + return Math.max(1, Math.min(needed, maxSeg)); + } + } + } + int n = (int) Math.ceil(sweep / quad * perQuad); + return Math.max(1, n); + } + + private Coordinate pointOnCircle(double angle) { + return new Coordinate( + center.x + radius * Math.cos(angle), + center.y + radius * Math.sin(angle)); + } + + private static boolean almostEqual(double a, double b) { + return Math.abs(a - b) <= ANGLE_EPS; + } + + public String toString() { + return "ExactCircularArc[" + p0 + ", " + p1 + ", " + p2 + "]"; + } + + private static final class CircleFit { + private final Coordinate center; + private final double radius; + + private CircleFit(Coordinate center, double radius) { + this.center = center; + this.radius = radius; + } + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCurve.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCurve.java new file mode 100644 index 0000000000..449e234372 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/ExactCurve.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Jeroen Bloemscheer. + * + * 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. + */ +/* + * AI Disclosure: This file was largely AI-generated. + * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence. + * The human contributor has reviewed and verified that the code is correct. + * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0 + * Assisted-by: Cursor Agent + * Assisted-by: xAI Grok + */ +package org.locationtech.jts.algorithm.exactcurve; + +import org.locationtech.jts.geom.Coordinate; + +/** + * Thin Year-1 exact-curve protocol (six methods only). + *

+ * {@link org.locationtech.jts.geom.CircularString} composes + * {@link ExactCircularArc} windows. Analytic extras (radius, sweep, + * center, collinear, envelope, append-linearize) live on + * {@code ExactCircularArc}, not here. Java 8; no {@code sealed}. + * Do not grow a Year-2 Exact* zoo. + * + * @author Jeroen Bloemscheer + */ +public interface ExactCurve { + + /** + * Start of this primitive (copy). + * + * @return the start control + */ + Coordinate getStart(); + + /** + * End of this primitive (copy). + * + * @return the end control + */ + Coordinate getEnd(); + + /** + * Exact length. Collinear windows use the control-point chord path, + * not a densified approximation. {@link #isExact()} is true in both cases. + * + * @return the length + */ + double length(); + + /** + * Point at parameter {@code t} in {@code [0, 1]} along this primitive + * (arc-length fraction). Endpoints: {@code t=0} is {@link #getStart()}, + * {@code t=1} is {@link #getEnd()}. + * + * @param t parameter in {@code [0, 1]} (clamped) + * @return a new coordinate on the primitive + */ + Coordinate pointAt(double t); + + /** + * Named densify path. Does not mutate this primitive. + * + * @param tolerance max distance from the exact curve; {@code 0} uses + * the maximum segment count; non-finite uses the default + * @return a new linearized vertex array + */ + Coordinate[] toLinear(double tolerance); + + /** + * Whether {@link #length()} / {@link #pointAt(double)} are exact + * (not a densified stand-in). Collinear 3-control windows stay + * {@code true}: they are exact chords. + * + * @return {@code true} if this primitive is exact + */ + boolean isExact(); +} diff --git a/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/package-info.java b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/package-info.java new file mode 100644 index 0000000000..4bc2a84df3 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/algorithm/exactcurve/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Jeroen Bloemscheer. + * + * 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. + */ +/* + * AI Disclosure: This file was largely AI-generated. + * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence. + * The human contributor has reviewed and verified that the code is correct. + * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0 + * Assisted-by: Cursor Agent + * Assisted-by: xAI Grok + */ + +/** + * Thin exact-curve primitives used internally by SQL/MM curve geometries. + *

+ * Year-1 contains only {@link org.locationtech.jts.algorithm.exactcurve.ExactCircularArc}. + * {@link org.locationtech.jts.algorithm.exactcurve.ExactCurve} is the six-method + * protocol ({@code getStart}, {@code getEnd}, {@code length}, {@code pointAt}, + * {@code toLinear}, {@code isExact}). This package is not a noding API + * and is not a rich curve-type zoo. + */ +package org.locationtech.jts.algorithm.exactcurve; diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/CircularString.java b/modules/core/src/main/java/org/locationtech/jts/geom/CircularString.java new file mode 100644 index 0000000000..100598ab2b --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/geom/CircularString.java @@ -0,0 +1,493 @@ +/* + * Copyright (c) 2026 Jeroen Bloemscheer. + * + * 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. + */ +/* + * AI Disclosure: This file was largely AI-generated. + * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence. + * The human contributor has reviewed and verified that the code is correct. + * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0 + * Assisted-by: Cursor Agent + * Assisted-by: xAI Grok + */ +package org.locationtech.jts.geom; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.algorithm.exactcurve.ExactCircularArc; + +/** + * A SQL/MM {@code CIRCULARSTRING}: a connected sequence of circular-arc + * windows, each defined by three control points (start, mid, end). + * The end of window {@code N} is the start of window {@code N+1}. + * A CircularString is closed when the first control equals the last. + *

+ * Control points are the stored {@link LineString} sequence. + * {@link #getCoordinates()}, {@link #getNumPoints()}, + * {@link #getCoordinateN(int)} and {@link #getCoordinateSequence()} + * speak those controls. Densify only via the named + * {@link #toLinear(double)} path ({@link #linearize(double)} / + * {@link #getLinearized(double)} are one-line aliases). + *

+ * Alignment: + *

+ * Each window is a cached {@link ExactCircularArc}. Collinear windows + * are exact chords ({@link ExactCircularArc#isExact()} stays true). + * Length and envelope use those windows; they do not densify. + *

+ * Full-circle convention: {@code CIRCULARSTRING(s, m, s)} is a circle + * whose diameter is the segment {@code s–m} (center = midpoint of + * {@code s} and {@code m}, radius = {@code |s-m|/2}). + *

+ * {@code SegmentString} stays linear. This class is a geometry type, + * not a noding type. + * + * @author Jeroen Bloemscheer + * @see ExactCircularArc + */ +public class CircularString extends LineString { + + private static final long serialVersionUID = 20260916001L; + + /** + * Minimum number of control points in a non-empty CircularString + * (one 3-point window). Empty CircularStrings with 0 points are valid. + */ + public static final int MINIMUM_VALID_SIZE = 3; + + /** + * Default linearization tolerance: use + * {@link ExactCircularArc#DEFAULT_SEGMENTS_PER_QUADRANT}. + */ + public static final double DEFAULT_LINEARIZATION_TOLERANCE = Double.POSITIVE_INFINITY; + + private transient LineString linearized; + private transient double linearizedTolerance = Double.NaN; + private transient ExactCircularArc[] windows; + + /** + * Constructs a CircularString from control points. + * A null or empty sequence creates an empty CircularString. + * The input sequence is copied. + * + * @param points control points, or {@code null} / empty for EMPTY + * @param factory the geometry factory + * @throws IllegalArgumentException if the point count is not 0 or an odd number >= 3 + */ + public CircularString(CoordinateSequence points, GeometryFactory factory) { + super(copyValidatedControls(points), factory); + } + + private static CoordinateSequence copyValidatedControls(CoordinateSequence points) { + if (points == null || points.size() == 0) { + return points; + } + if (points.size() < MINIMUM_VALID_SIZE || (points.size() % 2) == 0) { + throw new IllegalArgumentException("Invalid number of points in CircularString (found " + + points.size() + " - must be 0 or an odd number >= " + MINIMUM_VALID_SIZE + ")"); + } + return points.copy(); + } + + /** + * Returns a copy of the SQL/MM control points (not linearized vertices). + * + * @return a deep copy of the control-point array + */ + public Coordinate[] getControlPoints() { + return CoordinateArrays.copyDeep(points.toCoordinateArray()); + } + + /** + * Returns a copy of the control-point sequence used for WKT and windowing. + * + * @return a copy of the control-point sequence + */ + public CoordinateSequence getControlPointSequence() { + return points.copy(); + } + + /** + * Gets the number of control points (same as {@link #getNumPoints()}). + * + * @return the control-point count + */ + public int getNumControlPoints() { + return getNumPoints(); + } + + /** + * Gets the number of 3-point circular windows. + * + * @return {@code 0} if empty, otherwise {@code (nControl - 1) / 2} + */ + public int getNumArcs() { + if (isEmpty()) { + return 0; + } + return (points.size() - 1) / 2; + } + + /** + * Returns the composed {@link ExactCircularArc} window at {@code arcIndex}. + * Windows are cached; this does not allocate a new arc per call. + * + * @param arcIndex 0-based window index + * @return the composed {@link ExactCircularArc} + */ + public ExactCircularArc getArcN(int arcIndex) { + ExactCircularArc[] arcs = windows(); + if (arcIndex < 0 || arcIndex >= arcs.length) { + throw new IllegalArgumentException("arcIndex out of range: " + arcIndex); + } + return arcs[arcIndex]; + } + + private ExactCircularArc[] windows() { + if (windows != null) { + return windows; + } + int n = getNumArcs(); + ExactCircularArc[] arcs = new ExactCircularArc[n]; + for (int i = 0; i < n; i++) { + int k = i * 2; + arcs[i] = new ExactCircularArc( + points.getCoordinate(k), + points.getCoordinate(k + 1), + points.getCoordinate(k + 2)); + } + windows = arcs; + return arcs; + } + + /** + * GeoTools-aligned alias of {@link #toLinear()}. + * + * @return a newly created LineString + */ + public LineString linearize() { + return toLinear(); + } + + /** + * GeoTools-aligned alias of {@link #toLinear(double)}. + * + * @param tolerance max distance from the true arcs + * @return a newly created LineString + */ + public LineString linearize(double tolerance) { + return toLinear(tolerance); + } + + /** + * GEOS-aligned alias of {@link #toLinear()}. + * + * @return a newly created LineString + */ + public LineString getLinearized() { + return toLinear(); + } + + /** + * GEOS-aligned alias of {@link #toLinear(double)}. + * + * @param tolerance max distance from the true arcs + * @return a newly created LineString + */ + public LineString getLinearized(double tolerance) { + return toLinear(tolerance); + } + + /** + * Densifies this CircularString to a {@link LineString} + * at {@link #DEFAULT_LINEARIZATION_TOLERANCE}. + * + * @return a newly created LineString + */ + public LineString toLinear() { + return toLinear(DEFAULT_LINEARIZATION_TOLERANCE); + } + + /** + * Named Year-1 densify path (primary name). GeoTools {@code linearize} + * and GEOS {@code getLinearized} delegate here. + * + * @param tolerance max distance from the true arcs; {@code 0} uses the + * maximum segment count; non-finite uses the default quadrant count + * @return a newly created LineString + */ + public LineString toLinear(double tolerance) { + if (isEmpty()) { + return getFactory().createLineString(); + } + if (linearized != null && doubleEquals(linearizedTolerance, tolerance)) { + return (LineString) linearized.copy(); + } + CoordinateSequence seq = createLinearizedSequence(tolerance); + LineString result = getFactory().createLineString(seq); + linearized = result; + linearizedTolerance = tolerance; + return (LineString) result.copy(); + } + + /** + * Returns a freshly copied linearized coordinate sequence + * (caller mutation cannot affect this geometry). + * + * @param tolerance linearization tolerance + * @return a new coordinate sequence + */ + public CoordinateSequence getLinearizedCoordinateSequence(double tolerance) { + return createLinearizedSequence(tolerance); + } + + private CoordinateSequence createLinearizedSequence(double tolerance) { + if (isEmpty()) { + return getFactory().getCoordinateSequenceFactory().create(new Coordinate[] {}); + } + List coords = new ArrayList(); + ExactCircularArc[] arcs = windows(); + for (int i = 0; i < arcs.length; i++) { + arcs[i].appendLinearized(coords, tolerance, i == 0); + } + return getFactory().getCoordinateSequenceFactory().create( + coords.toArray(new Coordinate[coords.size()])); + } + + public String getGeometryType() { + return Geometry.TYPENAME_CIRCULARSTRING; + } + + protected int getTypeCode() { + return Geometry.TYPECODE_CIRCULARSTRING; + } + + protected boolean isEquivalentClass(Geometry other) { + return other instanceof CircularString; + } + + public boolean isClosed() { + if (isEmpty()) { + return false; + } + return points.getCoordinate(0).equals2D(points.getCoordinate(points.size() - 1)); + } + + public boolean isEmpty() { + return points.size() == 0; + } + + public Point getStartPoint() { + if (isEmpty()) { + return null; + } + return getFactory().createPoint(points.getCoordinate(0)); + } + + public Point getEndPoint() { + if (isEmpty()) { + return null; + } + return getFactory().createPoint(points.getCoordinate(points.size() - 1)); + } + + /** + * Exact circular length: sum of composed {@link ExactCircularArc#length()}. + * Collinear windows contribute the chord path. Does not densify. + */ + public double getLength() { + if (isEmpty()) { + return 0.0; + } + double tot = 0.0; + ExactCircularArc[] arcs = windows(); + for (int i = 0; i < arcs.length; i++) { + tot += arcs[i].length(); + } + return tot; + } + + /** + * Control points (deep copy). Does not linearize. + */ + public Coordinate[] getCoordinates() { + return CoordinateArrays.copyDeep(points.toCoordinateArray()); + } + + /** + * Control-point sequence (copy). Does not linearize. + */ + public CoordinateSequence getCoordinateSequence() { + return points.copy(); + } + + /** + * Control point at index {@code n} (copy). Does not linearize. + */ + public Coordinate getCoordinateN(int n) { + return points.getCoordinate(n).copy(); + } + + public Coordinate getCoordinate() { + if (isEmpty()) { + return null; + } + return points.getCoordinate(0).copy(); + } + + /** + * Number of control points. Does not linearize. + */ + public int getNumPoints() { + return points.size(); + } + + public Point getPointN(int n) { + return getFactory().createPoint(getCoordinateN(n)); + } + + public boolean isCoordinate(Coordinate pt) { + for (int i = 0; i < points.size(); i++) { + if (points.getCoordinate(i).equals(pt)) { + return true; + } + } + return false; + } + + protected Envelope computeEnvelopeInternal() { + Envelope env = new Envelope(); + ExactCircularArc[] arcs = windows(); + for (int i = 0; i < arcs.length; i++) { + arcs[i].expandEnvelope(env); + } + return env; + } + + public boolean equalsExact(Geometry other, double tolerance) { + if (!isEquivalentClass(other)) { + return false; + } + CircularString cs = (CircularString) other; + if (points.size() != cs.points.size()) { + return false; + } + for (int i = 0; i < points.size(); i++) { + if (!equal(points.getCoordinate(i), cs.points.getCoordinate(i), tolerance)) { + return false; + } + } + return true; + } + + public void apply(CoordinateFilter filter) { + for (int i = 0; i < points.size(); i++) { + filter.filter(points.getCoordinate(i)); + } + } + + public void apply(CoordinateSequenceFilter filter) { + if (points.size() == 0) { + return; + } + for (int i = 0; i < points.size(); i++) { + filter.filter(points, i); + if (filter.isDone()) { + break; + } + } + if (filter.isGeometryChanged()) { + geometryChanged(); + } + } + + protected void geometryChangedAction() { + super.geometryChangedAction(); + linearized = null; + linearizedTolerance = Double.NaN; + windows = null; + } + + public CircularString reverse() { + return (CircularString) super.reverse(); + } + + protected CircularString reverseInternal() { + CoordinateSequence seq = points.copy(); + CoordinateSequences.reverse(seq); + return getFactory().createCircularString(seq); + } + + protected CircularString copyInternal() { + return new CircularString(points.copy(), factory); + } + + public void normalize() { + if (isEmpty()) { + return; + } + for (int i = 0; i < points.size() / 2; i++) { + int j = points.size() - 1 - i; + if (!points.getCoordinate(i).equals(points.getCoordinate(j))) { + if (points.getCoordinate(i).compareTo(points.getCoordinate(j)) > 0) { + CoordinateSequence copy = points.copy(); + CoordinateSequences.reverse(copy); + points = copy; + geometryChanged(); + } + return; + } + } + } + + protected int compareToSameClass(Object o) { + CircularString other = (CircularString) o; + int i = 0; + int j = 0; + while (i < points.size() && j < other.points.size()) { + int comparison = points.getCoordinate(i).compareTo(other.points.getCoordinate(j)); + if (comparison != 0) { + return comparison; + } + i++; + j++; + } + if (i < points.size()) { + return 1; + } + if (j < other.points.size()) { + return -1; + } + return 0; + } + + protected int compareToSameClass(Object o, CoordinateSequenceComparator comp) { + CircularString other = (CircularString) o; + return comp.compare(this.points, other.points); + } + + private static boolean doubleEquals(double a, double b) { + if (Double.isNaN(a) || Double.isNaN(b)) { + return false; + } + if (a == b) { + return true; + } + return Double.isInfinite(a) && Double.isInfinite(b) && Math.signum(a) == Math.signum(b); + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java b/modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java index 104a885c8e..b9cd8f59ee 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/Geometry.java @@ -161,6 +161,7 @@ public abstract class Geometry protected static final int TYPECODE_POLYGON = 5; protected static final int TYPECODE_MULTIPOLYGON = 6; protected static final int TYPECODE_GEOMETRYCOLLECTION = 7; + protected static final int TYPECODE_CIRCULARSTRING = 8; public static final String TYPENAME_POINT = "Point"; public static final String TYPENAME_MULTIPOINT = "MultiPoint"; @@ -170,6 +171,7 @@ public abstract class Geometry public static final String TYPENAME_POLYGON = "Polygon"; public static final String TYPENAME_MULTIPOLYGON = "MultiPolygon"; public static final String TYPENAME_GEOMETRYCOLLECTION = "GeometryCollection"; + public static final String TYPENAME_CIRCULARSTRING = "CircularString"; private final static GeometryComponentFilter geometryChangedFilter = new GeometryComponentFilter() { public void filter(Geometry geom) { diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java b/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java index de8da951db..0a9a15dc71 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/GeometryFactory.java @@ -621,6 +621,39 @@ public LineString createLineString(CoordinateSequence coordinates) { return new LineString(coordinates, this); } + /** + * Constructs an empty {@link CircularString} geometry. + * + * @return an empty CircularString + */ + public CircularString createCircularString() { + return createCircularString(getCoordinateSequenceFactory().create(new Coordinate[]{})); + } + + /** + * Creates a CircularString using the given control-point Coordinates. + * A null or empty array creates an empty CircularString. + * Non-empty input must have an odd number of points >= 3. + * + * @param coordinates control points without null elements, or an empty array, or null + * @return the created CircularString + */ + public CircularString createCircularString(Coordinate[] coordinates) { + return createCircularString(coordinates != null ? getCoordinateSequenceFactory().create(coordinates) : null); + } + + /** + * Creates a CircularString using the given control-point CoordinateSequence. + * A null or empty CoordinateSequence creates an empty CircularString. + * Non-empty input must have an odd number of points >= 3. + * + * @param coordinates a CoordinateSequence of control points (possibly empty), or null + * @return the created CircularString + */ + public CircularString createCircularString(CoordinateSequence coordinates) { + return new CircularString(coordinates, this); + } + /** * Creates an empty atomic geometry of the given dimension. * If passed a dimension of -1 will create an empty {@link GeometryCollection}. diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryExtracter.java b/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryExtracter.java index cde0cf1f6a..f9d00c0c54 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryExtracter.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryExtracter.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; +import org.locationtech.jts.geom.CircularString; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.GeometryFilter; @@ -54,6 +55,8 @@ private static String toGeometryType(Class clz) { return null; else if (clz.isAssignableFrom(Point.class)) return Geometry.TYPENAME_POINT; + else if (clz == CircularString.class) + return Geometry.TYPENAME_CIRCULARSTRING; else if (clz.isAssignableFrom(LineString.class)) return Geometry.TYPENAME_LINESTRING; else if (clz.isAssignableFrom(LinearRing.class)) diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java b/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java index 8245e6d94a..400d240d5b 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKTConstants.java @@ -20,6 +20,7 @@ public class WKTConstants { public static final String GEOMETRYCOLLECTION = "GEOMETRYCOLLECTION"; + public static final String CIRCULARSTRING = "CIRCULARSTRING"; public static final String LINEARRING = "LINEARRING"; public static final String LINESTRING = "LINESTRING"; public static final String MULTIPOLYGON = "MULTIPOLYGON"; diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKTReader.java b/modules/core/src/main/java/org/locationtech/jts/io/WKTReader.java index 99ae9a95a8..edabf15ab6 100644 --- a/modules/core/src/main/java/org/locationtech/jts/io/WKTReader.java +++ b/modules/core/src/main/java/org/locationtech/jts/io/WKTReader.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Locale; +import org.locationtech.jts.geom.CircularString; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.CoordinateSequenceFactory; @@ -92,7 +93,7 @@ *

  * WKTGeometry: one of
  *
- *       WKTPoint  WKTLineString  WKTLinearRing  WKTPolygon
+ *       WKTPoint  WKTLineString  WKTCircularString  WKTLinearRing  WKTPolygon
  *       WKTMultiPoint  WKTMultiLineString  WKTMultiPolygon
  *       WKTGeometryCollection
  *
@@ -100,6 +101,8 @@
  *
  * WKTLineString: LINESTRING[Dimension] CoordinateSequence
  *
+ * WKTCircularString: CIRCULARSTRING[Dimension] CoordinateSequence
+ *
  * WKTLinearRing: LINEARRING[Dimension] CoordinateSequence
  *
  * WKTPolygon: POLYGON[Dimension] CoordinateSequenceList
@@ -146,6 +149,8 @@
  * POINT EMPTY
  * LINESTRING (0 0, 0 1, 1 2)
  * LINESTRING EMPTY
+ * CIRCULARSTRING (0 0, 1 1, 1 0)
+ * CIRCULARSTRING EMPTY
  * POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))
  * POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))
  * POLYGON EMPTY
@@ -777,6 +782,9 @@ private Geometry readGeometryTaggedText(StreamTokenizer tokenizer, String type,
     if (isTypeName(tokenizer, type, WKTConstants.POINT)) {
       return readPointText(tokenizer, ordinateFlags);
     }
+    else if (isTypeName(tokenizer, type, WKTConstants.CIRCULARSTRING)) {
+      return readCircularStringText(tokenizer, ordinateFlags);
+    }
     else if (isTypeName(tokenizer, type, WKTConstants.LINESTRING)) {
       return readLineStringText(tokenizer, ordinateFlags);
     }
@@ -847,6 +855,20 @@ private LineString readLineStringText(StreamTokenizer tokenizer, EnumSet ordinateFlags)
+      throws IOException, ParseException {
+    return geometryFactory.createCircularString(
+        getCoordinateSequence(tokenizer, ordinateFlags, CircularString.MINIMUM_VALID_SIZE, false));
+  }
+
   /**
    *  Creates a LinearRing using the next token in the stream.
    *
diff --git a/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java b/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java
index 709dbe6869..0b505a1a7a 100644
--- a/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java
+++ b/modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java
@@ -17,6 +17,7 @@
 import java.io.Writer;
 import java.util.EnumSet;
 
+import org.locationtech.jts.geom.CircularString;
 import org.locationtech.jts.geom.Coordinate;
 import org.locationtech.jts.geom.CoordinateSequence;
 import org.locationtech.jts.geom.Geometry;
@@ -451,6 +452,10 @@ else if (geometry instanceof LinearRing) {
       appendLinearRingTaggedText((LinearRing) geometry, outputOrdinates, useFormatting,
               level, writer, formatter);
     }
+    else if (geometry instanceof CircularString) {
+      appendCircularStringTaggedText((CircularString) geometry, outputOrdinates, useFormatting,
+              level, writer, formatter);
+    }
     else if (geometry instanceof LineString) {
       appendLineStringTaggedText((LineString) geometry, outputOrdinates, useFormatting,
               level, writer, formatter);
@@ -526,6 +531,22 @@ private void appendLineStringTaggedText(
             level, false, writer, formatter);
   }
 
+  /**
+   * Converts a {@link CircularString} to CIRCULARSTRING tagged text
+   * using control points (not the linearized vertices).
+   */
+  private void appendCircularStringTaggedText(
+          CircularString circularString, EnumSet outputOrdinates, boolean useFormatting,
+          int level, Writer writer, OrdinateFormat formatter)
+    throws IOException
+  {
+    writer.write(WKTConstants.CIRCULARSTRING);
+    writer.write(" ");
+    appendOrdinateText(outputOrdinates, writer);
+    appendSequenceText(circularString.getControlPointSequence(), outputOrdinates, useFormatting,
+            level, false, writer, formatter);
+  }
+
   /**
    *  Converts a LinearRing to <LinearRing Tagged Text>
    *  format, then appends it to the writer.
diff --git a/modules/core/src/test/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArcTest.java b/modules/core/src/test/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArcTest.java
new file mode 100644
index 0000000000..6ba53a6e60
--- /dev/null
+++ b/modules/core/src/test/java/org/locationtech/jts/algorithm/exactcurve/ExactCircularArcTest.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2026 Jeroen Bloemscheer.
+ *
+ * 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.
+ */
+/*
+ * AI Disclosure: This file was largely AI-generated.
+ * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence.
+ * The human contributor has reviewed and verified that the code is correct.
+ * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0
+ * Assisted-by: Cursor Agent
+ * Assisted-by: xAI Grok
+ */
+package org.locationtech.jts.algorithm.exactcurve;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Envelope;
+
+import junit.framework.TestCase;
+import junit.framework.TestSuite;
+import junit.textui.TestRunner;
+
+/**
+ * Tests for {@link ExactCircularArc} collinear degrade and exact length.
+ *
+ * @author Jeroen Bloemscheer
+ */
+public class ExactCircularArcTest extends TestCase {
+
+  public static void main(String[] args) {
+    TestRunner.run(new TestSuite(ExactCircularArcTest.class));
+  }
+
+  public ExactCircularArcTest(String name) {
+    super(name);
+  }
+
+  public void testCollinearIsChord() {
+    ExactCircularArc arc = new ExactCircularArc(
+        new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(2, 0));
+    assertTrue(arc.isCollinear());
+    assertTrue(arc.isExact());
+    assertNull(arc.getCenter());
+    assertEquals(2.0, arc.length(), 1e-15);
+    assertTrue(arc.getStart().equals2D(new Coordinate(0, 0)));
+    assertTrue(arc.getEnd().equals2D(new Coordinate(2, 0)));
+    assertTrue(arc.pointAt(0.0).equals2D(new Coordinate(0, 0)));
+    assertTrue(arc.pointAt(0.5).equals2D(new Coordinate(1, 0)));
+    assertTrue(arc.pointAt(1.0).equals2D(new Coordinate(2, 0)));
+    Coordinate[] linear = arc.toLinear(0.001);
+    assertEquals(3, linear.length);
+
+    List pts = new ArrayList();
+    arc.appendLinearized(pts, 0.001, true);
+    assertEquals(3, pts.size());
+    assertTrue(pts.get(0).equals2D(new Coordinate(0, 0)));
+    assertTrue(pts.get(1).equals2D(new Coordinate(1, 0)));
+    assertTrue(pts.get(2).equals2D(new Coordinate(2, 0)));
+  }
+
+  public void testSemicircleLengthAndEnvelope() {
+    ExactCircularArc arc = new ExactCircularArc(
+        new Coordinate(1, 0), new Coordinate(0, 1), new Coordinate(-1, 0));
+    assertFalse(arc.isCollinear());
+    assertEquals(1.0, arc.getRadius(), 1e-10);
+    assertTrue(arc.isExact());
+    assertEquals(Math.PI, arc.length(), 1e-10);
+    assertTrue(arc.pointAt(0.0).equals2D(new Coordinate(1, 0)));
+    assertTrue(arc.pointAt(1.0).equals2D(new Coordinate(-1, 0)));
+
+    Envelope env = new Envelope();
+    arc.expandEnvelope(env);
+    assertEquals(-1.0, env.getMinX(), 1e-10);
+    assertEquals(1.0, env.getMaxX(), 1e-10);
+    assertEquals(0.0, env.getMinY(), 1e-10);
+    assertEquals(1.0, env.getMaxY(), 1e-10);
+  }
+
+  public void testFullCircle() {
+    ExactCircularArc arc = new ExactCircularArc(
+        new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(0, 0));
+    assertTrue(arc.isFullCircle());
+    assertFalse(arc.isCollinear());
+    assertEquals(0.5, arc.getRadius(), 1e-12);
+    assertTrue(arc.isExact());
+    assertTrue(arc.getCenter().equals2D(new Coordinate(0.5, 0)));
+    assertEquals(Math.PI, arc.length(), 1e-12);
+  }
+
+  public void testDegeneratePoint() {
+    ExactCircularArc arc = new ExactCircularArc(
+        new Coordinate(3, 4), new Coordinate(3, 4), new Coordinate(3, 4));
+    assertTrue(arc.isCollinear());
+    assertTrue(arc.isExact());
+    assertEquals(0.0, arc.length(), 0.0);
+  }
+}
diff --git a/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringShelfATest.java b/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringShelfATest.java
new file mode 100644
index 0000000000..d078e7fca9
--- /dev/null
+++ b/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringShelfATest.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (c) 2026 Jeroen Bloemscheer.
+ *
+ * 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.
+ */
+/*
+ * AI Disclosure: This file was largely AI-generated.
+ * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence.
+ * The human contributor has reviewed and verified that the code is correct.
+ * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0
+ * Assisted-by: Cursor Agent
+ * Assisted-by: xAI Grok
+ */
+package org.locationtech.jts.geom;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+
+import org.locationtech.jts.algorithm.exactcurve.ExactCircularArc;
+import org.locationtech.jts.io.WKTReader;
+import org.locationtech.jts.io.WKTWriter;
+
+import junit.framework.Test;
+import junit.framework.TestSuite;
+import junit.textui.TestRunner;
+import test.jts.GeometryTestCase;
+
+/**
+ * Shelf-a fixtures / golden round-trips for {@code CIRCULARSTRING (5 0, 3 4, 0 5)}.
+ * κ=8; centre (0,0) r=5; exact length = 5·π/2 =
+ * {@code 0x1.f6a7a2955385fp+2}.
+ * 

+ * WKB hex is vendored as golden bytes only. Year-1 does not implement + * WKB type 8 (HOLD WKB 13–21). + * + * @author Jeroen Bloemscheer + */ +public class CircularStringShelfATest extends GeometryTestCase { + + /** ADR-0006 LENGTH_UNIFIED hex-float for the shelf-a quarter window. */ + public static final String ORACLE_LENGTH_HEX = "0x1.f6a7a2955385fp+2"; + + private static final String SHELF_WKT = "CIRCULARSTRING (5 0, 3 4, 0 5)"; + + private final WKTReader reader = new WKTReader(); + private final WKTWriter writer = new WKTWriter(); + + public static void main(String[] args) { + TestRunner.run(suite()); + } + + public CircularStringShelfATest(String name) { + super(name); + } + + public static Test suite() { + return new TestSuite(CircularStringShelfATest.class); + } + + public void testShelfAWktRoundTripAndControlsNotDensify() throws Exception { + String vendored = readResource("testdata/oracle/circularstring-quarter.wkt").trim(); + assertEquals(SHELF_WKT, vendored); + + Geometry g = reader.read(vendored); + assertTrue(g instanceof CircularString); + CircularString cs = (CircularString) g; + assertEquals(Geometry.TYPENAME_CIRCULARSTRING, cs.getGeometryType()); + assertEquals(SHELF_WKT, writer.write(cs)); + + assertEquals("I/O must not silent-densify", 3, cs.getNumPoints()); + assertEquals(3, cs.getCoordinates().length); + assertEquals(3, cs.getCoordinateSequence().size()); + assertTrue(cs.getCoordinateN(0).equals2D(new Coordinate(5, 0))); + assertTrue(cs.getCoordinateN(1).equals2D(new Coordinate(3, 4))); + assertTrue(cs.getCoordinateN(2).equals2D(new Coordinate(0, 5))); + + LineString linear = cs.toLinear(0.01); + assertTrue("named toLinear is the densify path", linear.getNumPoints() > 3); + assertEquals(3, cs.getNumPoints()); + } + + public void testShelfAExactLengthVsCertifiedHex() throws Exception { + CircularString cs = (CircularString) reader.read(SHELF_WKT); + ExactCircularArc arc = cs.getArcN(0); + assertTrue(arc.isExact()); + assertFalse(arc.isCollinear()); + assertEquals(5.0, arc.getRadius(), 1e-10); + assertTrue(arc.getCenter().equals2D(new Coordinate(0, 0))); + + double expected = Double.valueOf(ORACLE_LENGTH_HEX).doubleValue(); + assertEquals(5.0 * Math.PI / 2.0, expected, 1e-15); + assertEquals(expected, cs.getLength(), 1e-12); + assertEquals(expected, arc.length(), 1e-12); + } + + public void testShelfAWkbHexVendoredNotDecoded() throws Exception { + String ndr = readResource("testdata/oracle/circularstring-quarter.wkb-ndr.hex").trim(); + String xdr = readResource("testdata/oracle/circularstring-quarter.wkb-xdr.hex").trim(); + assertTrue("NDR type 8 CircularString", ndr.toLowerCase().startsWith("01080000")); + assertTrue("XDR type 8 CircularString", xdr.toLowerCase().startsWith("0000000008")); + // HOLD WKB 13–21: do not decode these into a JTS CircularString this Year-1. + } + + public void testLengthUnifiedAgainstOracleBinIfPresent() throws Exception { + File bin = findOracleBin(); + CircularString cs = (CircularString) reader.read(SHELF_WKT); + double jtsLen = cs.getLength(); + if (bin == null) { + // Fixtures + certified hex are the CI path; proofs oracle_bin is optional. + assertEquals(Double.valueOf(ORACLE_LENGTH_HEX).doubleValue(), jtsLen, 1e-12); + return; + } + + ProcessBuilder pb = new ProcessBuilder(bin.getAbsolutePath()); + pb.redirectErrorStream(true); + Process proc = pb.start(); + OutputStream stdin = proc.getOutputStream(); + stdin.write("LENGTH_UNIFIED\n1\nA 5 0 3 4 0 5\n".getBytes(Charset.forName("UTF-8"))); + stdin.close(); + BufferedReader stdout = new BufferedReader( + new InputStreamReader(proc.getInputStream(), Charset.forName("UTF-8"))); + String line = stdout.readLine(); + int code = proc.waitFor(); + assertEquals("oracle_bin exit", 0, code); + assertNotNull(line); + double oracle = Double.valueOf(line.trim()).doubleValue(); + assertEquals(ORACLE_LENGTH_HEX, line.trim()); + assertEquals(oracle, jtsLen, 1e-12); + } + + /** Optional proofs LENGTH_UNIFIED checker (not Oracle Database). */ + private static File findOracleBin() { + String[] candidates = new String[] { + System.getProperty("jts.oracle.bin"), + System.getenv("JTS_ORACLE_BIN"), + "/tmp/oracle-bin/oracle_bin" + }; + for (int i = 0; i < candidates.length; i++) { + if (candidates[i] == null || candidates[i].length() == 0) { + continue; + } + File f = new File(candidates[i]); + if (f.canExecute()) { + return f; + } + } + return null; + } + + private static String readResource(String path) throws Exception { + InputStream in = CircularStringShelfATest.class.getClassLoader().getResourceAsStream(path); + assertNotNull("missing resource " + path, in); + BufferedReader r = new BufferedReader(new InputStreamReader(in, Charset.forName("UTF-8"))); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = r.readLine()) != null) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(line); + } + r.close(); + return sb.toString(); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringTest.java b/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringTest.java new file mode 100644 index 0000000000..f5be9e5c39 --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/geom/CircularStringTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026 Jeroen Bloemscheer. + * + * 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. + */ +/* + * AI Disclosure: This file was largely AI-generated. + * The AI-generated portions are made available under CC0-1.0 and not subject to the project's licence. + * The human contributor has reviewed and verified that the code is correct. + * SPDX-License-Identifier: EPL-2.0 OR EDL-1.0 and CC0-1.0 + * Assisted-by: Cursor Agent + * Assisted-by: xAI Grok + */ +package org.locationtech.jts.geom; + +import org.locationtech.jts.algorithm.exactcurve.ExactCircularArc; +import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.WKTWriter; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Tests for {@link CircularString}: WKT parse/write, closed rings, + * collinear degrade, linearize tolerance, and getCoordinates safety. + * + * @author Jeroen Bloemscheer + */ +public class CircularStringTest extends GeometryTestCase { + + private final GeometryFactory factory = new GeometryFactory(); + private final WKTReader reader = new WKTReader(factory); + private final WKTWriter writer = new WKTWriter(); + + public static void main(String[] args) { + TestRunner.run(suite()); + } + + public CircularStringTest(String name) { + super(name); + } + + public static Test suite() { + return new TestSuite(CircularStringTest.class); + } + + public void testParseAndWrite() throws Exception { + String wkt = "CIRCULARSTRING (0 0, 1 1, 1 0)"; + Geometry g = reader.read(wkt); + assertTrue(g instanceof CircularString); + CircularString cs = (CircularString) g; + assertEquals(Geometry.TYPENAME_CIRCULARSTRING, cs.getGeometryType()); + assertEquals(3, cs.getNumControlPoints()); + assertEquals(1, cs.getNumArcs()); + assertEquals(wkt, writer.write(cs)); + } + + public void testParseWriteEmpty() throws Exception { + String wkt = "CIRCULARSTRING EMPTY"; + CircularString cs = (CircularString) reader.read(wkt); + assertTrue(cs.isEmpty()); + assertEquals(0, cs.getNumControlPoints()); + assertEquals(0, cs.getNumArcs()); + assertEquals(0.0, cs.getLength(), 1e-15); + assertEquals(wkt, writer.write(cs)); + } + + public void testParseWriteClosedRing() throws Exception { + String wkt = "CIRCULARSTRING (0 0, 1 0, 1 1, 0 1, 0 0)"; + CircularString cs = (CircularString) reader.read(wkt); + assertTrue(cs.isClosed()); + assertEquals(5, cs.getNumControlPoints()); + assertEquals(2, cs.getNumArcs()); + assertEquals(wkt, writer.write(cs)); + Coordinate[] controls = cs.getControlPoints(); + assertTrue(controls[0].equals2D(controls[controls.length - 1])); + } + + public void testClosedSingleArcCircleDiameterConvention() throws Exception { + // CIRCULARSTRING(s, m, s) is a full circle with diameter s–m. + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (0 0, 1 0, 0 0)"); + assertTrue(cs.isClosed()); + assertEquals(3, cs.getNumPoints()); + ExactCircularArc arc = cs.getArcN(0); + assertTrue(arc.isFullCircle()); + assertTrue(arc.isExact()); + assertFalse(arc.isCollinear()); + assertEquals(0.5, arc.getRadius(), 1e-12); + assertTrue(arc.getCenter().equals2D(new Coordinate(0.5, 0))); + assertEquals(Math.PI, cs.getLength(), 1e-10); + assertSame("windows are composed, not rebuilt", arc, cs.getArcN(0)); + } + + public void testCollinearDegradesToChord() throws Exception { + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (0 0, 1 0, 2 0)"); + ExactCircularArc arc = cs.getArcN(0); + assertTrue(arc.isCollinear()); + assertEquals(2.0, cs.getLength(), 1e-12); + + LineString linear = cs.toLinear(); + assertEquals(3, linear.getNumPoints()); + assertTrue(linear.getCoordinateN(0).equals2D(new Coordinate(0, 0))); + assertTrue(linear.getCoordinateN(1).equals2D(new Coordinate(1, 0))); + assertTrue(linear.getCoordinateN(2).equals2D(new Coordinate(2, 0))); + assertEquals(2.0, linear.getLength(), 1e-12); + + Envelope env = cs.getEnvelopeInternal(); + assertEquals(0.0, env.getMinX(), 1e-12); + assertEquals(2.0, env.getMaxX(), 1e-12); + assertEquals(0.0, env.getMinY(), 1e-12); + assertEquals(0.0, env.getMaxY(), 1e-12); + } + + public void testLinearizeTolerance() throws Exception { + // quarter-ish arc: (1,0) through (0,1) to (-1,0) on the unit circle + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (1 0, 0 1, -1 0)"); + ExactCircularArc arc = cs.getArcN(0); + assertFalse(arc.isCollinear()); + assertEquals(1.0, arc.getRadius(), 1e-8); + assertEquals(Math.PI, cs.getLength(), 1e-8); + + LineString coarse = cs.toLinear(0.5); + LineString fine = cs.toLinear(0.01); + assertTrue(fine.getNumPoints() > coarse.getNumPoints()); + + Coordinate center = arc.getCenter(); + Coordinate[] finePts = fine.getCoordinates(); + for (int i = 0; i < finePts.length; i++) { + assertEquals(1.0, finePts[i].distance(center), 0.01 + 1e-6); + } + assertTrue(fine.getCoordinateN(0).equals2D(new Coordinate(1, 0))); + assertTrue(fine.getCoordinateN(fine.getNumPoints() - 1).equals2D(new Coordinate(-1, 0))); + } + + public void testGetCoordinatesSafety() throws Exception { + Coordinate[] input = new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 1), new Coordinate(1, 0) + }; + CircularString cs = factory.createCircularString(input); + + input[0].x = 999; + input[1].x = 999; + assertEquals(0.0, cs.getControlPoints()[0].x, 0.0); + assertEquals("CIRCULARSTRING (0 0, 1 1, 1 0)", writer.write(cs)); + + Coordinate[] coords = cs.getCoordinates(); + assertEquals("getCoordinates speaks controls, not linearized vertices", 3, coords.length); + assertEquals(3, cs.getNumPoints()); + assertTrue(cs.getCoordinateN(1).equals2D(new Coordinate(1, 1))); + assertEquals(3, cs.getCoordinateSequence().size()); + assertTrue(cs.toLinear().getNumPoints() >= 3); + double origX = coords[0].x; + coords[0].x = 12345; + coords[1] = new Coordinate(-99, -99); + + Coordinate[] again = cs.getCoordinates(); + assertEquals(origX, again[0].x, 0.0); + assertFalse(again[1].equals2D(new Coordinate(-99, -99))); + assertEquals(0.0, cs.getControlPoints()[0].x, 0.0); + } + + public void testControlPointSequenceIsCopy() throws Exception { + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (0 0, 1 1, 1 0)"); + CoordinateSequence seq = cs.getControlPointSequence(); + seq.setOrdinate(0, 0, 42); + assertEquals(0.0, cs.getControlPoints()[0].x, 0.0); + } + + public void testInvalidControlCount() { + try { + factory.createCircularString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0) + }); + fail("expected IllegalArgumentException for 2 control points"); + } + catch (IllegalArgumentException ex) { + // expected + } + try { + factory.createCircularString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0), + new Coordinate(1, 1), new Coordinate(0, 1) + }); + fail("expected IllegalArgumentException for even control count"); + } + catch (IllegalArgumentException ex) { + // expected + } + } + + public void testCopyAndReversePreserveType() throws Exception { + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (0 0, 1 1, 1 0)"); + CircularString copy = (CircularString) cs.copy(); + assertTrue(cs.equalsExact(copy)); + assertEquals(Geometry.TYPENAME_CIRCULARSTRING, copy.getGeometryType()); + + CircularString rev = cs.reverse(); + assertEquals(Geometry.TYPENAME_CIRCULARSTRING, rev.getGeometryType()); + assertEquals("CIRCULARSTRING (1 0, 1 1, 0 0)", writer.write(rev)); + } + + public void testChainedWindows() throws Exception { + CircularString cs = (CircularString) reader.read( + "CIRCULARSTRING (0 0, 1 1, 1 0, 2 1, 2 0)"); + assertEquals(5, cs.getNumControlPoints()); + assertEquals(2, cs.getNumArcs()); + assertFalse(cs.getArcN(0).isCollinear()); + assertFalse(cs.getArcN(1).isCollinear()); + LineString linear = cs.toLinear(); + assertTrue(linear.getCoordinateN(0).equals2D(new Coordinate(0, 0))); + assertTrue(linear.getCoordinateN(linear.getNumPoints() - 1).equals2D(new Coordinate(2, 0))); + } + + public void testEqualsExactRequiresCircularString() throws Exception { + CircularString cs = (CircularString) reader.read("CIRCULARSTRING (0 0, 1 0, 2 0)"); + LineString line = factory.createLineString(cs.getControlPoints()); + assertFalse(cs.equalsExact(line)); + } +} diff --git a/modules/core/src/test/java/org/locationtech/jts/io/WKTReadWriteTest.java b/modules/core/src/test/java/org/locationtech/jts/io/WKTReadWriteTest.java index 38e7027386..2453c3ee70 100644 --- a/modules/core/src/test/java/org/locationtech/jts/io/WKTReadWriteTest.java +++ b/modules/core/src/test/java/org/locationtech/jts/io/WKTReadWriteTest.java @@ -54,6 +54,12 @@ public void testReadLineString() { checkReadWrite("LINESTRING EMPTY"); } + public void testReadCircularString() { + checkReadWrite("CIRCULARSTRING (10 14, 6 10, 14 10)"); + checkReadWrite("CIRCULARSTRING (0 0, 1 0, 1 1, 0 1, 0 0)"); + checkReadWrite("CIRCULARSTRING EMPTY"); + } + public void testReadLinearRing() { checkReadWrite("LINEARRING (10 10, 20 20, 30 40, 10 10)"); checkReadWrite("LINEARRING EMPTY"); diff --git a/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-ndr.hex b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-ndr.hex new file mode 100644 index 0000000000..cbd9e62bbd --- /dev/null +++ b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-ndr.hex @@ -0,0 +1 @@ +010800000003000000000000000000144000000000000000000000000000000840000000000000104000000000000000000000000000001440 diff --git a/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-xdr.hex b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-xdr.hex new file mode 100644 index 0000000000..bf12157621 --- /dev/null +++ b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkb-xdr.hex @@ -0,0 +1 @@ +000000000800000003401400000000000000000000000000004008000000000000401000000000000000000000000000004014000000000000 diff --git a/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkt b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkt new file mode 100644 index 0000000000..1058f3d25e --- /dev/null +++ b/modules/core/src/test/resources/testdata/oracle/circularstring-quarter.wkt @@ -0,0 +1 @@ +CIRCULARSTRING (5 0, 3 4, 0 5)