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