/*
* Copyright (C) 2014.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 or
* version 2 as published by the Free Software Foundation.
*
* This program 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
* General Public License for more details.
*/
package uk.me.parabola.mkgmap.filters;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import uk.me.parabola.imgfmt.app.Area;
import uk.me.parabola.imgfmt.app.Coord;
import uk.me.parabola.log.Logger;
import uk.me.parabola.mkgmap.general.MapShape;
import uk.me.parabola.mkgmap.osmstyle.WrongAngleFixer;
import uk.me.parabola.mkgmap.reader.osm.FakeIdGenerator;
import uk.me.parabola.mkgmap.reader.osm.GType;
import uk.me.parabola.util.MultiHashMap;
/**
* Merge shapes with same Garmin type and similar attributes if they have common
* points. This reduces the number of shapes as well as the number of points.
* @author GerdP
*
*/
public class ShapeMergeFilter{
private static final Logger log = Logger.getLogger(ShapeMergeFilter.class);
private final int resolution;
private final ShapeHelper dupShape = new ShapeHelper(new ArrayList<Coord>(0));
private final boolean orderByDecreasingArea;
public ShapeMergeFilter(int resolution, boolean orderByDecreasingArea) {
this.resolution = resolution;
this.orderByDecreasingArea = orderByDecreasingArea;
}
public List<MapShape> merge(List<MapShape> shapes) {
if (shapes.size() <= 1)
return shapes;
int count = 0;
MultiHashMap<Integer, Map<MapShape, List<ShapeHelper>>> topMap = new MultiHashMap<>();
List<MapShape> mergedShapes = new ArrayList<>();
for (MapShape shape: shapes) {
if (shape.getMinResolution() > resolution || shape.getMaxResolution() < resolution)
continue;
count++;
if (shape.getPoints().get(0) != shape.getPoints().get(shape.getPoints().size()-1)){
// should not happen here
log.error("shape is not closed with identical points", shape.getOsmid());
mergedShapes.add(shape);
continue;
}
List<Map<MapShape, List<ShapeHelper>>> sameTypeList = topMap.get(shape.getType());
ShapeHelper sh = new ShapeHelper(shape.getPoints());
sh.id = shape.getOsmid();
if (sh.areaTestVal == 0){
// should not happen here
log.error("ignoring shape with id", sh.id, "and type",
GType.formatType(shape.getType()), "at resolution", resolution + ", it",
(shape.wasClipped() ? "was clipped to" : "has"),
shape.getPoints().size(), "points and has an empty area ");
continue;
}
if (sameTypeList.isEmpty()){
Map<MapShape, List<ShapeHelper>> lowMap = new LinkedHashMap<>();
ArrayList<ShapeHelper> list = new ArrayList<>();
list.add(sh);
lowMap.put(shape, list);
topMap.add(shape.getType(),lowMap);
continue;
}
for (Map<MapShape, List<ShapeHelper>> lowMap : sameTypeList){
boolean added = false;
for (MapShape ms: lowMap.keySet()){
if (orderByDecreasingArea && ms.getFullArea() != shape.getFullArea())
// must not merge areas unless derived from same thing
continue;
// we do not use isSimilar() here, as it compares minRes and maxRes as well
String s1 = ms.getName();
String s2 = shape.getName();
if (s1 == s2 || s1 != null && s1.equals(s2)){
List<ShapeHelper> list = lowMap.get(ms);
int oldSize = list.size();
list = addWithConnectedHoles(list, sh, ms.getType());
lowMap.put(ms, list);
if (list.size() < oldSize+1){
log.debug("shape with id", sh.id, "was merged", (oldSize+1 - list.size()), " time(s) at resolution", resolution);
}
added = true;
break;
}
}
if (!added){
ArrayList<ShapeHelper> list = new ArrayList<>();
list.add(sh);
lowMap.put(shape, list);
}
}
}
for (List<Map<MapShape, List<ShapeHelper>>> sameTypeList : topMap.values()){
for (Map<MapShape, List<ShapeHelper>> lowMap : sameTypeList){
Iterator<Entry<MapShape, List<ShapeHelper>>> iter = lowMap.entrySet().iterator();
while (iter.hasNext()){
Entry<MapShape, List<ShapeHelper>> item = iter.next();
MapShape ms = item.getKey();
List<ShapeHelper> shapeHelpers = item.getValue();
for (ShapeHelper sh:shapeHelpers){
MapShape newShape = ms.copy();
assert sh.getPoints().get(0) == sh.getPoints().get(sh.getPoints().size()-1);
if (sh.id == 0){
// this shape is the result of a merge
List<Coord> optimizedPoints = WrongAngleFixer.fixAnglesInShape(sh.getPoints());
if (optimizedPoints.isEmpty())
continue;
newShape.setPoints(optimizedPoints);
newShape.setOsmid(FakeIdGenerator.makeFakeId());
} else {
newShape.setPoints(sh.getPoints());
newShape.setOsmid(sh.id);
}
mergedShapes.add(newShape);
}
}
}
}
log.info("merged shapes", count, "->", mergedShapes.size(), "at resolution", resolution);
return mergedShapes;
}
/**
* Try to merge a shape with one or more of the shapes in the list.
* If it cannot be merged, it is added to the list.
* Holes in shapes are connected with the outer lines,
* so no following routine must use {@link Java2DConverter}
* to process these shapes.
* @param list list of shapes with equal type
* @param toAdd new shape
* @return new list of shapes, this might contain fewer (merged) elements
*/
private List<ShapeHelper> addWithConnectedHoles(List<ShapeHelper> list,
final ShapeHelper toAdd, final int type) {
assert toAdd.getPoints().size() > 3;
List<ShapeHelper> result = new ArrayList<>(list.size()+1);
ShapeHelper shNew = new ShapeHelper(toAdd);
for (ShapeHelper shOld:list){
if (shOld.getBounds().intersects(shNew.getBounds()) == false){
result.add(shOld);
continue;
}
ShapeHelper mergeRes = tryMerge(shOld, shNew, type);
if (mergeRes == shOld){
result.add(shOld);
continue;
} else if (mergeRes != null){
shNew = mergeRes;
}
if (shNew == dupShape){
log.warn("ignoring duplicate shape with id", toAdd.id, "at", toAdd.getPoints().get(0).toOSMURL(), "with type", GType.formatType(type), "for resolution", resolution);
return list; // nothing to do
}
}
if (shNew != null && shNew != dupShape)
result.add(shNew);
if (result.size() > list.size()+1 )
log.error("result list size is wrong", list.size(), "->", result.size());
return result;
}
/**
* Find out if two shapes have common points. If yes, merge them.
* @param sh1 1st shape1
* @param sh2 2st shape2
* @param type Garmin type (used for log messages)
* @return merged shape or 1st shape if no common point found or {@code dupShape}
* if both shapes describe the same area.
*/
private ShapeHelper tryMerge(ShapeHelper sh1, ShapeHelper sh2, int type) {
// both clockwise or both ccw ?
boolean sameDir = sh1.areaTestVal > 0 && sh2.areaTestVal > 0 || sh1.areaTestVal < 0 && sh2.areaTestVal < 0;
List<Coord> points1, points2;
if (sh2.getPoints().size()> sh1.getPoints().size()){
points1 = sh2.getPoints();
points2 = sh1.getPoints();
} else {
points1 = sh1.getPoints();
points2 = sh2.getPoints();
}
// find all coords that are common in the two shapes
IntArrayList sh1PositionsToCheck = new IntArrayList();
IntArrayList sh2PositionsToCheck = new IntArrayList();
findCommonCoords(points1, points2, sh1PositionsToCheck, sh2PositionsToCheck);
if (sh1PositionsToCheck.isEmpty()){
return sh1;
}
if (sh2PositionsToCheck.size() + 1 >= points2.size()){
// all points are identical, might be a duplicate
// or a piece that fills a hole
if (points1.size() == points2.size() && Math.abs(sh1.areaTestVal) == Math.abs(sh2.areaTestVal)){
// it is a duplicate, we can ignore it
// XXX this might fail if one of the shapes is self intersecting
return dupShape;
}
}
List<Coord> merged = null;
if (points1.size() + points2.size() - 2*sh1PositionsToCheck.size() < PolygonSplitterFilter.MAX_POINT_IN_ELEMENT){
merged = mergeLongestSequence(points1, points2, sh1PositionsToCheck, sh2PositionsToCheck, sameDir);
if (merged.isEmpty())
return dupShape;
if (merged.get(0) != merged.get(merged.size()-1))
merged = null;
else if (merged.size() > PolygonSplitterFilter.MAX_POINT_IN_ELEMENT){
// don't merge because merged polygon would be split again
log.info("merge rejected: merged shape has too many points " + merged.size());
merged = null;
}
}
ShapeHelper shm = null;
if (merged != null){
shm = new ShapeHelper(merged);
if (Math.abs(shm.areaTestVal) != Math.abs(sh1.areaTestVal) + Math.abs(sh2.areaTestVal)){
log.warn("merging shapes skipped for shapes near", points1.get(sh1PositionsToCheck.getInt(0)).toOSMURL(),
"(maybe overlapping shapes?)");
merged = null;
shm = null;
} else {
if (log.isInfoEnabled()){
log.info("merge of shapes near",points1.get(sh1PositionsToCheck.getInt(0)).toOSMURL(),
"reduces number of points from",(points1.size()+points2.size()),
"to",merged.size());
}
}
}
if (shm != null)
return shm;
if (merged == null)
return sh1;
return null;
}
/**
* Find the common Coord instances and save their positions for both shapes.
* @param s1 shape 1
* @param s2 shape 2
* @param s1PositionsToCheck will contain common positions in shape 1
* @param s2PositionsToCheck will contain common positions in shape 2
*/
private static void findCommonCoords(List<Coord> s1, List<Coord> s2,
IntArrayList s1PositionsToCheck,
IntArrayList s2PositionsToCheck) {
Map<Coord, Integer> s2PosMap = new IdentityHashMap<>(s2.size() - 1);
for (int i = 0; i+1 < s1.size(); i++){
Coord co = s1.get(i);
co.setPartOfShape2(false);
}
for (int i = 0; i+1 < s2.size(); i++){
Coord co = s2.get(i);
co.setPartOfShape2(true);
s2PosMap.put(co, i);
}
int start = 0;
while(start < s1.size()){
Coord co = s1.get(start);
if (!co.isPartOfShape2())
break;
start++;
}
int pos = start+1;
int tested = 0;
while(true){
if (pos+1 >= s1.size())
pos = 0;
Coord co = s1.get(pos);
if (++tested >= s1.size())
break;
if (co.isPartOfShape2()){
s1PositionsToCheck.add(pos);
Integer posInSh2 = s2PosMap.get(co);
assert posInSh2 != null;
s2PositionsToCheck.add(posInSh2);
}
pos++;
}
return;
}
/**
* Finds the longest sequence of common points in two shapes.
* @param points1 list of Coord instances that describes the 1st shape
* @param points2 list of Coord instances that describes the 2nd shape
* @param sh1PositionsToCheck positions in the 1st shape that are common
* @param sh2PositionsToCheck positions in the 2nd shape that are common
* @param sameDir true if both shapes are clockwise or both are ccw
* @return the merged shape or null if no points are common.
*/
private static List<Coord> mergeLongestSequence(List<Coord> points1, List<Coord> points2, IntArrayList sh1PositionsToCheck,
IntArrayList sh2PositionsToCheck, boolean sameDir) {
if (sh1PositionsToCheck.isEmpty())
return null;
int s1Size = points1.size();
int s2Size = points2.size();
int longestSequence = 0;
int startOfLongestSequence = 0;
int length = 0;
int start = -1;
int n1 = sh1PositionsToCheck.size();
assert sh2PositionsToCheck.size() == n1;
boolean inSequence = false;
for (int i = 0; i+1 < n1; i++){
int pred1 = sh1PositionsToCheck.getInt(i);
int succ1 = sh1PositionsToCheck.getInt(i+1);
if (Math.abs(succ1-pred1) == 1 || pred1+2 == s1Size && succ1 == 0 || succ1+2 == s1Size && pred1 == 0 ){
// found sequence in s1
int pred2 = sh2PositionsToCheck.getInt(i);
int succ2 = sh2PositionsToCheck.getInt(i+1);
if (Math.abs(succ2-pred2) == 1 || pred2+2 == s2Size && succ2 == 0 || succ2+2 == s2Size && pred2 == 0 ){
// found common sequence
if (start < 0)
start = i;
inSequence = true;
length++;
} else {
inSequence = false;
}
} else {
inSequence = false;
}
if (!inSequence){
if (length > longestSequence){
longestSequence = length;
startOfLongestSequence = start;
}
length = 0;
start = -1;
}
}
if (length > longestSequence){
longestSequence = length;
startOfLongestSequence = start;
}
// now merge the shapes. The longest sequence of common points is removed.
// The remaining points are connected in the direction of the 1st shape.
int remaining = s1Size + s2Size - 2*longestSequence -1;
if (remaining < 3) {
return Collections.emptyList(); // may happen with self-intersecting duplicated shapes
}
List<Coord> merged = new ArrayList<>(remaining);
int s1Pos = sh1PositionsToCheck.getInt(startOfLongestSequence+longestSequence);
for (int i = 0; i < s1Size - longestSequence - 1; i++){
merged.add(points1.get(s1Pos));
s1Pos++;
if (s1Pos+1 >= s1Size)
s1Pos = 0;
}
int s2Pos = sh2PositionsToCheck.getInt(startOfLongestSequence);
int s2Step = sameDir ? 1:-1;
for (int i = 0; i < s2Size - longestSequence; i++){
merged.add(points2.get(s2Pos));
s2Pos += s2Step;
if (s2Pos < 0)
s2Pos = s2Size-2;
else if (s2Pos+1 >= s2Size)
s2Pos = 0;
}
// if (merged.get(0).equals(new Coord(2438126,342573))){
// GpxCreator.createGpx("e:/ld/s1", points1);
// GpxCreator.createGpx("e:/ld/s2", points2);
// GpxCreator.createGpx("e:/ld/merged", merged);
// long dd = 4;
// }
return merged;
}
private static class ShapeHelper{
final private List<Coord> points;
long id; // TODO: remove debugging aid
long areaTestVal;
private final Area bounds;
public ShapeHelper(List<Coord> merged) {
this.points = merged;
areaTestVal = calcAreaSizeTestVal(points);
bounds = prep();
}
public ShapeHelper(ShapeHelper other) {
this.points = new ArrayList<>(other.getPoints());
this.areaTestVal = other.areaTestVal;
this.id = other.id;
this.bounds = new Area(other.getBounds().getMinLat(),
other.getBounds().getMinLong(),
other.getBounds().getMaxLat(),
other.getBounds().getMaxLong());
}
public List<Coord> getPoints() {
// return Collections.unmodifiableList(points); // too slow, use only while testing
return points;
}
public Area getBounds(){
return bounds;
}
/**
* Calculates a unitless number that gives a value for the size
* of the area and the direction (clockwise/ccw)
*
*/
Area prep() {
int minLat = Integer.MAX_VALUE;
int maxLat = Integer.MIN_VALUE;
int minLon = Integer.MAX_VALUE;
int maxLon = Integer.MIN_VALUE;
for (Coord co: points) {
if (co.getLatitude() > maxLat)
maxLat = co.getLatitude();
if (co.getLatitude() < minLat)
minLat = co.getLatitude();
if (co.getLongitude() > maxLon)
maxLon = co.getLongitude();
if (co.getLongitude() < minLon)
minLon = co.getLongitude();
}
return new Area(minLat, minLon, maxLat, maxLon);
}
}
public final static long SINGLE_POINT_AREA = 1L<<6 * 1L<<6;
/**
* Calculate the high precision area size test value.
* @param points
* @return area size in high precision map units * 2.
* The value is >= 0 if the shape is clockwise, else < 0
*/
public static long calcAreaSizeTestVal(List<Coord> points){
if (points.size() < 4)
return 0; // straight line cannot enclose an area
if (points.get(0).highPrecEquals(points.get(points.size()-1)) == false){
log.error("shape is not closed");
return 0;
}
Iterator<Coord> polyIter = points.iterator();
Coord c2 = polyIter.next();
long signedAreaSize = 0;
while (polyIter.hasNext()) {
Coord c1 = c2;
c2 = polyIter.next();
signedAreaSize += (long) (c2.getHighPrecLon() + c1.getHighPrecLon())
* (c1.getHighPrecLat() - c2.getHighPrecLat());
}
if (Math.abs(signedAreaSize) < SINGLE_POINT_AREA){
log.debug("very small shape near", points.get(0).toOSMURL(), "signed area in high prec map units:", signedAreaSize );
}
return signedAreaSize;
}
}