// License: GPL. For details, see LICENSE file.
package sk.zdila.josm.plugin.simplify;
import static java.lang.Math.cos;
import static java.lang.Math.sin;
import static java.lang.Math.toRadians;
import static org.openstreetmap.josm.tools.I18n.tr;
import static org.openstreetmap.josm.tools.I18n.trn;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import javax.swing.JOptionPane;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.actions.JosmAction;
import org.openstreetmap.josm.command.ChangeCommand;
import org.openstreetmap.josm.command.Command;
import org.openstreetmap.josm.command.DeleteCommand;
import org.openstreetmap.josm.command.MoveCommand;
import org.openstreetmap.josm.command.SequenceCommand;
import org.openstreetmap.josm.data.Bounds;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.data.osm.OsmPrimitive;
import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
import org.openstreetmap.josm.data.osm.Way;
import org.openstreetmap.josm.gui.HelpAwareOptionPane;
import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
import org.openstreetmap.josm.tools.ImageProvider;
import org.openstreetmap.josm.tools.Shortcut;
public final class SimplifyAreaAction extends JosmAction {
public SimplifyAreaAction() {
super(tr("Simplify Area"), "simplify", tr("Delete unnecessary nodes from an area."),
Shortcut.registerShortcut("tools:simplifyArea", tr("Tool: {0}", tr("Simplify Area")), KeyEvent.VK_Y, Shortcut.CTRL_SHIFT),
true, "simplifyarea", true);
}
private List<Bounds> getCurrentEditBounds() {
return Main.getLayerManager().getEditLayer().data.getDataSourceBounds();
}
private static boolean isInBounds(final Node node, final List<Bounds> bounds) {
for (final Bounds b : bounds) {
if (b.contains(node.getCoor())) {
return true;
}
}
return false;
}
private static boolean confirmWayWithNodesOutsideBoundingBox() {
final ButtonSpec[] options = new ButtonSpec[] { new ButtonSpec(tr("Yes, delete nodes"), ImageProvider.get("ok"), tr("Delete nodes outside of downloaded data regions"), null),
new ButtonSpec(tr("No, abort"), ImageProvider.get("cancel"), tr("Cancel operation"), null) };
return 0 == HelpAwareOptionPane.showOptionDialog(
Main.parent,
"<html>" + trn("The selected way has nodes outside of the downloaded data region.", "The selected ways have nodes outside of the downloaded data region.",
Main.getLayerManager().getEditDataSet().getSelectedWays().size())
+ "<br>" + tr("This can lead to nodes being deleted accidentally.") + "<br>" + tr("Do you want to delete them anyway?") + "</html>",
tr("Delete nodes outside of data regions?"), JOptionPane.WARNING_MESSAGE, null, // no special icon
options, options[0], null);
}
private void alertSelectAtLeastOneWay() {
HelpAwareOptionPane.showOptionDialog(Main.parent, tr("Please select at least one way to simplify."), tr("Warning"), JOptionPane.WARNING_MESSAGE, null);
}
private boolean confirmSimplifyManyWays(final int numWays) {
final ButtonSpec[] options = new ButtonSpec[] { new ButtonSpec(tr("Yes"), ImageProvider.get("ok"), tr("Simplify all selected ways"), null),
new ButtonSpec(tr("Cancel"), ImageProvider.get("cancel"), tr("Cancel operation"), null) };
return 0 == HelpAwareOptionPane.showOptionDialog(Main.parent, tr("The selection contains {0} ways. Are you sure you want to simplify them all?", numWays),
tr("Simplify ways?"),
JOptionPane.WARNING_MESSAGE, null, // no special icon
options, options[0], null);
}
@Override
public void actionPerformed(final ActionEvent e) {
final Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected();
final List<Bounds> bounds = getCurrentEditBounds();
for (final OsmPrimitive prim : selection) {
if (prim instanceof Way && bounds.size() > 0) {
final Way way = (Way) prim;
// We check if each node of each way is at least in one download
// bounding box. Otherwise nodes may get deleted that are necessary by
// unloaded ways (see Ticket #1594)
for (final Node node : way.getNodes()) {
if (!isInBounds(node, bounds)) {
if (!confirmWayWithNodesOutsideBoundingBox()) {
return;
}
break;
}
}
}
}
final Collection<Way> ways = OsmPrimitive.getFilteredSet(selection, Way.class);
if (ways.isEmpty()) {
alertSelectAtLeastOneWay();
return;
} else if (ways.size() > 10) {
if (!confirmSimplifyManyWays(ways.size())) {
return;
}
}
final List<Node> nodesToDelete = new ArrayList<>(); // can contain duplicate instances
for (final Way way : ways) {
addNodesToDelete(nodesToDelete, way);
}
final Map<Node, Integer> nodeCountMap = new HashMap<>();
for (final Node node : nodesToDelete) {
Integer count = nodeCountMap.get(node);
if (count == null) {
count = 0;
}
nodeCountMap.put(node, ++count);
}
final Collection<Node> nodesReallyToRemove = new ArrayList<>();
for (final Entry<Node, Integer> entry : nodeCountMap.entrySet()) {
final Node node = entry.getKey();
final Integer count = entry.getValue();
if (!node.isTagged() && node.getReferrers().size() == count) {
nodesReallyToRemove.add(node);
}
}
final Collection<Command> allCommands = new ArrayList<>();
if (!nodesReallyToRemove.isEmpty()) {
for (final Way way : ways) {
final List<Node> nodes = way.getNodes();
final boolean closed = nodes.get(0).equals(nodes.get(nodes.size() - 1));
if (closed) {
nodes.remove(nodes.size() - 1);
}
if (nodes.removeAll(nodesReallyToRemove)) {
if (closed) {
nodes.add(nodes.get(0));
}
final Way newWay = new Way(way);
newWay.setNodes(nodes);
allCommands.add(new ChangeCommand(way, newWay));
}
}
allCommands.add(new DeleteCommand(nodesReallyToRemove));
}
final Collection<Command> avgCommands = averageNearbyNodes(ways, nodesReallyToRemove);
if (avgCommands != null && !avgCommands.isEmpty()) {
allCommands.add(new SequenceCommand(tr("average nearby nodes"), avgCommands));
}
if (!allCommands.isEmpty()) {
final SequenceCommand rootCommand = new SequenceCommand(trn("Simplify {0} way", "Simplify {0} ways", allCommands.size(), allCommands.size()), allCommands);
Main.main.undoRedo.add(rootCommand);
Main.map.repaint();
}
}
private static boolean nodeGluesWays(final Node node) {
Set<Node> referenceNeighbours = null;
for (final OsmPrimitive ref : node.getReferrers()) {
if (ref.getType() == OsmPrimitiveType.WAY) {
final Way way = ((Way) ref);
final Set<Node> neighbours = way.getNeighbours(node);
if (referenceNeighbours == null) {
referenceNeighbours = neighbours;
} else if (!referenceNeighbours.containsAll(neighbours)) {
return true;
}
}
}
return false;
}
// average nearby nodes
private static Collection<Command> averageNearbyNodes(final Collection<Way> ways, final Collection<Node> nodesAlreadyDeleted) {
final double mergeThreshold = Main.pref.getDouble(SimplifyAreaPreferenceSetting.MERGE_THRESHOLD, 0.2);
final Map<Node, LatLon> coordMap = new HashMap<>();
for (final Way way : ways) {
for (final Node n : way.getNodes()) {
coordMap.put(n, n.getCoor());
}
}
coordMap.keySet().removeAll(nodesAlreadyDeleted);
for (final Way w : ways) {
final List<Node> nodes = w.getNodes();
final Node lastNode = nodes.get(nodes.size() - 1);
final boolean closed = nodes.get(0).equals(lastNode);
if (closed) {
nodes.remove(lastNode);
}
nodes.retainAll(coordMap.keySet()); // removes already deleted nodes
while (true) {
double minDist = Double.POSITIVE_INFINITY;
Node node1 = null;
Node node2 = null;
final int len = nodes.size();
if (len == 0) {
break;
}
// find smallest distance
for (int i = 0; i <= len; i++) {
final Node n1 = nodes.get(i % len);
final Node n2 = nodes.get((i + 1) % len);
if (n1.isTagged() || n2.isTagged()) {
continue;
}
// test if both nodes are on the same ways
final List<OsmPrimitive> referrers = n1.getReferrers();
if (!ways.containsAll(referrers)) {
continue;
}
final List<OsmPrimitive> referrers2 = n2.getReferrers();
if (!ways.containsAll(referrers2)) {
continue;
}
// test if both nodes have same parents
if (!referrers.containsAll(referrers2) || !referrers2.containsAll(referrers)) {
continue;
}
final LatLon a = coordMap.get(n1);
final LatLon b = coordMap.get(n2);
if (a != null && b != null) {
final double dist = a.greatCircleDistance(b);
if (dist < minDist && dist < mergeThreshold) {
minDist = dist;
node1 = n1;
node2 = n2;
}
}
}
if (node1 == null || node2 == null) {
break;
}
final LatLon coord = coordMap.get(node1).getCenter(coordMap.get(node2));
coordMap.put(node1, coord);
nodes.remove(node2);
coordMap.remove(node2);
}
}
final Collection<Command> commands = new ArrayList<>();
final Set<Node> nodesToDelete2 = new HashSet<>();
for (final Way way : ways) {
final List<Node> nodesToDelete = way.getNodes();
nodesToDelete.removeAll(nodesAlreadyDeleted);
if (nodesToDelete.removeAll(coordMap.keySet())) {
nodesToDelete2.addAll(nodesToDelete);
final Way newWay = new Way(way);
final List<Node> nodes = way.getNodes();
final boolean closed = nodes.get(0).equals(nodes.get(nodes.size() - 1));
if (closed) {
nodes.remove(nodes.size() - 1);
}
nodes.retainAll(coordMap.keySet());
if (closed) {
nodes.add(nodes.get(0));
}
newWay.setNodes(nodes);
if (!way.getNodes().equals(nodes)) {
commands.add(new ChangeCommand(way, newWay));
}
}
}
if (!nodesToDelete2.isEmpty()) {
commands.add(new DeleteCommand(nodesToDelete2));
}
for (final Entry<Node, LatLon> entry : coordMap.entrySet()) {
final Node node = entry.getKey();
final LatLon coord = entry.getValue();
if (!node.getCoor().equals(coord)) {
commands.add(new MoveCommand(node, coord));
}
}
return commands;
}
private static void addNodesToDelete(final Collection<Node> nodesToDelete, final Way w) {
final double angleThreshold = Main.pref.getDouble(SimplifyAreaPreferenceSetting.ANGLE_THRESHOLD, 10);
final double angleFactor = Main.pref.getDouble(SimplifyAreaPreferenceSetting.ANGLE_FACTOR, 1.0);
final double areaThreshold = Main.pref.getDouble(SimplifyAreaPreferenceSetting.AREA_THRESHOLD, 5.0);
final double areaFactor = Main.pref.getDouble(SimplifyAreaPreferenceSetting.AREA_FACTOR, 1.0);
final double distanceThreshold = Main.pref.getDouble(SimplifyAreaPreferenceSetting.DIST_THRESHOLD, 3);
final double distanceFactor = Main.pref.getDouble(SimplifyAreaPreferenceSetting.DIST_FACTOR, 3);
final List<Node> nodes = w.getNodes();
final int size = nodes.size();
if (size == 0) {
return;
}
final boolean closed = nodes.get(0).equals(nodes.get(size - 1));
if (closed) {
nodes.remove(size - 1); // remove end node ( = start node)
}
// remove nodes within threshold
final List<Double> weightList = new ArrayList<>(nodes.size()); // weight cache
for (int i = 0; i < nodes.size(); i++) {
weightList.add(null);
}
while (true) {
Node prevNode = null;
LatLon coord1 = null;
LatLon coord2 = null;
int prevIndex = -1;
double minWeight = Double.POSITIVE_INFINITY;
Node bestMatch = null;
final int size2 = nodes.size();
if (size2 == 0) {
break;
}
for (int i = 0, len = size2 + (closed ? 2 : 1); i < len; i++) {
final int index = i % size2;
final Node n = nodes.get(index);
final LatLon coord3 = n.getCoor();
if (coord1 != null) {
final double weight;
if (weightList.get(prevIndex) == null) {
final double angleWeight = computeConvectAngle(coord1, coord2, coord3) / angleThreshold;
final double areaWeight = computeArea(coord1, coord2, coord3) / areaThreshold;
final double distanceWeight = Math.abs(crossTrackError(coord1, coord2, coord3)) / distanceThreshold;
weight = !closed && i == len - 1 || // don't remove last node of the not closed way
nodeGluesWays(prevNode) ||
angleWeight > 1.0 || areaWeight > 1.0 || distanceWeight > 1.0 ? Double.POSITIVE_INFINITY :
angleWeight * angleFactor + areaWeight * areaFactor + distanceWeight * distanceFactor;
weightList.set(prevIndex, weight);
} else {
weight = weightList.get(prevIndex);
}
if (weight < minWeight) {
minWeight = weight;
bestMatch = prevNode;
}
}
coord1 = coord2;
coord2 = coord3;
prevNode = n;
prevIndex = index;
}
if (bestMatch == null) {
break;
}
final int index = nodes.indexOf(bestMatch);
weightList.set((index - 1 + size2) % size2, null);
weightList.set((index + 1 + size2) % size2, null);
weightList.remove(index);
nodes.remove(index);
}
final HashSet<Node> delNodes = new HashSet<>(w.getNodes());
delNodes.removeAll(nodes);
nodesToDelete.addAll(delNodes);
}
public static double computeConvectAngle(final LatLon coord1, final LatLon coord2, final LatLon coord3) {
final double angle = Math.abs(heading(coord2, coord3) - heading(coord1, coord2));
return Math.toDegrees(angle < Math.PI ? angle : 2 * Math.PI - angle);
}
public static double computeArea(final LatLon coord1, final LatLon coord2, final LatLon coord3) {
final double a = coord1.greatCircleDistance(coord2);
final double b = coord2.greatCircleDistance(coord3);
final double c = coord3.greatCircleDistance(coord1);
final double p = (a + b + c) / 2.0;
final double q = p * (p - a) * (p - b) * (p - c); // I found this negative in one case (:-o) when nodes were in line on a small area
return q < 0.0 ? 0.0 : Math.sqrt(q);
}
public static double R = 6378135;
public static double crossTrackError(final LatLon l1, final LatLon l2, final LatLon l3) {
return R * Math.asin(sin(l1.greatCircleDistance(l2) / R) * sin(heading(l1, l2) - heading(l1, l3)));
}
public static double heading(final LatLon a, final LatLon b) {
double hd = Math.atan2(sin(toRadians(a.lon() - b.lon())) * cos(toRadians(b.lat())),
cos(toRadians(a.lat())) * sin(toRadians(b.lat())) -
sin(toRadians(a.lat())) * cos(toRadians(b.lat())) * cos(toRadians(a.lon() - b.lon())));
hd %= 2 * Math.PI;
if (hd < 0) {
hd += 2 * Math.PI;
}
return hd;
}
@Override
protected void updateEnabledState() {
if (getLayerManager().getEditDataSet() == null) {
setEnabled(false);
} else {
updateEnabledState(getLayerManager().getEditDataSet().getSelected());
}
}
@Override
protected void updateEnabledState(final Collection<? extends OsmPrimitive> selection) {
setEnabled(selection != null && !selection.isEmpty());
}
}