/* (c) 2015 Open Source Geospatial Foundation - all rights reserved
* This code is licensed under the GPL 2.0 license, available at the root
* application directory.
*/
package org.geoserver.wms.vector;
import static org.geotools.renderer.lite.VectorMapRenderUtils.buildTransform;
import java.awt.Rectangle;
import java.awt.geom.AffineTransform;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Nullable;
import org.geotools.geometry.jts.Decimator;
import org.geotools.geometry.jts.GeometryClipper;
import org.geotools.geometry.jts.JTS;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.referencing.operation.transform.ConcatenatedTransform;
import org.geotools.referencing.operation.transform.ProjectiveTransform;
import org.geotools.renderer.ScreenMap;
import org.geotools.renderer.crs.ProjectionHandler;
import org.geotools.renderer.crs.ProjectionHandlerFinder;
import org.geotools.renderer.lite.RendererUtilities;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import com.google.common.base.Throwables;
import com.vividsolutions.jts.geom.Envelope;
import com.vividsolutions.jts.geom.Geometry;
import com.vividsolutions.jts.geom.GeometryCollection;
import com.vividsolutions.jts.geom.LineString;
import com.vividsolutions.jts.geom.MultiLineString;
import com.vividsolutions.jts.geom.MultiPoint;
import com.vividsolutions.jts.geom.MultiPolygon;
import com.vividsolutions.jts.geom.Point;
import com.vividsolutions.jts.geom.Polygon;
import com.vividsolutions.jts.simplify.TopologyPreservingSimplifier;
class PipelineBuilder {
static class Context {
@Nullable
ProjectionHandler projectionHandler;
MathTransform sourceToTargetCrs;
MathTransform targetToScreen;
MathTransform sourceToScreen;
ReferencedEnvelope renderingArea; // WMS request; bounding box - in final map (target) CRS (BBOX from WMS)
Rectangle paintArea; // WMS request; rectangle of the image (width and height from WMS)
public ScreenMap screenMap;
public CoordinateReferenceSystem sourceCrs; // data's CRS
public AffineTransform worldToScreen;
public double targetCRSSimplificationDistance;
public double screenSimplificationDistance;
public double pixelSizeInTargetCRS; // approximate size of a pixel in the Target CRS
}
Context context;
// When clipping, we want to expand the clipping box by a bit so that
// the client (i.e. OpenLayers) doesn't draw the clip lines created when
// the polygon is clipped to the request BBOX.
// 12 is what the current streaming renderer (for WMS images) uses
final int clipBBOXSizeIncreasePixels = 12;
private Pipeline first = Pipeline.END, last = Pipeline.END;
private PipelineBuilder(Context context) {
this.context = context;
}
public static PipelineBuilder newBuilder(ReferencedEnvelope renderingArea, Rectangle paintArea,
CoordinateReferenceSystem sourceCrs, double overSampleFactor) throws FactoryException {
Context context = createContext(renderingArea, paintArea, sourceCrs, overSampleFactor);
return new PipelineBuilder(context);
}
private static Context createContext(ReferencedEnvelope mapArea, Rectangle paintArea,
CoordinateReferenceSystem sourceCrs, double overSampleFactor) throws FactoryException {
Context context = new Context();
context.renderingArea = mapArea;
context.paintArea = paintArea;
context.sourceCrs = sourceCrs;
context.worldToScreen = RendererUtilities.worldToScreenTransform(mapArea, paintArea);
final boolean wrap = false;
context.projectionHandler = ProjectionHandlerFinder.getHandler(mapArea, sourceCrs, wrap);
CoordinateReferenceSystem mapCrs = context.renderingArea.getCoordinateReferenceSystem();
context.sourceToTargetCrs = buildTransform(sourceCrs, mapCrs);
context.targetToScreen = ProjectiveTransform.create(context.worldToScreen);
context.sourceToScreen = ConcatenatedTransform.create(context.sourceToTargetCrs,
context.targetToScreen);
double[] spans_sourceCRS;
double[] spans_targetCRS;
try {
MathTransform screenToWorld = context.sourceToScreen.inverse();
// 0.8px is used to make sure the generalization isn't too much (doesn't make visible changes)
spans_sourceCRS = Decimator.computeGeneralizationDistances(screenToWorld,
context.paintArea, 0.8);
spans_targetCRS = Decimator.computeGeneralizationDistances(
context.targetToScreen.inverse(), context.paintArea, 1.0);
// this is used for clipping the data to A pixels around request BBOX, so we want this to be the larger of the two spans
// so we are getting at least A pixels around.
context.pixelSizeInTargetCRS = Math.max(spans_targetCRS[0], spans_targetCRS[1]);
} catch (TransformException e) {
throw Throwables.propagate(e);
}
context.screenSimplificationDistance = 0.25 / overSampleFactor;
// use min so generalize "less" (if pixel is different size in X and Y)
context.targetCRSSimplificationDistance = Math.min(spans_targetCRS[0], spans_targetCRS[1])
/ overSampleFactor;
context.screenMap = new ScreenMap(0, 0, paintArea.width, paintArea.height);
context.screenMap.setSpans(spans_sourceCRS[0] / overSampleFactor,
spans_sourceCRS[1] / overSampleFactor);
context.screenMap.setTransform(context.sourceToScreen);
return context;
}
public PipelineBuilder preprocess() {
addLast(new PreProcess(context.projectionHandler, context.screenMap));
return this;
}
public PipelineBuilder collapseCollections() {
addLast(new CollapseCollections());
return this;
}
public Pipeline build() {
return first;
}
private void addLast(Pipeline step) {
if (first == Pipeline.END) {
first = step;
last = first;
} else {
last.setNext(step);
last = step;
}
}
private static final class CollapseCollections extends Pipeline {
@Override
protected Geometry _run(Geometry geom) throws Exception {
if (geom instanceof GeometryCollection && geom.getNumGeometries() == 1) {
return geom.getGeometryN(0);
}
return geom;
}
}
private static final class PreProcess extends Pipeline {
private final ProjectionHandler projectionHandler;
private final ScreenMap screenMap;
PreProcess(@Nullable ProjectionHandler projectionHandler, ScreenMap screenMap) {
this.projectionHandler = projectionHandler;
this.screenMap = screenMap;
}
@Override
protected Geometry _run(Geometry geom) throws TransformException, FactoryException {
Geometry preProcessed = geom;
if (this.projectionHandler != null) {
preProcessed = projectionHandler.preProcess(geom);
}
if (preProcessed == null || preProcessed.isEmpty()) {
return EMPTY;
}
if (preProcessed.getDimension() > 0) {
Envelope env = preProcessed.getEnvelopeInternal();
if (screenMap.canSimplify(env))
if (screenMap.checkAndSet(env)) {
return EMPTY;
} else {
preProcessed = screenMap.getSimplifiedShape(env.getMinX(), env.getMinY(),
env.getMaxX(), env.getMaxY(), preProcessed.getFactory(),
preProcessed.getClass());
}
}
return preProcessed;
}
}
public PipelineBuilder transform(final boolean transformToScreenCoordinates) {
final MathTransform sourceToScreen = context.sourceToScreen;
final MathTransform sourceToTargetCrs = context.sourceToTargetCrs;
final MathTransform tx = transformToScreenCoordinates ? sourceToScreen : sourceToTargetCrs;
addLast(new Transform(tx));
return this;
}
public PipelineBuilder simplify(boolean isTransformToScreenCoordinates) {
double pixelDistance = context.screenSimplificationDistance;
double simplificationDistance = context.targetCRSSimplificationDistance;
double distanceTolerance = isTransformToScreenCoordinates ? pixelDistance
: simplificationDistance;
addLast(new Simplify(distanceTolerance));
return this;
}
public PipelineBuilder clip(boolean clipToMapBounds, boolean transformToScreenCoordinates) {
if (clipToMapBounds) {
Envelope clippingEnvelope;
if (transformToScreenCoordinates) {
Rectangle screen = context.paintArea;
Envelope paintArea = new Envelope(0, screen.getWidth(), 0, screen.getHeight());
paintArea.expandBy(clipBBOXSizeIncreasePixels);
clippingEnvelope = paintArea;
} else {
ReferencedEnvelope renderingArea = context.renderingArea;
renderingArea.expandBy(clipBBOXSizeIncreasePixels * context.pixelSizeInTargetCRS);
clippingEnvelope = renderingArea;
}
addLast(new ClipRemoveDegenerateGeometries(clippingEnvelope));
}
return this;
}
private static final class Transform extends Pipeline {
private final MathTransform tx;
Transform(MathTransform tx) {
this.tx = tx;
}
@Override
protected Geometry _run(Geometry geom) throws Exception {
Geometry transformed = JTS.transform(geom, this.tx);
return transformed;
}
}
private static final class Simplify extends Pipeline {
private final double distanceTolerance;
Simplify(double distanceTolerance) {
this.distanceTolerance = distanceTolerance;
}
@Override
protected Geometry _run(Geometry geom) throws Exception {
if (geom.getDimension() == 0) {
return geom;
}
// DJB: Use this instead of com.vividsolutions.jts.simplify.DouglasPeuckerSimplifier because
// DPS does NOT do a good job with polygons.
TopologyPreservingSimplifier simplifier = new TopologyPreservingSimplifier(geom);
simplifier.setDistanceTolerance(this.distanceTolerance);
Geometry simplified = simplifier.getResultGeometry();
return simplified;
}
}
protected static class Clip extends Pipeline {
private final Envelope clippingEnvelope;
Clip(Envelope clippingEnvelope) {
this.clippingEnvelope = clippingEnvelope;
}
@Override
protected Geometry _run(Geometry geom) throws Exception {
GeometryClipper clipper = new GeometryClipper(clippingEnvelope);
try {
return clipper.clip(geom, true);
} catch (Exception e) {
return clipper.clip(geom, false); // use non-robust clipper
}
}
}
/**
* Does the normal clipping, but removes degenerative geometries. For example, a polygon-polygon intersection can result in polygons (normal), but
* also points and line (degenerative).
*
* This will remove the degenerative geometries from the result. ie. input is polygon(s), only polygons are returned input is line(s), only lines
* are returned input is point(s), only points returned
*
* For mixed input (GeometryCollection), we do the above for each component in the GeometryCollection. i.e. for GeometryCollection( POLYGON(...),
* LINESTRING(...) ) it would ensure that the POLYGON(...) only adds Polygons and that the LINESTRING() only adds Lines
*
*/
public static final class ClipRemoveDegenerateGeometries extends Clip {
ClipRemoveDegenerateGeometries(Envelope clippingEnvelope) {
super(clippingEnvelope);
}
@Override
protected Geometry _run(Geometry geom) throws Exception {
// protect against empty input geometry
if ((geom == null) || (geom.isEmpty())) {
return null;
}
// if its a geometrycollection we do each piece individually
if (geom.getGeometryType() == "GeometryCollection") {
return collectionClip((GeometryCollection) geom);
}
// normal clipping done here
Geometry result = super._run(geom);
// if there's no resulting geometry, don't need to deal with it
if ((result == null) || (result.isEmpty())) {
return null;
}
// make sure the resulting geometry matches the input geometry
if ((geom instanceof Point) || (geom instanceof MultiPoint)) {
return onlyPoints(result);
} else if ((geom instanceof LineString) || (geom instanceof MultiLineString)) {
return onlyLines(result);
} else if ((geom instanceof Polygon) || (geom instanceof MultiPolygon)) {
return onlyPolygon(result);
}
return result;
}
/*
* We do each geometry in sequence, and remove degenerative geometries generated at each step. For example, if the input is a
* GeometryCollection(POLYGON(...), LINESTRING(...)) the POLYGON(...) will be clipped (and only polygons preserved) the LINESTRING(..) will be
* clipped (and only lines preserved)
*
* There are some edge cases unhandled here - if the input contains multiple polygons, the result might be better reduced to a multipolygon
* instead of a GeometryCollect with multiple polygons in it. However, this would be computationally expensive and unlikely to make any
* difference.
*/
private Geometry collectionClip(GeometryCollection geom) throws Exception {
ArrayList<Geometry> result = new ArrayList<Geometry>();
for (int t = 0; t < geom.getNumGeometries(); t++) {
Geometry g = geom.getGeometryN(0);
Geometry clipped = _run(g); // gets the non-degenerative of the result
if ((clipped != null) && (!clipped.isEmpty())) {
result.add(clipped);
}
}
if (result.size() == 0) {
return null;
}
return new GeometryCollection((Geometry[]) result.toArray(new Geometry[result.size()]),
geom.getFactory());
}
/*
* For a geometry, return only polygons. For example, for a GeometryCollection containing points, lines, and polygons only the polygons would
* be return - the others are removed.
*/
private Geometry onlyPolygon(Geometry result) {
if ((result instanceof Polygon) || (result instanceof MultiPolygon)) {
return result;
}
List polys = com.vividsolutions.jts.geom.util.PolygonExtracter.getPolygons(result);
if (polys.size() == 0) {
return null;
}
if (polys.size() == 1) {
return (Polygon) polys.get(0);
}
// this could, theoretically, produce invalid MULTIPOLYGONS since polygons cannot share edges. Taking
// 2 polygons and putting them in a multipolygon is not always valid. However, many systems will not correctly
// deal with a GeometryCollection with multiple polygons in them.
// The best strategy is to just create a (potentially) invalid multipolygon.
return new MultiPolygon((Polygon[]) polys.toArray(new Polygon[polys.size()]),
result.getFactory());
}
private Geometry onlyLines(Geometry result) {
if ((result instanceof LineString) || (result instanceof MultiLineString)) {
return result;
}
List lines = com.vividsolutions.jts.geom.util.LineStringExtracter.getLines(result);
if (lines.size() == 0) {
return null;
}
if (lines.size() == 1) {
return (LineString) lines.get(0);
}
return new MultiLineString((LineString[]) lines.toArray(new LineString[lines.size()]),
result.getFactory());
}
private Geometry onlyPoints(Geometry result) {
if ((result instanceof Point) || (result instanceof MultiPoint)) {
return result;
}
List pts = com.vividsolutions.jts.geom.util.PointExtracter.getPoints(result);
if (pts.size() == 0) {
return null;
}
if (pts.size() == 1) {
return (Point) pts.get(0);
}
return new MultiPoint((Point[]) pts.toArray(new Point[pts.size()]),
result.getFactory());
}
}
}