/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2013, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.renderer.style;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import javax.swing.Icon;
import org.geotools.geometry.jts.GeometryBuilder;
import org.geotools.geometry.jts.JTS;
import org.geotools.geometry.jts.LiteShape;
import org.geotools.referencing.operation.transform.AffineTransform2D;
import org.geotools.renderer.VendorOptionParser;
import org.geotools.styling.Graphic;
import org.geotools.styling.Mark;
import org.geotools.styling.Symbolizer;
import org.opengis.geometry.MismatchedDimensionException;
import org.opengis.referencing.operation.TransformException;
import com.vividsolutions.jts.algorithm.MinimumDiameter;
import com.vividsolutions.jts.geom.Geometry;
import com.vividsolutions.jts.index.quadtree.Quadtree;
/**
* Helper class that helps building (and caching) a texture fill built off a random symbol
* distribution
*
* @author Andrea Aime - GeoSolutions
*/
class RandomFillBuilder {
public enum PositionRandomizer {
/**
* No random symbol distribution
*/
NONE,
/**
* Freeform random distribution
*/
FREE,
/**
* Grid based random distribution
*/
GRID };
public enum RotationRandomizer {
/**
* No angle randomization
*/
NONE,
/**
* Freeform angle randomizer
*/
FREE
}
private static final int MAX_RANDOM_COUNT = Integer.getInteger(
"org.geotools.render.random.maxCount", 1024);
private static final int MAX_RANDOM_ATTEMPTS_MULTIPLIER = Integer.getInteger(
"org.geotools.render.random.maxAttemptsMultiplier", 5);
private static final boolean RANDOM_VISUAL_DEBUGGER = Boolean.getBoolean("org.geotools.render.random.visualDebugger");
private VendorOptionParser voParser;
private SLDStyleFactory factory;
public RandomFillBuilder(VendorOptionParser voParser, SLDStyleFactory factory) {
this.voParser = voParser;
this.factory = factory;
}
/**
* Builds a image with a random distribution of the graphic/mark
*
* @param symbolizer
* @param graphicSize
* @param gs
* @param mark
* @return
*/
BufferedImage buildRandomTilableImage(Symbolizer symbolizer, Graphic gr, Icon icon, Mark mark,
Shape shape, double markSize, Object feature) {
// grab the random generation options
PositionRandomizer randomizer = (PositionRandomizer) voParser.getEnumOption(symbolizer, "random", PositionRandomizer.NONE);
int seed = voParser.getIntOption(symbolizer, "random-seed", 0);
int tileSize = voParser.getIntOption(symbolizer, "random-tile-size", 256);
int count = voParser.getIntOption(symbolizer, "random-symbol-count", 16);
int spaceAround = voParser.getIntOption(symbolizer, "random-space-around", 0);
RotationRandomizer rotation = (RotationRandomizer) voParser.getEnumOption(symbolizer, "random-rotation", RotationRandomizer.NONE);
boolean randomRotation = rotation == RotationRandomizer.FREE;
// minimum validation
if (tileSize <= 0) {
throw new IllegalArgumentException("The random-tile-size parameter must be positive");
}
if (count > MAX_RANDOM_COUNT) {
throw new IllegalArgumentException(
"The random-symbol-count exceeds the safety limit "
+ MAX_RANDOM_COUNT
+ ". You can override this limit by setting the org.geotools.render.random.maxCount system property");
}
if (icon != null && (icon.getIconWidth() > tileSize || icon.getIconHeight() > tileSize)) {
throw new IllegalArgumentException(
"Cannot perform random image disposition, image size " + icon.getIconWidth()
+ "x" + icon.getIconHeight() + " exceeds randomized tile size: "
+ tileSize);
}
// prepare the rendering surface
BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_4BYTE_ABGR);
Graphics2D g2d = image.createGraphics();
// prepare the bounds of the tile
Geometry tileBounds = new GeometryBuilder().box(0, 0, tileSize, tileSize);
// prepare the bounds of the shape
Geometry bounds = getGeometryBounds(icon, mark, shape, markSize, feature);
Geometry conflictBounds = getConflictBounds(bounds, spaceAround);
ReservedAreaCache rac = buildReservedAreaCache(conflictBounds);
// establish the bounds for the random symbols
Rectangle targetArea = new Rectangle(0, 0, tileSize, tileSize);
// build the point sequence generator
Random random = new Random(seed);
PositionSequence ps;
if(randomizer == PositionRandomizer.GRID) {
ps = new GridBasedPositionGenerator(random, rac, count, targetArea, randomRotation);
} else {
ps = new FullyRandomizedPositionGenerator(random, rac, count, targetArea, randomRotation);
}
// if we are going to paint rotated images, better set the interpolation to bicubic
Object oldInterpolationValue = g2d.getRenderingHint(RenderingHints.KEY_INTERPOLATION);
if (randomRotation && icon != null) {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
}
AffineTransform originalTransform = g2d.getTransform();
try {
try {
// paint the random symbols
AffineTransform at = new AffineTransform();
Position p;
while((p = ps.getNextPosition()) != null) {
at.setToTranslation(p.x, p.y);
at.rotate(Math.toRadians(p.rotation));
List<AffineTransform2D> transforms = new ArrayList<AffineTransform2D>();
AffineTransform2D at2d = new AffineTransform2D(at);
transforms.add(at2d);
// do we have to build the other 8 possibilities? Happens only if the
// symbol is crossing the bounds
Geometry transformed = JTS.transform(bounds, at2d);
if (tileBounds.intersects(transformed) && !tileBounds.contains(transformed)) {
for (int dx = -tileSize; dx <= tileSize; dx += tileSize) {
for (int dy = -tileSize; dy <= tileSize; dy += tileSize) {
if (dx == 0 && dy == 0) {
continue;
}
int mx = p.x + dx;
int my = p.y + dy;
at.setToTranslation(mx, my);
at.rotate(Math.toRadians(p.rotation));
AffineTransform2D tx2d = new AffineTransform2D(at);
Geometry translatedBounds = JTS.transform(bounds, tx2d);
if (tileBounds.intersects(translatedBounds)
|| tileBounds.contains(translatedBounds)) {
// System.out.println("Adding " + translatedBounds);
transforms.add(tx2d);
}
}
}
}
// do we have a conflict in any of the positions?
if (!rac.checkAndReserve(transforms)) {
// System.out.println(p + " was busy");
ps.lastPositionResults(true);
continue;
}
// paint!
for (AffineTransform2D transform : transforms) {
if (icon != null) {
g2d.setTransform(originalTransform);
g2d.transform(transform);
icon.paintIcon(null, g2d, 0, 0);
} else if (shape != null) {
factory.fillDrawMark(g2d, transform.getTranslateX(),
transform.getTranslateY(), mark, markSize,
Math.toRadians(p.rotation), feature);
}
}
// System.out.println(p + " was free");
ps.lastPositionResults(false);
}
} catch (TransformException e) {
throw new RuntimeException(
"Unexpected error happened while paining the random symbols", e);
}
} finally {
g2d.setTransform(originalTransform);
if (oldInterpolationValue != null) {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, oldInterpolationValue);
}
}
// draw the conflict boxes
if (RANDOM_VISUAL_DEBUGGER) {
rac.paintReservedAreas(g2d);
}
g2d.dispose();
return image;
}
private ReservedAreaCache buildReservedAreaCache(Geometry conflictBounds) {
if (conflictBounds != null) {
return new DefaultReservedAreaCache(conflictBounds);
} else {
return new NoOpReservedAreaCache();
}
}
private Geometry getConflictBounds(Geometry bounds, int spaceAround) {
// apply the space around (with a negative one we might end up with nothing as the result)
Geometry conflictBounds = bounds;
if (spaceAround != 0) {
conflictBounds = bounds.buffer(spaceAround);
if (conflictBounds.isEmpty() || conflictBounds.getArea() == 0) {
conflictBounds = null;
} else {
conflictBounds = new MinimumDiameter(conflictBounds).getMinimumRectangle();
}
}
return conflictBounds;
}
private Geometry getGeometryBounds(Icon icon, Mark mark, Shape shape, double markSize,
Object feature) {
Geometry bounds;
if (icon != null) {
bounds = new GeometryBuilder().box(0, 0, icon.getIconWidth(), icon.getIconHeight());
} else {
// the shape can be very complicated, go for the MBR. Wanted to use ShapeReader, but it
// blindly assumes the shape is a polygon, while it may not be. Building a multipoint
// instead
AffineTransform at = AffineTransform.getScaleInstance(markSize, -markSize);
Shape ts = at.createTransformedShape(shape);
Geometry shapeGeometry = JTS.toGeometry(ts);
bounds = new MinimumDiameter(shapeGeometry).getMinimumRectangle();
}
// grow by the stroke size, if this is a mark
if (icon == null && mark != null) {
Stroke stroke = factory.getStroke(mark.getStroke(), feature);
if (stroke instanceof BasicStroke) {
float width = ((BasicStroke) stroke).getLineWidth() / 2 + 1;
if (width > 0) {
Geometry buffered = bounds.buffer(width);
bounds = new MinimumDiameter(buffered).getMinimumRectangle();
}
}
}
return bounds;
}
/**
* Checks and reserves areas to avoid overlaps between painted symbols
*
* @author Andrea Aime - GeoSolutions
*/
interface ReservedAreaCache {
public boolean checkAndReserve(List<AffineTransform2D> positions)
throws MismatchedDimensionException, TransformException;
public void paintReservedAreas(Graphics2D g2d);
}
/**
* No op implementation, does not do conflict resolution
*
* @author Andrea Aime - GeoSolutions
*
*/
private static class NoOpReservedAreaCache implements ReservedAreaCache {
@Override
public boolean checkAndReserve(List<AffineTransform2D> positions) {
return true;
}
@Override
public void paintReservedAreas(Graphics2D g2d) {
// nothing to paint
}
}
/**
* Stores the various positions of the reserved areas and checks for conflicts
*
* @author Andrea Aime - GeoSolutions
*
*/
private static class DefaultReservedAreaCache implements ReservedAreaCache {
Quadtree qt = new Quadtree();
Geometry conflictBounds;
public DefaultReservedAreaCache(Geometry conflictBounds) {
this.conflictBounds = conflictBounds;
}
@Override
public boolean checkAndReserve(List<AffineTransform2D> transforms)
throws MismatchedDimensionException, TransformException {
List<Geometry> transformedConflictBounds = new ArrayList<Geometry>();
boolean conflict = false;
for (AffineTransform2D tx2d : transforms) {
if (conflict) {
break;
}
Geometry cbTransformed = JTS.transform(conflictBounds, tx2d);
transformedConflictBounds.add(cbTransformed);
List results = qt.query(cbTransformed.getEnvelopeInternal());
for (Iterator it = results.iterator(); it.hasNext();) {
Geometry candidate = (Geometry) it.next();
if (candidate.intersects(cbTransformed)) {
// location conflict
conflict = true;
break;
}
}
}
// reserve the area if no conflict
if (!conflict) {
for (Geometry tcb : transformedConflictBounds) {
qt.insert(tcb.getEnvelopeInternal(), tcb);
}
}
return !conflict;
}
@Override
public void paintReservedAreas(Graphics2D g2d) {
g2d.setStroke(new BasicStroke(0.5f));
g2d.setColor(Color.LIGHT_GRAY);
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.XOR));
for (Object bound : qt.queryAll()) {
LiteShape ls = new LiteShape((Geometry) bound, new AffineTransform(), false);
g2d.draw(ls);
}
}
}
static final class Position {
int x;
int y;
double rotation;
public Position(int x, int y, double rotation) {
this.x = x;
this.y = y;
this.rotation = rotation;
}
@Override
public String toString() {
return "Position [x=" + x + ", y=" + y + ", rotation=" + rotation + "]";
}
}
interface PositionSequence {
Position getNextPosition();
void lastPositionResults(boolean conflict);
}
static class FullyRandomizedPositionGenerator implements PositionSequence {
int attempts;
int symbols;
int targetSymbolCount;
Random random;
Rectangle targetArea;
boolean randomRotation;
Position position = new Position(0, 0, 0);
public FullyRandomizedPositionGenerator(Random random, ReservedAreaCache rac, int targetSymbolCount, Rectangle targetArea, boolean randomRotation) {
this.targetSymbolCount = targetSymbolCount;
this.random = random;
this.targetArea = targetArea;
this.randomRotation = randomRotation;
}
@Override
public Position getNextPosition() {
if(attempts > targetSymbolCount * MAX_RANDOM_ATTEMPTS_MULTIPLIER
|| symbols > targetSymbolCount) {
return null;
}
attempts++;
position.x = targetArea.x + random.nextInt(targetArea.width);
position.y = targetArea.y + random.nextInt(targetArea.height);
if(randomRotation) {
position.rotation = random.nextDouble() * 360;
}
return position;
}
@Override
public void lastPositionResults(boolean conflict) {
if(!conflict) {
symbols++;
}
}
}
static class GridBasedPositionGenerator implements PositionSequence {
int attempts;
int symbols;
int targetSymbolCount;
double deltaX, deltaY;
Random random;
Rectangle targetArea;
int rows;
int cols;
int r;
int c;
boolean retry;
boolean randomRotation;
Position position = new Position(0, 0, 0);
public GridBasedPositionGenerator(Random random, ReservedAreaCache rac, int targetSymbolCount, Rectangle targetArea, boolean randomRotation) {
this.targetSymbolCount = targetSymbolCount;
this.random = random;
this.targetArea = targetArea;
// first attempt at computing rows and cols
this.rows = (int) Math.sqrt(targetSymbolCount);
this.cols = targetSymbolCount / rows;
// compute deltas, taking into account the symbol size
this.deltaX = 1d * targetArea.width / cols;
this.deltaY = 1d * targetArea.height / rows;
// adapt rows and cols to the deltas just computed
this.rows = (int) Math.max(Math.round(targetArea.width / deltaX), 1);
this.cols = (int) Math.max(Math.round(targetArea.height / deltaY), 1);
this.randomRotation = randomRotation;
}
@Override
public Position getNextPosition() {
if(symbols > targetSymbolCount || r >= rows) {
return null;
}
attempts++;
// System.out.println("Grid position: " + c + ", " + r + ", deltas: " + deltaX + "," + deltaY + " target area: " + targetArea);
position.x = (int) Math.round(targetArea.getMinX() + c * deltaX + random.nextDouble() * deltaX);
position.y = (int) Math.round(targetArea.getMinY() + r * deltaY + random.nextDouble() * deltaY);
if(randomRotation) {
position.rotation = random.nextDouble() * 360;
}
// System.out.println("Position: " + position);
return position;
}
@Override
public void lastPositionResults(boolean conflict) {
if(!conflict) {
// move on to the next location
symbols++;
moveToNextCell();
} else {
// can we retry?
if(attempts > MAX_RANDOM_ATTEMPTS_MULTIPLIER) {
// too many attempts, this cell will be left empty
moveToNextCell();
}
}
}
private void moveToNextCell() {
c++;
if(c >= cols) {
r++;
c = 0;
}
attempts = 0;
}
}
}