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
+ * {@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:
+ *
+ * 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
+ * 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)
+ *
+ * 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.
+ *
* 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, EnumSetLinearRing 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, EnumSetLinearRing 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