Skip to content

Commit 6c1a855

Browse files
[curved] Add factory, Linearizable, copy() preservation, hook tests, README
Addresses code-review feedback on PR locationtech#1194. All additions are still purely additive at the jts-core level. jts-curved - New CurvedGeometryFactory extends GeometryFactory with creation methods for the eight new types (createCircularString, createTriangle, etc.). Pair with CurvedWKTReader for the standard read-construct-emit flow. - New Linearizable interface (Geometry toLinear(double tolerance)) for converting curved geometries to non-curved approximations. Phase 1 returns a parent-type geometry built from the same control points; a future phase will swap in real arc densification. - Implement Linearizable on the seven curve / curve-bounded / triangulated-surface types (Triangle, PolyhedralSurface, Tin omit Linearizable but get correct copyInternal too). - Override copyInternal() on all eight types so copy() returns the correct subclass instead of degrading to the parent. - Module README: usage example, phase-1 limitations (with explicit callout for the CurvePolygon / MultiSurface inner-member round-trip degradation), and discovery rationale (explicit instantiation rather than ServiceLoader). WKTMultiSurfaceTest - testWKTRoundTripXY no longer compares the original CurvePolygon- bearing geometry directly to the round-tripped Polygon-bearing one. Polygon.isEquivalentClass is strict (LineString's is lenient, which is why MultiCurve passes the analogous test); a direct checkEqual would fail. Switched to (a) WKT-stability (write -> read -> write yields the same WKT) and (b) coordinate-equivalence via toLinear(). Comment documents the seam. jts-core - Add WKTReaderExtensionHookTest with 6 tests that exercise both the reader and writer extension hooks via dummy in-test subclasses, with no dependency on jts-curved. Confirms the seam is wired and that the promoted protected helpers are accessible across packages. - Class-level Javadoc on WKTReader now documents the extension contract and lists the protected helpers available to subclasses. Same for WKTWriter (intercept point, parameterised keyword emission, and the exposed text-emission helpers). - Per-field Javadoc on the geometryFactory and csFactory protected fields explaining their role in extension subclasses. Net change: jts-core grows by 6 tests (2288 total, was 2282). jts-curved holds 54 tests, all green. Combined: 2342 tests, BUILD SUCCESS. Discovered seam (recorded in MultiSurface test comment + README): Polygon.isEquivalentClass is strict, so a Polygon and a coord-identical CurvePolygon are not equalsExact even though LineString solves the analogous problem leniently. Phase 2 (member-tagged emission in CurvedWKTWriter) will close this gap; phase 1 documents and works around it.
1 parent 8a5d012 commit 6c1a855

15 files changed

Lines changed: 617 additions & 11 deletions

File tree

modules/core/src/main/java/org/locationtech/jts/io/WKTReader.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,34 @@
8080
* <li>The reader uses <tt>Double.parseDouble</tt> to perform the conversion of ASCII
8181
* numbers to floating point. This means it supports the Java
8282
* syntax for floating point literals (including scientific notation).
83-
* <li><tt>NaN</tt>, <tt>Inf</tt> and <tt>-Inf</tt> ordinate symbols are supported (case-insensitive),
83+
* <li><tt>NaN</tt>, <tt>Inf</tt> and <tt>-Inf</tt> ordinate symbols are supported (case-insensitive),
8484
* which convert to the corresponding IEE-754 value
8585
* </ul>
86+
* <h3>Extension</h3>
87+
* <p>This class is designed to be subclassed to support OGC SFA / ISO
88+
* 19125-2 extended geometry types (such as {@code CIRCULARSTRING},
89+
* {@code COMPOUNDCURVE}, {@code CURVEPOLYGON}, {@code TRIANGLE},
90+
* {@code POLYHEDRALSURFACE}, {@code TIN}). Subclasses should override
91+
* {@link #readOtherGeometryText} to recognise additional type keywords,
92+
* and may compose their implementation from the protected helpers
93+
* exposed by this class:
94+
* <ul>
95+
* <li>tokenizer helpers: {@link #getNextEmptyOrOpener},
96+
* {@link #getNextCloserOrComma}, {@link #getNextWord},
97+
* {@link #lookAheadWord};
98+
* <li>coordinate helpers: {@link #getCoordinate},
99+
* {@link #getCoordinateSequence},
100+
* {@link #createCoordinateSequenceEmpty};
101+
* <li>nested-geometry helpers: {@link #readLineStringText},
102+
* {@link #readLinearRingText}, {@link #readPolygonText},
103+
* {@link #readMultiPolygonText}, and the 3-arg form of
104+
* {@link #readGeometryTaggedText} for dispatching on a known type;
105+
* <li>error helper: {@link #parseErrorWithLine};
106+
* <li>fields: {@link #geometryFactory}, {@link #csFactory}.
107+
* </ul>
108+
* The default implementation of {@link #readOtherGeometryText} throws
109+
* a {@link ParseException}, preserving the historical behaviour for
110+
* direct (non-extending) callers.
86111
* <h3>Syntax</h3>
87112
* The following syntax specification describes the version of Well-Known Text
88113
* supported by JTS.
@@ -178,7 +203,17 @@ public class WKTReader
178203
private static final String INF_SYMBOL = "Inf";
179204
private static final String NEG_INF_SYMBOL = "-Inf";
180205

206+
/**
207+
* The factory used to construct the {@link Geometry} return values.
208+
* Exposed as {@code protected} so that extension subclasses (see
209+
* {@link #readOtherGeometryText}) can construct geometries with the
210+
* same factory the reader is parameterised with.
211+
*/
181212
protected GeometryFactory geometryFactory;
213+
/**
214+
* The {@link CoordinateSequenceFactory} of {@link #geometryFactory}.
215+
* Exposed as {@code protected} for the same reason.
216+
*/
182217
protected CoordinateSequenceFactory csFactory;
183218
private static CoordinateSequenceFactory csFactoryXYZM = CoordinateArraySequenceFactory.instance();
184219
private PrecisionModel precisionModel;

modules/core/src/main/java/org/locationtech/jts/io/WKTWriter.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,24 @@
4646
* <p>
4747
* The SFS WKT spec does not define a special tag for {@link LinearRing}s.
4848
* Under the spec, rings are output as <code>LINESTRING</code>s.
49-
* In order to allow precisely specifying constructed geometries,
50-
* JTS also supports a non-standard <code>LINEARRING</code> tag which is used
49+
* In order to allow precisely specifying constructed geometries,
50+
* JTS also supports a non-standard <code>LINEARRING</code> tag which is used
5151
* to output LinearRings.
52+
* <p>
53+
* <b>Extension:</b> this class is designed to be subclassed to support
54+
* OGC SFA / ISO 19125-2 extended geometry types. The keyword for each
55+
* tagged-text emission is now read from
56+
* {@code geometry.getGeometryType().toUpperCase()}, so {@link Geometry}
57+
* subclasses with structurally compatible bodies emit their own
58+
* keyword without any new dispatch branches. For types that need
59+
* different bodies (e.g. preserving member structure in
60+
* {@code CompoundCurve}), subclasses should override
61+
* {@link #appendOtherGeometryTaggedText}, which is invoked early in
62+
* the dispatch ladder. Helpers for composing emission output
63+
* ({@link #indent}, {@link #appendOrdinateText},
64+
* {@link #appendSequenceText}, {@link #appendPolygonText},
65+
* {@link #appendMultiLineStringText}, {@link #appendMultiPolygonText})
66+
* are exposed as {@code protected}.
5267
*
5368
* @version 1.7
5469
* @see WKTReader
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright (c) 2026 grootstebozewolf
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
7+
* The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
8+
* and the Eclipse Distribution License is available at
9+
*
10+
* http://www.eclipse.org/org/documents/edl-v10.php.
11+
*/
12+
package org.locationtech.jts.io;
13+
14+
import java.io.IOException;
15+
import java.io.StreamTokenizer;
16+
import java.io.Writer;
17+
import java.util.EnumSet;
18+
19+
import org.locationtech.jts.geom.Geometry;
20+
21+
import junit.framework.Test;
22+
import junit.framework.TestSuite;
23+
import junit.textui.TestRunner;
24+
import test.jts.GeometryTestCase;
25+
26+
/**
27+
* Verifies the {@link WKTReader#readOtherGeometryText} and
28+
* {@link WKTWriter#appendOtherGeometryTaggedText} extension hooks added
29+
* for SFA / ISO 19125-2 extended geometry support, without taking any
30+
* dependency on jts-curved. A dummy subclass of {@link WKTReader}
31+
* recognises a made-up keyword and a dummy subclass of
32+
* {@link WKTWriter} emits it; this confirms the seam is wired and the
33+
* promoted helpers are accessible across packages.
34+
*/
35+
public class WKTReaderExtensionHookTest extends GeometryTestCase {
36+
37+
public static void main(String args[]) {
38+
TestRunner.run(suite());
39+
}
40+
41+
public static Test suite() { return new TestSuite(WKTReaderExtensionHookTest.class); }
42+
43+
public WKTReaderExtensionHookTest(String name) { super(name); }
44+
45+
/** A reader that recognises a made-up {@code DUMMYTYPE} keyword and
46+
* returns an empty Point. Exercises the protected helpers. */
47+
private static class DummyReader extends WKTReader {
48+
boolean hookCalled = false;
49+
50+
@Override
51+
protected Geometry readOtherGeometryText(StreamTokenizer t, String type, EnumSet<Ordinate> ord)
52+
throws IOException, ParseException {
53+
if ("DUMMYTYPE".equals(type)) {
54+
hookCalled = true;
55+
// Use the promoted-protected helpers from core.
56+
String tok = getNextEmptyOrOpener(t);
57+
if (!WKTConstants.EMPTY.equals(tok)) {
58+
// burn through the body to a balanced ')'
59+
int depth = 1;
60+
while (depth > 0) {
61+
int c = t.nextToken();
62+
if (c == '(') depth++;
63+
else if (c == ')') depth--;
64+
else if (c == StreamTokenizer.TT_EOF)
65+
throw parseErrorWithLine(t, "Unexpected EOF in DUMMYTYPE body");
66+
}
67+
}
68+
return geometryFactory.createPoint();
69+
}
70+
return super.readOtherGeometryText(t, type, ord);
71+
}
72+
}
73+
74+
/** A writer that intercepts a dummy custom geometry type. */
75+
private static class DummyWriter extends WKTWriter {
76+
boolean hookCalled = false;
77+
78+
@Override
79+
protected boolean appendOtherGeometryTaggedText(Geometry geometry, EnumSet<Ordinate> outputOrdinates,
80+
boolean useFormatting, int level, Writer writer, OrdinateFormat formatter) throws IOException {
81+
// Pretend we have a custom type: any Geometry whose toString starts with "POINT"
82+
// (i.e. all Points) should be emitted with our marker. This confirms the hook
83+
// runs before the instanceof ladder.
84+
if ("Point".equals(geometry.getGeometryType()) && !geometry.isEmpty()) {
85+
writer.write("DUMMYTYPE EMPTY");
86+
return true;
87+
}
88+
return false;
89+
}
90+
}
91+
92+
public void testReaderHookIsInvokedForUnknownType() throws Exception {
93+
DummyReader reader = new DummyReader();
94+
Geometry g = reader.read("DUMMYTYPE EMPTY");
95+
assertTrue("readOtherGeometryText should have been called", reader.hookCalled);
96+
assertEquals("Point", g.getGeometryType());
97+
}
98+
99+
public void testReaderHookHandlesParenthesisedBody() throws Exception {
100+
DummyReader reader = new DummyReader();
101+
Geometry g = reader.read("DUMMYTYPE (1 2, 3 4)");
102+
assertTrue(reader.hookCalled);
103+
assertNotNull(g);
104+
}
105+
106+
public void testReaderHookFallsThroughToCoreError() {
107+
try {
108+
new DummyReader().read("UNKNOWNTYPE EMPTY");
109+
fail("Expected ParseException for unknown type");
110+
} catch (Throwable e) {
111+
assertTrue("Expected ParseException, got: " + e, e instanceof ParseException);
112+
}
113+
}
114+
115+
public void testCoreReaderStillThrowsForUnknownType() {
116+
try {
117+
new WKTReader().read("DUMMYTYPE EMPTY");
118+
fail("Expected ParseException from default WKTReader");
119+
} catch (Throwable e) {
120+
assertTrue("Expected ParseException, got: " + e, e instanceof ParseException);
121+
}
122+
}
123+
124+
public void testWriterHookFiresBeforeInstanceofLadder() throws Exception {
125+
DummyWriter writer = new DummyWriter();
126+
Geometry pt = read("POINT (1 2)");
127+
String wkt = writer.write(pt);
128+
assertEquals("Hook should have intercepted before the Point branch",
129+
"DUMMYTYPE EMPTY", wkt);
130+
}
131+
132+
public void testWriterDefaultHookReturnsFalse() throws Exception {
133+
// The default WKTWriter must keep writing "POINT (...)" — confirms the hook
134+
// returning false does not skip the standard path.
135+
Geometry pt = read("POINT (1 2)");
136+
String wkt = new WKTWriter().write(pt);
137+
assertTrue("Default writer should emit POINT, got: " + wkt, wkt.toUpperCase().startsWith("POINT"));
138+
}
139+
}

modules/curved/README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# jts-curved
2+
3+
Opt-in JTS module providing the OGC Simple Features Access (SFA) /
4+
ISO 19125-2 extended geometry types and a curve-aware WKT reader/writer.
5+
6+
## What it adds
7+
8+
| Geometry type | Java class | Extends |
9+
|--------------------|---------------------------------------------------------|---------------------------------|
10+
| `CircularString` | `org.locationtech.jts.geom.curved.CircularString` | `LineString` |
11+
| `CompoundCurve` | `org.locationtech.jts.geom.curved.CompoundCurve` | `LineString` |
12+
| `CurvePolygon` | `org.locationtech.jts.geom.curved.CurvePolygon` | `Polygon` |
13+
| `MultiCurve` | `org.locationtech.jts.geom.curved.MultiCurve` | `MultiLineString` |
14+
| `MultiSurface` | `org.locationtech.jts.geom.curved.MultiSurface` | `MultiPolygon` |
15+
| `Triangle` | `org.locationtech.jts.geom.curved.Triangle` | `Polygon` |
16+
| `PolyhedralSurface`| `org.locationtech.jts.geom.curved.PolyhedralSurface` | `MultiPolygon` |
17+
| `Tin` | `org.locationtech.jts.geom.curved.Tin` | `PolyhedralSurface` |
18+
19+
Plus:
20+
21+
- `CurvedGeometryFactory` — extends `GeometryFactory`, adds `createCircularString(...)`, `createTriangle(...)`, etc.
22+
- `CurvedWKTReader` — extends `WKTReader`, recognises the eight new keywords via the core `readOtherGeometryText` extension hook.
23+
- `CurvedWKTWriter` — extends `WKTWriter`. Phase-1 marker: the core writer already emits subclass keywords via `Geometry.getGeometryType().toUpperCase()`.
24+
- `Linearizable` interface — `Geometry toLinear(double tolerance)` for converting a curved geometry into a non-curved approximation.
25+
26+
The naming collision with the long-standing static-utility class
27+
`org.locationtech.jts.geom.Triangle` (centroid, circumradius, etc.) is
28+
resolved by package separation: that utility is preserved unchanged in
29+
core; the geometry type lives in `org.locationtech.jts.geom.curved`.
30+
31+
## Usage
32+
33+
```java
34+
import org.locationtech.jts.geom.Geometry;
35+
import org.locationtech.jts.geom.curved.CurvedGeometryFactory;
36+
import org.locationtech.jts.geom.curved.Linearizable;
37+
import org.locationtech.jts.io.curved.CurvedWKTReader;
38+
import org.locationtech.jts.io.curved.CurvedWKTWriter;
39+
40+
CurvedGeometryFactory factory = new CurvedGeometryFactory();
41+
CurvedWKTReader reader = new CurvedWKTReader(factory);
42+
43+
Geometry g = reader.read("CIRCULARSTRING(1 5, 6 2, 7 3)");
44+
System.out.println(g.getGeometryType()); // CircularString
45+
46+
String wkt = new CurvedWKTWriter().write(g);
47+
// wkt: CIRCULARSTRING (1 5, 6 2, 7 3)
48+
49+
// Linearise to a non-curved approximation
50+
Geometry linear = ((Linearizable) g).toLinear(0.0);
51+
System.out.println(linear.getGeometryType()); // LineString
52+
```
53+
54+
The standard `WKTReader` continues to throw `ParseException("Unknown
55+
geometry type: CIRCULARSTRING")` for the new keywords; a caller has to
56+
opt in by instantiating `CurvedWKTReader`.
57+
58+
## Maven coordinates
59+
60+
```xml
61+
<dependency>
62+
<groupId>org.locationtech.jts</groupId>
63+
<artifactId>jts-curved</artifactId>
64+
<version>1.20.1-SNAPSHOT</version>
65+
</dependency>
66+
```
67+
68+
## Phase 1 limitations
69+
70+
The current implementation is intentionally minimal so the module can
71+
land alongside the core extension hooks without dragging in a years-long
72+
algorithm program. Known limitations:
73+
74+
- **Spatial operations fall through to the parent type.** A
75+
`CircularString.intersects(g)` is computed against the polyline
76+
formed by the control points, not against the actual arcs. Use
77+
`Linearizable.toLinear(tolerance)` to make this explicit.
78+
- **`CompoundCurve` member structure is collapsed** to a flat
79+
concatenation of control points on read. The writer emits this flat
80+
form too, and the reader accepts both the flat form and the OGC
81+
member-structured form on input.
82+
- **`CurvePolygon` / `MultiSurface` round-trip degrades inner curve
83+
members.** Re-reading a written `MULTISURFACE(CURVEPOLYGON(...))`
84+
yields `MultiSurface[Polygon]` rather than
85+
`MultiSurface[CurvePolygon]`, because the writer does not yet emit
86+
inner-member tags. Tests use `Linearizable.toLinear(...)` for
87+
structural-fidelity comparison.
88+
- **Validation is best-effort.** Structural rules (Triangle 4-point
89+
ring, CircularString odd point count, CompoundCurve member
90+
connectivity, Tin triangle-only patches) are not enforced.
91+
- **No WKB support.** Defer to a follow-up phase for the SFA-MM type
92+
codes (8/9/10/11/12/15/16/17 with Z/M/ZM variants).
93+
- **`copy()` preserves the subclass** for top-level types via
94+
overridden `copyInternal()`, but `Polygon.isEquivalentClass` is
95+
strict — a `Polygon` is *not* `equalsExact` to a `CurvePolygon` with
96+
identical coordinates. (The same comparison is lenient for
97+
`LineString` subclasses.) Tests work around this where it matters.
98+
- **No `JTSTestBuilder` UI integration yet.**
99+
100+
## Discovery
101+
102+
This module deliberately does **not** register itself via
103+
`ServiceLoader` or any other automatic-discovery mechanism. Callers
104+
explicitly instantiate `CurvedWKTReader` / `CurvedWKTWriter` /
105+
`CurvedGeometryFactory` when they want curve support. This keeps the
106+
module GraalVM native-image friendly and avoids surprising other
107+
classpath users.
108+
109+
## References
110+
111+
- Discussion: <https://github.com/locationtech/jts/discussions/1193>
112+
- Design template: NetTopologySuite/NetTopologySuite#526
113+
- Specification: OGC Simple Features Access 1.2.1 / ISO 19125-2

modules/curved/src/main/java/org/locationtech/jts/geom/curved/CircularString.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
package org.locationtech.jts.geom.curved;
1313

1414
import org.locationtech.jts.geom.CoordinateSequence;
15+
import org.locationtech.jts.geom.Geometry;
1516
import org.locationtech.jts.geom.GeometryFactory;
1617
import org.locationtech.jts.geom.LineString;
1718

@@ -25,7 +26,7 @@
2526
* operations fall through to the parent's polyline behaviour. Native
2627
* arc-aware algorithms are out of scope for this module today.
2728
*/
28-
public class CircularString extends LineString {
29+
public class CircularString extends LineString implements Linearizable {
2930
private static final long serialVersionUID = 1L;
3031

3132
public CircularString(CoordinateSequence points, GeometryFactory factory) {
@@ -36,4 +37,14 @@ public CircularString(CoordinateSequence points, GeometryFactory factory) {
3637
public String getGeometryType() {
3738
return "CircularString";
3839
}
40+
41+
@Override
42+
protected CircularString copyInternal() {
43+
return new CircularString(getCoordinateSequence().copy(), getFactory());
44+
}
45+
46+
@Override
47+
public Geometry toLinear(double tolerance) {
48+
return getFactory().createLineString(getCoordinateSequence().copy());
49+
}
3950
}

modules/curved/src/main/java/org/locationtech/jts/geom/curved/CompoundCurve.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
package org.locationtech.jts.geom.curved;
1313

1414
import org.locationtech.jts.geom.CoordinateSequence;
15+
import org.locationtech.jts.geom.Geometry;
1516
import org.locationtech.jts.geom.GeometryFactory;
1617
import org.locationtech.jts.geom.LineString;
1718

@@ -20,7 +21,7 @@
2021
* segments. Phase-1 stand-in: member structure is collapsed to a flat
2122
* concatenation of control points. A future phase will preserve segments.
2223
*/
23-
public class CompoundCurve extends LineString {
24+
public class CompoundCurve extends LineString implements Linearizable {
2425
private static final long serialVersionUID = 1L;
2526

2627
public CompoundCurve(CoordinateSequence points, GeometryFactory factory) {
@@ -31,4 +32,14 @@ public CompoundCurve(CoordinateSequence points, GeometryFactory factory) {
3132
public String getGeometryType() {
3233
return "CompoundCurve";
3334
}
35+
36+
@Override
37+
protected CompoundCurve copyInternal() {
38+
return new CompoundCurve(getCoordinateSequence().copy(), getFactory());
39+
}
40+
41+
@Override
42+
public Geometry toLinear(double tolerance) {
43+
return getFactory().createLineString(getCoordinateSequence().copy());
44+
}
3445
}

0 commit comments

Comments
 (0)