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}