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.HashSet; 014import java.util.LinkedHashSet; 015import java.util.LinkedList; 016import java.util.List; 017import java.util.Objects; 018import java.util.Set; 019import java.util.stream.Collectors; 020 021import javax.swing.JOptionPane; 022 023import org.openstreetmap.josm.actions.corrector.ReverseWayTagCorrector; 024import org.openstreetmap.josm.command.ChangeCommand; 025import org.openstreetmap.josm.command.Command; 026import org.openstreetmap.josm.command.DeleteCommand; 027import org.openstreetmap.josm.command.SequenceCommand; 028import org.openstreetmap.josm.data.UndoRedoHandler; 029import org.openstreetmap.josm.data.osm.DataSet; 030import org.openstreetmap.josm.data.osm.Node; 031import org.openstreetmap.josm.data.osm.NodeGraph; 032import org.openstreetmap.josm.data.osm.OsmPrimitive; 033import org.openstreetmap.josm.data.osm.OsmUtils; 034import org.openstreetmap.josm.data.osm.TagCollection; 035import org.openstreetmap.josm.data.osm.Way; 036import org.openstreetmap.josm.data.osm.visitor.paint.relations.Multipolygon; 037import org.openstreetmap.josm.data.osm.visitor.paint.relations.Multipolygon.JoinedWay; 038import org.openstreetmap.josm.data.preferences.BooleanProperty; 039import org.openstreetmap.josm.data.validation.Test; 040import org.openstreetmap.josm.data.validation.tests.OverlappingWays; 041import org.openstreetmap.josm.data.validation.tests.SelfIntersectingWay; 042import org.openstreetmap.josm.gui.ExtendedDialog; 043import org.openstreetmap.josm.gui.MainApplication; 044import org.openstreetmap.josm.gui.Notification; 045import org.openstreetmap.josm.gui.conflict.tags.CombinePrimitiveResolverDialog; 046import org.openstreetmap.josm.gui.util.GuiHelper; 047import org.openstreetmap.josm.tools.Logging; 048import org.openstreetmap.josm.tools.Pair; 049import org.openstreetmap.josm.tools.Shortcut; 050import org.openstreetmap.josm.tools.UserCancelException; 051 052/** 053 * Combines multiple ways into one. 054 * @since 213 055 */ 056public class CombineWayAction extends JosmAction { 057 058 private static final BooleanProperty PROP_REVERSE_WAY = new BooleanProperty("tag-correction.reverse-way", true); 059 060 /** 061 * Constructs a new {@code CombineWayAction}. 062 */ 063 public CombineWayAction() { 064 super(tr("Combine Way"), "combineway", tr("Combine several ways into one."), 065 Shortcut.registerShortcut("tools:combineway", tr("Tool: {0}", tr("Combine Way")), KeyEvent.VK_C, Shortcut.DIRECT), true); 066 setHelpId(ht("/Action/CombineWay")); 067 } 068 069 protected static boolean confirmChangeDirectionOfWays() { 070 return new ExtendedDialog(MainApplication.getMainFrame(), 071 tr("Change directions?"), 072 tr("Reverse and Combine"), tr("Cancel")) 073 .setButtonIcons("wayflip", "cancel") 074 .setContent(tr("The ways can not be combined in their current directions. " 075 + "Do you want to reverse some of them?")) 076 .toggleEnable("combineway-reverse") 077 .showDialog() 078 .getValue() == 1; 079 } 080 081 protected static void warnCombiningImpossible() { 082 String msg = tr("Could not combine ways<br>" 083 + "(They could not be merged into a single string of nodes)"); 084 new Notification(msg) 085 .setIcon(JOptionPane.INFORMATION_MESSAGE) 086 .show(); 087 } 088 089 protected static Way getTargetWay(Collection<Way> combinedWays) { 090 // init with an arbitrary way 091 Way targetWay = combinedWays.iterator().next(); 092 093 // look for the first way already existing on 094 // the server 095 for (Way w : combinedWays) { 096 targetWay = w; 097 if (!w.isNew()) { 098 break; 099 } 100 } 101 return targetWay; 102 } 103 104 /** 105 * Combine multiple ways into one. 106 * @param ways the way to combine to one way 107 * @return null if ways cannot be combined. Otherwise returns the combined ways and the commands to combine 108 * @throws UserCancelException if the user cancelled a dialog. 109 */ 110 public static Pair<Way, Command> combineWaysWorker(Collection<Way> ways) throws UserCancelException { 111 112 // prepare and clean the list of ways to combine 113 // 114 if (ways == null || ways.isEmpty()) 115 return null; 116 ways.remove(null); // just in case - remove all null ways from the collection 117 118 // remove duplicates, preserving order 119 ways = new LinkedHashSet<>(ways); 120 // remove incomplete ways 121 ways.removeIf(OsmPrimitive::isIncomplete); 122 // we need at least two ways 123 if (ways.size() < 2) 124 return null; 125 126 List<DataSet> dataSets = ways.stream().map(Way::getDataSet).filter(Objects::nonNull).distinct().collect(Collectors.toList()); 127 if (dataSets.size() != 1) { 128 throw new IllegalArgumentException("Cannot combine ways of multiple data sets."); 129 } 130 131 // try to build a new way which includes all the combined ways 132 List<Node> path = tryJoin(ways); 133 if (path.isEmpty()) { 134 warnCombiningImpossible(); 135 return null; 136 } 137 // check whether any ways have been reversed in the process 138 // and build the collection of tags used by the ways to combine 139 // 140 TagCollection wayTags = TagCollection.unionOfAllPrimitives(ways); 141 142 final List<Command> reverseWayTagCommands = new LinkedList<>(); 143 List<Way> reversedWays = new LinkedList<>(); 144 List<Way> unreversedWays = new LinkedList<>(); 145 detectReversedWays(ways, path, reversedWays, unreversedWays); 146 // reverse path if all ways have been reversed 147 if (unreversedWays.isEmpty()) { 148 Collections.reverse(path); 149 unreversedWays = reversedWays; 150 reversedWays = null; 151 } 152 if ((reversedWays != null) && !reversedWays.isEmpty()) { 153 if (!confirmChangeDirectionOfWays()) return null; 154 // filter out ways that have no direction-dependent tags 155 unreversedWays = ReverseWayTagCorrector.irreversibleWays(unreversedWays); 156 reversedWays = ReverseWayTagCorrector.irreversibleWays(reversedWays); 157 // reverse path if there are more reversed than unreversed ways with direction-dependent tags 158 if (reversedWays.size() > unreversedWays.size()) { 159 Collections.reverse(path); 160 List<Way> tempWays = unreversedWays; 161 unreversedWays = null; 162 reversedWays = tempWays; 163 } 164 // if there are still reversed ways with direction-dependent tags, reverse their tags 165 if (!reversedWays.isEmpty() && Boolean.TRUE.equals(PROP_REVERSE_WAY.get())) { 166 List<Way> unreversedTagWays = new ArrayList<>(ways); 167 unreversedTagWays.removeAll(reversedWays); 168 ReverseWayTagCorrector reverseWayTagCorrector = new ReverseWayTagCorrector(); 169 List<Way> reversedTagWays = new ArrayList<>(reversedWays.size()); 170 for (Way w : reversedWays) { 171 Way wnew = new Way(w); 172 reversedTagWays.add(wnew); 173 reverseWayTagCommands.addAll(reverseWayTagCorrector.execute(w, wnew)); 174 } 175 if (!reverseWayTagCommands.isEmpty()) { 176 // commands need to be executed for CombinePrimitiveResolverDialog 177 UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Reverse Ways"), reverseWayTagCommands)); 178 } 179 wayTags = TagCollection.unionOfAllPrimitives(reversedTagWays); 180 wayTags.add(TagCollection.unionOfAllPrimitives(unreversedTagWays)); 181 } 182 } 183 184 // create the new way and apply the new node list 185 // 186 Way targetWay = getTargetWay(ways); 187 Way modifiedTargetWay = new Way(targetWay); 188 modifiedTargetWay.setNodes(path); 189 190 final List<Command> resolution; 191 try { 192 resolution = CombinePrimitiveResolverDialog.launchIfNecessary(wayTags, ways, Collections.singleton(targetWay)); 193 } finally { 194 if (!reverseWayTagCommands.isEmpty()) { 195 // undo reverseWayTagCorrector and merge into SequenceCommand below 196 UndoRedoHandler.getInstance().undo(); 197 } 198 } 199 200 List<Command> cmds = new LinkedList<>(); 201 List<Way> deletedWays = new LinkedList<>(ways); 202 deletedWays.remove(targetWay); 203 204 cmds.add(new ChangeCommand(dataSets.get(0), targetWay, modifiedTargetWay)); 205 cmds.addAll(reverseWayTagCommands); 206 cmds.addAll(resolution); 207 cmds.add(new DeleteCommand(dataSets.get(0), deletedWays)); 208 final Command sequenceCommand = new SequenceCommand(/* for correct i18n of plural forms - see #9110 */ 209 trn("Combine {0} way", "Combine {0} ways", ways.size(), ways.size()), cmds); 210 211 return new Pair<>(targetWay, sequenceCommand); 212 } 213 214 protected static void detectReversedWays(Collection<Way> ways, List<Node> path, List<Way> reversedWays, 215 List<Way> unreversedWays) { 216 for (Way w: ways) { 217 // Treat zero or one-node ways as unreversed as Combine action action is a good way to fix them (see #8971) 218 if (w.getNodesCount() < 2) { 219 unreversedWays.add(w); 220 } else { 221 boolean foundStartSegment = false; 222 int last = path.lastIndexOf(w.getNode(0)); 223 224 for (int i = path.indexOf(w.getNode(0)); i <= last; i++) { 225 if (path.get(i) == w.getNode(0) && i + 1 < path.size() && w.getNode(1) == path.get(i + 1)) { 226 foundStartSegment = true; 227 break; 228 } 229 } 230 if (foundStartSegment) { 231 unreversedWays.add(w); 232 } else { 233 reversedWays.add(w); 234 } 235 } 236 } 237 } 238 239 protected static List<Node> tryJoin(Collection<Way> ways) { 240 List<Node> path = joinWithMultipolygonCode(ways); 241 if (path.isEmpty()) { 242 NodeGraph graph = NodeGraph.createNearlyUndirectedGraphFromNodeWays(ways); 243 path = graph.buildSpanningPathNoRemove(); 244 } 245 return path; 246 } 247 248 /** 249 * Use {@link Multipolygon#joinWays(Collection)} to join ways. 250 * @param ways the ways 251 * @return List of nodes of the combined ways or null if ways could not be combined to a single way. 252 * Result may contain overlapping segments. 253 */ 254 private static List<Node> joinWithMultipolygonCode(Collection<Way> ways) { 255 // sort so that old unclosed ways appear first 256 LinkedList<Way> toJoin = new LinkedList<>(ways); 257 toJoin.sort((o1, o2) -> { 258 int d = Boolean.compare(o1.isNew(), o2.isNew()); 259 if (d == 0) 260 d = Boolean.compare(o1.isClosed(), o2.isClosed()); 261 return d; 262 }); 263 Collection<JoinedWay> list = Multipolygon.joinWays(toJoin); 264 if (list.size() == 1) { 265 // ways form a single line string 266 return new ArrayList<>(list.iterator().next().getNodes()); 267 } 268 return Collections.emptyList(); 269 } 270 271 @Override 272 public void actionPerformed(ActionEvent event) { 273 final DataSet ds = getLayerManager().getEditDataSet(); 274 if (ds == null) 275 return; 276 Collection<Way> selectedWays = ds.getSelectedWays(); 277 if (selectedWays.size() < 2) { 278 new Notification( 279 tr("Please select at least two ways to combine.")) 280 .setIcon(JOptionPane.INFORMATION_MESSAGE) 281 .setDuration(Notification.TIME_SHORT) 282 .show(); 283 return; 284 } 285 286 // see #18083: check if we will combine ways at nodes outside of the download area 287 Set<Node> endNodesOutside = new HashSet<>(); 288 for (Way w : selectedWays) { 289 final Node[] endnodes = {w.firstNode(), w.lastNode()}; 290 for (Node n : endnodes) { 291 if (!n.isNew() && n.isOutsideDownloadArea() && !endNodesOutside.add(n)) { 292 new Notification(tr("Combine ways refused<br>" + "(A shared node is outside of the download area)")) 293 .setIcon(JOptionPane.INFORMATION_MESSAGE).show(); 294 return; 295 296 } 297 } 298 } 299 300 // combine and update gui 301 Pair<Way, Command> combineResult; 302 try { 303 combineResult = combineWaysWorker(selectedWays); 304 } catch (UserCancelException ex) { 305 Logging.trace(ex); 306 return; 307 } 308 309 if (combineResult == null) 310 return; 311 312 final Way selectedWay = combineResult.a; 313 UndoRedoHandler.getInstance().add(combineResult.b); 314 Test test = new OverlappingWays(); 315 test.startTest(null); 316 test.visit(combineResult.a); 317 test.endTest(); 318 if (test.getErrors().isEmpty()) { 319 test = new SelfIntersectingWay(); 320 test.startTest(null); 321 test.visit(combineResult.a); 322 test.endTest(); 323 } 324 if (!test.getErrors().isEmpty()) { 325 new Notification(test.getErrors().get(0).getMessage()) 326 .setIcon(JOptionPane.WARNING_MESSAGE) 327 .setDuration(Notification.TIME_SHORT) 328 .show(); 329 } 330 if (selectedWay != null) { 331 GuiHelper.runInEDT(() -> ds.setSelected(selectedWay)); 332 } 333 } 334 335 @Override 336 protected void updateEnabledState() { 337 updateEnabledStateOnCurrentSelection(); 338 } 339 340 @Override 341 protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) { 342 int numWays = 0; 343 if (OsmUtils.isOsmCollectionEditable(selection)) { 344 for (OsmPrimitive osm : selection) { 345 if (osm instanceof Way && !osm.isIncomplete() && ++numWays >= 2) { 346 break; 347 } 348 } 349 } 350 setEnabled(numWays >= 2); 351 } 352 353}