001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions.mapmode;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Graphics2D;
007import java.awt.event.ActionEvent;
008import java.awt.event.MouseEvent;
009import java.awt.event.MouseListener;
010import java.util.ArrayList;
011import java.util.Arrays;
012import java.util.Collection;
013import java.util.Comparator;
014import java.util.stream.DoubleStream;
015
016import javax.swing.AbstractAction;
017import javax.swing.JCheckBoxMenuItem;
018import javax.swing.JPopupMenu;
019
020import org.openstreetmap.josm.Main;
021import org.openstreetmap.josm.data.coor.EastNorth;
022import org.openstreetmap.josm.data.coor.LatLon;
023import org.openstreetmap.josm.data.osm.DataSet;
024import org.openstreetmap.josm.data.osm.Node;
025import org.openstreetmap.josm.data.osm.Way;
026import org.openstreetmap.josm.data.osm.WaySegment;
027import org.openstreetmap.josm.gui.MainApplication;
028import org.openstreetmap.josm.gui.MapView;
029import org.openstreetmap.josm.gui.MapViewState;
030import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
031import org.openstreetmap.josm.gui.draw.MapViewPath;
032import org.openstreetmap.josm.gui.draw.SymbolShape;
033import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
034import org.openstreetmap.josm.spi.preferences.Config;
035import org.openstreetmap.josm.tools.Logging;
036import org.openstreetmap.josm.tools.Utils;
037
038/**
039 * Class that enables the user to draw way segments in angles of exactly 30, 45,
040 * 60, 90 degrees.
041 *
042 * With enabled snapping, the new way node will be projected onto the helper line
043 * that indicates a certain fixed angle relative to the previous segment.
044 */
045class DrawSnapHelper {
046
047    private final DrawAction drawAction;
048
049    /**
050     * Constructs a new {@code SnapHelper}.
051     * @param drawAction enclosing DrawAction
052     */
053    DrawSnapHelper(DrawAction drawAction) {
054        this.drawAction = drawAction;
055        this.anglePopupListener = new PopupMenuLauncher(new AnglePopupMenu(this)) {
056            @Override
057            public void mouseClicked(MouseEvent e) {
058                super.mouseClicked(e);
059                if (e.getButton() == MouseEvent.BUTTON1) {
060                    toggleSnapping();
061                    drawAction.updateStatusLine();
062                }
063            }
064        };
065    }
066
067    private static final String DRAW_ANGLESNAP_ANGLES = "draw.anglesnap.angles";
068
069    private static final class RepeatedAction extends AbstractAction {
070        RepeatedAction(DrawSnapHelper snapHelper) {
071            super(tr("Toggle snapping by {0}", snapHelper.drawAction.getShortcut().getKeyText()));
072        }
073
074        @Override
075        public void actionPerformed(ActionEvent e) {
076            boolean sel = ((JCheckBoxMenuItem) e.getSource()).getState();
077            DrawAction.USE_REPEATED_SHORTCUT.put(sel);
078        }
079    }
080
081    private static final class HelperAction extends AbstractAction {
082        private final transient DrawSnapHelper snapHelper;
083
084        HelperAction(DrawSnapHelper snapHelper) {
085            super(tr("Show helper geometry"));
086            this.snapHelper = snapHelper;
087        }
088
089        @Override
090        public void actionPerformed(ActionEvent e) {
091            boolean sel = ((JCheckBoxMenuItem) e.getSource()).getState();
092            DrawAction.DRAW_CONSTRUCTION_GEOMETRY.put(sel);
093            DrawAction.SHOW_PROJECTED_POINT.put(sel);
094            DrawAction.SHOW_ANGLE.put(sel);
095            snapHelper.enableSnapping();
096        }
097    }
098
099    private static final class ProjectionAction extends AbstractAction {
100        private final transient DrawSnapHelper snapHelper;
101
102        ProjectionAction(DrawSnapHelper snapHelper) {
103            super(tr("Snap to node projections"));
104            this.snapHelper = snapHelper;
105        }
106
107        @Override
108        public void actionPerformed(ActionEvent e) {
109            boolean sel = ((JCheckBoxMenuItem) e.getSource()).getState();
110            DrawAction.SNAP_TO_PROJECTIONS.put(sel);
111            snapHelper.enableSnapping();
112        }
113    }
114
115    private static final class DisableAction extends AbstractAction {
116        private final transient DrawSnapHelper snapHelper;
117
118        DisableAction(DrawSnapHelper snapHelper) {
119            super(tr("Disable"));
120            this.snapHelper = snapHelper;
121        }
122
123        @Override
124        public void actionPerformed(ActionEvent e) {
125            snapHelper.saveAngles("180");
126            snapHelper.init();
127            snapHelper.enableSnapping();
128        }
129    }
130
131    private static final class Snap90DegreesAction extends AbstractAction {
132        private final transient DrawSnapHelper snapHelper;
133
134        Snap90DegreesAction(DrawSnapHelper snapHelper) {
135            super(tr("0,90,..."));
136            this.snapHelper = snapHelper;
137        }
138
139        @Override
140        public void actionPerformed(ActionEvent e) {
141            snapHelper.saveAngles("0", "90", "180");
142            snapHelper.init();
143            snapHelper.enableSnapping();
144        }
145    }
146
147    private static final class Snap45DegreesAction extends AbstractAction {
148        private final transient DrawSnapHelper snapHelper;
149
150        Snap45DegreesAction(DrawSnapHelper snapHelper) {
151            super(tr("0,45,90,..."));
152            this.snapHelper = snapHelper;
153        }
154
155        @Override
156        public void actionPerformed(ActionEvent e) {
157            snapHelper.saveAngles("0", "45", "90", "135", "180");
158            snapHelper.init();
159            snapHelper.enableSnapping();
160        }
161    }
162
163    private static final class Snap30DegreesAction extends AbstractAction {
164        private final transient DrawSnapHelper snapHelper;
165
166        Snap30DegreesAction(DrawSnapHelper snapHelper) {
167            super(tr("0,30,45,60,90,..."));
168            this.snapHelper = snapHelper;
169        }
170
171        @Override
172        public void actionPerformed(ActionEvent e) {
173            snapHelper.saveAngles("0", "30", "45", "60", "90", "120", "135", "150", "180");
174            snapHelper.init();
175            snapHelper.enableSnapping();
176        }
177    }
178
179    private static final class AnglePopupMenu extends JPopupMenu {
180
181        private AnglePopupMenu(final DrawSnapHelper snapHelper) {
182            JCheckBoxMenuItem repeatedCb = new JCheckBoxMenuItem(new RepeatedAction(snapHelper));
183            JCheckBoxMenuItem helperCb = new JCheckBoxMenuItem(new HelperAction(snapHelper));
184            JCheckBoxMenuItem projectionCb = new JCheckBoxMenuItem(new ProjectionAction(snapHelper));
185
186            helperCb.setState(DrawAction.DRAW_CONSTRUCTION_GEOMETRY.get());
187            projectionCb.setState(DrawAction.SNAP_TO_PROJECTIONS.get());
188            repeatedCb.setState(DrawAction.USE_REPEATED_SHORTCUT.get());
189            add(repeatedCb);
190            add(helperCb);
191            add(projectionCb);
192            add(new DisableAction(snapHelper));
193            add(new Snap90DegreesAction(snapHelper));
194            add(new Snap45DegreesAction(snapHelper));
195            add(new Snap30DegreesAction(snapHelper));
196        }
197    }
198
199    private boolean snapOn; // snapping is turned on
200
201    private boolean active; // snapping is active for current mouse position
202    private boolean fixed; // snap angle is fixed
203    private boolean absoluteFix; // snap angle is absolute
204
205    EastNorth dir2;
206    private EastNorth projected;
207    private String labelText;
208    private double lastAngle;
209
210    private double customBaseHeading = -1; // angle of base line, if not last segment)
211    private EastNorth segmentPoint1; // remembered first point of base segment
212    private EastNorth segmentPoint2; // remembered second point of base segment
213    private EastNorth projectionSource; // point that we are projecting to the line
214
215    private double[] snapAngles;
216
217    private double pe, pn; // (pe, pn) - direction of snapping line
218    private double e0, n0; // (e0, n0) - origin of snapping line
219
220    private final String fixFmt = "%d "+tr("FIX");
221
222    private JCheckBoxMenuItem checkBox;
223
224    final MouseListener anglePopupListener;
225
226    /**
227     * Set the initial state
228     */
229    public void init() {
230        snapOn = false;
231        checkBox.setState(snapOn);
232        fixed = false;
233        absoluteFix = false;
234
235        computeSnapAngles();
236        Main.pref.addWeakKeyPreferenceChangeListener(DRAW_ANGLESNAP_ANGLES, e -> this.computeSnapAngles());
237    }
238
239    private void computeSnapAngles() {
240        snapAngles = Config.getPref().getList(DRAW_ANGLESNAP_ANGLES,
241                Arrays.asList("0", "30", "45", "60", "90", "120", "135", "150", "180"))
242                .stream()
243                .mapToDouble(DrawSnapHelper::parseSnapAngle)
244                .flatMap(s -> DoubleStream.of(s, 360-s))
245                .toArray();
246    }
247
248    private static double parseSnapAngle(String string) {
249        try {
250            return Double.parseDouble(string);
251        } catch (NumberFormatException e) {
252            Logging.warn("Incorrect number in draw.anglesnap.angles preferences: {0}", string);
253            return 0;
254        }
255    }
256
257    /**
258     * Save the snap angles
259     * @param angles The angles
260     */
261    public void saveAngles(String... angles) {
262        Config.getPref().putList(DRAW_ANGLESNAP_ANGLES, Arrays.asList(angles));
263    }
264
265    /**
266     * Sets the menu checkbox.
267     * @param checkBox menu checkbox
268     */
269    public void setMenuCheckBox(JCheckBoxMenuItem checkBox) {
270        this.checkBox = checkBox;
271    }
272
273    /**
274     * Draw the snap hint line.
275     * @param g2 graphics
276     * @param mv MapView state
277     * @since 10874
278     */
279    public void drawIfNeeded(Graphics2D g2, MapViewState mv) {
280        if (!snapOn || !active)
281            return;
282        MapViewPoint p1 = mv.getPointFor(drawAction.getCurrentBaseNode());
283        MapViewPoint p2 = mv.getPointFor(dir2);
284        MapViewPoint p3 = mv.getPointFor(projected);
285        if (DrawAction.DRAW_CONSTRUCTION_GEOMETRY.get()) {
286            g2.setColor(DrawAction.SNAP_HELPER_COLOR.get());
287            g2.setStroke(DrawAction.HELPER_STROKE.get());
288
289            MapViewPath b = new MapViewPath(mv);
290            b.moveTo(p2);
291            if (absoluteFix) {
292                b.lineTo(p2.interpolate(p1, 2)); // bi-directional line
293            } else {
294                b.lineTo(p3);
295            }
296            g2.draw(b);
297        }
298        if (projectionSource != null) {
299            g2.setColor(DrawAction.SNAP_HELPER_COLOR.get());
300            g2.setStroke(DrawAction.HELPER_STROKE.get());
301            MapViewPath b = new MapViewPath(mv);
302            b.moveTo(p3);
303            b.lineTo(projectionSource);
304            g2.draw(b);
305        }
306
307        if (customBaseHeading >= 0) {
308            g2.setColor(DrawAction.HIGHLIGHT_COLOR.get());
309            g2.setStroke(DrawAction.HIGHLIGHT_STROKE.get());
310            MapViewPath b = new MapViewPath(mv);
311            b.moveTo(segmentPoint1);
312            b.lineTo(segmentPoint2);
313            g2.draw(b);
314        }
315
316        g2.setColor(DrawAction.RUBBER_LINE_COLOR.get());
317        g2.setStroke(DrawAction.RUBBER_LINE_STROKE.get());
318        MapViewPath b = new MapViewPath(mv);
319        b.moveTo(p1);
320        b.lineTo(p3);
321        g2.draw(b);
322
323        g2.drawString(labelText, (int) p3.getInViewX()-5, (int) p3.getInViewY()+20);
324        if (DrawAction.SHOW_PROJECTED_POINT.get()) {
325            g2.setStroke(DrawAction.RUBBER_LINE_STROKE.get());
326            g2.draw(new MapViewPath(mv).shapeAround(p3, SymbolShape.CIRCLE, 10)); // projected point
327        }
328
329        g2.setColor(DrawAction.SNAP_HELPER_COLOR.get());
330        g2.setStroke(DrawAction.HELPER_STROKE.get());
331    }
332
333    /**
334     * If mouse position is close to line at 15-30-45-... angle, remembers this direction
335     * @param currentEN Current position
336     * @param baseHeading The heading
337     * @param curHeading The current mouse heading
338     */
339    public void checkAngleSnapping(EastNorth currentEN, double baseHeading, double curHeading) {
340        MapView mapView = MainApplication.getMap().mapView;
341        EastNorth p0 = drawAction.getCurrentBaseNode().getEastNorth();
342        EastNorth snapPoint = currentEN;
343        double angle = -1;
344
345        double activeBaseHeading = (customBaseHeading >= 0) ? customBaseHeading : baseHeading;
346
347        if (snapOn && (activeBaseHeading >= 0)) {
348            angle = curHeading - activeBaseHeading;
349            if (angle < 0) {
350                angle += 360;
351            }
352            if (angle > 360) {
353                angle = 0;
354            }
355
356            double nearestAngle;
357            if (fixed) {
358                nearestAngle = lastAngle; // if direction is fixed use previous angle
359                active = true;
360            } else {
361                nearestAngle = getNearestAngle(angle);
362                if (getAngleDelta(nearestAngle, angle) < DrawAction.SNAP_ANGLE_TOLERANCE.get()) {
363                    active = customBaseHeading >= 0 || Math.abs(nearestAngle - 180) > 1e-3;
364                    // if angle is to previous segment, exclude 180 degrees
365                    lastAngle = nearestAngle;
366                } else {
367                    active = false;
368                }
369            }
370
371            if (active) {
372                double phi;
373                e0 = p0.east();
374                n0 = p0.north();
375                buildLabelText((nearestAngle <= 180) ? nearestAngle : (nearestAngle-360));
376
377                phi = (nearestAngle + activeBaseHeading) * Math.PI / 180;
378                // (pe,pn) - direction of snapping line
379                pe = Math.sin(phi);
380                pn = Math.cos(phi);
381                double scale = 20 * mapView.getDist100Pixel();
382                dir2 = new EastNorth(e0 + scale * pe, n0 + scale * pn);
383                snapPoint = getSnapPoint(currentEN);
384            } else {
385                noSnapNow();
386            }
387        }
388
389        // find out the distance, in metres, between the base point and projected point
390        LatLon mouseLatLon = mapView.getProjection().eastNorth2latlon(snapPoint);
391        double distance = this.drawAction.getCurrentBaseNode().getCoor().greatCircleDistance(mouseLatLon);
392        double hdg = Utils.toDegrees(p0.heading(snapPoint));
393        // heading of segment from current to calculated point, not to mouse position
394
395        if (baseHeading >= 0) { // there is previous line segment with some heading
396            angle = hdg - baseHeading;
397            if (angle < 0) {
398                angle += 360;
399            }
400            if (angle > 360) {
401                angle = 0;
402            }
403        }
404        DrawAction.showStatusInfo(angle, hdg, distance, isSnapOn());
405    }
406
407    private void buildLabelText(double nearestAngle) {
408        if (DrawAction.SHOW_ANGLE.get()) {
409            if (fixed) {
410                if (absoluteFix) {
411                    labelText = "=";
412                } else {
413                    labelText = String.format(fixFmt, (int) nearestAngle);
414                }
415            } else {
416                labelText = String.format("%d", (int) nearestAngle);
417            }
418        } else {
419            if (fixed) {
420                if (absoluteFix) {
421                    labelText = "=";
422                } else {
423                    labelText = String.format(tr("FIX"), 0);
424                }
425            } else {
426                labelText = "";
427            }
428        }
429    }
430
431    /**
432     * Gets a snap point close to p. Stores the result for display.
433     * @param p The point
434     * @return The snap point close to p.
435     */
436    public EastNorth getSnapPoint(EastNorth p) {
437        if (!active)
438            return p;
439        double de = p.east()-e0;
440        double dn = p.north()-n0;
441        double l = de*pe+dn*pn;
442        double delta = MainApplication.getMap().mapView.getDist100Pixel()/20;
443        if (!absoluteFix && l < delta) {
444            active = false;
445            return p;
446        } //  do not go backward!
447
448        projectionSource = null;
449        if (DrawAction.SNAP_TO_PROJECTIONS.get()) {
450            DataSet ds = drawAction.getLayerManager().getActiveDataSet();
451            Collection<Way> selectedWays = ds.getSelectedWays();
452            if (selectedWays.size() == 1) {
453                Way w = selectedWays.iterator().next();
454                Collection<EastNorth> pointsToProject = new ArrayList<>();
455                if (w.getNodesCount() < 1000) {
456                    for (Node n: w.getNodes()) {
457                        pointsToProject.add(n.getEastNorth());
458                    }
459                }
460                if (customBaseHeading >= 0) {
461                    pointsToProject.add(segmentPoint1);
462                    pointsToProject.add(segmentPoint2);
463                }
464                EastNorth enOpt = null;
465                double dOpt = 1e5;
466                for (EastNorth en: pointsToProject) { // searching for besht projection
467                    double l1 = (en.east()-e0)*pe+(en.north()-n0)*pn;
468                    double d1 = Math.abs(l1-l);
469                    if (d1 < delta && d1 < dOpt) {
470                        l = l1;
471                        enOpt = en;
472                        dOpt = d1;
473                    }
474                }
475                if (enOpt != null) {
476                    projectionSource = enOpt;
477                }
478            }
479        }
480        projected = new EastNorth(e0+l*pe, n0+l*pn);
481        return projected;
482    }
483
484    /**
485     * Disables snapping
486     */
487    void noSnapNow() {
488        active = false;
489        dir2 = null;
490        projected = null;
491        labelText = null;
492    }
493
494    void setBaseSegment(WaySegment seg) {
495        if (seg == null) return;
496        segmentPoint1 = seg.getFirstNode().getEastNorth();
497        segmentPoint2 = seg.getSecondNode().getEastNorth();
498
499        double hdg = segmentPoint1.heading(segmentPoint2);
500        hdg = Utils.toDegrees(hdg);
501        if (hdg < 0) {
502            hdg += 360;
503        }
504        if (hdg > 360) {
505            hdg -= 360;
506        }
507        customBaseHeading = hdg;
508    }
509
510    /**
511     * Enable snapping.
512     */
513    void enableSnapping() {
514        snapOn = true;
515        checkBox.setState(snapOn);
516        customBaseHeading = -1;
517        unsetFixedMode();
518    }
519
520    void toggleSnapping() {
521        snapOn = !snapOn;
522        checkBox.setState(snapOn);
523        customBaseHeading = -1;
524        unsetFixedMode();
525    }
526
527    void setFixedMode() {
528        if (active) {
529            fixed = true;
530        }
531    }
532
533    void unsetFixedMode() {
534        fixed = false;
535        absoluteFix = false;
536        lastAngle = 0;
537        active = false;
538    }
539
540    boolean isActive() {
541        return active;
542    }
543
544    boolean isSnapOn() {
545        return snapOn;
546    }
547
548    private double getNearestAngle(double angle) {
549        double bestAngle = DoubleStream.of(snapAngles).boxed()
550                .min(Comparator.comparing(snapAngle -> getAngleDelta(angle, snapAngle))).orElse(0.0);
551        if (Math.abs(bestAngle-360) < 1e-3) {
552            bestAngle = 0;
553        }
554        return bestAngle;
555    }
556
557    private static double getAngleDelta(double a, double b) {
558        double delta = Math.abs(a-b);
559        if (delta > 180)
560            return 360-delta;
561        else
562            return delta;
563    }
564
565    void unFixOrTurnOff() {
566        if (absoluteFix) {
567            unsetFixedMode();
568        } else {
569            toggleSnapping();
570        }
571    }
572}