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