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