diff --git a/.github/workflows/auto-rebase-upstream.yml b/.github/workflows/auto-rebase-upstream.yml new file mode 100644 index 0000000000..c69bca0de7 --- /dev/null +++ b/.github/workflows/auto-rebase-upstream.yml @@ -0,0 +1,274 @@ +name: Auto-rebase saga branches + +# Two-stage scheduled rebase chain. +# +# Stage 1 ("rebase-master"): rebase this fork's master onto +# locationtech/jts:master, force-push on success, open/update a +# "Auto-rebase conflict on master" issue on failure. +# +# Stage 2 ("rebase-saga", matrix, needs: stage 1): for every saga +# branch in the matrix, rebase onto the (now upstream-current) fork +# master, force-push on success, open/update a per-branch conflict +# issue on failure. Branches run in parallel; one branch's conflict +# doesn't block the others (fail-fast: false). +# +# RULE-OUT spike branches (F-CP-spike-optionB, F-CP-spike-optionC) are +# intentionally NOT in the matrix — they're frozen records of failed +# experiments and force-pushing would erase the audit trail. Add them +# below if you decide they should follow master. +# +# All knobs are in the env block; the matrix list below is the second +# place to edit when the saga grows. + +on: + schedule: + - cron: '0 6 * * *' # 06:00 UTC daily + workflow_dispatch: # manual trigger from the Actions tab + +permissions: + contents: write + issues: write + +env: + UPSTREAM_REPO: locationtech/jts + UPSTREAM_BRANCH: master + CONFLICT_LABEL: upstream-rebase-conflict + +jobs: + # --------------------------------------------------------------- + # Stage 1: fork master <- locationtech/jts:master + # --------------------------------------------------------------- + rebase-master: + runs-on: ubuntu-latest + steps: + - name: Checkout master with full history + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote and fetch + run: | + git remote remove upstream 2>/dev/null || true + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch upstream "${UPSTREAM_BRANCH}" + + - name: Attempt rebase + id: rebase + run: | + set +e + git rebase "upstream/${UPSTREAM_BRANCH}" + RC=$? + if [ $RC -ne 0 ]; then + CONFLICTS=$(git diff --name-only --diff-filter=U) + git rebase --abort + { + echo "status=conflict" + echo "conflicts<> "$GITHUB_OUTPUT" + exit 0 + fi + echo "status=clean" >> "$GITHUB_OUTPUT" + + - name: Force-push if ahead of origin + if: steps.rebase.outputs.status == 'clean' + run: | + if ! git diff --quiet origin/master..HEAD; then + git push --force-with-lease origin HEAD:master + fi + + - name: Ensure conflict label exists + if: steps.rebase.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "${CONFLICT_LABEL}" \ + --description "Auto-rebase against ${UPSTREAM_REPO} hit a conflict" \ + --color "B60205" \ + || true + + - name: Open or update master conflict issue + if: steps.rebase.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONFLICTS: ${{ steps.rebase.outputs.conflicts }} + run: | + UPSTREAM_SHA=$(git rev-parse "upstream/${UPSTREAM_BRANCH}") + TITLE_PREFIX="Auto-rebase conflict on master" + BODY_FILE=$(mktemp) + cat > "$BODY_FILE" <> "$GITHUB_OUTPUT" + exit 0 + fi + echo "status=clean" >> "$GITHUB_OUTPUT" + + - name: Force-push if ahead of origin + if: steps.rebase.outputs.status == 'clean' + run: | + BRANCH="${{ matrix.branch }}" + if ! git diff --quiet "origin/${BRANCH}..HEAD"; then + git push --force-with-lease origin "HEAD:${BRANCH}" + fi + + - name: Ensure conflict label exists + if: steps.rebase.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "${CONFLICT_LABEL}" \ + --description "Auto-rebase against ${UPSTREAM_REPO} hit a conflict" \ + --color "B60205" \ + || true + + - name: Open or update conflict issue for ${{ matrix.branch }} + if: steps.rebase.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONFLICTS: ${{ steps.rebase.outputs.conflicts }} + BRANCH: ${{ matrix.branch }} + run: | + MASTER_SHA=$(git rev-parse origin/master) + TITLE_PREFIX="Auto-rebase conflict on ${BRANCH}" + BODY_FILE=$(mktemp) + cat > "$BODY_FILE" <jts-core ${project.version} + + org.locationtech.jts + jts-curved + ${project.version} + org.locationtech.jts jts-tests diff --git a/modules/app/src/main/java/org/locationtech/jtstest/test/TestCase.java b/modules/app/src/main/java/org/locationtech/jtstest/test/TestCase.java index bd0a67503a..fbdd964021 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/test/TestCase.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/test/TestCase.java @@ -15,8 +15,10 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.IntersectionMatrix; import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.geom.curved.CurvedGeometryFactory; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.curved.CurvedWKTReader; import org.locationtech.jts.io.WKTWriter; import org.locationtech.jts.util.Assert; @@ -294,8 +296,8 @@ public void runTest() throws ParseException { } public void initGeometry() throws ParseException { - GeometryFactory fact = new GeometryFactory(pm, 0); - WKTReader wktRdr = new WKTReader(fact); + GeometryFactory fact = new CurvedGeometryFactory(pm, 0); + WKTReader wktRdr = new CurvedWKTReader(fact); if (geom[0] != null) { return; } @@ -350,8 +352,8 @@ private Geometry toNullOrGeometry(String wellKnownText) throws ParseException { if (wellKnownText == null) { return null; } - GeometryFactory fact = new GeometryFactory(pm, 0); - WKTReader wktRdr = new WKTReader(fact); + GeometryFactory fact = new CurvedGeometryFactory(pm, 0); + WKTReader wktRdr = new CurvedWKTReader(fact); return wktRdr.read(wellKnownText); } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryInputDialog.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryInputDialog.java index 1a403a67e1..923c37353b 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryInputDialog.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/GeometryInputDialog.java @@ -30,7 +30,9 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.curved.CurvedGeometryFactory; import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.curved.CurvedWKTReader; /** @@ -221,8 +223,8 @@ void btnLoad_actionPerformed(ActionEvent e) { Geometry parseGeometry(JTextComponent txt, Color clr) { try { WKTReader rdr = - new WKTReader( - new GeometryFactory(JTSTestBuilder.model().getPrecisionModel(), 0)); + new CurvedWKTReader( + new CurvedGeometryFactory(JTSTestBuilder.model().getPrecisionModel(), 0)); Geometry g = rdr.read(txt.getText()); txtError.setText(""); return g; diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilder.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilder.java index 774730cdd2..2716ae360f 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilder.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/JTSTestBuilder.java @@ -18,6 +18,7 @@ import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.geom.curved.CurvedGeometryFactory; import org.locationtech.jtstest.cmd.CommandOptions; import org.locationtech.jtstest.command.CommandLine; import org.locationtech.jtstest.command.Option; @@ -80,8 +81,8 @@ public static GeometryFactory getGeometryFactory() /** * Allow this to work even if TestBuilder is not initialized */ - if (instance() == null) - return new GeometryFactory(); + if (instance() == null) + return new CurvedGeometryFactory(); return model().getGeometryFactory(); } diff --git a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/model/TestBuilderModel.java b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/model/TestBuilderModel.java index 5f24aaab3d..9885da135c 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/model/TestBuilderModel.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/testbuilder/model/TestBuilderModel.java @@ -23,9 +23,11 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.geom.curved.CurvedGeometryFactory; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; import org.locationtech.jts.io.WKTWriter; +import org.locationtech.jts.io.curved.CurvedWKTReader; import org.locationtech.jts.math.MathUtil; import org.locationtech.jts.util.Assert; import org.locationtech.jtstest.test.TestCaseList; @@ -78,7 +80,7 @@ public void setPrecisionModel(PrecisionModel precisionModel) public GeometryFactory getGeometryFactory() { if (geometryFactory == null) - geometryFactory = new GeometryFactory(getPrecisionModel()); + geometryFactory = new CurvedGeometryFactory(getPrecisionModel()); return geometryFactory; } @@ -240,7 +242,7 @@ public void loadMultipleGeometriesFromFile(int geomIndex, String filename) } public void loadGeometryText(String wktA, String wktB) throws ParseException, IOException { - MultiFormatReader reader = new MultiFormatReader(new GeometryFactory(getPrecisionModel(),0)); + MultiFormatReader reader = new MultiFormatReader(new CurvedGeometryFactory(getPrecisionModel(),0)); // read geom A Geometry g0 = null; @@ -455,7 +457,7 @@ private void saveWKTBeforePMChange() { } private void loadWKTAfterPMChange() throws ParseException { - WKTReader reader = new WKTReader(new GeometryFactory(getPrecisionModel(), 0)); + WKTReader reader = new CurvedWKTReader(new CurvedGeometryFactory(getPrecisionModel(), 0)); for (int i = 0; i < getCases().size(); i++) { Testable testable = (Testable) getCases().get(i); String wktA = (String) wktABeforePMChange.get(i); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/util/io/IOUtil.java b/modules/app/src/main/java/org/locationtech/jtstest/util/io/IOUtil.java index a87abca075..02f5e6ec06 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/util/io/IOUtil.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/util/io/IOUtil.java @@ -26,6 +26,7 @@ import org.locationtech.jts.io.WKBReader; import org.locationtech.jts.io.WKTFileReader; import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.curved.CurvedWKTReader; import org.locationtech.jts.io.gml2.GMLReader; import org.locationtech.jtstest.testbuilder.io.shapefile.Shapefile; import org.locationtech.jtstest.util.FileUtil; @@ -117,7 +118,7 @@ public static Geometry readWKTString(String wkt, GeometryFactory geomFact, boolean isStrict) throws ParseException, IOException { - WKTReader reader = new WKTReader(geomFact); + WKTReader reader = new CurvedWKTReader(geomFact); WKTFileReader fileReader = new WKTFileReader(new StringReader(wkt), reader); fileReader.setStrictParsing(isStrict); List geomList = fileReader.read(); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatBufferedReader.java b/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatBufferedReader.java index 8afd177e4a..531d380404 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatBufferedReader.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatBufferedReader.java @@ -23,6 +23,7 @@ import org.locationtech.jts.io.WKBReader; import org.locationtech.jts.io.WKTFileReader; import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.curved.CurvedWKTReader; /** @@ -106,9 +107,9 @@ private List readWKBHex(Reader rdr, GeometryFactory geomFact) } public List readWKT(Reader rdr, GeometryFactory geomFact) - throws ParseException, IOException + throws ParseException, IOException { - WKTReader reader = new WKTReader(geomFact); + WKTReader reader = new CurvedWKTReader(geomFact); WKTFileReader fileReader = new WKTFileReader(rdr, reader); if (limit >= 0) fileReader.setLimit(limit); if (offset > 0) fileReader.setOffset(offset); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatFileReader.java b/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatFileReader.java index be72dd23a2..81b8109e34 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatFileReader.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/util/io/MultiFormatFileReader.java @@ -23,6 +23,7 @@ import org.locationtech.jts.io.WKBReader; import org.locationtech.jts.io.WKTFileReader; import org.locationtech.jts.io.WKTReader; +import org.locationtech.jts.io.curved.CurvedWKTReader; import org.locationtech.jtstest.testbuilder.io.shapefile.Shapefile; import org.locationtech.jtstest.util.FileUtil; @@ -139,7 +140,7 @@ private List readWKBHexFile(String filename) private List readWKTFile(String filename) throws ParseException, IOException { - WKTReader reader = new WKTReader(geomFact); + WKTReader reader = new CurvedWKTReader(geomFact); WKTFileReader fileReader = new WKTFileReader(filename, reader); if (limit >= 0) fileReader.setLimit(limit); if (offset > 0) fileReader.setOffset(offset); 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..f203365452 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 @@ -27,7 +27,20 @@ public class WKTConstants { public static final String MULTIPOINT = "MULTIPOINT"; public static final String POINT = "POINT"; public static final String POLYGON = "POLYGON"; - + + /* Extended OGC SFA / ISO 19125-2 type keywords. The core JTS readers and + * writers do not handle these directly; they are exposed here so that + * extension modules (e.g. {@code jts-curved}) and downstream tooling can + * share a single canonical set of strings. */ + public static final String CIRCULARSTRING = "CIRCULARSTRING"; + public static final String COMPOUNDCURVE = "COMPOUNDCURVE"; + public static final String CURVEPOLYGON = "CURVEPOLYGON"; + public static final String MULTICURVE = "MULTICURVE"; + public static final String MULTISURFACE = "MULTISURFACE"; + public static final String POLYHEDRALSURFACE = "POLYHEDRALSURFACE"; + public static final String TIN = "TIN"; + public static final String TRIANGLE = "TRIANGLE"; + public static final String EMPTY = "EMPTY"; public static final String M = "M"; 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..bd4dbd2bac 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 @@ -80,9 +80,34 @@ *
  • The reader uses Double.parseDouble to perform the conversion of ASCII * numbers to floating point. This means it supports the Java * syntax for floating point literals (including scientific notation). - *
  • NaN, Inf and -Inf ordinate symbols are supported (case-insensitive), + *
  • NaN, Inf and -Inf ordinate symbols are supported (case-insensitive), * which convert to the corresponding IEE-754 value * + *

    Extension

    + *

    This class is designed to be subclassed to support OGC SFA / ISO + * 19125-2 extended geometry types (such as {@code CIRCULARSTRING}, + * {@code COMPOUNDCURVE}, {@code CURVEPOLYGON}, {@code TRIANGLE}, + * {@code POLYHEDRALSURFACE}, {@code TIN}). Subclasses should override + * {@link #readOtherGeometryText} to recognise additional type keywords, + * and may compose their implementation from the protected helpers + * exposed by this class: + *

      + *
    • tokenizer helpers: {@link #getNextEmptyOrOpener}, + * {@link #getNextCloserOrComma}, {@link #getNextWord}, + * {@link #lookAheadWord}; + *
    • coordinate helpers: {@link #getCoordinate}, + * {@link #getCoordinateSequence}, + * {@link #createCoordinateSequenceEmpty}; + *
    • nested-geometry helpers: {@link #readLineStringText}, + * {@link #readLinearRingText}, {@link #readPolygonText}, + * {@link #readMultiPolygonText}, and the 3-arg form of + * {@link #readGeometryTaggedText} for dispatching on a known type; + *
    • error helper: {@link #parseErrorWithLine}; + *
    • fields: {@link #geometryFactory}, {@link #csFactory}. + *
    + * The default implementation of {@link #readOtherGeometryText} throws + * a {@link ParseException}, preserving the historical behaviour for + * direct (non-extending) callers. *

    Syntax

    * The following syntax specification describes the version of Well-Known Text * supported by JTS. @@ -178,8 +203,18 @@ public class WKTReader private static final String INF_SYMBOL = "Inf"; private static final String NEG_INF_SYMBOL = "-Inf"; - private GeometryFactory geometryFactory; - private CoordinateSequenceFactory csFactory; + /** + * The factory used to construct the {@link Geometry} return values. + * Exposed as {@code protected} so that extension subclasses (see + * {@link #readOtherGeometryText}) can construct geometries with the + * same factory the reader is parameterised with. + */ + protected GeometryFactory geometryFactory; + /** + * The {@link CoordinateSequenceFactory} of {@link #geometryFactory}. + * Exposed as {@code protected} for the same reason. + */ + protected CoordinateSequenceFactory csFactory; private static CoordinateSequenceFactory csFactoryXYZM = CoordinateArraySequenceFactory.instance(); private PrecisionModel precisionModel; @@ -328,7 +363,7 @@ private static StreamTokenizer createTokenizer(Reader reader) { *@throws IOException if an I/O error occurs *@throws ParseException if an unexpected token was encountered */ - private Coordinate getCoordinate(StreamTokenizer tokenizer, EnumSet ordinateFlags, boolean tryParen) + protected Coordinate getCoordinate(StreamTokenizer tokenizer, EnumSet ordinateFlags, boolean tryParen) throws IOException, ParseException { boolean opened = false; @@ -389,7 +424,7 @@ private Coordinate createCoordinate(EnumSet ordinateFlags) { *@throws IOException if an I/O error occurs *@throws ParseException if an unexpected token was encountered */ - private CoordinateSequence getCoordinateSequence(StreamTokenizer tokenizer, EnumSet ordinateFlags, int minSize, boolean isRing) + protected CoordinateSequence getCoordinateSequence(StreamTokenizer tokenizer, EnumSet ordinateFlags, int minSize, boolean isRing) throws IOException, ParseException { if (getNextEmptyOrOpener(tokenizer).equals(WKTConstants.EMPTY)) return createCoordinateSequenceEmpty(ordinateFlags); @@ -426,7 +461,7 @@ private static boolean isClosed(List coords) { return true; } - private CoordinateSequence createCoordinateSequenceEmpty(EnumSet ordinateFlags) + protected CoordinateSequence createCoordinateSequenceEmpty(EnumSet ordinateFlags) throws IOException, ParseException { return csFactory.create(0, toDimension(ordinateFlags), ordinateFlags.contains(Ordinate.M) ? 1 : 0); } @@ -553,7 +588,7 @@ private double getNextNumber(StreamTokenizer tokenizer) throws IOException, *@throws IOException if an I/O error occurs * @param tokenizer tokenizer over a stream of text in Well-known Text */ - private static String getNextEmptyOrOpener(StreamTokenizer tokenizer) throws IOException, ParseException { + protected static String getNextEmptyOrOpener(StreamTokenizer tokenizer) throws IOException, ParseException { String nextWord = getNextWord(tokenizer); if (nextWord.equalsIgnoreCase(WKTConstants.Z)) { //z = true; @@ -614,7 +649,7 @@ else if (nextWord.equalsIgnoreCase(WKTConstants.ZM)) { *@throws ParseException if the next token is not a word *@throws IOException if an I/O error occurs */ - private static String lookAheadWord(StreamTokenizer tokenizer) throws IOException, ParseException { + protected static String lookAheadWord(StreamTokenizer tokenizer) throws IOException, ParseException { String nextWord = getNextWord(tokenizer); tokenizer.pushBack(); return nextWord; @@ -628,7 +663,7 @@ private static String lookAheadWord(StreamTokenizer tokenizer) throws IOExceptio *@throws IOException if an I/O error occurs * @param tokenizer tokenizer over a stream of text in Well-known Text */ - private static String getNextCloserOrComma(StreamTokenizer tokenizer) throws IOException, ParseException { + protected static String getNextCloserOrComma(StreamTokenizer tokenizer) throws IOException, ParseException { String nextWord = getNextWord(tokenizer); if (nextWord.equals(COMMA) || nextWord.equals(R_PAREN)) { return nextWord; @@ -661,7 +696,7 @@ private String getNextCloser(StreamTokenizer tokenizer) throws IOException, Pars *@throws IOException if an I/O error occurs * @param tokenizer tokenizer over a stream of text in Well-known Text */ - private static String getNextWord(StreamTokenizer tokenizer) throws IOException, ParseException { + protected static String getNextWord(StreamTokenizer tokenizer) throws IOException, ParseException { int type = tokenizer.nextToken(); switch (type) { case StreamTokenizer.TT_WORD: @@ -704,7 +739,7 @@ private static ParseException parseErrorExpected(StreamTokenizer tokenizer, Stri * @param msg a description of what was expected * @throws AssertionFailedException if an invalid token is encountered */ - private static ParseException parseErrorWithLine(StreamTokenizer tokenizer, String msg) + protected static ParseException parseErrorWithLine(StreamTokenizer tokenizer, String msg) { return new ParseException(msg + " (line " + tokenizer.lineno() + ")"); } @@ -754,7 +789,7 @@ private Geometry readGeometryTaggedText(StreamTokenizer tokenizer) throws IOExce return readGeometryTaggedText(tokenizer, type, ordinateFlags); } - private Geometry readGeometryTaggedText(StreamTokenizer tokenizer, String type, EnumSet ordinateFlags) + protected Geometry readGeometryTaggedText(StreamTokenizer tokenizer, String type, EnumSet ordinateFlags) throws IOException, ParseException { if (ordinateFlags.size() == 2) { @@ -798,13 +833,46 @@ else if (isTypeName(tokenizer, type, WKTConstants.MULTIPOLYGON)) { else if (isTypeName(tokenizer, type, WKTConstants.GEOMETRYCOLLECTION)) { return readGeometryCollectionText(tokenizer, ordinateFlags); } + return readOtherGeometryText(tokenizer, type, ordinateFlags); + } + + /** + * Hook for subclasses to read geometry types that the core JTS WKT + * reader does not recognise (extended OGC SFA / ISO 19125-2 types + * such as {@code CIRCULARSTRING}, {@code COMPOUNDCURVE}, + * {@code CURVEPOLYGON}, {@code MULTICURVE}, {@code MULTISURFACE}, + * {@code TRIANGLE}, {@code POLYHEDRALSURFACE}, {@code TIN}, etc.). + *

    + * The default implementation throws a {@link ParseException} with + * the unknown-type message, preserving the previous behaviour. + * + * @param tokenizer tokenizer positioned just after the type keyword + * @param type the type keyword that was read (already uppercased) + * @param ordinateFlags the dimensional ordinates parsed for this geometry + * @return the decoded geometry + * @throws IOException if an I/O error occurs + * @throws ParseException if an unexpected token was encountered + */ + protected Geometry readOtherGeometryText(StreamTokenizer tokenizer, String type, EnumSet ordinateFlags) + throws IOException, ParseException { throw parseErrorWithLine(tokenizer, "Unknown geometry type: " + type); } - private boolean isTypeName(StreamTokenizer tokenizer, String type, String typeName) throws ParseException { + /** + * Returns whether the parsed {@code type} keyword (already uppercased, + * with the Z/M/ZM modifier optionally appended) matches the canonical + * {@code typeName}. Throws {@link ParseException} when the suffix is + * non-empty and not a recognised dimension modifier, so callers do not + * need to defend against that case themselves. + *

    + * Promoted from {@code private} to {@code protected} so that subclasses + * implementing {@link #readOtherGeometryText} can share a single canonical + * keyword/modifier matcher rather than rolling their own. + */ + protected boolean isTypeName(StreamTokenizer tokenizer, String type, String typeName) throws ParseException { if (! type.startsWith(typeName)) return false; - + String modifiers = type.substring(typeName.length()); boolean isValidMod = modifiers.length() <= 2 && (modifiers.length() == 0 @@ -814,7 +882,7 @@ private boolean isTypeName(StreamTokenizer tokenizer, String type, String typeNa if (! isValidMod) { throw parseErrorWithLine(tokenizer, "Invalid dimension modifiers: " + type); } - + return true; } @@ -843,7 +911,7 @@ private Point readPointText(StreamTokenizer tokenizer, EnumSet ordinat *@throws IOException if an I/O error occurs *@throws ParseException if an unexpected token was encountered */ - private LineString readLineStringText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { + protected LineString readLineStringText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { return geometryFactory.createLineString(getCoordinateSequence(tokenizer, ordinateFlags, LineString.MINIMUM_VALID_SIZE, false)); } @@ -859,7 +927,7 @@ private LineString readLineStringText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + protected LinearRing readLinearRingText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { return geometryFactory.createLinearRing(getCoordinateSequence(tokenizer, ordinateFlags, LinearRing.MINIMUM_VALID_SIZE, true)); @@ -918,7 +986,7 @@ private MultiPoint readMultiPointText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { + protected Polygon readPolygonText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { String nextToken = getNextEmptyOrOpener(tokenizer); if (nextToken.equals(WKTConstants.EMPTY)) { return geometryFactory.createPolygon(createCoordinateSequenceEmpty(ordinateFlags)); @@ -974,7 +1042,7 @@ private MultiLineString readMultiLineStringText(StreamTokenizer tokenizer, EnumS *@throws IOException if an I/O error occurs *@throws ParseException if an unexpected token was encountered */ - private MultiPolygon readMultiPolygonText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { + protected MultiPolygon readMultiPolygonText(StreamTokenizer tokenizer, EnumSet ordinateFlags) throws IOException, ParseException { String nextToken = getNextEmptyOrOpener(tokenizer); if (nextToken.equals(WKTConstants.EMPTY)) { return geometryFactory.createMultiPolygon(); 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..899af4ec61 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 @@ -16,6 +16,7 @@ import java.io.StringWriter; import java.io.Writer; import java.util.EnumSet; +import java.util.Locale; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; @@ -45,9 +46,24 @@ *

    * The SFS WKT spec does not define a special tag for {@link LinearRing}s. * Under the spec, rings are output as LINESTRINGs. - * In order to allow precisely specifying constructed geometries, - * JTS also supports a non-standard LINEARRING tag which is used + * In order to allow precisely specifying constructed geometries, + * JTS also supports a non-standard LINEARRING tag which is used * to output LinearRings. + *

    + * Extension: this class is designed to be subclassed to support + * OGC SFA / ISO 19125-2 extended geometry types. The keyword for each + * tagged-text emission is now read from + * {@code geometry.getGeometryType().toUpperCase()}, so {@link Geometry} + * subclasses with structurally compatible bodies emit their own + * keyword without any new dispatch branches. For types that need + * different bodies (e.g. preserving member structure in + * {@code CompoundCurve}), subclasses should override + * {@link #appendOtherGeometryTaggedText}, which is invoked early in + * the dispatch ladder. Helpers for composing emission output + * ({@link #indent}, {@link #appendOrdinateText}, + * {@link #appendSequenceText}, {@link #appendPolygonText}, + * {@link #appendMultiLineStringText}, {@link #appendMultiPolygonText}) + * are exposed as {@code protected}. * * @version 1.7 * @see WKTReader @@ -443,6 +459,12 @@ private void appendGeometryTaggedText( { indent(useFormatting, level, writer); + // Extension hook for SFA / ISO 19125-2 geometries (curve, surface, etc.) + if (appendOtherGeometryTaggedText(geometry, outputOrdinates, useFormatting, + level, writer, formatter)) { + return; + } + if (geometry instanceof Point) { appendPointTaggedText((Point) geometry, outputOrdinates, useFormatting, level, writer, formatter); @@ -481,6 +503,29 @@ else if (geometry instanceof GeometryCollection) { } } + /** + * Hook for subclasses to write geometry types not handled by the core + * dispatch (extended OGC SFA / ISO 19125-2 types such as + * {@code CircularString}, {@code CompoundCurve}, {@code CurvePolygon}, + * {@code MultiCurve}, {@code MultiSurface}, {@code Triangle}, + * {@code PolyhedralSurface}, {@code Tin}). + *

    + * Called early in {@link #appendGeometryTaggedText} so that subclasses + * can intercept geometries that would otherwise be routed to a parent + * class's handler by the {@code instanceof} ladder. Implementations + * should return {@code true} if the geometry was written, and + * {@code false} if the core dispatch should proceed. + *

    + * The default implementation returns {@code false}, preserving the + * previous behaviour. + */ + protected boolean appendOtherGeometryTaggedText( + Geometry geometry, EnumSet outputOrdinates, boolean useFormatting, + int level, Writer writer, OrdinateFormat formatter) + throws IOException { + return false; + } + /** * Converts a Coordinate to <Point Tagged Text> format, * then appends it to the writer. @@ -519,7 +564,7 @@ private void appendLineStringTaggedText( int level, Writer writer, OrdinateFormat formatter) throws IOException { - writer.write(WKTConstants.LINESTRING); + writer.write(lineString.getGeometryType().toUpperCase(Locale.ROOT)); writer.write(" "); appendOrdinateText(outputOrdinates, writer); appendSequenceText(lineString.getCoordinateSequence(), outputOrdinates, useFormatting, @@ -565,7 +610,7 @@ private void appendPolygonTaggedText( int level, Writer writer, OrdinateFormat formatter) throws IOException { - writer.write(WKTConstants.POLYGON); + writer.write(polygon.getGeometryType().toUpperCase(Locale.ROOT)); writer.write(" "); appendOrdinateText(outputOrdinates, writer); appendPolygonText(polygon, outputOrdinates, useFormatting, @@ -610,7 +655,7 @@ private void appendMultiLineStringTaggedText( int level, Writer writer, OrdinateFormat formatter) throws IOException { - writer.write(WKTConstants.MULTILINESTRING); + writer.write(multiLineString.getGeometryType().toUpperCase(Locale.ROOT)); writer.write(" "); appendOrdinateText(outputOrdinates, writer); appendMultiLineStringText(multiLineString, outputOrdinates, useFormatting, @@ -633,7 +678,7 @@ private void appendMultiPolygonTaggedText( int level, Writer writer, OrdinateFormat formatter) throws IOException { - writer.write(WKTConstants.MULTIPOLYGON); + writer.write(multiPolygon.getGeometryType().toUpperCase(Locale.ROOT)); writer.write(" "); appendOrdinateText(outputOrdinates, writer); appendMultiPolygonText(multiPolygon, outputOrdinates, useFormatting, @@ -723,7 +768,7 @@ private static String writeNumber(double d, OrdinateFormat formatter) { * @param writer the output writer to append to. * @throws IOException if an error occurs while using the writer. */ - private void appendOrdinateText(EnumSet outputOrdinates, Writer writer) throws IOException { + protected void appendOrdinateText(EnumSet outputOrdinates, Writer writer) throws IOException { if (outputOrdinates.contains(Ordinate.Z)) writer.append(WKTConstants.Z); @@ -743,7 +788,7 @@ private void appendOrdinateText(EnumSet outputOrdinates, Writer writer * @param writer the output writer to append to * @param formatter the formatter to use for writing ordinate values. */ - private void appendSequenceText(CoordinateSequence seq, EnumSet outputOrdinates, boolean useFormatting, + protected void appendSequenceText(CoordinateSequence seq, EnumSet outputOrdinates, boolean useFormatting, int level, boolean indentFirst, Writer writer, OrdinateFormat formatter) throws IOException { @@ -779,7 +824,7 @@ private void appendSequenceText(CoordinateSequence seq, EnumSet output * @param writer the output writer to append to * @param formatter the formatter to use for writing ordinate values. */ - private void appendPolygonText( + protected void appendPolygonText( Polygon polygon, EnumSet outputOrdinates, boolean useFormatting, int level, boolean indentFirst, Writer writer, OrdinateFormat formatter) throws IOException @@ -845,7 +890,7 @@ private void appendMultiPointText( * @param writer the output writer to append to * @param formatter the formatter to use for writing ordinate values. */ - private void appendMultiLineStringText(MultiLineString multiLineString, EnumSet outputOrdinates, + protected void appendMultiLineStringText(MultiLineString multiLineString, EnumSet outputOrdinates, boolean useFormatting, int level, /*boolean indentFirst, */Writer writer, OrdinateFormat formatter) throws IOException { @@ -879,7 +924,7 @@ private void appendMultiLineStringText(MultiLineString multiLineString, EnumSet< * @param writer the output writer to append to * @param formatter the formatter to use for writing ordinate values. */ - private void appendMultiPolygonText( + protected void appendMultiPolygonText( MultiPolygon multiPolygon, EnumSet outputOrdinates, boolean useFormatting, int level, Writer writer, OrdinateFormat formatter) throws IOException @@ -946,7 +991,7 @@ private void indentCoords(boolean useFormatting, int coordIndex, int level, Wri indent(useFormatting, level, writer); } - private void indent(boolean useFormatting, int level, Writer writer) + protected void indent(boolean useFormatting, int level, Writer writer) throws IOException { if (! useFormatting || level <= 0) diff --git a/modules/core/src/test/java/org/locationtech/jts/io/WKTReaderExtensionHookTest.java b/modules/core/src/test/java/org/locationtech/jts/io/WKTReaderExtensionHookTest.java new file mode 100644 index 0000000000..ca40dcf61c --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/io/WKTReaderExtensionHookTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.io; + +import java.io.IOException; +import java.io.StreamTokenizer; +import java.io.Writer; +import java.util.EnumSet; + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Verifies the {@link WKTReader#readOtherGeometryText} and + * {@link WKTWriter#appendOtherGeometryTaggedText} extension hooks added + * for SFA / ISO 19125-2 extended geometry support, without taking any + * dependency on jts-curved. A dummy subclass of {@link WKTReader} + * recognises a made-up keyword and a dummy subclass of + * {@link WKTWriter} emits it; this confirms the seam is wired and the + * promoted helpers are accessible across packages. + */ +public class WKTReaderExtensionHookTest extends GeometryTestCase { + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTReaderExtensionHookTest.class); } + + public WKTReaderExtensionHookTest(String name) { super(name); } + + /** A reader that recognises a made-up {@code DUMMYTYPE} keyword and + * returns an empty Point. Exercises the protected helpers. */ + private static class DummyReader extends WKTReader { + boolean hookCalled = false; + + @Override + protected Geometry readOtherGeometryText(StreamTokenizer t, String type, EnumSet ord) + throws IOException, ParseException { + if ("DUMMYTYPE".equals(type)) { + hookCalled = true; + // Use the promoted-protected helpers from core. + String tok = getNextEmptyOrOpener(t); + if (!WKTConstants.EMPTY.equals(tok)) { + // burn through the body to a balanced ')' + int depth = 1; + while (depth > 0) { + int c = t.nextToken(); + if (c == '(') depth++; + else if (c == ')') depth--; + else if (c == StreamTokenizer.TT_EOF) + throw parseErrorWithLine(t, "Unexpected EOF in DUMMYTYPE body"); + } + } + return geometryFactory.createPoint(); + } + return super.readOtherGeometryText(t, type, ord); + } + } + + /** A writer that intercepts a dummy custom geometry type. */ + private static class DummyWriter extends WKTWriter { + boolean hookCalled = false; + + @Override + protected boolean appendOtherGeometryTaggedText(Geometry geometry, EnumSet outputOrdinates, + boolean useFormatting, int level, Writer writer, OrdinateFormat formatter) throws IOException { + // Pretend we have a custom type: any Geometry whose toString starts with "POINT" + // (i.e. all Points) should be emitted with our marker. This confirms the hook + // runs before the instanceof ladder. + if ("Point".equals(geometry.getGeometryType()) && !geometry.isEmpty()) { + writer.write("DUMMYTYPE EMPTY"); + return true; + } + return false; + } + } + + public void testReaderHookIsInvokedForUnknownType() throws Exception { + DummyReader reader = new DummyReader(); + Geometry g = reader.read("DUMMYTYPE EMPTY"); + assertTrue("readOtherGeometryText should have been called", reader.hookCalled); + assertEquals("Point", g.getGeometryType()); + } + + public void testReaderHookHandlesParenthesisedBody() throws Exception { + DummyReader reader = new DummyReader(); + Geometry g = reader.read("DUMMYTYPE (1 2, 3 4)"); + assertTrue(reader.hookCalled); + assertNotNull(g); + } + + public void testReaderHookFallsThroughToCoreError() { + try { + new DummyReader().read("UNKNOWNTYPE EMPTY"); + fail("Expected ParseException for unknown type"); + } catch (Throwable e) { + assertTrue("Expected ParseException, got: " + e, e instanceof ParseException); + } + } + + public void testCoreReaderStillThrowsForUnknownType() { + try { + new WKTReader().read("DUMMYTYPE EMPTY"); + fail("Expected ParseException from default WKTReader"); + } catch (Throwable e) { + assertTrue("Expected ParseException, got: " + e, e instanceof ParseException); + } + } + + public void testWriterHookFiresBeforeInstanceofLadder() throws Exception { + DummyWriter writer = new DummyWriter(); + Geometry pt = read("POINT (1 2)"); + String wkt = writer.write(pt); + assertEquals("Hook should have intercepted before the Point branch", + "DUMMYTYPE EMPTY", wkt); + } + + public void testWriterDefaultHookReturnsFalse() throws Exception { + // The default WKTWriter must keep writing "POINT (...)" — confirms the hook + // returning false does not skip the standard path. + Geometry pt = read("POINT (1 2)"); + String wkt = new WKTWriter().write(pt); + assertTrue("Default writer should emit POINT, got: " + wkt, wkt.toUpperCase().startsWith("POINT")); + } +} diff --git a/modules/curved/README.md b/modules/curved/README.md new file mode 100644 index 0000000000..eb2872dfc1 --- /dev/null +++ b/modules/curved/README.md @@ -0,0 +1,146 @@ +# jts-curved + +Opt-in JTS module providing the OGC Simple Features Access (SFA) / +ISO 19125-2 extended geometry types and a curve-aware WKT reader/writer. + +## What it adds + +| Geometry type | Java class | Extends | +|--------------------|---------------------------------------------------------|---------------------------------| +| `CircularString` | `org.locationtech.jts.geom.curved.CircularString` | `LineString` | +| `CompoundCurve` | `org.locationtech.jts.geom.curved.CompoundCurve` | `LineString` | +| `CurvePolygon` | `org.locationtech.jts.geom.curved.CurvePolygon` | `Polygon` | +| `MultiCurve` | `org.locationtech.jts.geom.curved.MultiCurve` | `MultiLineString` | +| `MultiSurface` | `org.locationtech.jts.geom.curved.MultiSurface` | `MultiPolygon` | +| `Triangle` | `org.locationtech.jts.geom.curved.Triangle` | `Polygon` | +| `PolyhedralSurface`| `org.locationtech.jts.geom.curved.PolyhedralSurface` | `MultiPolygon` | +| `Tin` | `org.locationtech.jts.geom.curved.Tin` | `PolyhedralSurface` | + +Plus: + +- `CurvedGeometryFactory` — extends `GeometryFactory`, adds `createCircularString(...)`, `createTriangle(...)`, etc. +- `CurvedWKTReader` — extends `WKTReader`, recognises the eight new keywords via the core `readOtherGeometryText` extension hook. +- `CurvedWKTWriter` — extends `WKTWriter`. Phase-1 marker: the core writer already emits subclass keywords via `Geometry.getGeometryType().toUpperCase()`. +- `Linearizable` interface — `Geometry toLinear(double tolerance)` for converting a curved geometry into a non-curved approximation. + +The naming collision with the long-standing static-utility class +`org.locationtech.jts.geom.Triangle` (centroid, circumradius, etc.) is +resolved by package separation: that utility is preserved unchanged in +core; the geometry type lives in `org.locationtech.jts.geom.curved`. + +## Usage + +```java +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.curved.CurvedGeometryFactory; +import org.locationtech.jts.geom.curved.Linearizable; +import org.locationtech.jts.io.curved.CurvedWKTReader; +import org.locationtech.jts.io.curved.CurvedWKTWriter; + +CurvedGeometryFactory factory = new CurvedGeometryFactory(); +CurvedWKTReader reader = new CurvedWKTReader(factory); + +Geometry g = reader.read("CIRCULARSTRING(1 5, 6 2, 7 3)"); +System.out.println(g.getGeometryType()); // CircularString + +String wkt = new CurvedWKTWriter().write(g); +// wkt: CIRCULARSTRING (1 5, 6 2, 7 3) + +// Linearise to a non-curved approximation +Geometry linear = ((Linearizable) g).toLinear(0.0); +System.out.println(linear.getGeometryType()); // LineString +``` + +The standard `WKTReader` continues to throw `ParseException("Unknown +geometry type: CIRCULARSTRING")` for the new keywords; a caller has to +opt in by instantiating `CurvedWKTReader`. + +## Maven coordinates + +```xml + + org.locationtech.jts + jts-curved + 1.20.1-SNAPSHOT + +``` + +## Phase 1 limitations + +The current implementation is intentionally minimal so the module can +land alongside the core extension hooks without dragging in a years-long +algorithm program. Known limitations: + +- **Spatial operations fall through to the parent type.** A + `CircularString.intersects(g)` is computed against the polyline + formed by the control points, not against the actual arcs. Use + `Linearizable.toLinear(tolerance)` to make this explicit. +- **`CompoundCurve` member structure is collapsed** to a flat + concatenation of control points on read. The writer emits this flat + form too, and the reader accepts both the flat form and the OGC + member-structured form on input. +- **`CurvePolygon` / `MultiSurface` round-trip degrades inner curve + members.** Re-reading a written `MULTISURFACE(CURVEPOLYGON(...))` + yields `MultiSurface[Polygon]` rather than + `MultiSurface[CurvePolygon]`, because the writer does not yet emit + inner-member tags. Tests use `Linearizable.toLinear(...)` for + structural-fidelity comparison. +- **Validation is best-effort.** Structural rules (Triangle 4-point + ring, CircularString odd point count, CompoundCurve member + connectivity, Tin triangle-only patches) are not enforced. +- **No WKB support.** Defer to a follow-up phase for the SFA-MM type + codes (8/9/10/11/12/15/16/17 with Z/M/ZM variants). +- **`copy()` preserves the subclass** for top-level types via + overridden `copyInternal()`, but `Polygon.isEquivalentClass` is + strict — a `Polygon` is *not* `equalsExact` to a `CurvePolygon` with + identical coordinates. (The same comparison is lenient for + `LineString` subclasses.) Tests work around this where it matters. +- **No `JTSTestBuilder` UI integration yet.** + +## Discovery + +This module deliberately does **not** register itself via +`ServiceLoader` or any other automatic-discovery mechanism. Callers +explicitly instantiate `CurvedWKTReader` / `CurvedWKTWriter` / +`CurvedGeometryFactory` when they want curve support. This keeps the +module GraalVM native-image friendly and avoids surprising other +classpath users. + +## Verifying the JTSTestBuilder integration + +Phase 4-A wires `JTSTestBuilder` to use the curve-aware reader and +factory on every WKT-parsing path, so curved WKT round-trips through +the existing UI with no new controls. To smoke-test a build: + +```sh +mvn -B -q install -DskipTests -Dcheckstyle.skip=true +java -jar modules/app/target/JTSTestBuilder.jar +``` + +Then, for each row below, paste the WKT into **Input A**, click +**Load**, and check the geometry-tree label on the left: + +| Paste this WKT | Expected type | +|------------------------------------------------------------------|--------------------| +| `CIRCULARSTRING(1 5, 6 2, 7 3)` | `CircularString` | +| `COMPOUNDCURVE((5 3, 5 13), CIRCULARSTRING(5 13, 7 15, 9 13))` | `CompoundCurve` | +| `CURVEPOLYGON(CIRCULARSTRING(0 0, 4 0, 4 4, 0 4, 0 0))` | `CurvePolygon` | +| `MULTICURVE((0 0, 1 1), CIRCULARSTRING(2 2, 3 3, 4 2))` | `MultiCurve` | +| `MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 4 0, 4 4, 0 4, 0 0)))` | `MultiSurface` | +| `TRIANGLE((0 0, 1 0, 0 1, 0 0))` | `Triangle` | +| `POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)))` | `PolyhedralSurface`| +| `TIN(((0 0, 1 0, 0 1, 0 0)), ((1 0, 1 1, 0 1, 1 0)))` | `Tin` | +| `CIRCULARSTRING ZM(1 2 3 4, 5 6 7 8, 9 10 11 12)` | `CircularString` | + +Save the case (`File ▸ Save…`), reopen it, and confirm the tree +labels survive the round-trip. Spatial functions in the **Geometry +Functions** panel still operate on each curve's polyline / polygon +parent (per the phase-1 contract); the explicit linearised form is +available programmatically via `((Linearizable) g).toLinear(0.0)`. +A function-panel binding and drawing tools are deferred to Phase 4-B. + +## References + +- Discussion: +- Design template: NetTopologySuite/NetTopologySuite#526 +- Specification: OGC Simple Features Access 1.2.1 / ISO 19125-2 diff --git a/modules/curved/pom.xml b/modules/curved/pom.xml new file mode 100644 index 0000000000..09e19716af --- /dev/null +++ b/modules/curved/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + org.locationtech.jts + jts-modules + 1.20.1-SNAPSHOT + + org.locationtech.jts + jts-curved + ${project.groupId}:${project.artifactId} + jar + + + OGC SFA / ISO 19125-2 extended geometry types (CircularString, + CompoundCurve, CurvePolygon, MultiCurve, MultiSurface, Triangle, + PolyhedralSurface, Tin) and the corresponding WKT readers/writers. + Built on top of the extension hooks in jts-core; opt-in. + + + + + + maven-jar-plugin + + + + org.locationtech.jts.curved + + + + org.locationtech.jts.curved + + true + + + + + + + + + + + + org.locationtech.jts + jts-core + ${project.version} + + + org.locationtech.jts + jts-core + ${project.version} + tests + test + + + junit + junit + test + + + diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CircularString.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CircularString.java new file mode 100644 index 0000000000..538986103b --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CircularString.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * A connected sequence of circular arcs, where each consecutive triple of + * control points (start, mid, end) defines one arc and the end point of one + * arc is the start point of the next. + *

    + * This is a phase-1 stand-in: the control points are stored as a single + * {@link CoordinateSequence} (inherited via {@link LineString}) and spatial + * operations fall through to the parent's polyline behaviour. Native + * arc-aware algorithms are out of scope for this module today. + */ +public class CircularString extends LineString implements Linearizable { + private static final long serialVersionUID = 1L; + + public CircularString(CoordinateSequence points, GeometryFactory factory) { + super(points, factory); + } + + @Override + public String getGeometryType() { + return "CircularString"; + } + + @Override + protected CircularString copyInternal() { + return new CircularString(getCoordinateSequence().copy(), getFactory()); + } + + @Override + public Geometry toLinear(double tolerance) { + return getFactory().createLineString(getCoordinateSequence().copy()); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CompoundCurve.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CompoundCurve.java new file mode 100644 index 0000000000..e62991d13a --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CompoundCurve.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; + +/** + * A connected sequence of {@link LineString} and {@link CircularString} + * segments. Phase-1 stand-in: member structure is collapsed to a flat + * concatenation of control points. A future phase will preserve segments. + */ +public class CompoundCurve extends LineString implements Linearizable { + private static final long serialVersionUID = 1L; + + public CompoundCurve(CoordinateSequence points, GeometryFactory factory) { + super(points, factory); + } + + @Override + public String getGeometryType() { + return "CompoundCurve"; + } + + @Override + protected CompoundCurve copyInternal() { + return new CompoundCurve(getCoordinateSequence().copy(), getFactory()); + } + + @Override + public Geometry toLinear(double tolerance) { + return getFactory().createLineString(getCoordinateSequence().copy()); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvePolygon.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvePolygon.java new file mode 100644 index 0000000000..91de2c3ac2 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvePolygon.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; + +/** + * A polygon whose rings may be straight, circular, or compound curves. + * Phase-1 stand-in: rings are linearised to {@link LinearRing}s on read. + */ +public class CurvePolygon extends Polygon implements Linearizable { + private static final long serialVersionUID = 1L; + + public CurvePolygon(LinearRing shell, LinearRing[] holes, GeometryFactory factory) { + super(shell, holes, factory); + } + + public CurvePolygon(GeometryFactory factory) { + super(null, null, factory); + } + + @Override + public String getGeometryType() { + return "CurvePolygon"; + } + + @Override + protected CurvePolygon copyInternal() { + GeometryFactory f = getFactory(); + if (isEmpty()) return new CurvePolygon(f); + LinearRing shell = (LinearRing) getExteriorRing().copy(); + int holeCount = getNumInteriorRing(); + LinearRing[] holes = new LinearRing[holeCount]; + for (int i = 0; i < holeCount; i++) { + holes[i] = (LinearRing) getInteriorRingN(i).copy(); + } + return new CurvePolygon(shell, holes, f); + } + + @Override + public Geometry toLinear(double tolerance) { + GeometryFactory f = getFactory(); + if (isEmpty()) return f.createPolygon(); + LinearRing shell = (LinearRing) getExteriorRing().copy(); + int holeCount = getNumInteriorRing(); + LinearRing[] holes = new LinearRing[holeCount]; + for (int i = 0; i < holeCount; i++) { + holes[i] = (LinearRing) getInteriorRingN(i).copy(); + } + return f.createPolygon(shell, holes); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvedGeometryFactory.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvedGeometryFactory.java new file mode 100644 index 0000000000..a052b47d20 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/CurvedGeometryFactory.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.CoordinateSequenceFactory; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.PrecisionModel; + +/** + * A {@link GeometryFactory} subclass with creation methods for the + * extended OGC SFA / ISO 19125-2 geometry types implemented in + * {@code jts-curved}: {@link CircularString}, {@link CompoundCurve}, + * {@link CurvePolygon}, {@link MultiCurve}, {@link MultiSurface}, + * {@link Triangle}, {@link PolyhedralSurface}, and {@link Tin}. + *

    + * Behaves identically to {@link GeometryFactory} for all standard + * (non-curved) types. Use this factory when constructing curved + * geometries programmatically; pair it with {@link + * org.locationtech.jts.io.curved.CurvedWKTReader} when reading WKT. + */ +public class CurvedGeometryFactory extends GeometryFactory { + + public CurvedGeometryFactory() { + super(); + } + + public CurvedGeometryFactory(PrecisionModel pm) { + super(pm); + } + + public CurvedGeometryFactory(PrecisionModel pm, int srid) { + super(pm, srid); + } + + public CurvedGeometryFactory(PrecisionModel pm, int srid, CoordinateSequenceFactory csf) { + super(pm, srid, csf); + } + + public CurvedGeometryFactory(CoordinateSequenceFactory csf) { + super(csf); + } + + public CircularString createCircularString(CoordinateSequence points) { + return new CircularString(points, this); + } + + public CompoundCurve createCompoundCurve(CoordinateSequence points) { + return new CompoundCurve(points, this); + } + + public CurvePolygon createCurvePolygon() { + return new CurvePolygon(this); + } + + public CurvePolygon createCurvePolygon(LinearRing shell) { + return new CurvePolygon(shell, null, this); + } + + public CurvePolygon createCurvePolygon(LinearRing shell, LinearRing[] holes) { + return new CurvePolygon(shell, holes, this); + } + + public MultiCurve createMultiCurve(LineString[] members) { + return new MultiCurve(members, this); + } + + public MultiSurface createMultiSurface(Polygon[] members) { + return new MultiSurface(members, this); + } + + public Triangle createTriangle() { + return new Triangle(this); + } + + public Triangle createTriangle(LinearRing shell) { + return new Triangle(shell, this); + } + + public PolyhedralSurface createPolyhedralSurface(Polygon[] patches) { + return new PolyhedralSurface(patches, this); + } + + public Tin createTin(Polygon[] patches) { + return new Tin(patches, this); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Linearizable.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Linearizable.java new file mode 100644 index 0000000000..8cf29724fa --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Linearizable.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; + +/** + * Implemented by geometry types that can be approximated by a non-curved + * (linear) geometry to within a given coordinate tolerance. + *

    + * In the phase-1 jts-curved implementation, curve geometries are stored + * as their control points and {@link #toLinear(double)} returns a + * parent-type geometry built from those control points (no real arc + * densification is performed). The interface is published now so that + * downstream consumers can write code that survives the phase where + * native arc-aware representations land. + * + *

    Tolerance

    + * Implementations should treat tolerance as the maximum + * permissible distance between the original curved geometry and its + * linear approximation. A value of 0.0 means "use the + * implementation's default tolerance". Negative values are reserved. + */ +public interface Linearizable { + + /** + * Returns a non-curved geometry that approximates this geometry to + * within {@code tolerance} units of distance. + * + * @param tolerance maximum permissible distance between original and + * approximation; 0.0 selects the + * implementation default + * @return a linearised {@link Geometry} (never {@code null}) + */ + Geometry toLinear(double tolerance); +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiCurve.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiCurve.java new file mode 100644 index 0000000000..03c73f8b49 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiCurve.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.MultiLineString; + +/** + * A collection of {@link LineString}, {@link CircularString} and + * {@link CompoundCurve} members. + */ +public class MultiCurve extends MultiLineString implements Linearizable { + private static final long serialVersionUID = 1L; + + public MultiCurve(LineString[] members, GeometryFactory factory) { + super(members, factory); + } + + @Override + public String getGeometryType() { + return "MultiCurve"; + } + + @Override + protected MultiCurve copyInternal() { + int n = getNumGeometries(); + LineString[] members = new LineString[n]; + for (int i = 0; i < n; i++) { + members[i] = (LineString) getGeometryN(i).copy(); + } + return new MultiCurve(members, getFactory()); + } + + @Override + public Geometry toLinear(double tolerance) { + GeometryFactory f = getFactory(); + int n = getNumGeometries(); + LineString[] linearMembers = new LineString[n]; + for (int i = 0; i < n; i++) { + Geometry m = getGeometryN(i); + if (m instanceof Linearizable) { + linearMembers[i] = (LineString) ((Linearizable) m).toLinear(tolerance); + } else { + linearMembers[i] = (LineString) m.copy(); + } + } + return f.createMultiLineString(linearMembers); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiSurface.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiSurface.java new file mode 100644 index 0000000000..4e0ac6cf15 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/MultiSurface.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +/** A collection of {@link Polygon} and {@link CurvePolygon} members. */ +public class MultiSurface extends MultiPolygon implements Linearizable { + private static final long serialVersionUID = 1L; + + public MultiSurface(Polygon[] members, GeometryFactory factory) { + super(members, factory); + } + + @Override + public String getGeometryType() { + return "MultiSurface"; + } + + @Override + protected MultiSurface copyInternal() { + int n = getNumGeometries(); + Polygon[] members = new Polygon[n]; + for (int i = 0; i < n; i++) { + members[i] = (Polygon) getGeometryN(i).copy(); + } + return new MultiSurface(members, getFactory()); + } + + @Override + public Geometry toLinear(double tolerance) { + GeometryFactory f = getFactory(); + int n = getNumGeometries(); + Polygon[] linearMembers = new Polygon[n]; + for (int i = 0; i < n; i++) { + Geometry m = getGeometryN(i); + if (m instanceof Linearizable) { + linearMembers[i] = (Polygon) ((Linearizable) m).toLinear(tolerance); + } else { + linearMembers[i] = (Polygon) m.copy(); + } + } + return f.createMultiPolygon(linearMembers); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/PolyhedralSurface.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/PolyhedralSurface.java new file mode 100644 index 0000000000..84a3798544 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/PolyhedralSurface.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +/** A contiguous collection of polygonal patches that share edges. */ +public class PolyhedralSurface extends MultiPolygon implements Linearizable { + private static final long serialVersionUID = 1L; + + public PolyhedralSurface(Polygon[] patches, GeometryFactory factory) { + super(patches, factory); + } + + @Override + public String getGeometryType() { + return "PolyhedralSurface"; + } + + @Override + protected PolyhedralSurface copyInternal() { + int n = getNumGeometries(); + Polygon[] patches = new Polygon[n]; + for (int i = 0; i < n; i++) { + patches[i] = (Polygon) getGeometryN(i).copy(); + } + return new PolyhedralSurface(patches, getFactory()); + } + + @Override + public Geometry toLinear(double tolerance) { + GeometryFactory f = getFactory(); + int n = getNumGeometries(); + Polygon[] patches = new Polygon[n]; + for (int i = 0; i < n; i++) { + patches[i] = (Polygon) getGeometryN(i).copy(); + } + return f.createMultiPolygon(patches); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Tin.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Tin.java new file mode 100644 index 0000000000..c8edcb138d --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Tin.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Polygon; + +/** A {@link PolyhedralSurface} whose patches are all triangles (TIN). */ +public class Tin extends PolyhedralSurface { + private static final long serialVersionUID = 1L; + + public Tin(Polygon[] patches, GeometryFactory factory) { + super(patches, factory); + } + + @Override + public String getGeometryType() { + return "Tin"; + } + + @Override + protected Tin copyInternal() { + int n = getNumGeometries(); + Polygon[] patches = new Polygon[n]; + for (int i = 0; i < n; i++) { + patches[i] = (Polygon) getGeometryN(i).copy(); + } + return new Tin(patches, getFactory()); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Triangle.java b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Triangle.java new file mode 100644 index 0000000000..84bb3d5472 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/geom/curved/Triangle.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.geom.curved; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; + +/** + * A planar triangle as defined by OGC SFA / ISO 19125-2: a {@link Polygon} + * with a single 4-point closed exterior ring and no holes. + *

    + * Note: the unrelated static-utility class + * {@code org.locationtech.jts.geom.Triangle} (centroid, circumradius, etc.) + * is preserved unchanged in jts-core. The geometry type lives here in the + * curved package to avoid that name collision. + */ +public class Triangle extends Polygon implements Linearizable { + private static final long serialVersionUID = 1L; + + public Triangle(LinearRing shell, GeometryFactory factory) { + super(shell, null, factory); + } + + public Triangle(GeometryFactory factory) { + super(null, null, factory); + } + + @Override + public String getGeometryType() { + return "Triangle"; + } + + @Override + protected Triangle copyInternal() { + GeometryFactory f = getFactory(); + if (isEmpty()) return new Triangle(f); + return new Triangle((LinearRing) getExteriorRing().copy(), f); + } + + @Override + public Geometry toLinear(double tolerance) { + GeometryFactory f = getFactory(); + if (isEmpty()) return f.createPolygon(); + return f.createPolygon((LinearRing) getExteriorRing().copy()); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTReader.java b/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTReader.java new file mode 100644 index 0000000000..8fe4700221 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTReader.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.io.curved; + +import java.io.IOException; +import java.io.StreamTokenizer; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.curved.CircularString; +import org.locationtech.jts.geom.curved.CompoundCurve; +import org.locationtech.jts.geom.curved.CurvePolygon; +import org.locationtech.jts.geom.curved.MultiCurve; +import org.locationtech.jts.geom.curved.MultiSurface; +import org.locationtech.jts.geom.curved.PolyhedralSurface; +import org.locationtech.jts.geom.curved.Tin; +import org.locationtech.jts.geom.curved.Triangle; +import org.locationtech.jts.io.Ordinate; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTConstants; +import org.locationtech.jts.io.WKTReader; + +/** + * A {@link WKTReader} subclass that recognises the OGC SFA / ISO 19125-2 + * extended geometry types via the {@code readOtherGeometryText} extension + * point in core: + *

      + *
    • {@code CIRCULARSTRING}
    • + *
    • {@code COMPOUNDCURVE}
    • + *
    • {@code CURVEPOLYGON}
    • + *
    • {@code MULTICURVE}
    • + *
    • {@code MULTISURFACE}
    • + *
    • {@code TRIANGLE}
    • + *
    • {@code POLYHEDRALSURFACE}
    • + *
    • {@code TIN}
    • + *
    + *

    + * This is a phase-1 implementation: composite types (CompoundCurve, + * CurvePolygon, MultiCurve, MultiSurface) collapse member structure to + * concatenated coordinates / linearised rings on read. The classes are + * structurally simple wrappers over their parent geometry types so the + * existing JTS algorithm suite continues to work, treating curves as + * polylines and curve-bounded surfaces as polygons. + */ +public class CurvedWKTReader extends WKTReader { + + private static final String L_PAREN = "("; + + public CurvedWKTReader() { + super(); + } + + public CurvedWKTReader(GeometryFactory geometryFactory) { + super(geometryFactory); + } + + @Override + protected Geometry readOtherGeometryText(StreamTokenizer tokenizer, String type, EnumSet ordinateFlags) + throws IOException, ParseException { + if (isTypeName(tokenizer, type, WKTConstants.TRIANGLE)) { + return readTriangleText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.POLYHEDRALSURFACE)) { + return readPolyhedralSurfaceText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.TIN)) { + return readTinText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.CIRCULARSTRING)) { + return readCircularStringText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.COMPOUNDCURVE)) { + return readCompoundCurveText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.CURVEPOLYGON)) { + return readCurvePolygonText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.MULTICURVE)) { + return readMultiCurveText(tokenizer, ordinateFlags); + } + if (isTypeName(tokenizer, type, WKTConstants.MULTISURFACE)) { + return readMultiSurfaceText(tokenizer, ordinateFlags); + } + return super.readOtherGeometryText(tokenizer, type, ordinateFlags); + } + + private Triangle readTriangleText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + Polygon p = readPolygonText(tokenizer, ordinateFlags); + if (p.isEmpty()) return new Triangle(geometryFactory); + return new Triangle((LinearRing) p.getExteriorRing(), geometryFactory); + } + + private PolyhedralSurface readPolyhedralSurfaceText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + return new PolyhedralSurface(readPolygonArray(tokenizer, ordinateFlags), geometryFactory); + } + + private Tin readTinText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + return new Tin(readPolygonArray(tokenizer, ordinateFlags), geometryFactory); + } + + private Polygon[] readPolygonArray(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String tok = getNextEmptyOrOpener(tokenizer); + if (tok.equals(WKTConstants.EMPTY)) return new Polygon[0]; + List polygons = new ArrayList(); + do { + polygons.add(readPolygonText(tokenizer, ordinateFlags)); + tok = getNextCloserOrComma(tokenizer); + } while (tok.equals(",")); + return polygons.toArray(new Polygon[0]); + } + + private CircularString readCircularStringText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + LineString ls = readLineStringText(tokenizer, ordinateFlags); + return new CircularString(ls.getCoordinateSequence(), geometryFactory); + } + + private CompoundCurve readCompoundCurveText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String tok = getNextEmptyOrOpener(tokenizer); + if (tok.equals(WKTConstants.EMPTY)) { + return new CompoundCurve(createCoordinateSequenceEmpty(ordinateFlags), geometryFactory); + } + // Choose between SFA-structured form `((...), CIRCULARSTRING(...), ...)` + // and the writer's flat round-trip form `(p, p, p, ...)`. + String w = lookAheadWord(tokenizer); + if (!w.equals(L_PAREN) && !isCurveMemberTag(w)) { + List coords = new ArrayList(); + do { + coords.add(getCoordinate(tokenizer, ordinateFlags, false)); + } while (getNextCloserOrComma(tokenizer).equals(",")); + return new CompoundCurve(csFactory.create(coords.toArray(new Coordinate[0])), geometryFactory); + } + List all = new ArrayList(); + do { + Coordinate[] cc = readCurveMember(tokenizer, ordinateFlags).getCoordinates(); + int start = all.isEmpty() ? 0 : 1; + for (int i = start; i < cc.length; i++) all.add(cc[i]); + tok = getNextCloserOrComma(tokenizer); + } while (tok.equals(",")); + CoordinateSequence seq = csFactory.create(all.toArray(new Coordinate[0])); + return new CompoundCurve(seq, geometryFactory); + } + + private CurvePolygon readCurvePolygonText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String tok = getNextEmptyOrOpener(tokenizer); + if (tok.equals(WKTConstants.EMPTY)) return new CurvePolygon(geometryFactory); + List rings = new ArrayList(); + do { + Coordinate[] coords = readCurveMember(tokenizer, ordinateFlags).getCoordinates(); + rings.add(geometryFactory.createLinearRing(coords)); + tok = getNextCloserOrComma(tokenizer); + } while (tok.equals(",")); + LinearRing shell = rings.remove(0); + return new CurvePolygon(shell, rings.toArray(new LinearRing[0]), geometryFactory); + } + + private MultiCurve readMultiCurveText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String tok = getNextEmptyOrOpener(tokenizer); + if (tok.equals(WKTConstants.EMPTY)) return new MultiCurve(new LineString[0], geometryFactory); + List members = new ArrayList(); + do { + members.add(readCurveMember(tokenizer, ordinateFlags)); + tok = getNextCloserOrComma(tokenizer); + } while (tok.equals(",")); + return new MultiCurve(members.toArray(new LineString[0]), geometryFactory); + } + + private MultiSurface readMultiSurfaceText(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String tok = getNextEmptyOrOpener(tokenizer); + if (tok.equals(WKTConstants.EMPTY)) return new MultiSurface(new Polygon[0], geometryFactory); + List members = new ArrayList(); + do { + members.add(readSurfaceMember(tokenizer, ordinateFlags)); + tok = getNextCloserOrComma(tokenizer); + } while (tok.equals(",")); + return new MultiSurface(members.toArray(new Polygon[0]), geometryFactory); + } + + /** Reads a curve aggregate member: untagged {@code (...)}, tagged + * CIRCULARSTRING / COMPOUNDCURVE, or EMPTY. Returns a LineString. */ + private LineString readCurveMember(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String w = lookAheadWord(tokenizer); + if (w.equals(L_PAREN)) return readLineStringText(tokenizer, ordinateFlags); + if (w.equals(WKTConstants.EMPTY)) { + getNextWord(tokenizer); + return geometryFactory.createLineString(createCoordinateSequenceEmpty(ordinateFlags)); + } + String type = getNextWord(tokenizer).toUpperCase(Locale.ROOT); + Geometry g = readGeometryTaggedText(tokenizer, type, ordinateFlags); + if (g instanceof LineString) return (LineString) g; + throw parseErrorWithLine(tokenizer, "Expected curve member but got " + type); + } + + /** Reads a surface aggregate member: untagged polygon body or tagged CURVEPOLYGON. */ + private Polygon readSurfaceMember(StreamTokenizer tokenizer, EnumSet ordinateFlags) + throws IOException, ParseException { + String w = lookAheadWord(tokenizer); + if (w.equals(L_PAREN)) return readPolygonText(tokenizer, ordinateFlags); + String type = getNextWord(tokenizer).toUpperCase(Locale.ROOT); + Geometry g = readGeometryTaggedText(tokenizer, type, ordinateFlags); + if (g instanceof Polygon) return (Polygon) g; + throw parseErrorWithLine(tokenizer, "Expected surface member but got " + type); + } + + private static boolean isCurveMemberTag(String w) { + return w.equalsIgnoreCase(WKTConstants.CIRCULARSTRING) + || w.equalsIgnoreCase(WKTConstants.COMPOUNDCURVE); + } +} diff --git a/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTWriter.java b/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTWriter.java new file mode 100644 index 0000000000..6ff0951a42 --- /dev/null +++ b/modules/curved/src/main/java/org/locationtech/jts/io/curved/CurvedWKTWriter.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ +package org.locationtech.jts.io.curved; + +import org.locationtech.jts.io.WKTWriter; + +/** + * A {@link WKTWriter} subclass for the OGC SFA / ISO 19125-2 extended + * geometry types. + *

    + * In the current phase-1 implementation this class is a no-op marker: + * the curve geometry classes ({@code CircularString}, {@code Triangle}, + * {@code CurvePolygon}, etc.) extend their nearest core counterparts + * and the core {@code WKTWriter} already emits each subclass's keyword + * via {@code Geometry.getGeometryType().toUpperCase(Locale.ROOT)}. + *

    + * The class is provided here so that callers can pair {@code + * CurvedWKTReader} with {@code CurvedWKTWriter} symmetrically, and so + * that future enhancements (member-structured emission for + * {@code CompoundCurve}, etc.) can land here without changing caller + * code. + */ +public class CurvedWKTWriter extends WKTWriter { + + public CurvedWKTWriter() { + super(); + } + + public CurvedWKTWriter(int outputDimension) { + super(outputDimension); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCircularStringTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCircularStringTest.java new file mode 100644 index 0000000000..c1023a0d6d --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCircularStringTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code CircularString} geometry + * (OGC SFA / ISO 19125-2). + *

    + * These tests document the expected behavior via the {@link WKTReader} / + * {@link WKTWriter} public API. They fail against current JTS because + * the WKTReader does not recognize the {@code CIRCULARSTRING} keyword and + * the geometry implementation does not exist. + */ +public class WKTCircularStringTest extends GeometryTestCase { + + private static final String TYPENAME_CIRCULARSTRING = "CircularString"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTCircularStringTest.class); } + + public WKTCircularStringTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING(1 5, 6 2, 7 3)"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(3, g.getNumPoints()); + assertEquals(1, g.getDimension()); + assertEquals(0, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING Z(1 2 3, 4 5 6, 7 8 9)"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertEquals(3.0, g.getCoordinates()[0].getZ(), 0.0); + assertEquals(6.0, g.getCoordinates()[1].getZ(), 0.0); + } + + public void testReadXYM() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING M(1 2 7, 4 5 8, 7 8 9)"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertEquals(7.0, g.getCoordinates()[0].getM(), 0.0); + assertEquals(8.0, g.getCoordinates()[1].getM(), 0.0); + } + + public void testReadXYZM() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING ZM(1 2 3 4, 5 6 7 8, 9 10 11 12)"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertEquals(3.0, g.getCoordinates()[0].getZ(), 0.0); + assertEquals(4.0, g.getCoordinates()[0].getM(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING EMPTY"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertTrue(g.isEmpty()); + assertEquals(0, g.getNumPoints()); + } + + public void testReadEmptyZ() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING Z EMPTY"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "CIRCULARSTRING (1 5, 6 2, 7 3)"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to start with CIRCULARSTRING but was: " + emitted, + emitted.toUpperCase().contains("CIRCULARSTRING")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + public void testWKTRoundTripXYZM() throws Exception { + String wkt = "CIRCULARSTRING ZM (1 2 3 4, 5 6 7 8, 9 10 11 12)"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter(4).write(g); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqualXYZM(g, g2); + } + + /** + * Documents Phase-1 leniency: the parser does not enforce the OGC SFA rule + * that a CircularString must contain an odd number of points (each arc + * defined by a start/mid/end triple). A 4-point input parses without error. + *

    + * Tracked for the validation phase via the curve-awareness spec epic + * (sub-issue VAL-CS). When that lands this test should flip back to an + * explicit {@code expectThrows(ParseException)} — the assertion below will + * fail at that point, signalling the test author to update. + */ + public void testAcceptsEvenPointCountForNow() throws Exception { + Geometry g = new CurvedWKTReader().read("CIRCULARSTRING(0 0, 1 1, 2 0, 3 1)"); + assertEquals(TYPENAME_CIRCULARSTRING, g.getGeometryType()); + assertEquals(4, g.getNumPoints()); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCompoundCurveTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCompoundCurveTest.java new file mode 100644 index 0000000000..6e19a43966 --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCompoundCurveTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code CompoundCurve} geometry + * (OGC SFA / ISO 19125-2). A CompoundCurve is a connected sequence of + * LineStrings and CircularStrings. + */ +public class WKTCompoundCurveTest extends GeometryTestCase { + + private static final String TYPENAME_COMPOUNDCURVE = "CompoundCurve"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTCompoundCurveTest.class); } + + public WKTCompoundCurveTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "COMPOUNDCURVE((5 3, 5 13), CIRCULARSTRING(5 13, 7 15, 9 13), (9 13, 9 3))"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(1, g.getDimension()); + // open curve: boundary is its 2 endpoints (dim 0) + assertEquals(0, g.getBoundaryDimension()); + } + + public void testReadClosedXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "COMPOUNDCURVE((5 3, 5 13), CIRCULARSTRING(5 13, 7 15, 9 13), (9 13, 9 3), CIRCULARSTRING(9 3, 7 1, 5 3))"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + // closed curve: empty boundary (dim FALSE / -1) + assertEquals(-1, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "COMPOUNDCURVE Z((1 2 3, 4 5 6), CIRCULARSTRING(4 5 6, 7 8 9, 10 11 12))"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + assertEquals(3.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadXYZM() throws Exception { + Geometry g = new CurvedWKTReader().read( + "COMPOUNDCURVE ZM((1 2 3 4, 5 6 7 8), CIRCULARSTRING(5 6 7 8, 9 10 11 12, 13 14 15 16))"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + assertEquals(3.0, g.getCoordinates()[0].getZ(), 0.0); + assertEquals(4.0, g.getCoordinates()[0].getM(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("COMPOUNDCURVE EMPTY"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "COMPOUNDCURVE ((5 3, 5 13), CIRCULARSTRING (5 13, 7 15, 9 13))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention COMPOUNDCURVE but was: " + emitted, + emitted.toUpperCase().contains("COMPOUNDCURVE")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + /** + * Documents Phase-1 leniency around CompoundCurve member connectivity. + * The parser assumes adjacent members share endpoints and skips the first + * coordinate of each subsequent member without verifying. Disconnected + * input silently produces a CompoundCurve with the assumed-shared + * coordinate dropped — the 4-coord input below stores 3 coords. + *

    + * Tracked via the curve-awareness spec epic (sub-issue VAL-CC connectivity) + * and structurally fixed in the member-preservation phase, after which + * each member retains its own coordinates and the assertion below will + * fail (4 coords stored, not 3) — that's the cue to flip this back to an + * explicit {@code expectThrows(ParseException)}. + */ + public void testAcceptsDisconnectedMembersForNow() throws Exception { + Geometry g = new CurvedWKTReader().read( + "COMPOUNDCURVE((0 0, 1 1), (2 2, 3 3))"); + assertEquals(TYPENAME_COMPOUNDCURVE, g.getGeometryType()); + assertEquals(3, g.getNumPoints()); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCurvePolygonTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCurvePolygonTest.java new file mode 100644 index 0000000000..c782dfb77f --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTCurvePolygonTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code CurvePolygon} geometry + * (OGC SFA / ISO 19125-2). A CurvePolygon is a polygon whose rings may + * be CircularStrings, CompoundCurves, or LineStrings. + */ +public class WKTCurvePolygonTest extends GeometryTestCase { + + private static final String TYPENAME_CURVEPOLYGON = "CurvePolygon"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTCurvePolygonTest.class); } + + public WKTCurvePolygonTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "CURVEPOLYGON(CIRCULARSTRING(0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 3 3, 3 1, 1 1))"); + assertEquals(TYPENAME_CURVEPOLYGON, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(2, g.getDimension()); + assertEquals(1, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "CURVEPOLYGON Z(CIRCULARSTRING(0 0 0, 4 0 0, 4 4 0, 0 4 0, 0 0 0), (1 1 0, 3 3 0, 3 1 0, 1 1 0))"); + assertEquals(TYPENAME_CURVEPOLYGON, g.getGeometryType()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadCompoundCurveRing() throws Exception { + Geometry g = new CurvedWKTReader().read( + "CURVEPOLYGON(COMPOUNDCURVE(CIRCULARSTRING(0 0, 1 1, 2 0), (2 0, 0 0)))"); + assertEquals(TYPENAME_CURVEPOLYGON, g.getGeometryType()); + assertFalse(g.isEmpty()); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("CURVEPOLYGON EMPTY"); + assertEquals(TYPENAME_CURVEPOLYGON, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testReadEmptyZM() throws Exception { + Geometry g = new CurvedWKTReader().read("CURVEPOLYGON ZM EMPTY"); + assertEquals(TYPENAME_CURVEPOLYGON, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "CURVEPOLYGON (CIRCULARSTRING (0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 3 3, 3 1, 1 1))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention CURVEPOLYGON but was: " + emitted, + emitted.toUpperCase().contains("CURVEPOLYGON")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + /** + * The CurvePolygon outer ring must be closed (first point == last point). + * This rule is enforced today by the LinearRing factory inside + * {@code readCurvePolygonText}, which throws {@link IllegalArgumentException} + * for non-closed coordinate sequences. + */ + public void testRejectsUnclosedRing() throws Exception { + assertNotNull(new CurvedWKTReader().read("CURVEPOLYGON((0 0, 1 0, 1 1, 0 1, 0 0))")); + try { + new CurvedWKTReader().read("CURVEPOLYGON((0 0, 1 0, 1 1, 0 1))"); + fail("Expected parse failure for unclosed CURVEPOLYGON ring"); + } catch (IllegalArgumentException e) { + // expected: LinearRing factory rejects non-closed coordinate sequences + } + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiCurveTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiCurveTest.java new file mode 100644 index 0000000000..e874cdc5f2 --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiCurveTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code MultiCurve} geometry + * (OGC SFA / ISO 19125-2). A MultiCurve is a collection of LineStrings, + * CircularStrings, and/or CompoundCurves. + */ +public class WKTMultiCurveTest extends GeometryTestCase { + + private static final String TYPENAME_MULTICURVE = "MultiCurve"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTMultiCurveTest.class); } + + public WKTMultiCurveTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTICURVE((5 5, 3 5, 3 3, 0 3), CIRCULARSTRING(0 0, 0.2 1, 0.5 1.4), COMPOUNDCURVE(CIRCULARSTRING(0 0, 1 1, 1 0), (1 0, 0 1)))"); + assertEquals(TYPENAME_MULTICURVE, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(3, g.getNumGeometries()); + assertEquals(1, g.getDimension()); + } + + public void testReadHomogeneousLineStrings() throws Exception { + Geometry g = new CurvedWKTReader().read("MULTICURVE((0 0, 1 1), (2 2, 3 3))"); + assertEquals(TYPENAME_MULTICURVE, g.getGeometryType()); + assertEquals(2, g.getNumGeometries()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTICURVE Z(CIRCULARSTRING(0 0 0, 1 1 0, 2 0 0), (3 3 0, 4 4 0))"); + assertEquals(TYPENAME_MULTICURVE, g.getGeometryType()); + assertEquals(2, g.getNumGeometries()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("MULTICURVE EMPTY"); + assertEquals(TYPENAME_MULTICURVE, g.getGeometryType()); + assertTrue(g.isEmpty()); + assertEquals(0, g.getNumGeometries()); + } + + public void testReadWithEmptyMember() throws Exception { + Geometry g = new CurvedWKTReader().read("MULTICURVE((0 0, 1 1), EMPTY, CIRCULARSTRING(2 2, 3 3, 4 2))"); + assertEquals(TYPENAME_MULTICURVE, g.getGeometryType()); + assertEquals(3, g.getNumGeometries()); + assertTrue(g.getGeometryN(1).isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "MULTICURVE ((5 5, 3 5, 3 3, 0 3), CIRCULARSTRING (0 0, 1 1, 2 0))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention MULTICURVE but was: " + emitted, + emitted.toUpperCase().contains("MULTICURVE")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiSurfaceTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiSurfaceTest.java new file mode 100644 index 0000000000..aff8fd1f91 --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTMultiSurfaceTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code MultiSurface} geometry + * (OGC SFA / ISO 19125-2). A MultiSurface is a collection of Polygons + * and/or CurvePolygons. + */ +public class WKTMultiSurfaceTest extends GeometryTestCase { + + private static final String TYPENAME_MULTISURFACE = "MultiSurface"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTMultiSurfaceTest.class); } + + public WKTMultiSurfaceTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTISURFACE(CURVEPOLYGON(CIRCULARSTRING(0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 3 3, 3 1, 1 1)), ((10 10, 12 10, 12 12, 10 12, 10 10)))"); + assertEquals(TYPENAME_MULTISURFACE, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(2, g.getNumGeometries()); + assertEquals(2, g.getDimension()); + assertEquals(1, g.getBoundaryDimension()); + } + + public void testReadHomogeneousPolygons() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTISURFACE(((0 0, 1 0, 1 1, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2)))"); + assertEquals(TYPENAME_MULTISURFACE, g.getGeometryType()); + assertEquals(2, g.getNumGeometries()); + } + + /** + * MULTISURFACE accepts a tagged TRIANGLE member because {@code Triangle} + * extends {@code Polygon} and {@code readSurfaceMember} dispatches via + * {@code instanceof Polygon}. Locks in that dispatch contract. + */ + public void testReadHeterogeneousWithTriangleMember() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTISURFACE(TRIANGLE((0 0, 1 0, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2)))"); + assertEquals(TYPENAME_MULTISURFACE, g.getGeometryType()); + assertEquals(2, g.getNumGeometries()); + assertEquals("Triangle", g.getGeometryN(0).getGeometryType()); + assertEquals("Polygon", g.getGeometryN(1).getGeometryType()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "MULTISURFACE Z(CURVEPOLYGON(CIRCULARSTRING(0 0 0, 4 0 0, 4 4 0, 0 4 0, 0 0 0)))"); + assertEquals(TYPENAME_MULTISURFACE, g.getGeometryType()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("MULTISURFACE EMPTY"); + assertEquals(TYPENAME_MULTISURFACE, g.getGeometryType()); + assertTrue(g.isEmpty()); + assertEquals(0, g.getNumGeometries()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "MULTISURFACE (CURVEPOLYGON (CIRCULARSTRING (0 0, 4 0, 4 4, 0 4, 0 0)))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention MULTISURFACE but was: " + emitted, + emitted.toUpperCase().contains("MULTISURFACE")); + Geometry g2 = new CurvedWKTReader().read(emitted); + // Phase-1: the writer collapses inner CurvePolygon members to untagged + // polygon bodies, so re-reading yields MultiSurface[Polygon] instead of + // MultiSurface[CurvePolygon]. Polygon.isEquivalentClass is strict, so a + // direct checkEqual against the original would fail (LineString's lenient + // isEquivalentClass masks the same issue inside MultiCurve). Verify + // structural fidelity instead via WKT stability and linearised equality. + String emitted2 = new CurvedWKTWriter().write(g2); + assertEquals(emitted, emitted2); + checkEqual( + ((org.locationtech.jts.geom.curved.Linearizable) g).toLinear(0), + ((org.locationtech.jts.geom.curved.Linearizable) g2).toLinear(0)); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTPolyhedralSurfaceTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTPolyhedralSurfaceTest.java new file mode 100644 index 0000000000..54dd773c51 --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTPolyhedralSurfaceTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code PolyhedralSurface} geometry + * (OGC SFA / ISO 19125-2). A PolyhedralSurface is a contiguous collection + * of polygonal patches sharing edges. + */ +public class WKTPolyhedralSurfaceTest extends GeometryTestCase { + + private static final String TYPENAME_POLYHEDRALSURFACE = "PolyhedralSurface"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTPolyhedralSurfaceTest.class); } + + public WKTPolyhedralSurfaceTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "POLYHEDRALSURFACE(((0 0, 0 1, 1 1, 1 0, 0 0)), ((1 1, 1 2, 2 2, 2 1, 1 1)))"); + assertEquals(TYPENAME_POLYHEDRALSURFACE, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(2, g.getNumGeometries()); + assertEquals(2, g.getDimension()); + assertEquals(1, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "POLYHEDRALSURFACE Z(((0 0 0, 0 1 0, 0 1 1, 0 0 0)), ((0 0 0, 0 1 0, 1 0 0, 0 0 0)), ((0 0 0, 1 0 0, 0 1 1, 0 0 0)), ((1 0 0, 0 1 0, 0 1 1, 1 0 0)))"); + assertEquals(TYPENAME_POLYHEDRALSURFACE, g.getGeometryType()); + assertEquals(4, g.getNumGeometries()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("POLYHEDRALSURFACE EMPTY"); + assertEquals(TYPENAME_POLYHEDRALSURFACE, g.getGeometryType()); + assertTrue(g.isEmpty()); + assertEquals(0, g.getNumGeometries()); + } + + public void testReadEmptyZ() throws Exception { + Geometry g = new CurvedWKTReader().read("POLYHEDRALSURFACE Z EMPTY"); + assertEquals(TYPENAME_POLYHEDRALSURFACE, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "POLYHEDRALSURFACE (((0 0, 0 1, 1 1, 1 0, 0 0)), ((1 1, 1 2, 2 2, 2 1, 1 1)))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention POLYHEDRALSURFACE but was: " + emitted, + emitted.toUpperCase().contains("POLYHEDRALSURFACE")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + public void testWKTRoundTripXYZ() throws Exception { + String wkt = "POLYHEDRALSURFACE Z (((0 0 0, 0 1 0, 0 1 1, 0 0 0)), ((0 0 0, 0 1 0, 1 0 0, 0 0 0)))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter(3).write(g); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqualXYZ(g, g2); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTinTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTinTest.java new file mode 100644 index 0000000000..02295742b7 --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTinTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code Tin} geometry (Triangulated Irregular + * Network, OGC SFA / ISO 19125-2). A Tin is a PolyhedralSurface whose patches + * are all triangles. + */ +public class WKTTinTest extends GeometryTestCase { + + private static final String TYPENAME_TIN = "Tin"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTTinTest.class); } + + public WKTTinTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read( + "TIN(((0 0, 1 0, 0 1, 0 0)), ((1 0, 1 1, 0 1, 1 0)))"); + assertEquals(TYPENAME_TIN, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(2, g.getNumGeometries()); + assertEquals(2, g.getDimension()); + assertEquals(1, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read( + "TIN Z(((0 0 0, 1 0 0, 0 1 0, 0 0 0)), ((1 0 0, 1 1 0, 0 1 0, 1 0 0)))"); + assertEquals(TYPENAME_TIN, g.getGeometryType()); + assertEquals(2, g.getNumGeometries()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("TIN EMPTY"); + assertEquals(TYPENAME_TIN, g.getGeometryType()); + assertTrue(g.isEmpty()); + assertEquals(0, g.getNumGeometries()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "TIN (((0 0, 1 0, 0 1, 0 0)), ((1 0, 1 1, 0 1, 1 0)))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention TIN but was: " + emitted, + emitted.toUpperCase().contains("TIN")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + /** + * Documents Phase-1 leniency: the parser does not enforce the OGC SFA rule + * that every TIN patch is a triangle (4-point closed ring). A quadrilateral + * patch parses as a TIN containing that patch. + *

    + * Tracked via the curve-awareness spec epic (sub-issue VAL-TIN). + */ + public void testAcceptsNonTrianglePatchForNow() throws Exception { + Geometry g = new CurvedWKTReader().read("TIN(((0 0, 1 0, 1 1, 0 1, 0 0)))"); + assertEquals(TYPENAME_TIN, g.getGeometryType()); + assertEquals(1, g.getNumGeometries()); + } +} diff --git a/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTriangleTest.java b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTriangleTest.java new file mode 100644 index 0000000000..23ba4b8b6f --- /dev/null +++ b/modules/curved/src/test/java/org/locationtech/jts/io/curved/WKTTriangleTest.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 grootstebozewolf + * + * 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. + */ + +package org.locationtech.jts.io.curved; + + +import org.locationtech.jts.geom.Geometry; + +import junit.framework.Test; +import junit.framework.TestSuite; +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +/** + * Red tests for WKT support of {@code Triangle} geometry + * (OGC SFA / ISO 19125-2). A Triangle is a Polygon with exactly one + * outer ring of exactly 4 points (first == last). + */ +public class WKTTriangleTest extends GeometryTestCase { + + private static final String TYPENAME_TRIANGLE = "Triangle"; + + public static void main(String args[]) { + TestRunner.run(suite()); + } + + public static Test suite() { return new TestSuite(WKTTriangleTest.class); } + + public WKTTriangleTest(String name) { super(name); } + + public void testReadXY() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE((0 0, 1 0, 0 1, 0 0))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertFalse(g.isEmpty()); + assertEquals(4, g.getNumPoints()); + assertEquals(2, g.getDimension()); + assertEquals(1, g.getBoundaryDimension()); + } + + public void testReadXYZ() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE Z((0 0 0, 1 0 0, 0 1 0, 0 0 0))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + assertEquals(0.0, g.getCoordinates()[2].getZ(), 0.0); + } + + public void testReadXYM() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE M((0 0 7, 1 0 8, 0 1 9, 0 0 7))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertEquals(7.0, g.getCoordinates()[0].getM(), 0.0); + } + + public void testReadXYZM() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE ZM((0 0 0 7, 1 0 0 8, 0 1 0 9, 0 0 0 7))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertEquals(0.0, g.getCoordinates()[0].getZ(), 0.0); + assertEquals(7.0, g.getCoordinates()[0].getM(), 0.0); + } + + public void testReadEmpty() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE EMPTY"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertTrue(g.isEmpty()); + } + + public void testWKTRoundTripXY() throws Exception { + String wkt = "TRIANGLE ((0 0, 1 0, 0 1, 0 0))"; + Geometry g = new CurvedWKTReader().read(wkt); + String emitted = new CurvedWKTWriter().write(g); + assertTrue("Expected emitted WKT to mention TRIANGLE but was: " + emitted, + emitted.toUpperCase().contains("TRIANGLE")); + Geometry g2 = new CurvedWKTReader().read(emitted); + checkEqual(g, g2); + } + + /** + * Documents Phase-1 leniency: the parser does not enforce the OGC SFA rule + * that a Triangle's ring contains exactly 4 points. A 5-point closed ring + * parses as a Triangle with the extra vertex retained. + *

    + * Tracked via the curve-awareness spec epic (sub-issue VAL-T point-count). + */ + public void testAcceptsWrongPointCountForNow() throws Exception { + Geometry g = new CurvedWKTReader().read("TRIANGLE((0 0, 1 0, 1 1, 0 1, 0 0))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + assertEquals(5, g.getNumPoints()); + } + + /** + * The Triangle ring must be closed (first point == last point). This rule + * is actually enforced today — not by jts-curved but by the LinearRing + * factory used inside {@code readTriangleText}, which throws + * {@link IllegalArgumentException} for non-closed coordinate sequences. + */ + public void testRejectsUnclosedRing() throws Exception { + assertNotNull(new CurvedWKTReader().read("TRIANGLE((0 0, 1 0, 0 1, 0 0))")); + try { + new CurvedWKTReader().read("TRIANGLE((0 0, 1 0, 0 1, 0 1))"); + fail("Expected parse failure for unclosed TRIANGLE ring"); + } catch (IllegalArgumentException e) { + // expected: LinearRing factory rejects non-closed coordinate sequences + } + } + + /** + * Documents Phase-1 leniency: the parser does not enforce the OGC SFA rule + * that a Triangle has no holes. A polygon body with an inner ring parses + * as a Triangle whose inner ring is silently dropped (only the exterior + * ring is forwarded by {@code readTriangleText}). + *

    + * Tracked via the curve-awareness spec epic (sub-issue VAL-T holes). + */ + public void testAcceptsInnerRingForNow() throws Exception { + Geometry g = new CurvedWKTReader().read( + "TRIANGLE((0 0, 10 0, 0 10, 0 0), (1 1, 2 1, 1 2, 1 1))"); + assertEquals(TYPENAME_TRIANGLE, g.getGeometryType()); + } +} diff --git a/modules/pom.xml b/modules/pom.xml index 1c49ffa427..8df22b3e41 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -20,6 +20,7 @@ core io + curved