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}