001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.actions; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005import static org.openstreetmap.josm.tools.I18n.tr; 006import static org.openstreetmap.josm.tools.I18n.trn; 007 008import java.awt.event.ActionEvent; 009import java.awt.event.KeyEvent; 010import java.util.ArrayList; 011import java.util.Collection; 012import java.util.Collections; 013import java.util.LinkedHashSet; 014import java.util.LinkedList; 015import java.util.List; 016import java.util.Objects; 017import java.util.stream.Collectors; 018 019import javax.swing.JOptionPane; 020 021import org.openstreetmap.josm.Main; 022import org.openstreetmap.josm.command.ChangeCommand; 023import org.openstreetmap.josm.command.Command; 024import org.openstreetmap.josm.command.DeleteCommand; 025import org.openstreetmap.josm.command.SequenceCommand; 026import org.openstreetmap.josm.corrector.ReverseWayTagCorrector; 027import org.openstreetmap.josm.data.osm.DataSet; 028import org.openstreetmap.josm.data.osm.Node; 029import org.openstreetmap.josm.data.osm.NodeGraph; 030import org.openstreetmap.josm.data.osm.OsmPrimitive; 031import org.openstreetmap.josm.data.osm.OsmUtils; 032import org.openstreetmap.josm.data.osm.TagCollection; 033import org.openstreetmap.josm.data.osm.Way; 034import org.openstreetmap.josm.data.preferences.BooleanProperty; 035import org.openstreetmap.josm.gui.ExtendedDialog; 036import org.openstreetmap.josm.gui.MainApplication; 037import org.openstreetmap.josm.gui.Notification; 038import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog; 039import org.openstreetmap.josm.gui.util.GuiHelper; 040import org.openstreetmap.josm.tools.Logging; 041import org.openstreetmap.josm.tools.Pair; 042import org.openstreetmap.josm.tools.Shortcut; 043import org.openstreetmap.josm.tools.UserCancelException; 044 045/** 046 * Combines multiple ways into one. 047 * @since 213 048 */ 049public class CombineWayAction extends JosmAction { 050 051 private static final BooleanProperty PROP_REVERSE_WAY = new BooleanProperty("tag-correction.reverse-way", true); 052 053 /** 054 * Constructs a new {@code CombineWayAction}. 055 */ 056 public CombineWayAction() { 057 super(tr("Combine Way"), "combineway", tr("Combine several ways into one."), 058 Shortcut.registerShortcut("tools:combineway", tr("Tool: {0}", tr("Combine Way")), KeyEvent.VK_C, Shortcut.DIRECT), true); 059 putValue("help", ht("/Action/CombineWay")); 060 } 061 062 protected static boolean confirmChangeDirectionOfWays() { 063 return new ExtendedDialog(Main.parent, 064 tr("Change directions?"), 065 tr("Reverse and Combine"), tr("Cancel")) 066 .setButtonIcons("wayflip", "cancel") 067 .setContent(tr("The ways can not be combined in their current directions. " 068 + "Do you want to reverse some of them?")) 069 .toggleEnable("combineway-reverse") 070 .showDialog() 071 .getValue() == 1; 072 } 073 074 protected static void warnCombiningImpossible() { 075 String msg = tr("Could not combine ways<br>" 076 + "(They could not be merged into a single string of nodes)"); 077 new Notification(msg) 078 .setIcon(JOptionPane.INFORMATION_MESSAGE) 079 .show(); 080 } 081 082 protected static Way getTargetWay(Collection<Way> combinedWays) { 083 // init with an arbitrary way 084 Way targetWay = combinedWays.iterator().next(); 085 086 // look for the first way already existing on 087 // the server 088 for (Way w : combinedWays) { 089 targetWay = w; 090 if (!w.isNew()) { 091 break; 092 } 093 } 094 return targetWay; 095 } 096 097 /** 098 * Combine multiple ways into one. 099 * @param ways the way to combine to one way 100 * @return null if ways cannot be combined. Otherwise returns the combined ways and the commands to combine 101 * @throws UserCancelException if the user cancelled a dialog. 102 */ 103 public static Pair<Way, Command> combineWaysWorker(Collection<Way> ways) throws UserCancelException { 104 105 // prepare and clean the list of ways to combine 106 // 107 if (ways == null || ways.isEmpty()) 108 return null; 109 ways.remove(null); // just in case - remove all null ways from the collection 110 111 // remove duplicates, preserving order 112 ways = new LinkedHashSet<>(ways); 113 // remove incomplete ways 114 ways.removeIf(OsmPrimitive::isIncomplete); 115 // we need at least two ways 116 if (ways.size() < 2) 117 return null; 118 119 List<DataSet> dataSets = ways.stream().map(Way::getDataSet).filter(Objects::nonNull).distinct().collect(Collectors.toList()); 120 if (dataSets.size() != 1) { 121 throw new IllegalArgumentException("Cannot combine ways of multiple data sets."); 122 } 123 124 // try to build a new way which includes all the combined ways 125 NodeGraph graph = NodeGraph.createNearlyUndirectedGraphFromNodeWays(ways); 126 List<Node> path = graph.buildSpanningPath(); 127 if (path == null) { 128 warnCombiningImpossible(); 129 return null; 130 } 131 // check whether any ways have been reversed in the process 132 // and build the collection of tags used by the ways to combine 133 // 134 TagCollection wayTags = TagCollection.unionOfAllPrimitives(ways); 135 136 final List<Command> reverseWayTagCommands = new LinkedList<>(); 137 List<Way> reversedWays = new LinkedList<>(); 138 List<Way> unreversedWays = new LinkedList<>(); 139 for (Way w: ways) { 140 // Treat zero or one-node ways as unreversed as Combine action action is a good way to fix them (see #8971) 141 if (w.getNodesCount() < 2 || (path.indexOf(w.getNode(0)) + 1) == path.lastIndexOf(w.getNode(1))) { 142 unreversedWays.add(w); 143 } else { 144 reversedWays.add(w); 145 } 146 } 147 // reverse path if all ways have been reversed 148 if (unreversedWays.isEmpty()) { 149 Collections.reverse(path); 150 unreversedWays = reversedWays; 151 reversedWays = null; 152 } 153 if ((reversedWays != null) && !reversedWays.isEmpty()) { 154 if (!confirmChangeDirectionOfWays()) return null; 155 // filter out ways that have no direction-dependent tags 156 unreversedWays = ReverseWayTagCorrector.irreversibleWays(unreversedWays); 157 reversedWays = ReverseWayTagCorrector.irreversibleWays(reversedWays); 158 // reverse path if there are more reversed than unreversed ways with direction-dependent tags 159 if (reversedWays.size() > unreversedWays.size()) { 160 Collections.reverse(path); 161 List<Way> tempWays = unreversedWays; 162 unreversedWays = null; 163 reversedWays = tempWays; 164 } 165 // if there are still reversed ways with direction-dependent tags, reverse their tags 166 if (!reversedWays.isEmpty() && PROP_REVERSE_WAY.get()) { 167 List<Way> unreversedTagWays = new ArrayList<>(ways); 168 unreversedTagWays.removeAll(reversedWays); 169 ReverseWayTagCorrector reverseWayTagCorrector = new ReverseWayTagCorrector(); 170 List<Way> reversedTagWays = new ArrayList<>(reversedWays.size()); 171 for (Way w : reversedWays) { 172 Way wnew = new Way(w); 173 reversedTagWays.add(wnew); 174 reverseWayTagCommands.addAll(reverseWayTagCorrector.execute(w, wnew)); 175 } 176 if (!reverseWayTagCommands.isEmpty()) { 177 // commands need to be executed for CombinePrimitiveResolverDialog 178 MainApplication.undoRedo.add(new SequenceCommand(tr("Reverse Ways"), reverseWayTagCommands)); 179 } 180 wayTags = TagCollection.unionOfAllPrimitives(reversedTagWays); 181 wayTags.add(TagCollection.unionOfAllPrimitives(unreversedTagWays)); 182 } 183 } 184 185 // create the new way and apply the new node list 186 // 187 Way targetWay = getTargetWay(ways); 188 Way modifiedTargetWay = new Way(targetWay); 189 modifiedTargetWay.setNodes(path); 190 191 final List<Command> resolution; 192 try { 193 resolution = CombinePrimitiveResolverDialog.launchIfNecessary(wayTags, ways, Collections.singleton(targetWay)); 194 } finally { 195 if (!reverseWayTagCommands.isEmpty()) { 196 // undo reverseWayTagCorrector and merge into SequenceCommand below 197 MainApplication.undoRedo.undo(); 198 } 199 } 200 201 List<Command> cmds = new LinkedList<>(); 202 List<Way> deletedWays = new LinkedList<>(ways); 203 deletedWays.remove(targetWay); 204 205 cmds.add(new ChangeCommand(dataSets.get(0), targetWay, modifiedTargetWay)); 206 cmds.addAll(reverseWayTagCommands); 207 cmds.addAll(resolution); 208 cmds.add(new DeleteCommand(dataSets.get(0), deletedWays)); 209 final Command sequenceCommand = new SequenceCommand(/* for correct i18n of plural forms - see #9110 */ 210 trn("Combine {0} way", "Combine {0} ways", ways.size(), ways.size()), cmds); 211 212 return new Pair<>(targetWay, sequenceCommand); 213 } 214 215 @Override 216 public void actionPerformed(ActionEvent event) { 217 final DataSet ds = getLayerManager().getEditDataSet(); 218 if (ds == null) 219 return; 220 Collection<Way> selectedWays = ds.getSelectedWays(); 221 if (selectedWays.size() < 2) { 222 new Notification( 223 tr("Please select at least two ways to combine.")) 224 .setIcon(JOptionPane.INFORMATION_MESSAGE) 225 .setDuration(Notification.TIME_SHORT) 226 .show(); 227 return; 228 } 229 // combine and update gui 230 Pair<Way, Command> combineResult; 231 try { 232 combineResult = combineWaysWorker(selectedWays); 233 } catch (UserCancelException ex) { 234 Logging.trace(ex); 235 return; 236 } 237 238 if (combineResult == null) 239 return; 240 final Way selectedWay = combineResult.a; 241 MainApplication.undoRedo.add(combineResult.b); 242 if (selectedWay != null) { 243 GuiHelper.runInEDT(() -> ds.setSelected(selectedWay)); 244 } 245 } 246 247 @Override 248 protected void updateEnabledState() { 249 updateEnabledStateOnCurrentSelection(); 250 } 251 252 @Override 253 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 254 int numWays = 0; 255 if (OsmUtils.isOsmCollectionEditable(selection)) { 256 for (OsmPrimitive osm : selection) { 257 if (osm instanceof Way && !osm.isIncomplete() && ++numWays >= 2) { 258 break; 259 } 260 } 261 } 262 setEnabled(numWays >= 2); 263 } 264}