// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.plugins.utilsplugin2.replacegeometry;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.awt.geom.Area;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import javax.swing.JOptionPane;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.actions.MergeNodesAction;
import org.openstreetmap.josm.command.ChangeNodesCommand;
import org.openstreetmap.josm.command.ChangePropertyCommand;
import org.openstreetmap.josm.command.Command;
import org.openstreetmap.josm.command.DeleteCommand;
import org.openstreetmap.josm.command.MoveCommand;
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.Relation;
import org.openstreetmap.josm.data.osm.RelationMember;
import org.openstreetmap.josm.data.osm.TagCollection;
import org.openstreetmap.josm.data.osm.Way;
import org.openstreetmap.josm.gui.DefaultNameFormatter;
import org.openstreetmap.josm.gui.Notification;
import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog;
import org.openstreetmap.josm.tools.UserCancelException;
import edu.princeton.cs.algs4.AssignmentProblem;
/**
*
* @author joshdoe
*/
public final class ReplaceGeometryUtils {
private ReplaceGeometryUtils() {
// Hide default constructor for utilities classes
}
/**
* Replace new or uploaded object with new object
*/
public static ReplaceGeometryCommand buildReplaceWithNewCommand(OsmPrimitive firstObject, OsmPrimitive secondObject) {
if (firstObject instanceof Node && secondObject instanceof Node) {
return buildReplaceNodeWithNewCommand((Node) firstObject, (Node) secondObject);
} else if (firstObject instanceof Way && secondObject instanceof Way) {
return buildReplaceWayWithNewCommand(Arrays.asList((Way) firstObject, (Way) secondObject));
} else if (firstObject instanceof Node) {
return buildUpgradeNodeCommand((Node) firstObject, secondObject);
} else if (secondObject instanceof Node) {
return buildUpgradeNodeCommand((Node) secondObject, firstObject);
} else {
throw new IllegalArgumentException(
tr("This tool can only replace a node, upgrade a node to a way or a multipolygon, or replace a way with a way."));
}
}
/**
* Replace subjectObject geometry with referenceObject geometry and merge tags
* and relation memberships.
*/
public static ReplaceGeometryCommand buildReplaceCommand(OsmPrimitive subjectObject, OsmPrimitive referenceSubject) {
if (subjectObject instanceof Node && referenceSubject instanceof Node) {
return buildReplaceNodeCommand((Node) subjectObject, (Node) referenceSubject);
} else if (subjectObject instanceof Way && referenceSubject instanceof Way) {
return buildReplaceWayCommand((Way) subjectObject, (Way) referenceSubject);
} else if (subjectObject instanceof Node) {
return buildUpgradeNodeCommand((Node) subjectObject, referenceSubject);
} else if (referenceSubject instanceof Node) {
// TODO: fix this illogical reversal?
return buildUpgradeNodeCommand((Node) referenceSubject, subjectObject);
} else {
throw new IllegalArgumentException(
tr("This tool can only replace a node, upgrade a node to a way or a multipolygon, or replace a way with a way."));
}
}
/**
* Replace a new or uploaded node with a new node
*/
public static ReplaceGeometryCommand buildReplaceNodeWithNewCommand(Node firstNode, Node secondNode) {
if (firstNode.isNew() && !secondNode.isNew())
return buildReplaceNodeCommand(secondNode, firstNode);
else if (!firstNode.isNew() && secondNode.isNew())
return buildReplaceNodeCommand(firstNode, secondNode);
else
// both nodes are new OR uploaded, act like MergeNodes, moving first
// node to second
return buildReplaceNodeCommand(firstNode, secondNode);
}
/**
* Replace a node with another node (similar to MergeNodesAction)
*/
public static ReplaceGeometryCommand buildReplaceNodeCommand(Node subjectNode, Node referenceNode) {
if (!OsmPrimitive.getFilteredList(subjectNode.getReferrers(), Way.class).isEmpty()) {
throw new ReplaceGeometryException(tr("Node belongs to way(s), cannot replace."));
}
// FIXME: handle different layers
List<Command> commands = new ArrayList<>();
Command c = MergeNodesAction.mergeNodes(Main.getLayerManager().getEditLayer(), Arrays.asList(subjectNode, referenceNode), referenceNode);
if (c == null) {
// User canceled
return null;
}
commands.add(c);
return new ReplaceGeometryCommand(
tr("Replace geometry for node {0}", subjectNode.getDisplayName(DefaultNameFormatter.getInstance())),
commands);
}
/**
* Upgrade a node to a way or multipolygon
*
* @param subjectNode node to be replaced
* @param referenceObject object with greater spatial quality
*/
public static ReplaceGeometryCommand buildUpgradeNodeCommand(Node subjectNode, OsmPrimitive referenceObject) {
if (!OsmPrimitive.getFilteredList(subjectNode.getReferrers(), Way.class).isEmpty()) {
throw new ReplaceGeometryException(tr("Node belongs to way(s), cannot replace."));
}
if (referenceObject instanceof Relation && !((Relation) referenceObject).isMultipolygon()) {
throw new ReplaceGeometryException(tr("Relation is not a multipolygon, cannot be used as a replacement."));
}
Node nodeToReplace = null;
// see if we need to replace a node in the replacement way to preserve connection in history
if (!subjectNode.isNew()) {
// Prepare a list of nodes that are not important
Collection<Node> nodePool = new HashSet<>();
if (referenceObject instanceof Way) {
nodePool.addAll(getUnimportantNodes((Way) referenceObject));
} else if (referenceObject instanceof Relation) {
for (RelationMember member : ((Relation) referenceObject).getMembers()) {
if ((member.getRole().equals("outer") || member.getRole().equals("inner"))
&& member.isWay()) {
// TODO: could consider more nodes, such as nodes that are members of other ways,
// just need to replace occurences in all referrers
nodePool.addAll(getUnimportantNodes(member.getWay()));
}
}
} else {
assert false;
}
nodeToReplace = findNearestNode(subjectNode, nodePool);
}
List<Command> commands = new ArrayList<>();
AbstractMap<String, String> nodeTags = subjectNode.getKeys();
// merge tags
try {
commands.addAll(getTagConflictResolutionCommands(subjectNode, referenceObject));
} catch (UserCancelException e) {
// user canceled tag merge dialog
return null;
}
// replace sacrificial node in way with node that is being upgraded
if (nodeToReplace != null) {
// node should only have one parent, a way
Way parentWay = (Way) nodeToReplace.getReferrers().get(0);
List<Node> wayNodes = parentWay.getNodes();
int idx = wayNodes.indexOf(nodeToReplace);
wayNodes.set(idx, subjectNode);
if (idx == 0 && parentWay.isClosed()) {
// node is at start/end of way
wayNodes.set(wayNodes.size() - 1, subjectNode);
}
commands.add(new ChangeNodesCommand(parentWay, wayNodes));
commands.add(new MoveCommand(subjectNode, nodeToReplace.getCoor()));
commands.add(new DeleteCommand(nodeToReplace));
// delete tags from node
if (!nodeTags.isEmpty()) {
for (String key : nodeTags.keySet()) {
commands.add(new ChangePropertyCommand(subjectNode, key, null));
}
}
} else {
// no node to replace, so just delete the original node
commands.add(new DeleteCommand(subjectNode));
}
Main.getLayerManager().getEditDataSet().setSelected(referenceObject);
return new ReplaceGeometryCommand(
tr("Replace geometry for node {0}", subjectNode.getDisplayName(DefaultNameFormatter.getInstance())),
commands);
}
public static ReplaceGeometryCommand buildReplaceWayWithNewCommand(List<Way> selection) {
// determine which way will be replaced and which will provide the geometry
boolean overrideNewCheck = false;
int idxNew = selection.get(0).isNew() ? 0 : 1;
if (selection.get(1-idxNew).isNew()) {
// if both are new, select the one with all the DB nodes
boolean areNewNodes = false;
for (Node n : selection.get(0).getNodes()) {
if (n.isNew()) {
areNewNodes = true;
}
}
idxNew = areNewNodes ? 0 : 1;
overrideNewCheck = true;
for (Node n : selection.get(1 - idxNew).getNodes()) {
if (n.isNew()) {
overrideNewCheck = false;
}
}
}
Way referenceWay = selection.get(idxNew);
Way subjectWay = selection.get(1 - idxNew);
if (!overrideNewCheck && (subjectWay.isNew() || !referenceWay.isNew())) {
throw new ReplaceGeometryException(
tr("Please select one way that exists in the database and one new way with correct geometry."));
}
return buildReplaceWayCommand(subjectWay, referenceWay);
}
public static ReplaceGeometryCommand buildReplaceWayCommand(Way subjectWay, Way referenceWay) {
Area a = Main.getLayerManager().getEditDataSet().getDataSourceArea();
if (!isInArea(subjectWay, a) || !isInArea(referenceWay, a)) {
throw new ReplaceGeometryException(tr("The ways must be entirely within the downloaded area."));
}
if (hasImportantNode(referenceWay, subjectWay)) {
throw new ReplaceGeometryException(
tr("The way to be replaced cannot have any nodes with properties or relation memberships unless they belong to both ways."));
}
List<Command> commands = new ArrayList<>();
// merge tags
try {
commands.addAll(getTagConflictResolutionCommands(referenceWay, subjectWay));
} catch (UserCancelException e) {
// user canceled tag merge dialog
Main.trace(e);
return null;
}
// Prepare a list of nodes that are not used anywhere except in the way
List<Node> nodePool = getUnimportantNodes(subjectWay);
// And the same for geometry, list nodes that can be freely deleted
List<Node> geometryPool = new LinkedList<>();
for (Node node : referenceWay.getNodes()) {
List<OsmPrimitive> referrers = node.getReferrers();
if (node.isNew() && !node.isDeleted() && referrers.size() == 1
&& referrers.get(0).equals(referenceWay) && !subjectWay.containsNode(node)
&& !hasInterestingKey(node) && !geometryPool.contains(node))
geometryPool.add(node);
}
boolean useRobust = Main.pref.getBoolean("utilsplugin2.replace-geometry.robustAssignment", true);
// Find new nodes that are closest to the old ones, remove matching old ones from the pool
// Assign node moves with least overall distance moved
Map<Node, Node> nodeAssoc = new HashMap<>();
if (geometryPool.size() > 0 && nodePool.size() > 0) {
if (useRobust) { // use robust, but slower assignment
int gLen = geometryPool.size();
int nLen = nodePool.size();
int N = Math.max(gLen, nLen);
double[][] cost = new double[N][N];
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
cost[i][j] = Double.MAX_VALUE;
}
}
double maxDistance = Double.parseDouble(Main.pref.get("utilsplugin2.replace-geometry.max-distance", "1"));
for (int i = 0; i < nLen; i++) {
for (int j = 0; j < gLen; j++) {
double d = nodePool.get(i).getCoor().distance(geometryPool.get(j).getCoor());
if (d > maxDistance) {
cost[i][j] = Double.MAX_VALUE;
} else {
cost[i][j] = d;
}
}
}
AssignmentProblem assignment;
try {
assignment = new AssignmentProblem(cost);
for (int i = 0; i < N; i++) {
int nIdx = i;
int gIdx = assignment.sol(i);
if (cost[nIdx][gIdx] != Double.MAX_VALUE) {
nodeAssoc.put(geometryPool.get(gIdx), nodePool.get(nIdx));
}
}
// node will be moved, remove from pool
for (Node n : nodeAssoc.values()) {
nodePool.remove(n);
}
} catch (Exception e) {
useRobust = false;
new Notification(
tr("Exceeded iteration limit for robust method, using simpler method.")
).setIcon(JOptionPane.WARNING_MESSAGE).show();
nodeAssoc = new HashMap<>();
}
}
if (!useRobust) { // use simple, faster, but less robust assignment method
for (Node n : geometryPool) {
Node nearest = findNearestNode(n, nodePool);
if (nearest != null) {
nodeAssoc.put(n, nearest);
nodePool.remove(nearest);
}
}
}
}
// Now that we have replacement list, move all unused new nodes to nodePool (and delete them afterwards)
for (Node n : geometryPool) {
if (nodeAssoc.containsKey(n))
nodePool.add(n);
}
// And prepare a list of nodes with all the replacements
List<Node> geometryNodes = referenceWay.getNodes();
for (int i = 0; i < geometryNodes.size(); i++) {
if (nodeAssoc.containsKey(geometryNodes.get(i)))
geometryNodes.set(i, nodeAssoc.get(geometryNodes.get(i)));
}
// Now do the replacement
commands.add(new ChangeNodesCommand(subjectWay, geometryNodes));
// Move old nodes to new positions
for (Node node : nodeAssoc.keySet()) {
commands.add(new MoveCommand(nodeAssoc.get(node), node.getCoor()));
}
// Remove geometry way from selection
Main.getLayerManager().getEditDataSet().clearSelection(referenceWay);
// And delete old geometry way
commands.add(new DeleteCommand(referenceWay));
// Delete nodes that are not used anymore
if (!nodePool.isEmpty())
commands.add(new DeleteCommand(nodePool));
// Two items in undo stack: change original way and delete geometry way
return new ReplaceGeometryCommand(
tr("Replace geometry for way {0}", subjectWay.getDisplayName(DefaultNameFormatter.getInstance())),
commands);
}
/**
* Create a list of nodes that are not used anywhere except in the way.
*/
protected static List<Node> getUnimportantNodes(Way way) {
List<Node> nodePool = new LinkedList<>();
for (Node n : way.getNodes()) {
List<OsmPrimitive> referrers = n.getReferrers();
if (!n.isDeleted() && referrers.size() == 1 && referrers.get(0).equals(way)
&& !hasInterestingKey(n) && !nodePool.contains(n)) {
nodePool.add(n);
}
}
return nodePool;
}
/**
* Checks if a way has at least one important node (e.g. interesting tag,
* role membership), and thus cannot be safely modified.
*/
protected static boolean hasImportantNode(Way geometry, Way way) {
for (Node n : way.getNodes()) {
// if original and replacement way share a node, it's safe to replace
if (geometry.containsNode(n)) {
continue;
}
//TODO: if way is connected to other ways, warn or disallow?
for (OsmPrimitive o : n.getReferrers()) {
if (o instanceof Relation) {
return true;
}
}
if (hasInterestingKey(n)) {
return true;
}
}
return false;
}
protected static boolean hasInterestingKey(OsmPrimitive object) {
for (String key : object.getKeys().keySet()) {
if (!OsmPrimitive.isUninterestingKey(key)) {
return true;
}
}
return false;
}
protected static boolean isInArea(Node node, Area area) {
LatLon ll = node.getCoor();
if (node.isNewOrUndeleted() || area == null || ll == null || area.contains(ll.getX(), ll.getY())) {
return true;
}
return false;
}
protected static boolean isInArea(Way way, Area area) {
if (area == null) {
return true;
}
for (Node n : way.getNodes()) {
if (!isInArea(n, area)) {
return false;
}
}
return true;
}
/**
* Merge tags from source to target object, showing resolution dialog if
* needed.
*
* @param source object tags are merged from
* @param target object tags are merged to
* @return The list of {@link Command commands} needed to apply resolution actions.
* @throws UserCancelException If the user cancelled a dialog.
*/
protected static List<Command> getTagConflictResolutionCommands(OsmPrimitive source, OsmPrimitive target) throws UserCancelException {
Collection<OsmPrimitive> primitives = Arrays.asList(source, target);
// launch a conflict resolution dialog, if necessary
return CombinePrimitiveResolverDialog.launchIfNecessary(
TagCollection.unionOfAllPrimitives(primitives), primitives, Collections.singleton(target));
}
/**
* Find node from the collection which is nearest to <tt>node</tt>. Max distance is taken in consideration.
* @return null if there is no such node.
*/
protected static Node findNearestNode(Node node, Collection<Node> nodes) {
if (nodes.contains(node))
return node;
Node nearest = null;
// TODO: use meters instead of degrees, but do it fast
double distance = Double.parseDouble(Main.pref.get("utilsplugin2.replace-geometry.max-distance", "1"));
LatLon coor = node.getCoor();
for (Node n : nodes) {
double d = n.getCoor().distance(coor);
if (d < distance) {
distance = d;
nearest = n;
}
}
return nearest;
}
}