Skip to content

Commit f977fd6

Browse files
spec: F-MC + F-MS spike -- reader half free, writer override closes both
Probe of current Phase-1 behaviour against six F-MC / F-MS red tests (MultiCompositeMemberSpec): - test_FMC_READ_membersRetainSubtypes passes today - test_FMC_COPY_copyPreservesMemberSubtypes passes today - test_FMS_READ_membersRetainSubtypes passes today - test_FMS_COPY_copyPreservesMemberSubtypes passes today - test_FMC_WKT_writerEmitsMemberTagsForRoundTrip RED - test_FMS_WKT_writerEmitsCurvePolygonTagForRoundTrip RED Finding: the reader half of F-MC and F-MS is **already done** on the extension-points base. CurvedWKTReader.readCurveMember dispatches through readGeometryTaggedText on a tagged member, so a CIRCULARSTRING or COMPOUNDCURVE inside MULTICURVE comes back as its proper subtype; likewise CurvePolygon inside MULTISURFACE. MultiLineString and MultiPolygon store LineString[] / Polygon[] internally, so copy() through the standard copyInternal already preserves member subtypes without F-CP-style storage redesign. The writer half loses member identity: appendMultiLineStringText and appendMultiPolygonText emit per-member coordinates as bare parenthesised bodies, dropping the type tag. So a MULTICURVE containing a CIRCULARSTRING round-trips as MULTICURVE with all plain-LineString members. Fix (this commit): CurvedWKTWriter overrides the appendOtherGeometryTaggedText extension hook from PR locationtech#1194 to intercept MultiCurve and MultiSurface. The override emits the OGC-conventional shape: - plain LineString member -> body-only `(x y, x y, …)` - CircularString member -> tagged `CIRCULARSTRING (…)` - CompoundCurve member -> tagged `COMPOUNDCURVE (…)` - plain Polygon member -> body-only `((x y, …))` - CurvePolygon member -> tagged `CURVEPOLYGON (…)` Implementation is ~75 LOC additive on CurvedWKTWriter. Per-member emission uses `new CurvedWKTWriter().write(member)` for the tagged form and a leading-keyword-strip helper for the body-only form. No jts-core API change required -- everything routes through the extension hook already in place. Posture verified: - default `mvn -pl modules/curved test` 55 / 55 green - on-demand `-Dtest=MultiCompositeMemberSpec` 6 / 6 green - full reactor with checkstyle BUILD SUCCESS Spec class joins the curveawareness/ exclusion convention so it runs on demand only (same pattern as CurvePolygonStructuralSpec). Phase-1 status after this commit: F-CP still pending (Option A recommended, see PR locationtech#1194 comment thread) F-MC READER done on base; WRITER closed by this spike F-MS READER done on base; WRITER closed by this spike F-RD testbuilder rendering, separate Phase 4-B / lab work Once F-CP lands under Option A, Phase 1 is complete and Phases 2/3/4/5/7 unlock per the §10 dependency graph. Assisted-by: Claude (Opus-4.7) Signed-off-by: Jeroen Bloemscheer <jeroen@jeroentechsolutions.uk>
1 parent 22533d8 commit f977fd6

3 files changed

Lines changed: 298 additions & 12 deletions

File tree

modules/curved/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@
3939
</archive>
4040
</configuration>
4141
</plugin>
42+
<plugin>
43+
<!-- Spec / red-test suites under spec/curveawareness/ are
44+
intentionally red and document deferred behaviour
45+
(see issue #1195 §11). They run on demand with
46+
`mvn -pl modules/curved test -Dtest=<SpecClassName>`. -->
47+
<artifactId>maven-surefire-plugin</artifactId>
48+
<configuration>
49+
<excludes>
50+
<exclude>**/spec/curveawareness/*.java</exclude>
51+
</excludes>
52+
</configuration>
53+
</plugin>
4254
</plugins>
4355
</build>
4456

modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTWriter.java

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,44 @@
1111
*/
1212
package org.locationtech.jts.io.curved;
1313

14+
import java.io.IOException;
15+
import java.io.Writer;
16+
import java.util.EnumSet;
17+
import java.util.Locale;
18+
19+
import org.locationtech.jts.geom.Geometry;
20+
import org.locationtech.jts.geom.LineString;
21+
import org.locationtech.jts.geom.Polygon;
22+
import org.locationtech.jts.geom.curved.MultiCurve;
23+
import org.locationtech.jts.geom.curved.MultiSurface;
24+
import org.locationtech.jts.io.Ordinate;
25+
import org.locationtech.jts.io.OrdinateFormat;
1426
import org.locationtech.jts.io.WKTWriter;
1527

1628
/**
1729
* A {@link WKTWriter} subclass for the OGC SFA / ISO 19125-2 extended
1830
* geometry types.
19-
* <p>
20-
* In the current phase-1 implementation this class is a no-op marker:
21-
* the curve geometry classes ({@code CircularString}, {@code Triangle},
22-
* {@code CurvePolygon}, etc.) extend their nearest core counterparts
23-
* and the core {@code WKTWriter} already emits each subclass's keyword
24-
* via {@code Geometry.getGeometryType().toUpperCase(Locale.ROOT)}.
25-
* <p>
26-
* The class is provided here so that callers can pair {@code
27-
* CurvedWKTReader} with {@code CurvedWKTWriter} symmetrically, and so
28-
* that future enhancements (member-structured emission for
29-
* {@code CompoundCurve}, etc.) can land here without changing caller
30-
* code.
31+
*
32+
* <p>Phase-1 keyword emission is inherited from the core writer
33+
* (which now reads the keyword from
34+
* {@code Geometry.getGeometryType().toUpperCase(Locale.ROOT)}, courtesy
35+
* of PR #1194). What this subclass adds, via the
36+
* {@link #appendOtherGeometryTaggedText} extension hook, is
37+
* <em>member-tag preservation</em> for the multi-composite types:
38+
*
39+
* <ul>
40+
* <li><b>MULTICURVE</b> — emits {@code CIRCULARSTRING (…)} or
41+
* {@code COMPOUNDCURVE (…)} for non-plain members, body-only
42+
* parentheses for plain {@code LineString} members.</li>
43+
* <li><b>MULTISURFACE</b> — emits {@code CURVEPOLYGON (…)} for
44+
* {@code CurvePolygon} members, body-only parentheses for plain
45+
* {@code Polygon} members.</li>
46+
* </ul>
47+
*
48+
* <p>This closes F-MC-WKT and F-MS-WKT (locationtech/jts#1195). The
49+
* reader-side preservation of member subtypes already works on the
50+
* current Phase-1 base via {@code CurvedWKTReader.readCurveMember /
51+
* readSurfaceMember}; only the writer side needed to learn.
3152
*/
3253
public class CurvedWKTWriter extends WKTWriter {
3354

@@ -38,4 +59,75 @@ public CurvedWKTWriter() {
3859
public CurvedWKTWriter(int outputDimension) {
3960
super(outputDimension);
4061
}
62+
63+
@Override
64+
protected boolean appendOtherGeometryTaggedText(
65+
Geometry geometry, EnumSet<Ordinate> outputOrdinates, boolean useFormatting,
66+
int level, Writer writer, OrdinateFormat formatter) throws IOException {
67+
if (geometry instanceof MultiCurve) {
68+
writeMultiCurveText((MultiCurve) geometry, outputOrdinates, writer);
69+
return true;
70+
}
71+
if (geometry instanceof MultiSurface) {
72+
writeMultiSurfaceText((MultiSurface) geometry, outputOrdinates, writer);
73+
return true;
74+
}
75+
return false;
76+
}
77+
78+
private void writeMultiCurveText(MultiCurve mc, EnumSet<Ordinate> outputOrdinates, Writer writer)
79+
throws IOException {
80+
writer.write(mc.getGeometryType().toUpperCase(Locale.ROOT));
81+
appendOrdinateText(outputOrdinates, writer);
82+
if (mc.isEmpty()) {
83+
writer.write(" EMPTY");
84+
return;
85+
}
86+
writer.write(" (");
87+
for (int i = 0; i < mc.getNumGeometries(); i++) {
88+
if (i > 0) writer.write(", ");
89+
Geometry m = mc.getGeometryN(i);
90+
// Plain LineString members emit body-only (OGC SFA conventional).
91+
// Curve-typed members emit the tagged form so the reader recovers
92+
// their subtype on round-trip.
93+
if (m.getClass() == LineString.class) {
94+
writer.write(emitBodyOnly(m));
95+
} else {
96+
writer.write(new CurvedWKTWriter().write(m));
97+
}
98+
}
99+
writer.write(")");
100+
}
101+
102+
private void writeMultiSurfaceText(MultiSurface ms, EnumSet<Ordinate> outputOrdinates, Writer writer)
103+
throws IOException {
104+
writer.write(ms.getGeometryType().toUpperCase(Locale.ROOT));
105+
appendOrdinateText(outputOrdinates, writer);
106+
if (ms.isEmpty()) {
107+
writer.write(" EMPTY");
108+
return;
109+
}
110+
writer.write(" (");
111+
for (int i = 0; i < ms.getNumGeometries(); i++) {
112+
if (i > 0) writer.write(", ");
113+
Geometry m = ms.getGeometryN(i);
114+
if (m.getClass() == Polygon.class) {
115+
writer.write(emitBodyOnly(m));
116+
} else {
117+
writer.write(new CurvedWKTWriter().write(m));
118+
}
119+
}
120+
writer.write(")");
121+
}
122+
123+
/**
124+
* Body-only emission for plain LineString / Polygon members.
125+
* Strips the leading keyword (e.g. "LINESTRING ", "POLYGON ") so that
126+
* the result reads as a bare parenthesised body within a multi-composite.
127+
*/
128+
private static String emitBodyOnly(Geometry plainMember) {
129+
String full = new CurvedWKTWriter().write(plainMember);
130+
int firstParen = full.indexOf('(');
131+
return firstParen < 0 ? full : full.substring(firstParen);
132+
}
41133
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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.spec.curveawareness;
13+
14+
import org.locationtech.jts.geom.Geometry;
15+
import org.locationtech.jts.geom.LineString;
16+
import org.locationtech.jts.geom.Polygon;
17+
import org.locationtech.jts.geom.curved.CircularString;
18+
import org.locationtech.jts.geom.curved.CompoundCurve;
19+
import org.locationtech.jts.geom.curved.CurvePolygon;
20+
import org.locationtech.jts.geom.curved.MultiCurve;
21+
import org.locationtech.jts.geom.curved.MultiSurface;
22+
import org.locationtech.jts.io.curved.CurvedWKTReader;
23+
import org.locationtech.jts.io.curved.CurvedWKTWriter;
24+
25+
import junit.framework.Test;
26+
import junit.framework.TestSuite;
27+
import junit.textui.TestRunner;
28+
import test.jts.GeometryTestCase;
29+
30+
/**
31+
* Focused red-test suite for sub-issues <strong>F-MC</strong> (structural
32+
* MultiCurve) and <strong>F-MS</strong> (structural MultiSurface) of the
33+
* SFA Curve Awareness epic (locationtech/jts#1195).
34+
*
35+
* <p>Unlike F-CP, these two have <b>no DOVE-style risk</b>:
36+
* {@code MultiLineString.getGeometryN(int)} already returns
37+
* {@code Geometry}, and {@code MultiPolygon.getGeometryN(int)} returns
38+
* {@code Geometry} too — so a member typed as a {@code CircularString}
39+
* or {@code CurvePolygon} satisfies the existing contract without
40+
* widening any return type.
41+
*
42+
* <p>The questions for F-MC / F-MS are simpler:
43+
* <ol>
44+
* <li><b>FMC-READ</b>: does the WKT reader preserve heterogeneous
45+
* members (CircularString / CompoundCurve / plain LineString)
46+
* inside MULTICURVE?</li>
47+
* <li><b>FMC-COPY</b>: does {@code MultiCurve.copy()} preserve each
48+
* member's subtype, not just the parent {@code LineString}?</li>
49+
* <li><b>FMC-WKT</b>: does the writer emit member type tags so
50+
* round-trip is lossless?</li>
51+
* <li><b>FMS-READ</b>: same question for MULTISURFACE
52+
* (Polygon / CurvePolygon members).</li>
53+
* <li><b>FMS-COPY</b>: same copy preservation question for
54+
* MultiSurface.</li>
55+
* <li><b>FMS-WKT</b>: writer round-trip for MULTISURFACE.</li>
56+
* </ol>
57+
*
58+
* <p>Each {@code fail("FMC-…: …")} / {@code "FMS-…: …"} message names
59+
* the sub-issue tag so the message stream is a live progress meter.
60+
* Tests already known to pass via the {@code readCurveMember} /
61+
* {@code readSurfaceMember} dispatch in PR #1194 will go green
62+
* immediately; the rest mark the remaining gap.
63+
*
64+
* <p>Run on demand: {@code mvn -pl modules/curved test
65+
* -Dtest=MultiCompositeMemberSpec}.
66+
*/
67+
public class MultiCompositeMemberSpec extends GeometryTestCase {
68+
69+
public static void main(String[] args) { TestRunner.run(suite()); }
70+
public static Test suite() { return new TestSuite(MultiCompositeMemberSpec.class); }
71+
public MultiCompositeMemberSpec(String name) { super(name); }
72+
73+
// -- F-MC --------------------------------------------------------------
74+
75+
private static final String WKT_MC_HETEROGENEOUS =
76+
"MULTICURVE("
77+
+ "(5 5, 3 5, 3 3, 0 3), "
78+
+ "CIRCULARSTRING(0 0, 1 1, 2 0), "
79+
+ "COMPOUNDCURVE(CIRCULARSTRING(0 0, 1 1, 2 0), (2 0, 5 0))"
80+
+ ")";
81+
82+
/** FMC-READ: MultiCurve member subtypes survive the reader. */
83+
public void test_FMC_READ_membersRetainSubtypes() throws Exception {
84+
Geometry g = new CurvedWKTReader().read(WKT_MC_HETEROGENEOUS);
85+
assertTrue("Reader returns MultiCurve", g instanceof MultiCurve);
86+
MultiCurve mc = (MultiCurve) g;
87+
assertEquals("FMC-READ: three members", 3, mc.getNumGeometries());
88+
89+
Geometry m0 = mc.getGeometryN(0);
90+
Geometry m1 = mc.getGeometryN(1);
91+
Geometry m2 = mc.getGeometryN(2);
92+
assertEquals("FMC-READ: member 0 is plain LineString, got " + m0.getGeometryType(),
93+
"LineString", m0.getGeometryType());
94+
assertTrue("FMC-READ: member 1 is CircularString, got " + m1.getClass().getSimpleName(),
95+
m1 instanceof CircularString);
96+
assertTrue("FMC-READ: member 2 is CompoundCurve, got " + m2.getClass().getSimpleName(),
97+
m2 instanceof CompoundCurve);
98+
}
99+
100+
/** FMC-COPY: MultiCurve.copy() preserves each member's subtype. */
101+
public void test_FMC_COPY_copyPreservesMemberSubtypes() throws Exception {
102+
MultiCurve mc = (MultiCurve) new CurvedWKTReader().read(WKT_MC_HETEROGENEOUS);
103+
MultiCurve copy = (MultiCurve) mc.copy();
104+
for (int i = 0; i < mc.getNumGeometries(); i++) {
105+
assertEquals("FMC-COPY: member " + i + " class survives copy",
106+
mc.getGeometryN(i).getClass(),
107+
copy.getGeometryN(i).getClass());
108+
}
109+
assertNotSame("FMC-COPY: deep copy of first member",
110+
mc.getGeometryN(0), copy.getGeometryN(0));
111+
}
112+
113+
/** FMC-WKT: writer emits member type tags so re-reading recovers the same shape. */
114+
public void test_FMC_WKT_writerEmitsMemberTagsForRoundTrip() throws Exception {
115+
MultiCurve mc = (MultiCurve) new CurvedWKTReader().read(WKT_MC_HETEROGENEOUS);
116+
String emitted = new CurvedWKTWriter().write(mc);
117+
assertTrue("FMC-WKT: emitted WKT mentions CIRCULARSTRING tag inside body, got: "
118+
+ emitted, emitted.toUpperCase().contains("CIRCULARSTRING"));
119+
assertTrue("FMC-WKT: emitted WKT mentions COMPOUNDCURVE tag inside body, got: "
120+
+ emitted, emitted.toUpperCase().contains("COMPOUNDCURVE"));
121+
122+
MultiCurve roundTripped = (MultiCurve) new CurvedWKTReader().read(emitted);
123+
assertEquals("FMC-WKT: same member count", mc.getNumGeometries(),
124+
roundTripped.getNumGeometries());
125+
for (int i = 0; i < mc.getNumGeometries(); i++) {
126+
assertEquals("FMC-WKT: member " + i + " class survives round-trip",
127+
mc.getGeometryN(i).getClass(),
128+
roundTripped.getGeometryN(i).getClass());
129+
}
130+
}
131+
132+
// -- F-MS --------------------------------------------------------------
133+
134+
private static final String WKT_MS_HETEROGENEOUS =
135+
"MULTISURFACE("
136+
+ "((0 0, 10 0, 10 10, 0 10, 0 0)), "
137+
+ "CURVEPOLYGON(CIRCULARSTRING(0 0, 4 0, 4 4, 0 4, 0 0))"
138+
+ ")";
139+
140+
/** FMS-READ: MultiSurface preserves Polygon vs CurvePolygon members. */
141+
public void test_FMS_READ_membersRetainSubtypes() throws Exception {
142+
Geometry g = new CurvedWKTReader().read(WKT_MS_HETEROGENEOUS);
143+
assertTrue("Reader returns MultiSurface", g instanceof MultiSurface);
144+
MultiSurface ms = (MultiSurface) g;
145+
assertEquals("FMS-READ: two members", 2, ms.getNumGeometries());
146+
147+
Geometry m0 = ms.getGeometryN(0);
148+
Geometry m1 = ms.getGeometryN(1);
149+
assertEquals("FMS-READ: member 0 is plain Polygon, got " + m0.getGeometryType(),
150+
"Polygon", m0.getGeometryType());
151+
assertTrue("FMS-READ: member 1 is CurvePolygon, got " + m1.getClass().getSimpleName(),
152+
m1 instanceof CurvePolygon);
153+
}
154+
155+
/** FMS-COPY: MultiSurface.copy() preserves Polygon vs CurvePolygon. */
156+
public void test_FMS_COPY_copyPreservesMemberSubtypes() throws Exception {
157+
MultiSurface ms = (MultiSurface) new CurvedWKTReader().read(WKT_MS_HETEROGENEOUS);
158+
MultiSurface copy = (MultiSurface) ms.copy();
159+
for (int i = 0; i < ms.getNumGeometries(); i++) {
160+
assertEquals("FMS-COPY: member " + i + " class survives copy",
161+
ms.getGeometryN(i).getClass(),
162+
copy.getGeometryN(i).getClass());
163+
}
164+
}
165+
166+
/** FMS-WKT: round-trip preserves the CURVEPOLYGON member tag. */
167+
public void test_FMS_WKT_writerEmitsCurvePolygonTagForRoundTrip() throws Exception {
168+
MultiSurface ms = (MultiSurface) new CurvedWKTReader().read(WKT_MS_HETEROGENEOUS);
169+
String emitted = new CurvedWKTWriter().write(ms);
170+
assertTrue("FMS-WKT: emitted WKT mentions CURVEPOLYGON tag inside body, got: "
171+
+ emitted, emitted.toUpperCase().contains("CURVEPOLYGON"));
172+
173+
MultiSurface roundTripped = (MultiSurface) new CurvedWKTReader().read(emitted);
174+
assertEquals("FMS-WKT: same member count", ms.getNumGeometries(),
175+
roundTripped.getNumGeometries());
176+
for (int i = 0; i < ms.getNumGeometries(); i++) {
177+
assertEquals("FMS-WKT: member " + i + " class survives round-trip",
178+
ms.getGeometryN(i).getClass(),
179+
roundTripped.getGeometryN(i).getClass());
180+
}
181+
}
182+
}

0 commit comments

Comments
 (0)