Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
/*
* Copyright (c) 2020 Martin Davis.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.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-v10.html
* and the Eclipse Distribution License is available at
*
* http://www.eclipse.org/org/documents/edl-v10.php.
*/
package org.locationtech.jts.algorithm.construct;

import java.util.PriorityQueue;

import org.locationtech.jts.algorithm.locate.IndexedPointInAreaLocator;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Location;
import org.locationtech.jts.geom.MultiPolygon;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.operation.distance.IndexedFacetDistance;

/**
* Constructs the Maximum Inscribed Circle for a
* polygonal {@link Geometry}, up to a specified tolerance.
* The Maximum Inscribed Circle is determined by a point in the interior of the area
* which has the farthest distance from the area boundary,
* along with a boundary point at that distance.
* <p>
* In the context of geography the center of the Maximum Inscribed Circle
* is known as the <b>Pole of Inaccessibility</b>.
* A cartographic use case is to determine a suitable point
* to place a map label within a polygon.
* <p>
* The radius length of the Maximum Inscribed Circle is a
* measure of how "narrow" a polygon is. It is the
* distance at which the negative buffer becomes empty.
* <p>
* The class supports polygons with holes and multipolygons.
* <p>
* The implementation uses a successive-approximation technique
* over a grid of square cells covering the area geometry.
* The grid is refined using a branch-and-bound algorithm.
* Point containment and distance are computed in a performant
* way by using spatial indexes.
*
* <h3>Future Enhancements</h3>
* <ul>
* <li>Support a polygonal constraint on placement of center
* </ul>
*
* @author Martin Davis
*
*/
public class MaximumInscribedCircle {

/**
* Computes the center point of the Maximum Inscribed Circle
* of a polygonal geometry, up to a given tolerance distance.
*
* @param polygonal a polygonal geometry
* @param tolerance the distance tolerance for computing the center point
* @return the center point of the maximum inscribed circle
*/
public static Point getCenter(Geometry polygonal, double tolerance) {
MaximumInscribedCircle mic = new MaximumInscribedCircle(polygonal, tolerance);
return mic.getCenter();
}

/**
* Computes a radius line of the Maximum Inscribed Circle
* of a polygonal geometry, up to a given tolerance distance.
*
* @param polygonal a polygonal geometry
* @param tolerance the distance tolerance for computing the center point
* @return a line from the center to a point on the circle
*/
public static LineString getRadiusLine(Geometry polygonal, double tolerance) {
MaximumInscribedCircle mic = new MaximumInscribedCircle(polygonal, tolerance);
return mic.getRadiusLine();
}

private Geometry inputGeom;
private double tolerance;

private GeometryFactory factory;
private IndexedPointInAreaLocator ptLocater;
private IndexedFacetDistance indexedDistance;
private Cell centerCell = null;
private Coordinate centerPt = null;
private Coordinate radiusPt;
private Point centerPoint;
private Point radiusPoint;

/**
* Creates a new instance of a Maximum Inscribed Circle computation.
*
* @param polygonal an areal geometry
* @param tolerance the distance tolerance for computing the centre point
*/
public MaximumInscribedCircle(Geometry polygonal, double tolerance) {
if (! (polygonal instanceof Polygon || polygonal instanceof MultiPolygon)) {
throw new IllegalArgumentException("Input geometry must be a Polygon or MultiPolygon");
}
if (polygonal.isEmpty()) {
throw new IllegalArgumentException("Empty input geometry is not supported");
}

this.inputGeom = polygonal;
this.factory = polygonal.getFactory();
this.tolerance = tolerance;
ptLocater = new IndexedPointInAreaLocator(polygonal);
indexedDistance = new IndexedFacetDistance( polygonal.getBoundary() );
}

/**
* Gets the center point of the maximum inscribed circle
* (up to the tolerance distance).
*
* @return the center point of the maximum inscribed circle
*/
public Point getCenter() {
compute();
return centerPoint;
}

/**
* Gets a point defining the radius of the Maximum Inscribed Circle.
* This is a point on the boundary which is
* nearest to the computed center of the Maximum Inscribed Circle.
* The line segment from the center to this point
* is a radius of the constructed circle, and this point
* lies on the boundary of the circle.
*
* @return a point defining the radius of the Maximum Inscribed Circle
*/
public Point getRadiusPoint() {
compute();
return radiusPoint;
}

/**
* Gets a line representing a radius of the Largest Empty Circle.
*
* @return a line from the center of the circle to a point on the edge
*/
public LineString getRadiusLine() {
compute();
LineString radiusLine = factory.createLineString(
new Coordinate[] { centerPt.copy(), radiusPt.copy() });
return radiusLine;
}

/**
* Computes the signed distance from a point to the area boundary.
* Points outside the polygon are assigned a negative distance.
* Their containing cells will be last in the priority queue
* (but may still end up being tested since they may need to be refined).
*
* @param p the point to compute the distance for
* @return the signed distance to the area boundary (negative indicates outside the area)
*/
private double distanceToBoundary(Point p) {
double dist = indexedDistance.distance(p);
boolean isOutide = Location.EXTERIOR == ptLocater.locate(p.getCoordinate());
if (isOutide) return -dist;
return dist;
}

private double distanceToBoundary(double x, double y) {
Coordinate coord = new Coordinate(x, y);
Point pt = factory.createPoint(coord);
return distanceToBoundary(pt);
}

private void compute() {
// check if already computed
if (centerCell != null) return;

// Priority queue of cells, ordered by maximum distance from boundary
PriorityQueue<Cell> cellQueue = new PriorityQueue<>();

createInitialGrid(inputGeom.getEnvelopeInternal(), cellQueue);

// use the area centroid as the initial candidate center point
Cell farthestCell = createCentroidCell(inputGeom);
//int totalCells = cellQueue.size();

/**
* Carry out the branch-and-bound search
* of the cell space
*/
while (! cellQueue.isEmpty()) {
// pick the most promising cell from the queue
Cell cell = cellQueue.remove();
//System.out.println(factory.toGeometry(cell.getEnvelope()));

// update the center cell if the candidate is further from the boundary
if (cell.getDistance() > farthestCell.getDistance()) {
farthestCell = cell;
}
/**
* Refine this cell if the potential distance improvement
* is greater than the required tolerance.
* Otherwise the cell is pruned (not investigated further),
* since no point in it is further than
* the current farthest distance.
*/
double potentialIncrease = cell.getMaxDistance() - farthestCell.getDistance();
if (potentialIncrease > tolerance) {
// split the cell into four sub-cells
double h2 = cell.getHSide() / 2;
cellQueue.add( createCell( cell.getX() - h2, cell.getY() - h2, h2));
cellQueue.add( createCell( cell.getX() + h2, cell.getY() - h2, h2));
cellQueue.add( createCell( cell.getX() - h2, cell.getY() + h2, h2));
cellQueue.add( createCell( cell.getX() + h2, cell.getY() + h2, h2));
//totalCells += 4;
}
}
// the farthest cell is the best approximation to the MIC center
centerCell = farthestCell;
centerPt = new Coordinate(centerCell.getX(), centerCell.getY());
centerPoint = factory.createPoint(centerPt);
// compute radius point
Coordinate[] nearestPts = indexedDistance.nearestPoints(centerPoint);
radiusPt = nearestPts[0].copy();
radiusPoint = factory.createPoint(radiusPt);
}

/**
* Initializes the queue with a grid of cells covering
* the extent of the area.
*
* @param env the area extent to cover
* @param cellQueue the queue to initialize
*/
private void createInitialGrid(Envelope env, PriorityQueue<Cell> cellQueue) {
double minX = env.getMinX();
double maxX = env.getMaxX();
double minY = env.getMinY();
double maxY = env.getMaxY();
double width = env.getWidth();
double height = env.getHeight();
double cellSize = Math.min(width, height);
double hSide = cellSize / 2.0;

// compute initial grid of cells to cover area
for (double x = minX; x < maxX; x += cellSize) {
for (double y = minY; y < maxY; y += cellSize) {
cellQueue.add(createCell(x + hSide, y + hSide, hSide));
}
}
}

private Cell createCell(double x, double y, double hSide) {
return new Cell(x, y, hSide, distanceToBoundary(x, y));
}

// create a cell centered on area centroid
private Cell createCentroidCell(Geometry geom) {
Point p = geom.getCentroid();
return new Cell(p.getX(), p.getY(), 0, distanceToBoundary(p));
}

/**
* A square grid cell centered on a given point,
* with a given half-side size, and having a given distance
* to the area boundary.
* The maximum possible distance from any point in the cell to the
* boundary can be computed, and is used
* as the ordering and upper-bound function in
* the branch-and-bound algorithm.
*
*/
private static class Cell implements Comparable<Cell> {

private static final double SQRT2 = 1.4142135623730951;

private double x;
private double y;
private double hSide;
private double distance;
private double maxDist;

Cell(double x, double y, double hSide, double distanceToBoundary) {
this.x = x; // cell center x
this.y = y; // cell center y
this.hSide = hSide; // half the cell size

// the distance from cell center to area boundary
distance = distanceToBoundary;

// the maximum possible distance to area boundary for points in this cell
this.maxDist = distance + hSide * SQRT2;
}

public Envelope getEnvelope() {
return new Envelope(x - hSide, x + hSide, y - hSide, y + hSide);
}

public double getMaxDistance() {
return maxDist;
}

public double getDistance() {
return distance;
}

public double getHSide() {
return hSide;
}

public double getX() {
return x;
}

public double getY() {
return y;
}

/**
* A cell is greater iff its maximum possible distance is larger.
*/
public int compareTo(Cell o) {
return (int) (o.maxDist - this.maxDist);
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<!--
-->
</head>
<body bgcolor="white">

Provides classes that implement various kinds of geometric constructions.

</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.locationtech.jts.algorithm.construct;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;

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

public class LargestEmptyCircleTest extends GeometryTestCase {

public static void main(String args[]) {
TestRunner.run(LargestEmptyCircleTest.class);
}

public LargestEmptyCircleTest(String name) { super(name); }

public void testPointsSquare() {
checkCircle("MULTIPOINT ((100 100), (100 200), (200 200), (200 100))",
0.01, 150, 150, 70.71 );
}

public void testPointsTriangleOnHull() {
checkCircle("MULTIPOINT ((100 100), (300 100), (150 50))",
0.01, 216.66, 99.99, 83.33 );
}

public void testPointsTriangleInterior() {
checkCircle("MULTIPOINT ((100 100), (300 100), (200 250))",
0.01, 200.00, 141.66, 108.33 );
}

public void testLinesOpenDiamond() {
checkCircle("MULTILINESTRING ((50 100, 150 50), (250 50, 350 100), (350 150, 250 200), (50 150, 150 200))",
0.01, 200, 125, 90.13 );
}

public void testLinesCrossed() {
checkCircle("MULTILINESTRING ((100 100, 300 300), (100 200, 300 0))",
0.01, 299.99, 150.00, 106.05 );
}

public void testLinesZigzag() {
checkCircle("MULTILINESTRING ((100 100, 200 150, 100 200, 250 250, 100 300, 300 350, 100 400), (50 400, 0 350, 50 300, 0 250, 50 200, 0 150, 50 100))",
0.01, 77.52, 349.99, 54.81 );
}

public void testPointsLinesTriangle() {
checkCircle("GEOMETRYCOLLECTION (LINESTRING (100 100, 300 100), POINT (250 200))",
0.01, 196.49, 164.31, 64.31 );
}

public void testPoint() {
checkCircleZeroRadius("POINT (100 100)",
0.01 );
}

public void testLineFlat() {
checkCircleZeroRadius("LINESTRING (0 0, 50 50)",
0.01 );
}


private void checkCircle(String wkt, double tolerance,
double x, double y, double expectedRadius) {
checkCircle(read(wkt), tolerance, x, y, expectedRadius);
}

private void checkCircle(Geometry geom, double tolerance,
double x, double y, double expectedRadius) {
LargestEmptyCircle lec = new LargestEmptyCircle(geom, tolerance);
Geometry centerPoint = lec.getCenter();
Coordinate centerPt = centerPoint.getCoordinate();
Coordinate expectedCenter = new Coordinate(x, y);
checkEqualXY(expectedCenter, centerPt, tolerance);

LineString radiusLine = lec.getRadiusLine();
double actualRadius = radiusLine.getLength();
assertEquals("Radius: ", expectedRadius, actualRadius, tolerance);

checkEqualXY("Radius line center point: ", centerPt, radiusLine.getCoordinateN(0));
Coordinate radiusPt = lec.getRadiusPoint().getCoordinate();
checkEqualXY("Radius line endpoint point: ", radiusPt, radiusLine.getCoordinateN(1));
}

private void checkCircleZeroRadius(String wkt, double tolerance) {
checkCircleZeroRadius(read(wkt), tolerance);
}

private void checkCircleZeroRadius(Geometry geom, double tolerance) {
LargestEmptyCircle lec = new LargestEmptyCircle(geom, tolerance);

LineString radiusLine = lec.getRadiusLine();
double actualRadius = radiusLine.getLength();
assertEquals("Radius: ", 0.0, actualRadius, tolerance);

Coordinate centerPt = lec.getCenter().getCoordinate();
checkEqualXY("Radius line center point: ", centerPt, radiusLine.getCoordinateN(0));
Coordinate radiusPt = lec.getRadiusPoint().getCoordinate();
checkEqualXY("Radius line endpoint point: ", radiusPt, radiusLine.getCoordinateN(1));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package org.locationtech.jts.algorithm.construct;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.LineString;

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

public class MaximumInscibedCircleTest extends GeometryTestCase {

public static void main(String args[]) {
TestRunner.run(MaximumInscibedCircleTest.class);
}

public MaximumInscibedCircleTest(String name) { super(name); }

public void testSquare() {
checkCircle("POLYGON ((100 200, 200 200, 200 100, 100 100, 100 200))",
0.001, 150, 150, 50 );
}

public void testDiamond() {
checkCircle("POLYGON ((150 250, 50 150, 150 50, 250 150, 150 250))",
0.001, 150, 150, 70.71 );
}

public void testCircle() {
Geometry centre = read("POINT (100 100)");
Geometry circle = centre.buffer(100, 20);
// MIC radius is less than 100 because buffer boundary segments lie inside circle
checkCircle(circle, 0.01, 100, 100, 99.92);
}

public void testKite() {
checkCircle("POLYGON ((100 0, 200 200, 300 200, 300 100, 100 0))",
0.01, 238.19, 138.19, 61.80 );
}

public void testKiteWithHole() {
checkCircle("POLYGON ((100 0, 200 200, 300 200, 300 100, 100 0), (200 150, 200 100, 260 100, 200 150))",
0.01, 257.47, 157.47, 42.52 );
}

public void testDoubleKite() {
checkCircle("MULTIPOLYGON (((150 200, 100 150, 150 100, 250 150, 150 200)), ((400 250, 300 150, 400 50, 560 150, 400 250)))",
0.01, 411.38, 149.99, 78.75 );
}

/**
* Invalid polygon collapsed to a line
*/
public void testCollapsedLine() {
checkCircle("POLYGON ((100 100, 200 200, 100 100, 100 100))",
0.01, 150, 150, 0 );
}

/**
* Invalid polygon collapsed to a point
*/
public void testCollapsedPoint() {
checkCircle("POLYGON ((100 100, 100 100, 100 100, 100 100))",
0.01, 100, 100, 0 );
}

private void checkCircle(String wkt, double tolerance,
double x, double y, double expectedRadius) {
checkCircle(read(wkt), tolerance, x, y, expectedRadius);
}

private void checkCircle(Geometry geom, double tolerance,
double x, double y, double expectedRadius) {
MaximumInscribedCircle mic = new MaximumInscribedCircle(geom, tolerance);
Geometry centerPoint = mic.getCenter();
Coordinate centerPt = centerPoint.getCoordinate();
Coordinate expectedCenter = new Coordinate(x, y);
checkEqualXY(expectedCenter, centerPt, tolerance);

LineString radiusLine = mic.getRadiusLine();
double actualRadius = radiusLine.getLength();
assertEquals("Radius: ", expectedRadius, actualRadius, tolerance);

checkEqualXY("Radius line center point: ", centerPt, radiusLine.getCoordinateN(0));
Coordinate radiusPt = mic.getRadiusPoint().getCoordinate();
checkEqualXY("Radius line endpoint point: ", radiusPt, radiusLine.getCoordinateN(1));

}
}
21 changes: 21 additions & 0 deletions modules/core/src/test/java/test/jts/GeometryTestCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,27 @@ GeometryCollection toGeometryCollection(Collection geoms) {
return geomFactory.createGeometryCollection(GeometryFactory.toGeometryArray(geoms));
}

protected void checkEqualXY(Coordinate expected, Coordinate actual) {
assertEquals("Coordinate X", expected.getX(), actual.getX() );
assertEquals("Coordinate Y", expected.getY(), actual.getY() );
}

protected void checkEqualXY(String message, Coordinate expected, Coordinate actual) {
assertEquals(message + " X", expected.getX(), actual.getX() );
assertEquals(message + " Y", expected.getY(), actual.getY() );
}

protected void checkEqualXY(Coordinate expected, Coordinate actual, double tolerance) {
assertEquals("Coordinate X", expected.getX(), actual.getX(), tolerance);
assertEquals("Coordinate Y", expected.getY(), actual.getY(), tolerance);
}

protected void checkEqualXY(String message, Coordinate expected, Coordinate actual, double tolerance) {
assertEquals(message + " X", expected.getX(), actual.getX(), tolerance);
assertEquals(message + " Y", expected.getY(), actual.getY(), tolerance);
}


/**
* Reads a {@link Geometry} from a WKT string using a custom {@link GeometryFactory}.
*
Expand Down