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}