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