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