diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java index 133a3585c7..751a5ce700 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/BufferFunctions.java @@ -28,7 +28,7 @@ import org.locationtech.jts.operation.buffer.BufferOp; import org.locationtech.jts.operation.buffer.BufferParameters; import org.locationtech.jts.operation.buffer.OffsetCurveBuilder; -import org.locationtech.jts.operation.buffer.OffsetCurveSetBuilder; +import org.locationtech.jts.operation.buffer.BufferCurveSetBuilder; import org.locationtech.jts.operation.buffer.VariableBuffer; import org.locationtech.jts.operation.buffer.validate.BufferResultValidator; import org.locationtech.jtstest.geomfunction.Metadata; @@ -108,10 +108,9 @@ public static Geometry bufferCurveWithParams(Geometry g, private static Geometry buildCurveSet(Geometry g, double dist, BufferParameters bufParams) { // --- now construct curve - OffsetCurveBuilder ocb = new OffsetCurveBuilder( + BufferCurveSetBuilder ocsb = new BufferCurveSetBuilder(g, dist, g.getFactory().getPrecisionModel(), bufParams); - OffsetCurveSetBuilder ocsb = new OffsetCurveSetBuilder(g, dist, ocb); List curves = ocsb.getCurves(); List lines = new ArrayList(); diff --git a/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java b/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java index 0f2e0cca9a..b720717287 100644 --- a/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java +++ b/modules/app/src/main/java/org/locationtech/jtstest/function/OffsetCurveFunctions.java @@ -14,21 +14,29 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.operation.buffer.BufferParameters; -import org.locationtech.jts.operation.buffer.OffsetCurveBuilder; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.util.GeometryCombiner; +import org.locationtech.jts.operation.buffer.OffsetCurve; public class OffsetCurveFunctions { public static Geometry offsetCurve(Geometry geom, double distance) { - BufferParameters bufParams = new BufferParameters(); - OffsetCurveBuilder ocb = new OffsetCurveBuilder( - geom.getFactory().getPrecisionModel(), bufParams - ); - Coordinate[] pts = ocb.getOffsetCurve(geom.getCoordinates(), distance); + return OffsetCurve.getCurve(geom, distance); + } + + public static Geometry offsetCurveBoth(Geometry geom, double distance) + { + Geometry curve1 = OffsetCurve.getCurve(geom, distance); + Geometry curve2 = OffsetCurve.getCurve(geom, -distance); + return GeometryCombiner.combine(curve1, curve2); + } + + public static Geometry rawCurve(Geometry geom, double distance) + { + Coordinate[] pts = OffsetCurve.rawOffset((LineString) geom, distance); Geometry curve = geom.getFactory().createLineString(pts); return curve; } - } diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/LineSegment.java b/modules/core/src/main/java/org/locationtech/jts/geom/LineSegment.java index a94aeff7f8..800398e340 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/LineSegment.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/LineSegment.java @@ -445,6 +445,24 @@ public LineSegment project(LineSegment seg) return new LineSegment(newp0, newp1); } + /** + * Computes the {@link LineSegment} that is offset from + * the segment by a given distance. + * The computed segment is offset to the left of the line if the offset distance is + * positive, to the right if negative. + * + * @param offsetDistance the distance the point is offset from the segment + * (positive is to the left, negative is to the right) + * @return a line segment offset by the specified distance + * + * @throws IllegalStateException if the segment has zero length + */ + public LineSegment offset(double offsetDistance) { + Coordinate offset0 = pointAlongOffset(0, offsetDistance); + Coordinate offset1 = pointAlongOffset(1, offsetDistance); + return new LineSegment(offset0, offset1); + } + /** * Computes the reflection of a point in the line defined * by this line segment. diff --git a/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryMapper.java b/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryMapper.java index 23642b0f4a..802afa85f9 100644 --- a/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryMapper.java +++ b/modules/core/src/main/java/org/locationtech/jts/geom/util/GeometryMapper.java @@ -65,7 +65,64 @@ public static Collection map(Collection geoms, MapOp op) } /** - * An interface for geometry functions used for mapping. + * Maps the atomic elements of a {@link Geometry} + * (which may be atomic or composite) + * using a {@link MapOp} mapping operation + * into an atomic Geometry or a flat collection + * of the most specific type. + * null and empty values returned from the mapping operation + * are discarded. + * + * @param geom the geometry to map + * @param emptyDim the dimension of empty geometry to create + * @param op the mapping operation + * @return the mapped result + */ + public static Geometry flatMap(Geometry geom, int emptyDim, MapOp op) + { + List mapped = new ArrayList(); + flatMap(geom, op, mapped); + + if (mapped.size() == 0) { + return geom.getFactory().createEmpty(emptyDim); + } + if (mapped.size() == 1) + return mapped.get(0); + return geom.getFactory().buildGeometry(mapped); + } + + private static void flatMap(Geometry geom, MapOp op, List mapped) + { + for (int i = 0; i < geom.getNumGeometries(); i++) { + Geometry g = geom.getGeometryN(i); + if (g instanceof GeometryCollection) { + flatMap(g, op, mapped); + } + else { + Geometry res = op.map(g); + if (res != null && ! res.isEmpty()) { + addFlat(res, mapped); + } + } + } + } + + private static void addFlat(Geometry geom, List geomList) { + if (geom.isEmpty()) return; + if (geom instanceof GeometryCollection) { + for (int i = 0; i < geom.getNumGeometries(); i++) { + addFlat(geom.getGeometryN(i), geomList); + } + } + else { + geomList.add(geom); + } + } + + /** + * An interface for geometry functions that map a geometry input to a geometry output. + * The output may be null if there is no valid output value for + * the given input value. * * @author Martin Davis * @@ -73,11 +130,11 @@ public static Collection map(Collection geoms, MapOp op) public interface MapOp { /** - * Computes a new geometry value. + * Maps a geometry value into another value. * - * @param g the input geometry + * @param geom the input geometry * @return a result geometry */ - Geometry map(Geometry g); + Geometry map(Geometry geom); } } diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java index c415e61251..3ff7ba3a07 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java @@ -137,9 +137,7 @@ public Geometry buffer(Geometry g, double distance) // factory must be the same as the one used by the input geomFact = g.getFactory(); - OffsetCurveBuilder curveBuilder = new OffsetCurveBuilder(precisionModel, bufParams); - - OffsetCurveSetBuilder curveSetBuilder = new OffsetCurveSetBuilder(g, distance, curveBuilder); + BufferCurveSetBuilder curveSetBuilder = new BufferCurveSetBuilder(g, distance, precisionModel, bufParams); curveSetBuilder.setInvertOrientation(isInvertOrientation); List bufferSegStrList = curveSetBuilder.getCurves(); diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveSetBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferCurveSetBuilder.java similarity index 98% rename from modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveSetBuilder.java rename to modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferCurveSetBuilder.java index e3e6494de5..0f491bc8cf 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveSetBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferCurveSetBuilder.java @@ -33,6 +33,7 @@ import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.Position; +import org.locationtech.jts.geom.PrecisionModel; import org.locationtech.jts.geom.Triangle; import org.locationtech.jts.geomgraph.Label; import org.locationtech.jts.noding.NodedSegmentString; @@ -44,7 +45,7 @@ * * @version 1.7 */ -public class OffsetCurveSetBuilder { +public class BufferCurveSetBuilder { private Geometry inputGeom; private double distance; @@ -54,14 +55,15 @@ public class OffsetCurveSetBuilder { private boolean isInvertOrientation = false; - public OffsetCurveSetBuilder( + public BufferCurveSetBuilder( Geometry inputGeom, double distance, - OffsetCurveBuilder curveBuilder) + PrecisionModel precisionModel, + BufferParameters bufParams) { this.inputGeom = inputGeom; this.distance = distance; - this.curveBuilder = curveBuilder; + this.curveBuilder = new OffsetCurveBuilder(precisionModel, bufParams); } /** diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java new file mode 100644 index 0000000000..729b83514a --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurve.java @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2021 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.operation.buffer; + +import org.locationtech.jts.algorithm.Distance; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateList; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineSegment; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.util.GeometryMapper; +import org.locationtech.jts.index.chain.MonotoneChain; +import org.locationtech.jts.index.chain.MonotoneChainSelectAction; + +/** + * Computes an offset curve from a geometry. + * The offset curve of a line is a {@link LineString} which + * lies at a given distance from the input line. + * If the offset distance is positive the curve lies on the left side of the input; + * if it is negative the curve is on the right side. + *

+ * The offset curve of a Point is an empty LineString. + * The offset curve of a Polygon is the boundary of the polygon buffer (which + * may be a {@link MultiLineString}. + * For a collection the output is a {@link MultiLineString} of the element offset curves. + *

+ * The offset curve is computed as a single contiguous section of the geometry buffer boundary. + * In some geometric situations this definition is ill-defined. + * This algorithm provides a "best-effort" interpretation. + * In particular: + *

    + *
  • For self-intersecting lines, the buffer boundary includes + * offset lines for both left and right sides of the input line. + * Only a single contiguous portion on the specified side is returned.
  • + *
  • If the offset corresponds to buffer holes, only the largest hole is used. + *
  • + *
+ * + * @author Martin Davis + * + */ +public class OffsetCurve { + + /** + * The nearness tolerance between the raw offset linework and the buffer curve. + */ + private static final int NEARNESS_FACTOR = 10000; + + /** + * Computes the offset curve of a linear geometry. + * + * @param geom a linear geometry + * @param distance the offset curve distance + * @return the offset curve + */ + public static Geometry getCurve(Geometry geom, double distance) { + OffsetCurve oc = new OffsetCurve(geom, distance); + return oc.getCurve(); + } + + private Geometry inputGeom; + private double distance; + private double matchDistance; + private GeometryFactory geomFactory; + + public OffsetCurve(Geometry geom, double distance) { + this.inputGeom = geom; + this.distance = distance; + matchDistance = Math.abs(distance) / NEARNESS_FACTOR; + geomFactory = inputGeom.getFactory(); + } + + /** + * Gets the computed offset curve. + * + * @return the offset curve geometry + */ + public Geometry getCurve() { + return GeometryMapper.flatMap(inputGeom, 1, new GeometryMapper.MapOp() { + + @Override + public Geometry map(Geometry geom) { + if (geom instanceof Point) return null; + if (geom instanceof Polygon ) { + return toLineString(geom.buffer(distance).getBoundary()); + } + return computeCurve((LineString) geom, distance); + } + + /** + * Force LinearRings to be LineStrings. + * + * @param geom a geometry which may be a LinearRing + * @return a geometry which will be a LineString or MultiLineString + */ + private Geometry toLineString(Geometry geom) { + if (geom instanceof LinearRing) { + LinearRing ring = (LinearRing) geom; + return geom.getFactory().createLineString(ring.getCoordinateSequence()); + } + return geom; + } + }); + } + + /** + * Gets the raw offset line. + * This may contain loops and other artifacts which are + * not present in the actual offset curve. + * The raw offset line is used to extract the offset curve + * by matching it to a buffer ring (which is clean). + * + * @param geom the linestring to offset + * @param distance the offset distance + * @return the raw offset line + */ + public static Coordinate[] rawOffset(LineString geom, double distance) + { + BufferParameters bufParams = new BufferParameters(); + OffsetCurveBuilder ocb = new OffsetCurveBuilder( + geom.getFactory().getPrecisionModel(), bufParams + ); + Coordinate[] pts = ocb.getOffsetCurve(geom.getCoordinates(), distance); + return pts; + } + + private LineString computeCurve(LineString lineGeom, double distance) { + //-- first handle special/simple cases + if (lineGeom.getNumPoints() < 2 || lineGeom.getLength() == 0.0) { + return geomFactory.createLineString(); + } + if (lineGeom.getNumPoints() == 2) { + return offsetSegment(lineGeom.getCoordinates(), distance); + } + + Coordinate[] rawOffset = rawOffset(lineGeom, distance); + if (rawOffset.length == 0) { + return geomFactory.createLineString(); + } + /** + * Note: If the raw offset curve has no + * narrow concave angles or self-intersections it could be returned as is. + * However, this is likely to be a less frequent situation, + * and testing indicates little performance advantage, + * so not doing this. + */ + + Polygon bufferPoly = getBufferOriented(lineGeom, distance); + + //-- first try matching shell to raw curve + Coordinate[] shell = bufferPoly.getExteriorRing().getCoordinates(); + LineString offsetCurve = computeCurve(shell, rawOffset); + if (! offsetCurve.isEmpty() + || bufferPoly.getNumInteriorRing() == 0) + return offsetCurve; + + //-- if shell didn't work, try matching to largest hole + Coordinate[] holePts = extractLongestHole(bufferPoly).getCoordinates(); + offsetCurve = computeCurve(holePts, rawOffset); + return offsetCurve; + } + + private LineString offsetSegment(Coordinate[] pts, double distance) { + LineSegment offsetSeg = (new LineSegment(pts[0], pts[1])).offset(distance); + return geomFactory.createLineString(new Coordinate[] { offsetSeg.p0, offsetSeg.p1 }); + } + + private static Polygon getBufferOriented(LineString geom, double distance) { + Geometry buffer = geom.buffer(Math.abs(distance)); + Polygon bufferPoly = extractMaxAreaPolygon(buffer); + //-- for negative distances (Right of input) reverse buffer direction to match offset curve + if (distance < 0) { + bufferPoly = bufferPoly.reverse(); + } + return bufferPoly; + } + + /** + * Extracts the largest polygon by area from a geometry. + * Used here to avoid issues with non-robust buffer results which have spurious extra polygons. + * + * @param geom a geometry + * @return the polygon element of largest area + */ + private static Polygon extractMaxAreaPolygon(Geometry geom) { + if (geom.getNumGeometries() == 1) + return (Polygon) geom; + + double maxArea = 0; + Polygon maxPoly = null; + for (int i = 0; i < geom.getNumGeometries(); i++) { + Polygon poly = (Polygon) geom.getGeometryN(i); + double area = poly.getArea(); + if (maxPoly == null || area > maxArea) { + maxPoly = poly; + maxArea = area; + } + } + return maxPoly; + } + + private static LinearRing extractLongestHole(Polygon poly) { + LinearRing largestHole = null; + double maxLen = -1; + for (int i = 0; i < poly.getNumInteriorRing(); i++) { + LinearRing hole = poly.getInteriorRingN(i); + double len = hole.getLength(); + if (len > maxLen) { + largestHole = hole; + maxLen = len; + } + } + return largestHole; + } + + private LineString computeCurve(Coordinate[] bufferPts, Coordinate[] rawOffset) { + boolean[] isInCurve = new boolean[bufferPts.length - 1]; + SegmentMCIndex segIndex = new SegmentMCIndex(bufferPts); + int curveStart = -1; + for (int i = 0; i < rawOffset.length - 1; i++) { + int index = markMatchingSegments( + rawOffset[i], rawOffset[i + 1], segIndex, bufferPts, isInCurve); + if (curveStart < 0) { + curveStart = index; + } + } + Coordinate[] curvePts = extractSection(bufferPts, curveStart, isInCurve); + return geomFactory.createLineString(curvePts); + } + + private int markMatchingSegments(Coordinate p0, Coordinate p1, + SegmentMCIndex segIndex, Coordinate[] bufferPts, + boolean[] isInCurve) { + Envelope matchEnv = new Envelope(p0, p1); + matchEnv.expandBy(matchDistance); + MatchCurveSegmentAction action = new MatchCurveSegmentAction(p0, p1, bufferPts, matchDistance, isInCurve); + segIndex.query(matchEnv, action); + return action.getMinCurveIndex(); + } + + /** + * An action to match a raw offset curve segment + * to segments in the buffer ring + * and mark them as being in the offset curve. + * + * @author Martin Davis + */ + private static class MatchCurveSegmentAction + extends MonotoneChainSelectAction + { + private Coordinate p0; + private Coordinate p1; + private Coordinate[] bufferPts; + private double matchDistance; + private boolean[] isInCurve; + + private double minFrac = -1; + private int minCurveIndex = -1; + + public MatchCurveSegmentAction(Coordinate p0, Coordinate p1, + Coordinate[] bufferPts, double matchDistance, boolean[] isInCurve) { + this.p0 = p0; + this.p1 = p1; + this.bufferPts = bufferPts; + this.matchDistance = matchDistance; + this.isInCurve = isInCurve; + } + + public void select(MonotoneChain mc, int segIndex) + { + /** + * A curveRingPt segment may match all or only a portion of a single raw segment. + * There may be multiple curve ring segs that match along the raw segment. + * The one closest to the segment start is recorded as the offset curve start. + */ + double frac = subsegmentMatchFrac(bufferPts[segIndex], bufferPts[segIndex+1], p0, p1, matchDistance); + //-- no match + if (frac < 0) return; + + isInCurve[segIndex] = true; + + //-- record lowest index + if (minFrac < 0 || frac < minFrac) { + minFrac = frac; + minCurveIndex = segIndex; + } + } + + public int getMinCurveIndex() { + return minCurveIndex; + } + } + + /* + // Slower, non-indexed algorithm. Left here for future testing. + + private Coordinate[] OLDcomputeCurve(Coordinate[] curveRingPts, Coordinate[] rawOffset) { + boolean[] isInCurve = new boolean[curveRingPts.length - 1]; + int curveStart = -1; + for (int i = 0; i < rawOffset.length - 1; i++) { + int index = markMatchingSegments( + rawOffset[i], rawOffset[i + 1], curveRingPts, isInCurve); + if (curveStart < 0) { + curveStart = index; + } + } + Coordinate[] curvePts = extractSection(curveRingPts, isInCurve, curveStart); + return curvePts; + } + + private int markMatchingSegments(Coordinate p0, Coordinate p1, Coordinate[] curveRingPts, boolean[] isInCurve) { + double minFrac = -1; + int minCurveIndex = -1; + for (int i = 0; i < curveRingPts.length - 1; i++) { + // A curveRingPt seg will only match a portion of a single raw segment. + // But there may be multiple curve ring segs that match along that segment. + // The one closest to the segment start is recorded. + double frac = subsegmentMatchFrac(curveRingPts[i], curveRingPts[i+1], p0, p1, matchDistance); + //-- no match + if (frac < 0) continue; + + isInCurve[i] = true; + + //-- record lowest index + if (minFrac < 0 || frac < minFrac) { + minFrac = frac; + minCurveIndex = i; + } + } + return minCurveIndex; + } + */ + + private static double subsegmentMatchFrac(Coordinate p0, Coordinate p1, + Coordinate seg0, Coordinate seg1, double matchDistance) { + if (matchDistance < Distance.pointToSegment(p0, seg0, seg1)) + return -1; + if (matchDistance < Distance.pointToSegment(p1, seg0, seg1)) + return -1; + //-- matched - determine position as fraction + LineSegment seg = new LineSegment(seg0, seg1); + return seg.segmentFraction(p0); + } + + /** + * Extracts a section of a ring of coordinates, starting at a given index, + * and keeping coordinates which are flagged as being required. + * + * @param ring the ring of points + * @param startIndex the index of the start coordinate + * @param isExtracted flag indicating if coordinate is to be extracted + * @return + */ + private static Coordinate[] extractSection(Coordinate[] ring, int startIndex, boolean[] isExtracted) { + if (startIndex < 0) + return new Coordinate[0]; + + CoordinateList coordList = new CoordinateList(); + int i = startIndex; + do { + coordList.add(ring[i], false); + if (! isExtracted[i]) { + break; + } + i = next(i, ring.length - 1); + } while (i != startIndex); + //-- handle case where every segment is extracted + if (isExtracted[i]) { + coordList.add(ring[i], false); + } + + //-- if only one point found return empty LineString + if (coordList.size() == 1) + return new Coordinate[0]; + + return coordList.toCoordinateArray(); + } + + private static int next(int i, int size) { + i += 1; + return (i < size) ? i : 0; + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveBuilder.java index e068a464a4..f4b6022ead 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetCurveBuilder.java @@ -14,6 +14,7 @@ import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateArrays; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineSegment; import org.locationtech.jts.geom.Position; import org.locationtech.jts.geom.PrecisionModel; @@ -157,7 +158,7 @@ public Coordinate[] getOffsetCurve(Coordinate[] inputPts, double distance) CoordinateArrays.reverse(curvePts); return curvePts; } - + private static Coordinate[] copyCoordinates(Coordinate[] pts) { Coordinate[] copy = new Coordinate[pts.length]; @@ -279,7 +280,7 @@ private void computeSingleSidedBufferCurve(Coordinate[] inputPts, boolean isRigh private void computeOffsetCurve(Coordinate[] inputPts, boolean isRightSide, OffsetSegmentGenerator segGen) { - double distTol = simplifyTolerance(distance); + double distTol = simplifyTolerance(Math.abs(distance)); if (isRightSide) { //---------- compute points for right side of line diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetSegmentGenerator.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetSegmentGenerator.java index 616ed01845..72cfd0a726 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetSegmentGenerator.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/OffsetSegmentGenerator.java @@ -382,7 +382,7 @@ private void addInsideTurn(int orientation, boolean addStartPoint) { * @param distance the offset distance * @param offset the points computed for the offset segment */ - private void computeOffsetSegment(LineSegment seg, int side, double distance, LineSegment offset) + static void computeOffsetSegment(LineSegment seg, int side, double distance, LineSegment offset) { int sideSign = side == Position.LEFT ? 1 : -1; double dx = seg.p1.x - seg.p0.x; diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/SegmentMCIndex.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/SegmentMCIndex.java new file mode 100644 index 0000000000..ca6a109ba7 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/SegmentMCIndex.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 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.operation.buffer; + +import java.util.List; + +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.index.ItemVisitor; +import org.locationtech.jts.index.chain.MonotoneChain; +import org.locationtech.jts.index.chain.MonotoneChainBuilder; +import org.locationtech.jts.index.chain.MonotoneChainSelectAction; +import org.locationtech.jts.index.strtree.STRtree; + +/** + * A spatial index over a segment sequence + * using {@link MonotoneChain}s. + * + * @author mdavis + * + */ +class SegmentMCIndex { + private STRtree index; + + public SegmentMCIndex(Coordinate[] segs) { + index = buildIndex(segs); + } + + private STRtree buildIndex(Coordinate[] segs) { + STRtree index = new STRtree(); + List segChains = MonotoneChainBuilder.getChains(segs, segs); + for (MonotoneChain mc : segChains ) { + index.insert(mc.getEnvelope(), mc); + } + return index; + } + + public void query(Envelope env, MonotoneChainSelectAction action) { + index.query(env, new ItemVisitor() { + public void visitItem(Object item) { + MonotoneChain testChain = (MonotoneChain) item; + testChain.select(env, action); + } + }); + } +} \ No newline at end of file diff --git a/modules/core/src/test/java/org/locationtech/jts/geom/LineSegmentTest.java b/modules/core/src/test/java/org/locationtech/jts/geom/LineSegmentTest.java index 9f841a3b96..3305dd4862 100644 --- a/modules/core/src/test/java/org/locationtech/jts/geom/LineSegmentTest.java +++ b/modules/core/src/test/java/org/locationtech/jts/geom/LineSegmentTest.java @@ -74,32 +74,50 @@ private void checkLineIntersection(double p1x, double p1y, double p2x, double p2 assertTrue(dist <= MAX_ABS_ERROR_INTERSECTION); } - public void testOffset() throws Exception + public void testOffsetPoint() throws Exception { - checkOffset(0, 0, 10, 10, 0.0, ROOT2, -1, 1); - checkOffset(0, 0, 10, 10, 0.0, -ROOT2, 1, -1); + checkOffsetPoint(0, 0, 10, 10, 0.0, ROOT2, -1, 1); + checkOffsetPoint(0, 0, 10, 10, 0.0, -ROOT2, 1, -1); - checkOffset(0, 0, 10, 10, 1.0, ROOT2, 9, 11); - checkOffset(0, 0, 10, 10, 0.5, ROOT2, 4, 6); + checkOffsetPoint(0, 0, 10, 10, 1.0, ROOT2, 9, 11); + checkOffsetPoint(0, 0, 10, 10, 0.5, ROOT2, 4, 6); - checkOffset(0, 0, 10, 10, 0.5, -ROOT2, 6, 4); - checkOffset(0, 0, 10, 10, 0.5, -ROOT2, 6, 4); + checkOffsetPoint(0, 0, 10, 10, 0.5, -ROOT2, 6, 4); + checkOffsetPoint(0, 0, 10, 10, 0.5, -ROOT2, 6, 4); - checkOffset(0, 0, 10, 10, 2.0, ROOT2, 19, 21); - checkOffset(0, 0, 10, 10, 2.0, -ROOT2, 21, 19); + checkOffsetPoint(0, 0, 10, 10, 2.0, ROOT2, 19, 21); + checkOffsetPoint(0, 0, 10, 10, 2.0, -ROOT2, 21, 19); - checkOffset(0, 0, 10, 10, 2.0, 5 * ROOT2, 15, 25); - checkOffset(0, 0, 10, 10, -2.0, 5 * ROOT2, -25, -15); + checkOffsetPoint(0, 0, 10, 10, 2.0, 5 * ROOT2, 15, 25); + checkOffsetPoint(0, 0, 10, 10, -2.0, 5 * ROOT2, -25, -15); } - void checkOffset(double x0, double y0, double x1, double y1, double segFrac, double offset, - double expectedX, double expectedY) + public void testOffsetLine() throws Exception { - LineSegment seg = new LineSegment(x0, y0, x1, y1); - Coordinate p = seg.pointAlongOffset(segFrac, offset); - - assertTrue(equalsTolerance(new Coordinate(expectedX, expectedY), p, 0.000001)); + checkOffsetLine(0, 0, 10, 10, 0, 0, 0, 10, 10 ); + + checkOffsetLine(0, 0, 10, 10, ROOT2, -1, 1, 9, 11 ); + checkOffsetLine(0, 0, 10, 10, -ROOT2, 1, -1, 11, 9); + } + + void checkOffsetPoint(double x0, double y0, double x1, double y1, double segFrac, double offset, + double expectedX, double expectedY) + { + LineSegment seg = new LineSegment(x0, y0, x1, y1); + Coordinate p = seg.pointAlongOffset(segFrac, offset); + + assertTrue(equalsTolerance(new Coordinate(expectedX, expectedY), p, 0.000001)); + } + + void checkOffsetLine(double x0, double y0, double x1, double y1, double offset, + double expectedX0, double expectedY0, double expectedX1, double expectedY1) + { + LineSegment seg = new LineSegment(x0, y0, x1, y1); + LineSegment actual = seg.offset(offset); + + assertTrue(equalsTolerance(new Coordinate(expectedX0, expectedY0), actual.p0, 0.000001)); + assertTrue(equalsTolerance(new Coordinate(expectedX1, expectedY1), actual.p1, 0.000001)); } public static boolean equalsTolerance(Coordinate p0, Coordinate p1, double tolerance) diff --git a/modules/core/src/test/java/org/locationtech/jts/geom/util/GeometryMapperTest.java b/modules/core/src/test/java/org/locationtech/jts/geom/util/GeometryMapperTest.java new file mode 100644 index 0000000000..d39359bffc --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/geom/util/GeometryMapperTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021 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.geom.util; + +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.util.GeometryMapper.MapOp; +import org.locationtech.jts.io.ParseException; + +import junit.textui.TestRunner; +import test.jts.GeometryTestCase; + +public class GeometryMapperTest extends GeometryTestCase { + + public static void main(String args[]) { + TestRunner.run(GeometryMapperTest.class); + } + + public GeometryMapperTest(String name) { + super(name); + } + + /** + * Mapping: + * LineString -> LineString, + * Point -> empty LineString, + * Polygon -> null + */ + static GeometryMapper.MapOp KEEP_LINE = new GeometryMapper.MapOp() { + @Override + public Geometry map(Geometry geom) { + if (geom instanceof Point) { + return geom.getFactory().createEmpty(1); + } + if (geom instanceof LineString) + return geom; + return null; + } + }; + + static GeometryMapper.MapOp BOUNDARY = new GeometryMapper.MapOp() { + @Override + public Geometry map(Geometry geom) { + return geom.getBoundary(); + } + }; + + public void testFlatMapInputEmpty() throws ParseException { + checkFlatMap("GEOMETRYCOLLECTION( POINT EMPTY, LINESTRING EMPTY)", + 1, KEEP_LINE, "LINESTRING EMPTY"); + } + + public void testFlatMapInputMulti() throws ParseException { + checkFlatMap("GEOMETRYCOLLECTION( MULTILINESTRING((0 0, 1 1), (1 1, 2 2)), LINESTRING(2 2, 3 3))", + 1, KEEP_LINE, "MULTILINESTRING ((0 0, 1 1), (1 1, 2 2), (2 2, 3 3))"); + } + + public void testFlatMapResultEmpty() throws ParseException { + checkFlatMap("GEOMETRYCOLLECTION( LINESTRING(0 0, 1 1), LINESTRING(1 1, 2 2))", + 1, KEEP_LINE, "MULTILINESTRING((0 0, 1 1), (1 1, 2 2))"); + + checkFlatMap("GEOMETRYCOLLECTION( POINT(0 0), POINT(0 0), LINESTRING(0 0, 1 1))", + 1, KEEP_LINE, "LINESTRING(0 0, 1 1)"); + + checkFlatMap("MULTIPOINT((0 0), (1 1))", + 1, KEEP_LINE, "LINESTRING EMPTY"); + } + + public void testFlatMapResultNull() throws ParseException { + checkFlatMap("GEOMETRYCOLLECTION( POINT(0 0), LINESTRING(0 0, 1 1), POLYGON ((1 1, 1 2, 2 1, 1 1)))", + 1, KEEP_LINE, "LINESTRING(0 0, 1 1)"); + } + + public void testFlatMapBoundary() throws ParseException { + checkFlatMap("GEOMETRYCOLLECTION( POINT(0 0), LINESTRING(0 0, 1 1), POLYGON ((1 1, 1 2, 2 1, 1 1)))", + 0, BOUNDARY, "GEOMETRYCOLLECTION (POINT (0 0), POINT (1 1), LINEARRING (1 1, 1 2, 2 1, 1 1))"); + + checkFlatMap("LINESTRING EMPTY", + 0, BOUNDARY, "POINT EMPTY"); + } + + + private void checkFlatMap(String wkt, int dim, MapOp op, String wktExpected) { + Geometry geom = read(wkt); + Geometry actual = GeometryMapper.flatMap(geom, dim, op); + Geometry expected = read(wktExpected); + checkEqual(expected, actual); + } + +} diff --git a/modules/core/src/test/java/org/locationtech/jts/operation/buffer/OffsetCurveTest.java b/modules/core/src/test/java/org/locationtech/jts/operation/buffer/OffsetCurveTest.java new file mode 100644 index 0000000000..6d0bb25e30 --- /dev/null +++ b/modules/core/src/test/java/org/locationtech/jts/operation/buffer/OffsetCurveTest.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2021 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.operation.buffer; + +import org.locationtech.jts.geom.Geometry; + +import test.jts.GeometryTestCase; + +/** + * + * Note: most expected results are rounded to precision of 100, to reduce + * size and improve robustness. + * + * @author Martin Davis + * + */ +public class OffsetCurveTest extends GeometryTestCase { + public static void main(String[] args) { + junit.textui.TestRunner.run(OffsetCurveTest.class); + } + + public OffsetCurveTest(String name) { + super(name); + } + + public void testPoint() { + checkOffsetCurve( + "POINT (0 0)", 1, + "LINESTRING EMPTY" + ); + } + + public void testEmpty() { + checkOffsetCurve( + "LINESTRING EMPTY", 1, + "LINESTRING EMPTY" + ); + } + + public void testZeroLenLine() { + checkOffsetCurve( + "LINESTRING (1 1, 1 1)", 1, + "LINESTRING EMPTY" + ); + } + + public void testSegment1Short() { + checkOffsetCurve( + "LINESTRING (2 2, 2 2.0000001)", 1, + "LINESTRING (1 2, 1 2.0000001)", + 0.00000001 + ); + } + + public void testSegment1() { + checkOffsetCurve( + "LINESTRING (0 0, 9 9)", 1, + "LINESTRING (-0.71 0.71, 8.29 9.71)" + ); + } + + public void testSegment1Neg() { + checkOffsetCurve( + "LINESTRING (0 0, 9 9)", -1, + "LINESTRING (0.71 -0.71, 9.71 8.29)" + ); + } + + public void testSegments2() { + checkOffsetCurve( + "LINESTRING (0 0, 9 9, 25 0)", 1, + "LINESTRING (-0.707 0.707, 8.293 9.707, 8.435 9.825, 8.597 9.915, 8.773 9.974, 8.956 9.999, 9.141 9.99, 9.321 9.947, 9.49 9.872, 25.49 0.872)" + ); + } + + public void testSegments3() { + checkOffsetCurve( + "LINESTRING (0 0, 9 9, 25 0, 30 15)", 1, + "LINESTRING (-0.71 0.71, 8.29 9.71, 8.44 9.83, 8.6 9.92, 8.77 9.97, 8.96 10, 9.14 9.99, 9.32 9.95, 9.49 9.87, 24.43 1.47, 29.05 15.32)" + ); + } + + public void testZigzagOneEndCurved4() { + checkOffsetCurve( + "LINESTRING (1 3, 6 3, 4 5, 9 5)", 4, + "LINESTRING (0.53 6.95, 0.67 7.22, 1.17 7.83, 1.78 8.33, 2.47 8.7, 3.22 8.92, 4 9, 9 9)" + ); + } + + public void testZigzagOneEndCurved1() { + checkOffsetCurve( + "LINESTRING (1 3, 6 3, 4 5, 9 5)", 1, + "LINESTRING (1 4, 3.59 4, 3.29 4.29, 3.17 4.44, 3.08 4.62, 3.02 4.8, 3 5, 3.02 5.2, 3.08 5.38, 3.17 5.56, 3.29 5.71, 3.44 5.83, 3.62 5.92, 3.8 5.98, 4 6, 9 6)" + ); + } + + public void testEmptyResult() { + checkOffsetCurve( + "LINESTRING (3 5, 5 7, 7 5)", -4, + "LINESTRING EMPTY" + ); + } + + public void testSelfCross() { + checkOffsetCurve( + "LINESTRING (50 90, 50 10, 90 50, 10 50)", 10, + "LINESTRING (60 90, 60 60)" ); + } + + public void testSelfCrossNeg() { + checkOffsetCurve( + "LINESTRING (50 90, 50 10, 90 50, 10 50)", -10, + "LINESTRING (40 90, 40 60, 10 60)" ); + } + + public void testRing() { + checkOffsetCurve( + "LINESTRING (10 10, 50 90, 90 10, 10 10)", -10, + "LINESTRING (26.18 20, 50 67.63, 73.81 20, 26.18 20)" ); + } + + public void testClosedCurve() { + checkOffsetCurve( + "LINESTRING (30 70, 80 80, 50 10, 10 80, 60 70)", 10, + "LINESTRING (45 83.2, 78.04 89.81, 80 90, 81.96 89.81, 83.85 89.23, 85.59 88.29, 87.11 87.04, 88.35 85.5, 89.27 83.76, 89.82 81.87, 90 79.9, 89.79 77.94, 89.19 76.06, 59.19 6.06, 58.22 4.3, 56.91 2.77, 55.32 1.53, 53.52 0.64, 51.57 0.12, 49.56 0.01, 47.57 0.3, 45.68 0.98, 43.96 2.03, 42.49 3.4, 41.32 5.04, 1.32 75.04, 0.53 76.77, 0.09 78.63, 0.01 80.53, 0.29 82.41, 0.93 84.2, 1.89 85.85, 3.14 87.28, 4.65 88.45, 6.34 89.31, 8.17 89.83, 10.07 90, 11.96 89.81, 45 83.2)" + ); + } + + public void testMultiLine() { + checkOffsetCurve( + "MULTILINESTRING ((20 30, 60 10, 80 60), (40 50, 80 30))", 10, + "MULTILINESTRING ((24.47 38.94, 54.75 23.8, 70.72 63.71), (44.47 58.94, 84.47 38.94))" + ); + } + + public void testPolygon() { + checkOffsetCurve( + "POLYGON ((100 200, 200 100, 100 100, 100 200))", 10, + "LINESTRING (90 200, 90.19 201.95, 90.76 203.83, 91.69 205.56, 92.93 207.07, 94.44 208.31, 96.17 209.24, 98.05 209.81, 100 210, 101.95 209.81, 103.83 209.24, 105.56 208.31, 107.07 207.07, 207.07 107.07, 208.31 105.56, 209.24 103.83, 209.81 101.95, 210 100, 209.81 98.05, 209.24 96.17, 208.31 94.44, 207.07 92.93, 205.56 91.69, 203.83 90.76, 201.95 90.19, 200 90, 100 90, 98.05 90.19, 96.17 90.76, 94.44 91.69, 92.93 92.93, 91.69 94.44, 90.76 96.17, 90.19 98.05, 90 100, 90 200)" + ); + checkOffsetCurve( + "POLYGON ((100 200, 200 100, 100 100, 100 200))", -10, + "LINESTRING (110 175.86, 175.86 110, 110 110, 110 175.86)" + ); + } + + public void testPolygonWithHole() { + checkOffsetCurve( + "POLYGON ((20 80, 80 80, 80 20, 20 20, 20 80), (30 70, 70 70, 70 30, 30 30, 30 70))", 10, + "MULTILINESTRING ((10 80, 10.19 81.95, 10.76 83.83, 11.69 85.56, 12.93 87.07, 14.44 88.31, 16.17 89.24, 18.05 89.81, 20 90, 80 90, 81.95 89.81, 83.83 89.24, 85.56 88.31, 87.07 87.07, 88.31 85.56, 89.24 83.83, 89.81 81.95, 90 80, 90 20, 89.81 18.05, 89.24 16.17, 88.31 14.44, 87.07 12.93, 85.56 11.69, 83.83 10.76, 81.95 10.19, 80 10, 20 10, 18.05 10.19, 16.17 10.76, 14.44 11.69, 12.93 12.93, 11.69 14.44, 10.76 16.17, 10.19 18.05, 10 20, 10 80), (40 60, 40 40, 60 40, 60 60, 40 60))" + ); + checkOffsetCurve( + "POLYGON ((20 80, 80 80, 80 20, 20 20, 20 80), (30 70, 70 70, 70 30, 30 30, 30 70))", -10, + "LINESTRING EMPTY" + ); + + } + + private void checkOffsetCurve(String wkt, double distance, String wktExpected) { + checkOffsetCurve(wkt, distance, wktExpected, 0.05); + } + + private void checkOffsetCurve(String wkt, double distance, String wktExpected, double tolerance) { + Geometry geom = read(wkt); + Geometry result = OffsetCurve.getCurve(geom, distance); + //System.out.println(result); + + if (wktExpected == null) + return; + + Geometry expected = read(wktExpected); + checkEqual(expected, result, tolerance); + } +}