CoverageCleanerTest.java

/*
 * Copyright (c) 2025 Martin Davis.
 *
 * 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.coverage;

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.util.PolygonalExtracter;

import junit.textui.TestRunner;
import test.jts.GeometryTestCase;

public class CoverageCleanerTest extends GeometryTestCase {
  public static void main(String args[]) {
    TestRunner.run(CoverageCleanerTest.class);
  }
  
  public CoverageCleanerTest(String name) {
    super(name);
  }
  
  public void testCoverageWithEmpty() {
    checkClean(
        "GEOMETRYCOLLECTION (POLYGON ((1 9, 9 9, 9 4, 1 4, 1 9)), POLYGON EMPTY, POLYGON ((2 1, 2 5, 8 5, 8 1, 2 1)))",
        "GEOMETRYCOLLECTION (POLYGON ((1 4, 1 9, 9 9, 9 4, 8 4, 2 4, 1 4)), POLYGON EMPTY, POLYGON ((8 1, 2 1, 2 4, 8 4, 8 1)))");
  }

  public void testSingleNearMatch() {
    checkCleanSnap(readArray(
        "POLYGON ((1 9, 9 9, 9 4.99, 1 5, 1 9))",
        "POLYGON ((1 1, 1 5, 9 5, 9 1, 1 1))"),
        0.1);
  }

  public void testManyNearMatches() {
    checkCleanSnap(readArray(
        "POLYGON ((1 9, 9 9, 9 5, 8 5, 7 5, 4 5.5, 3 5, 2 5, 1 5, 1 9))",
        "POLYGON ((1 1, 1 4.99, 2 5.01, 3.01 4.989, 5 3, 6.99 4.99, 7.98 4.98, 9 5, 9 1, 1 1))"),
        0.1);
  }

  // Tests that if interior point lies in a spike that is snapped away, polygon is still in result
  public void testPolygonSnappedPreserved() {
    checkCleanSnap(readArray(
        "POLYGON ((90 0, 10 0, 89.99 30, 90 100, 90 0))"),
        0.1,
        readArray(
            "POLYGON ((90 0, 10 0, 89.99 30, 90 0))"));
  }
  
  // Tests that if interior point lies in a spike that is snapped away, polygon is still in result
  public void testPolygonsSnappedPreserved() {
    checkCleanSnap(readArray(
        "POLYGON ((0 0, 0 2, 5 2, 5 8, 5.01 0, 0 0))",
        "POLYGON ((0 8, 5 8, 5 2, 0 2, 0 8))"
        ),
        0.02,
        readArray(
            "POLYGON ((0 0, 0 2, 5 2, 5.01 0, 0 0))",
            "POLYGON ((0 8, 5 8, 5 2, 0 2, 0 8))"
            ));
  }  
  
  // Tests that a collapsed polygon due to snapping is returned as EMPTY
  public void testPolygonsSnappedCollapse() {
    checkCleanSnap(readArray(
        "POLYGON ((1 1, 1 9, 6 5, 9 1, 1 1))",
        "POLYGON ((9 1, 6 5.1, 1 9, 9 9, 9 1))",
        "POLYGON ((9 1, 6 5, 1 9, 6 5.1, 9 1))"
        ),
        1,
        readArray(
            "POLYGON ((6 5, 9 1, 1 1, 1 9, 6 5))",
            "POLYGON ((9 9, 9 1, 6 5, 1 9, 9 9))",
            "POLYGON EMPTY"
            ));
  }
  
  public void testMergeGapToLongestBorder() {
    checkCleanGapWidth("GEOMETRYCOLLECTION (POLYGON ((1 9, 9 9, 9 5, 1 5, 1 9)), POLYGON ((5 1, 5 5, 1 5, 5 1)), POLYGON ((5 1, 5.1 5, 9 5, 5 1)))",
        1,
        "GEOMETRYCOLLECTION (POLYGON ((5.1 5, 5 5, 1 5, 1 9, 9 9, 9 5, 5.1 5)), POLYGON ((5 1, 1 5, 5 5, 5 1)), POLYGON ((5 1, 5 5, 5.1 5, 9 5, 5 1)))"
        );
  }

  String covWithGaps = "GEOMETRYCOLLECTION (POLYGON ((1 3, 9 3, 9 1, 1 1, 1 3)), POLYGON ((1 3, 1 9, 4 9, 4 3, 3 4, 1 3)), POLYGON ((4 9, 7 9, 7 3, 6 5, 5 5, 4 3, 4 9)), POLYGON ((7 9, 9 9, 9 3, 8 3.1, 7 3, 7 9)))";

  public void testMergeGapWidth_0() {
    checkCleanGapWidth(covWithGaps,
        0,
        "GEOMETRYCOLLECTION (POLYGON ((9 3, 9 1, 1 1, 1 3, 4 3, 7 3, 9 3)), POLYGON ((1 9, 4 9, 4 3, 3 4, 1 3, 1 9)), POLYGON ((6 5, 5 5, 4 3, 4 9, 7 9, 7 3, 6 5)), POLYGON ((7 9, 9 9, 9 3, 8 3.1, 7 3, 7 9)))"
        );
  }

  public void testMergeGapWidth_1() {
    checkCleanGapWidth(covWithGaps,
        1,
        "GEOMETRYCOLLECTION (POLYGON ((7 3, 9 3, 9 1, 1 1, 1 3, 4 3, 7 3)), POLYGON ((1 9, 4 9, 4 3, 1 3, 1 9)), POLYGON ((7 3, 6 5, 5 5, 4 3, 4 9, 7 9, 7 3)), POLYGON ((7 9, 9 9, 9 3, 7 3, 7 9)))"
        );
  }

  public void testMergeGapWidth_2() {
    checkCleanGapWidth(covWithGaps,
        2,
        "GEOMETRYCOLLECTION (POLYGON ((9 3, 9 1, 1 1, 1 3, 4 3, 7 3, 9 3)), POLYGON ((1 9, 4 9, 4 3, 1 3, 1 9)), POLYGON ((7 3, 4 3, 4 9, 7 9, 7 3)), POLYGON ((9 9, 9 3, 7 3, 7 9, 9 9)))"
        );
  }

  String covWithOverlap = "GEOMETRYCOLLECTION (POLYGON ((1 3, 5 3, 4 1, 1 1, 1 3)), POLYGON ((1 3, 1 9, 4 9, 4 3, 3 1.9, 1 3)))";
  
  public void testMergeOverlapMinArea() {
    checkCleanOverlapMerge(covWithOverlap,
        CoverageCleaner.MERGE_MIN_AREA,
        "GEOMETRYCOLLECTION (POLYGON ((5 3, 4 1, 1 1, 1 3, 4 3, 5 3)), POLYGON ((1 9, 4 9, 4 3, 1 3, 1 9)))"
        );
  }

  public void testMergeOverlapMaxArea() {
    checkCleanOverlapMerge(covWithOverlap,
        CoverageCleaner.MERGE_MAX_AREA,
        "GEOMETRYCOLLECTION (POLYGON ((1 1, 1 3, 3 1.9, 4 3, 5 3, 4 1, 1 1)), POLYGON ((1 3, 1 9, 4 9, 4 3, 3 1.9, 1 3)))"
        );
  }

  public void testMergeOverlapMinId() {
    checkCleanOverlapMerge(covWithOverlap,
        CoverageCleaner.MERGE_MIN_INDEX,
        "GEOMETRYCOLLECTION (POLYGON ((5 3, 4 1, 1 1, 1 3, 4 3, 5 3)), POLYGON ((1 9, 4 9, 4 3, 1 3, 1 9)))"
        );
  }

  public void testMergeOverlap2() {
    checkCleanSnap(readArray(
        "POLYGON ((5 9, 9 9, 9 1, 5 1, 5 9))",
        "POLYGON ((1 5, 5 5, 5 2, 1 2, 1 5))",
        "POLYGON ((2 7, 5 7, 5 4, 2 4, 2 7))"
        ),
        0.1,
        readArray(
            "POLYGON ((5 1, 5 2, 5 4, 5 5, 5 7, 5 9, 9 9, 9 1, 5 1))",
            "POLYGON ((5 2, 1 2, 1 5, 2 5, 5 5, 5 4, 5 2))",
            "POLYGON ((5 5, 2 5, 2 7, 5 7, 5 5))"
            ));
  }
  
  public void testMergeOverlap() {
    checkCleanOverlapMerge("GEOMETRYCOLLECTION (POLYGON ((5 9, 9 9, 9 1, 5 1, 5 9)), POLYGON ((1 5, 5 5, 5 2, 1 2, 1 5)), POLYGON ((2 7, 5 7, 5 4, 2 4, 2 7)))",
        CoverageCleaner.MERGE_LONGEST_BORDER,
        "GEOMETRYCOLLECTION (POLYGON ((5 7, 5 9, 9 9, 9 1, 5 1, 5 2, 5 4, 5 5, 5 7)), POLYGON ((5 2, 1 2, 1 5, 2 5, 5 5, 5 4, 5 2)), POLYGON ((2 5, 2 7, 5 7, 5 5, 2 5)))"
        );
  }
  
  //-------------------------------------------
  
  //-- a duplicate coverage element is assigned to the lowest result index 
  public void testDuplicateItems() {
    checkClean("GEOMETRYCOLLECTION (POLYGON ((1 9, 9 1, 1 1, 1 9)), POLYGON ((1 9, 9 1, 1 1, 1 9)))",
        "GEOMETRYCOLLECTION (POLYGON ((1 9, 9 1, 1 1, 1 9)), POLYGON EMPTY)"
        );
  }
  
  public void testCoveredItem() {
    checkClean("GEOMETRYCOLLECTION (POLYGON ((1 9, 9 9, 9 4, 1 4, 1 9)), POLYGON ((2 5, 2 8, 8 8, 8 5, 2 5)))",
        "GEOMETRYCOLLECTION (POLYGON ((9 9, 9 4, 1 4, 1 9, 9 9)), POLYGON EMPTY)"
        );
  }
  
  public void testCoveredItemMultiPolygon() {
    checkClean("GEOMETRYCOLLECTION (MULTIPOLYGON (((1 1, 1 5, 5 5, 5 1, 1 1)), ((6 5, 6 1, 9 1, 6 5))), POLYGON ((6 1, 6 5, 9 1, 6 1)))",
        "GEOMETRYCOLLECTION (MULTIPOLYGON (((1 5, 5 5, 5 1, 1 1, 1 5)), ((6 5, 9 1, 6 1, 6 5))), POLYGON EMPTY)"
        );
  }

  
  //TODO: add test with MultiPolygon that snaps together (so needs merging)
  
  //=========================================================
 
  private void checkClean(String wkt, String wktExpected) {
    Geometry geom = read(wkt);
    Geometry[] cov = toArray(geom);
    Geometry[] actual = CoverageCleaner.cleanGapWidth(cov, 0);
    Geometry[] covExpected = toArray(read(wktExpected));
    checkEqual(covExpected, actual);   
  }
  
  private void checkCleanGapWidth(String wkt, double gapWidth, String wktExpected) {
    Geometry geom = read(wkt);
    Geometry[] cov = toArray(geom);
    Geometry[] actual = CoverageCleaner.cleanGapWidth(cov, gapWidth);
    Geometry[] covExpected = toArray(read(wktExpected));
    checkEqual(covExpected, actual);   
  }
  
  private void checkCleanOverlapMerge(String wkt, int mergeStrategy, String wktExpected) {
    Geometry geom = read(wkt);
    Geometry[] cov = toArray(geom);
    Geometry[] actual = CoverageCleaner.cleanOverlapGap(cov, mergeStrategy, 0);
    Geometry[] covExpected = toArray(read(wktExpected));
    try {
      checkEqual(covExpected, actual);  
    }
    catch (Throwable ex) {
      Geometry actualGeom = toGeometryCollection(actual);
      System.out.println(actualGeom);
      throw ex;
    }
  }
  
  private Geometry toGeometryCollection(Geometry[] actual) {
    return ((new GeometryFactory().createGeometryCollection(actual)));
  }

  private Geometry[] toArray(Geometry geom) {
    return GeometryFactory.toGeometryArray(PolygonalExtracter.getPolygonals(geom));
  }

  private void checkCleanSnap(Geometry[] cov, double snapDist) {
    Geometry[] covClean = CoverageCleaner.clean(cov, snapDist, 0);
    checkValidCoverage(covClean, snapDist);
  }

  private void checkCleanSnap(Geometry[] cov, double snapDist, Geometry[] expected) {
    Geometry[] actual = CoverageCleaner.clean(cov, snapDist, 0);
    checkValidCoverage(actual, snapDist);
    checkEqual(expected, actual);
  }

  public void checkCleanSnap(String wkt, double snapDist) {
    Geometry[] cov = readArray(wkt);
    checkCleanSnap(cov, snapDist);
  }

  private void checkValidCoverage(Geometry[] coverage, double tolerance) {
    for (Geometry geom : coverage) {
      assertTrue(geom.isValid());
    }
    boolean isValid = CoverageValidator.isValid(coverage, tolerance);
    assertTrue(isValid);  
  }
}