diff --git resources/images/grid.svg resources/images/grid.svg
new file mode 100644
index 0000000000..a68166e9cc
--- /dev/null
+++ resources/images/grid.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="2.5" y="2.5" width="19" height="19" rx="1.5" fill="#f4f4f4" stroke="#606060"/>
+  <path d="M9 2.5v19M15.5 2.5v19M2.5 9h19M2.5 15.5h19" fill="none" stroke="#3d7ac2"/>
+</svg>
diff --git resources/images/gridorigin.svg resources/images/gridorigin.svg
new file mode 100644
index 0000000000..f92f0fb38e
--- /dev/null
+++ resources/images/gridorigin.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <rect x="2.5" y="2.5" width="19" height="19" rx="1.5" fill="#f4f4f4" stroke="#606060"/>
+  <path d="M12 2.5v19M2.5 12h19" fill="none" stroke="#3d7ac2"/>
+  <rect x="9" y="9" width="6" height="6" fill="#ffffff" stroke="#df421e" stroke-width="2"/><!-- origin at a grid crossing -->
+</svg>
diff --git resources/images/gridrotate.svg resources/images/gridrotate.svg
new file mode 100644
index 0000000000..527ad84175
--- /dev/null
+++ resources/images/gridrotate.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <g transform="rotate(-30 12 12)" fill="none" stroke="#3d7ac2" stroke-width="1.5"><!-- grid turned to the way below -->
+    <rect x="4.5" y="4.5" width="15" height="15"/>
+    <path d="M12 4.5v15"/>
+  </g>
+  <path d="M2.4 17.7 21.6 6.6" fill="none" stroke="#df421e" stroke-width="2.5"/><!-- the selected way -->
+</svg>
diff --git resources/images/preferences/grid.svg resources/images/preferences/grid.svg
new file mode 100644
index 0000000000..9107df1f5d
--- /dev/null
+++ resources/images/preferences/grid.svg
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
+  <rect x="4" y="4" width="40" height="40" rx="3" fill="#f4f4f4" stroke="#606060" stroke-width="2"/>
+  <path d="M17.3 4v40M30.7 4v40M4 17.3h40M4 30.7h40" fill="none" stroke="#3d7ac2" stroke-width="2"/>
+</svg>
diff --git src/org/openstreetmap/josm/actions/AlignGridRotationAction.java src/org/openstreetmap/josm/actions/AlignGridRotationAction.java
new file mode 100644
index 0000000000..b3b0eb616c
--- /dev/null
+++ src/org/openstreetmap/josm/actions/AlignGridRotationAction.java
@@ -0,0 +1,109 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+import java.util.Iterator;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+
+/**
+ * Rotates the grid drawn over the map so that its lines run parallel to the current selection: either a single way
+ * (the direction from its first to its last node) or exactly two nodes. Switches the grid to projected coordinates,
+ * since a latitude/longitude grid cannot be rotated, and enables it.
+ * @see MapGridPaintable
+ * @see SetGridOriginAction
+ * @since xxx
+ */
+public class AlignGridRotationAction extends JosmAction {
+
+    /**
+     * Constructs a new {@code AlignGridRotationAction}.
+     */
+    public AlignGridRotationAction() {
+        super(tr("Align rotation to selection"), "gridrotate",
+                tr("Rotate the grid so that its lines are parallel to the selected way "
+                        + "(first to last node) or to the line between the two selected nodes. Switches to a projected grid."),
+                null, false);
+    }
+
+    /**
+     * Determines the direction given by the selection: a single way with at least two nodes, or exactly two nodes.
+     * @param ds the data set, may be null
+     * @return start and end point, or {@code null} if the selection does not define a direction
+     */
+    static EastNorth[] getSelectedDirection(DataSet ds) {
+        if (ds == null) {
+            return null;
+        }
+        Collection<Way> ways = ds.getSelectedWays();
+        Collection<Node> nodes = ds.getSelectedNodes();
+        Node a = null;
+        Node b = null;
+        if (ways.size() == 1 && nodes.isEmpty()) {
+            Way w = ways.iterator().next();
+            if (w.getNodesCount() >= 2) {
+                a = w.firstNode();
+                b = w.isClosed() ? w.getNode(1) : w.lastNode();
+            }
+        } else if (nodes.size() == 2 && ways.isEmpty()) {
+            Iterator<Node> it = nodes.iterator();
+            a = it.next();
+            b = it.next();
+        }
+        if (a == null || b == null || !a.isLatLonKnown() || !b.isLatLonKnown()) {
+            return null;
+        }
+        EastNorth[] result = {a.getEastNorth(), b.getEastNorth()};
+        return result[0].equalsEpsilon(result[1], 1e-9) ? null : result;
+    }
+
+    /**
+     * Computes the grid rotation (counter clockwise, in degrees, in the range [0, 90)) for which a grid line is
+     * parallel to the given direction.
+     * @param from start point
+     * @param to end point
+     * @return the rotation in degrees
+     */
+    public static double rotationOf(EastNorth from, EastNorth to) {
+        double angle = Math.toDegrees(Math.atan2(to.north() - from.north(), to.east() - from.east()));
+        angle %= 90;
+        if (angle < 0) {
+            angle += 90;
+        }
+        return angle >= 90 - 1e-9 ? 0 : angle;
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(getSelectedDirection(getLayerManager().getEditDataSet()) != null);
+    }
+
+    @Override
+    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
+        updateEnabledState();
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        EastNorth[] direction = getSelectedDirection(getLayerManager().getEditDataSet());
+        if (direction == null) {
+            return;
+        }
+        MapGridPaintable.ROTATION.put(rotationOf(direction[0], direction[1]));
+        MapGridPaintable.TYPE.put(GridType.PROJECTED);
+        MapGridPaintable.ENABLED.put(true);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+}
diff --git src/org/openstreetmap/josm/actions/SetGridOriginAction.java src/org/openstreetmap/josm/actions/SetGridOriginAction.java
new file mode 100644
index 0000000000..d45b72ce5a
--- /dev/null
+++ src/org/openstreetmap/josm/actions/SetGridOriginAction.java
@@ -0,0 +1,112 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.visitor.AllNodesVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Moves the origin of the grid drawn over the map to the current selection, so that grid lines pass through it,
+ * and enables the grid. The origin is the position of the selected node, or the centroid (arithmetic mean
+ * position) of all nodes reachable from the current selection (nodes of selected ways, node members of selected
+ * relations) if more than one node is involved.
+ * @see MapGridPaintable
+ * @see AlignGridRotationAction
+ * @since xxx
+ */
+public class SetGridOriginAction extends JosmAction {
+
+    /**
+     * Constructs a new {@code SetGridOriginAction}.
+     */
+    public SetGridOriginAction() {
+        super(tr("Set origin to selection"), "gridorigin",
+                tr("Move the grid so that a grid line passes through the selected node, "
+                        + "or through the centroid of the current selection, and show the grid."),
+                null, false);
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(getCentroid(getLayerManager().getEditDataSet()) != null);
+    }
+
+    @Override
+    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
+        updateEnabledState();
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        EastNorth centroid = getCentroid(getLayerManager().getEditDataSet());
+        if (centroid == null) {
+            return;
+        }
+        setOrigin(centroid);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+
+    /**
+     * Computes the centroid of the current selection: the (arithmetic mean) position of every node reachable from
+     * the selected primitives (the selected nodes themselves, the nodes of selected ways, and the node members of
+     * selected relations). For a single selected node this is simply that node's position.
+     * @param ds the data set, may be {@code null}
+     * @return the centroid, or {@code null} if the selection contains no node with known coordinates
+     */
+    static EastNorth getCentroid(DataSet ds) {
+        if (ds == null) {
+            return null;
+        }
+        double sumEast = 0;
+        double sumNorth = 0;
+        int count = 0;
+        for (Node n : AllNodesVisitor.getAllNodes(ds.getSelected())) {
+            if (n.isLatLonKnown()) {
+                EastNorth en = n.getEastNorth();
+                sumEast += en.east();
+                sumNorth += en.north();
+                count++;
+            }
+        }
+        return count == 0 ? null : new EastNorth(sumEast / count, sumNorth / count);
+    }
+
+    /**
+     * Sets the grid origin (in the coordinates of the current grid type) and enables the grid.
+     * @param position the new grid origin, in projected coordinates
+     */
+    public static void setOrigin(EastNorth position) {
+        if (MapGridPaintable.TYPE.get() == GridType.PROJECTED) {
+            MapGridPaintable.ORIGIN_X.put(position.east());
+            MapGridPaintable.ORIGIN_Y.put(position.north());
+        } else {
+            final LatLon ll;
+            try {
+                ll = ProjectionRegistry.getProjection().eastNorth2latlon(position);
+            } catch (IllegalArgumentException e) {
+                // the position is outside the domain of the projection, leave the grid as it is
+                Logging.warn("Cannot use {0} as grid origin: {1}", position, e.getMessage());
+                Logging.trace(e);
+                return;
+            }
+            MapGridPaintable.ORIGIN_X.put(ll.lon());
+            MapGridPaintable.ORIGIN_Y.put(ll.lat());
+        }
+        MapGridPaintable.ENABLED.put(true);
+    }
+}
diff --git src/org/openstreetmap/josm/actions/ShowGridAction.java src/org/openstreetmap/josm/actions/ShowGridAction.java
new file mode 100644
index 0000000000..4f1343ff37
--- /dev/null
+++ src/org/openstreetmap/josm/actions/ShowGridAction.java
@@ -0,0 +1,49 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+/**
+ * This action toggles the display of the grid over the map view.
+ * @see MapGridPaintable
+ * @since xxx
+ */
+public class ShowGridAction extends PreferenceToggleAction {
+
+    /**
+     * Constructs a new {@link ShowGridAction}.
+     */
+    public ShowGridAction() {
+        super(tr("Show"),
+                new ImageProvider("grid"),
+                tr("Enable/disable the grid drawn over the map. Its spacing and orientation are set in the display preferences."),
+                MapGridPaintable.ENABLED
+        );
+        setHelpId(ht("/MapView#Grid"));
+    }
+
+    @Override
+    protected boolean listenToSelectionChange() {
+        return false;
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(MainApplication.isDisplayingMapView());
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        super.actionPerformed(e);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+}
diff --git src/org/openstreetmap/josm/gui/MainMenu.java src/org/openstreetmap/josm/gui/MainMenu.java
index 3a089782ba..eeb0d37368 100644
--- src/org/openstreetmap/josm/gui/MainMenu.java
+++ src/org/openstreetmap/josm/gui/MainMenu.java
@@ -30,6 +30,7 @@ import javax.swing.event.MenuListener;
 
 import org.openstreetmap.josm.actions.AboutAction;
 import org.openstreetmap.josm.actions.AddNodeAction;
+import org.openstreetmap.josm.actions.AlignGridRotationAction;
 import org.openstreetmap.josm.actions.AlignInCircleAction;
 import org.openstreetmap.josm.actions.AlignInLineAction;
 import org.openstreetmap.josm.actions.AutoScaleAction;
@@ -100,6 +101,8 @@ import org.openstreetmap.josm.actions.SelectNonBranchingWaySequencesAction;
 import org.openstreetmap.josm.actions.SelectSharedChildObjectsAction;
 import org.openstreetmap.josm.actions.SessionSaveAction;
 import org.openstreetmap.josm.actions.SessionSaveAsAction;
+import org.openstreetmap.josm.actions.SetGridOriginAction;
+import org.openstreetmap.josm.actions.ShowGridAction;
 import org.openstreetmap.josm.actions.ShowStatusReportAction;
 import org.openstreetmap.josm.actions.SimplifyWayAction;
 import org.openstreetmap.josm.actions.SplitWayAction;
@@ -135,6 +138,7 @@ import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent;
 import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
 import org.openstreetmap.josm.gui.layer.geoimage.WikimediaCommonsLoader.WikimediaCommonsLoadImagesAction;
 import org.openstreetmap.josm.gui.mappaint.MapPaintMenu;
+import org.openstreetmap.josm.gui.preferences.display.GridPreference;
 import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
 import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSearchPrimitiveDialog;
 import org.openstreetmap.josm.spi.preferences.Config;
@@ -256,6 +260,14 @@ public class MainMenu extends JMenuBar {
     public final TiledRenderToggleAction tiledRenderToggleAction = new TiledRenderToggleAction();
     /** View / Hatch area outside download */
     public final DrawBoundariesOfDownloadedDataAction drawBoundariesOfDownloadedDataAction = new DrawBoundariesOfDownloadedDataAction();
+    /** View / Grid submenu: the grid drawn over the map and its placement */
+    public final JMenu gridMenu = new JMenu(tr("Grid"));
+    /** View / Grid / Show */
+    public final ShowGridAction showGridAction = new ShowGridAction();
+    /** View / Grid / Set origin to selection */
+    public final SetGridOriginAction setGridOriginAction = new SetGridOriginAction();
+    /** View / Grid / Align rotation to selection */
+    public final AlignGridRotationAction alignGridRotationAction = new AlignGridRotationAction();
     /** View / Advanced info */
     public final InfoAction info = new InfoAction();
     /** View / Advanced info (web) */
@@ -815,6 +827,15 @@ public class MainMenu extends JMenuBar {
         final JCheckBoxMenuItem hatchAreaOutsideDownloadMenuItem = drawBoundariesOfDownloadedDataAction.getCheckbox();
         viewMenu.add(hatchAreaOutsideDownloadMenuItem);
         ExpertToggleAction.addVisibilitySwitcher(hatchAreaOutsideDownloadMenuItem);
+        // -- Grid submenu
+        gridMenu.setIcon(ImageProvider.get("grid", ImageProvider.ImageSizes.MENU));
+        gridMenu.add(showGridAction.getCheckbox());
+        add(gridMenu, setGridOriginAction);
+        add(gridMenu, alignGridRotationAction);
+        gridMenu.addSeparator();
+        add(gridMenu, PreferencesAction.forPreferenceTab(tr("Grid preferences..."),
+                tr("Click to open the grid tab in the preferences"), GridPreference.class));
+        viewMenu.add(gridMenu);
 
         viewMenu.add(new MapPaintMenu());
         viewMenu.addSeparator();
diff --git src/org/openstreetmap/josm/gui/MapFrame.java src/org/openstreetmap/josm/gui/MapFrame.java
index f3e81c9953..341371d0c4 100644
--- src/org/openstreetmap/josm/gui/MapFrame.java
+++ src/org/openstreetmap/josm/gui/MapFrame.java
@@ -73,6 +73,7 @@ import org.openstreetmap.josm.gui.dialogs.UserListDialog;
 import org.openstreetmap.josm.gui.dialogs.ValidatorDialog;
 import org.openstreetmap.josm.gui.dialogs.properties.PropertiesDialog;
 import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
 import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
 import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
 import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
@@ -122,6 +123,8 @@ public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi
      * The view control displayed.
      */
     public final MapView mapView;
+    /** The grid drawn over the map view, see {@link MapGridPaintable} */
+    private final MapGridPaintable gridOverlay = new MapGridPaintable();
 
     /**
      * This object allows to detect key press and release events
@@ -204,6 +207,7 @@ public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi
         setLayout(new BorderLayout());
 
         mapView = new MapView(MainApplication.getLayerManager(), viewportData);
+        mapView.addTemporaryLayer(gridOverlay);
 
         splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true);
 
@@ -366,6 +370,8 @@ public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi
         toolBarToggle.removeAll();
 
         statusLine.destroy();
+        mapView.removeTemporaryLayer(gridOverlay);
+        gridOverlay.destroy();
         mapView.destroy();
         keyDetector.unregister();
 
diff --git src/org/openstreetmap/josm/gui/draw/BlendComposite.java src/org/openstreetmap/josm/gui/draw/BlendComposite.java
new file mode 100644
index 0000000000..e00e14ca3d
--- /dev/null
+++ src/org/openstreetmap/josm/gui/draw/BlendComposite.java
@@ -0,0 +1,188 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.draw;
+
+import java.awt.Composite;
+import java.awt.CompositeContext;
+import java.awt.RenderingHints;
+import java.awt.image.ColorModel;
+import java.awt.image.Raster;
+import java.awt.image.WritableRaster;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * A {@link Composite} implementing the common "blend modes" of image editors (multiply, burn, hard light,
+ * difference, divide), which the standard {@link java.awt.AlphaComposite} does not provide.
+ * <p>
+ * The source alpha (including the coverage produced by antialiasing) controls how strongly the blended color
+ * replaces the destination, following the "source over" rule of the W3C compositing model, so that a
+ * translucent destination is handled correctly as well. It works on any color model, but is implemented per
+ * pixel and thus only meant for drawing thin shapes such as grid lines. It must be used on
+ * {@link java.awt.image.BufferedImage} backed graphics, since hardware accelerated pipelines do not support
+ * custom composites.
+ * @since xxx
+ */
+public final class BlendComposite implements Composite {
+
+    /**
+     * The supported blend modes.
+     */
+    public enum Mode {
+        /** Normal alpha blending (like {@link java.awt.AlphaComposite#SrcOver}) */
+        NORMAL,
+        /** Multiplies source and destination: darkens, white is neutral */
+        MULTIPLY,
+        /** Color burn: darkens and increases contrast, white is neutral */
+        BURN,
+        /** Hard light: multiplies for dark sources, screens for light sources; strong contrast */
+        HARD_LIGHT,
+        /** Absolute difference between source and destination: always visible on any background */
+        DIFFERENCE,
+        /** Divides destination by source: lightens, white is neutral, dark sources give bright lines */
+        DIVIDE
+    }
+
+    private static final Map<Mode, BlendComposite> INSTANCES = new EnumMap<>(Mode.class);
+
+    private final Mode mode;
+
+    private BlendComposite(Mode mode) {
+        this.mode = mode;
+    }
+
+    /**
+     * Returns the composite for the given mode.
+     * @param mode the blend mode
+     * @return the composite
+     */
+    public static synchronized BlendComposite getInstance(Mode mode) {
+        return INSTANCES.computeIfAbsent(mode, BlendComposite::new);
+    }
+
+    /**
+     * Returns the blend mode of this composite.
+     * @return the blend mode
+     */
+    public Mode getMode() {
+        return mode;
+    }
+
+    @Override
+    public CompositeContext createContext(ColorModel srcColorModel, ColorModel dstColorModel, RenderingHints hints) {
+        return new BlendContext(mode, srcColorModel, dstColorModel);
+    }
+
+    /**
+     * Blends one color channel.
+     * @param mode blend mode
+     * @param s source channel value (0-255)
+     * @param d destination channel value (0-255)
+     * @return blended value (0-255)
+     */
+    static int blend(Mode mode, int s, int d) {
+        switch (mode) {
+        case MULTIPLY:
+            return s * d / 255;
+        case BURN:
+            return s == 0 ? 0 : 255 - Math.min(255, (255 - d) * 255 / s);
+        case HARD_LIGHT:
+            return s < 128 ? 2 * s * d / 255 : 255 - (255 - d) * (510 - 2 * s) / 255;
+        case DIFFERENCE:
+            return Math.abs(s - d);
+        case DIVIDE:
+            return s == 0 ? 255 : Math.min(255, d * 255 / s);
+        case NORMAL:
+        default:
+            return s;
+        }
+    }
+
+    /**
+     * Composes one pixel: blends the source color with the destination and combines the two with the
+     * "source over" rule, so that both the source alpha (including the coverage produced by antialiasing)
+     * and a translucent destination are handled correctly.
+     * @param mode blend mode
+     * @param s source color (ARGB, not premultiplied)
+     * @param d destination color (ARGB, not premultiplied)
+     * @return the resulting color (ARGB, not premultiplied)
+     */
+    static int composePixel(Mode mode, int s, int d) {
+        int sa = s >>> 24;
+        if (sa == 0) {
+            return d;
+        }
+        int da = d >>> 24;
+        if (da == 0xff) {
+            // opaque destination (the map view): the result is the destination moved towards the blended color
+            return 0xff000000
+                    | (mix(blend(mode, (s >> 16) & 0xff, (d >> 16) & 0xff), (d >> 16) & 0xff, sa) << 16)
+                    | (mix(blend(mode, (s >> 8) & 0xff, (d >> 8) & 0xff), (d >> 8) & 0xff, sa) << 8)
+                    | mix(blend(mode, s & 0xff, d & 0xff), d & 0xff, sa);
+        }
+        int a = sa + da * (0xff - sa) / 0xff;
+        if (a == 0) {
+            return 0;
+        }
+        return (a << 24)
+                | (composeChannel(mode, (s >> 16) & 0xff, (d >> 16) & 0xff, sa, da, a) << 16)
+                | (composeChannel(mode, (s >> 8) & 0xff, (d >> 8) & 0xff, sa, da, a) << 8)
+                | composeChannel(mode, s & 0xff, d & 0xff, sa, da, a);
+    }
+
+    /**
+     * Composes one color channel of a translucent destination, see
+     * <a href="https://www.w3.org/TR/compositing-1/#blending">the W3C compositing model</a>:
+     * {@code co = as*(1-ab)*Cs + as*ab*B(Cb,Cs) + (1-as)*ab*Cb} and {@code Co = co/ao}.
+     * @param mode blend mode
+     * @param cs source channel value (0-255)
+     * @param cb destination (backdrop) channel value (0-255)
+     * @param sa source alpha (0-255)
+     * @param da destination alpha (0-255)
+     * @param a the resulting alpha (0-255), must not be 0
+     * @return the resulting channel value (0-255)
+     */
+    private static int composeChannel(Mode mode, int cs, int cb, int sa, int da, int a) {
+        int co = sa * (0xff - da) * cs + sa * da * blend(mode, cs, cb) + (0xff - sa) * da * cb;
+        return co / (0xff * a);
+    }
+
+    /** linear interpolation between d (alpha 0) and s (alpha 255) */
+    private static int mix(int s, int d, int alpha) {
+        return d + (s - d) * alpha / 0xff;
+    }
+
+    private static final class BlendContext implements CompositeContext {
+        private final Mode mode;
+        private final ColorModel srcColorModel;
+        private final ColorModel dstColorModel;
+
+        BlendContext(Mode mode, ColorModel srcColorModel, ColorModel dstColorModel) {
+            this.mode = mode;
+            this.srcColorModel = srcColorModel;
+            this.dstColorModel = dstColorModel;
+        }
+
+        @Override
+        public void compose(Raster src, Raster dstIn, WritableRaster dstOut) {
+            int w = Math.min(Math.min(src.getWidth(), dstIn.getWidth()), dstOut.getWidth());
+            int h = Math.min(Math.min(src.getHeight(), dstIn.getHeight()), dstOut.getHeight());
+            Object srcPixel = null;
+            Object dstPixel = null;
+            Object outPixel = null;
+            for (int y = 0; y < h; y++) {
+                for (int x = 0; x < w; x++) {
+                    srcPixel = src.getDataElements(x, y, srcPixel);
+                    dstPixel = dstIn.getDataElements(x, y, dstPixel);
+                    int result = composePixel(mode, srcColorModel.getRGB(srcPixel), dstColorModel.getRGB(dstPixel));
+                    outPixel = dstColorModel.getDataElements(result, outPixel);
+                    dstOut.setDataElements(x, y, outPixel);
+                }
+            }
+        }
+
+        @Override
+        public void dispose() {
+            // nothing to dispose
+        }
+    }
+}
diff --git src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
index 510fad35d1..f5f96bca4c 100644
--- src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
+++ src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
@@ -4,6 +4,7 @@ package org.openstreetmap.josm.gui.layer;
 import static org.openstreetmap.josm.tools.I18n.marktr;
 import static org.openstreetmap.josm.tools.I18n.tr;
 
+import java.awt.BasicStroke;
 import java.awt.Color;
 import java.awt.Component;
 import java.awt.Dimension;
@@ -15,6 +16,7 @@ import java.awt.GridBagLayout;
 import java.awt.Image;
 import java.awt.Shape;
 import java.awt.Toolkit;
+import java.awt.Stroke;
 import java.awt.event.ActionEvent;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
@@ -93,6 +95,7 @@ import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;
 import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
+import org.openstreetmap.josm.data.preferences.NamedColorProperty;
 import org.openstreetmap.josm.data.projection.Projection;
 import org.openstreetmap.josm.data.projection.ProjectionRegistry;
 import org.openstreetmap.josm.data.projection.Projections;
@@ -116,6 +119,7 @@ import org.openstreetmap.josm.gui.layer.imagery.LoadErroneousTilesAction;
 import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
 import org.openstreetmap.josm.gui.layer.imagery.ReprojectionTile;
 import org.openstreetmap.josm.gui.layer.imagery.ShowErrorsAction;
+import org.openstreetmap.josm.gui.layer.imagery.ShowTileBordersAction;
 import org.openstreetmap.josm.gui.layer.imagery.TileAnchor;
 import org.openstreetmap.josm.gui.layer.imagery.TileCoordinateConverter;
 import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
@@ -181,6 +185,9 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
     public static final IntegerProperty ZOOM_OFFSET = new IntegerProperty(PREFERENCE_PREFIX + ".zoom_offset", 0);
 
     private static final BooleanProperty POPUP_MENU_ENABLED = new BooleanProperty(PREFERENCE_PREFIX + ".popupmenu", true);
+    /** Color of the border drawn around each tile if enabled, see {@link TileSourceDisplaySettings#isShowTileBorders()} */
+    private static final NamedColorProperty TILE_BORDER_COLOR = new NamedColorProperty(marktr("imagery tile border"), new Color(0, 0, 0, 96));
+    private static final Stroke TILE_BORDER_STROKE = new BasicStroke(1f);
     private static final String ERROR_STRING = marktr("Error");
 
     /*
@@ -1226,7 +1233,16 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
             //texty += 1 + fontHeight;
         }
 
-        if (Logging.isDebugEnabled()) {
+        if (getDisplaySettings().isShowTileBorders()) {
+            // draw a thin border around the tile
+            Color oldColor = g.getColor();
+            Stroke oldStroke = g.getStroke();
+            g.setColor(TILE_BORDER_COLOR.get());
+            g.setStroke(TILE_BORDER_STROKE);
+            g.draw(coordinateConverter.getTileShapeScreen(tile));
+            g.setStroke(oldStroke);
+            g.setColor(oldColor);
+        } else if (Logging.isDebugEnabled()) {
             // draw tile outline in semi-transparent red
             g.setColor(new Color(255, 0, 0, 50));
             g.draw(coordinateConverter.getTileShapeScreen(tile));
@@ -1853,6 +1869,7 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
             new AutoLoadTilesAction(this),
             new AutoZoomAction(this),
             new ShowErrorsAction(this),
+            new ShowTileBordersAction(this),
             new IncreaseZoomAction(this),
             new DecreaseZoomAction(this),
             new ZoomToBestAction(this),
diff --git src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java
new file mode 100644
index 0000000000..221c6041f9
--- /dev/null
+++ src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java
@@ -0,0 +1,412 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer;
+
+import static org.openstreetmap.josm.tools.I18n.marktr;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.geom.Line2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.Point2D;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.ProjectionBounds;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.ILatLon;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.preferences.BooleanProperty;
+import org.openstreetmap.josm.data.preferences.DoubleProperty;
+import org.openstreetmap.josm.data.preferences.EnumProperty;
+import org.openstreetmap.josm.data.preferences.NamedColorProperty;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.draw.BlendComposite;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
+import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
+import org.openstreetmap.josm.tools.Destroyable;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A grid drawn over the whole map view, on top of all layers.
+ * <p>
+ * The grid is a pure visual aid (there is no snapping to it). It is either aligned to latitude/longitude, with a
+ * spacing in degrees, or to the projected coordinates, with a spacing in metres (true distance, measured at the
+ * grid origin), an optional rotation and an origin offset. When the grid cells would become
+ * smaller than a minimal size on screen, the spacing is multiplied by 10 until the cells are large enough, so the
+ * grid stays readable at every zoom level while remaining aligned to the configured one.
+ * <p>
+ * All settings are preferences (prefix {@code draw.grid.}), see {@link org.openstreetmap.josm.gui.preferences.display.GridPreference}.
+ * <p>
+ * An instance registers itself as a preference listener, so {@link #destroy()} must be called when it is no
+ * longer used ({@link org.openstreetmap.josm.gui.MapFrame} does this), otherwise the listener is leaked.
+ * @since xxx
+ */
+public class MapGridPaintable extends AbstractMapViewPaintable implements PreferenceChangedListener, Destroyable {
+
+    /**
+     * The kind of coordinates a grid is aligned to.
+     */
+    public enum GridType {
+        /** lines of constant latitude and longitude, spacing in degrees */
+        LATLON,
+        /** lines of constant projected east/north coordinate (optionally rotated), spacing in metres */
+        PROJECTED
+    }
+
+    private static final String PREFIX = "draw.grid.";
+
+    /** Whether the grid is shown */
+    public static final BooleanProperty ENABLED = new BooleanProperty(PREFIX + "enabled", false);
+    /** The kind of grid */
+    public static final EnumProperty<GridType> TYPE = new EnumProperty<>(PREFIX + "type", GridType.class, GridType.PROJECTED);
+    /** Spacing of the vertical lines (longitude resp. east), in degrees resp. metres (true distance at the origin) */
+    public static final DoubleProperty SPACING_X = new DoubleProperty(PREFIX + "spacing-x", 1000);
+    /** Spacing of the horizontal lines (latitude resp. north), in degrees resp. metres (true distance at the origin) */
+    public static final DoubleProperty SPACING_Y = new DoubleProperty(PREFIX + "spacing-y", 1000);
+    /** Rotation of a projected grid, in degrees counter clockwise */
+    public static final DoubleProperty ROTATION = new DoubleProperty(PREFIX + "rotation", 0);
+    /** Origin of the grid: a grid line passes through this coordinate (longitude resp. east) */
+    public static final DoubleProperty ORIGIN_X = new DoubleProperty(PREFIX + "origin-x", 0);
+    /** Origin of the grid: a grid line passes through this coordinate (latitude resp. north) */
+    public static final DoubleProperty ORIGIN_Y = new DoubleProperty(PREFIX + "origin-y", 0);
+    /** Below this distance between lines (in pixels) the spacing is multiplied by 10 */
+    public static final DoubleProperty MIN_PIXEL_SPACING = new DoubleProperty(PREFIX + "min-pixel-spacing", 25);
+    /** The blend mode used to draw the lines */
+    public static final EnumProperty<BlendComposite.Mode> BLEND_MODE
+            = new EnumProperty<>(PREFIX + "blend-mode", BlendComposite.Mode.class, BlendComposite.Mode.HARD_LIGHT);
+    /** Width of the lines in pixels */
+    public static final DoubleProperty LINE_WIDTH = new DoubleProperty(PREFIX + "line-width", 2);
+    /** Color of the lines of constant east coordinate resp. longitude (the lines running north-south) */
+    public static final NamedColorProperty COLOR_EAST = new NamedColorProperty(marktr("grid east lines"), Color.YELLOW);
+    /** Color of the lines of constant north coordinate resp. latitude (the lines running east-west) */
+    public static final NamedColorProperty COLOR_NORTH = new NamedColorProperty(marktr("grid north lines"), Color.YELLOW);
+
+    /**
+     * A straight line of a projected grid.
+     * @since xxx
+     */
+    public static final class ProjectedGridLine {
+        /** start point */
+        public final EastNorth start;
+        /** end point */
+        public final EastNorth end;
+        /** {@code true} for a line of constant (rotated) east coordinate, {@code false} for constant north */
+        public final boolean constantEast;
+
+        ProjectedGridLine(EastNorth start, EastNorth end, boolean constantEast) {
+            this.start = start;
+            this.end = end;
+            this.constantEast = constantEast;
+        }
+
+        @Override
+        public String toString() {
+            return (constantEast ? "east " : "north ") + start + " -> " + end;
+        }
+    }
+
+    /**
+     * A (curved) line of a latitude/longitude grid.
+     * @since xxx
+     */
+    public static final class LatLonGridLine {
+        /** the points of the polyline */
+        public final List<LatLon> points;
+        /** {@code true} for a meridian (constant longitude), {@code false} for a parallel (constant latitude) */
+        public final boolean meridian;
+
+        LatLonGridLine(List<LatLon> points, boolean meridian) {
+            this.points = points;
+            this.meridian = meridian;
+        }
+
+        @Override
+        public String toString() {
+            return (meridian ? "meridian " : "parallel ") + points;
+        }
+    }
+
+    /** number of segments used to draw a curved (lat/lon) grid line across the view */
+    private static final int CURVE_SEGMENTS = 32;
+    /** hard limit for the number of lines in one direction, whatever the settings are */
+    private static final int MAX_LINES = 500;
+
+    /** written by {@link #paint} and read by {@link #preferenceChanged}, which may run on another thread */
+    private volatile MapView mapView;
+
+    /**
+     * Constructs a new {@code MapGridPaintable}.
+     */
+    public MapGridPaintable() {
+        Config.getPref().addPreferenceChangeListener(this);
+    }
+
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
+        mapView = mv;
+        if (!Boolean.TRUE.equals(ENABLED.get()) || mv.getWidth() <= 0 || mv.getHeight() <= 0) {
+            return;
+        }
+        Graphics2D g2 = (Graphics2D) g.create();
+        try {
+            g2.setStroke(new BasicStroke((float) Math.max(0.1, LINE_WIDTH.get())));
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                    Config.getPref().getBoolean("mappaint.use-antialiasing", true)
+                    ? RenderingHints.VALUE_ANTIALIAS_ON : RenderingHints.VALUE_ANTIALIAS_OFF);
+            BlendComposite.Mode mode = BLEND_MODE.get();
+            if (mode != null && mode != BlendComposite.Mode.NORMAL) {
+                g2.setComposite(BlendComposite.getInstance(mode));
+            }
+            if (TYPE.get() == GridType.PROJECTED) {
+                paintProjectedGrid(g2, mv);
+            } else {
+                paintLatLonGrid(g2, mv);
+            }
+        } finally {
+            g2.dispose();
+        }
+    }
+
+    private static void paintProjectedGrid(Graphics2D g, MapView mv) {
+        double sx = SPACING_X.get();
+        double sy = SPACING_Y.get();
+        if (sx <= 0 || sy <= 0) {
+            return;
+        }
+        // the spacing is a true distance in metres, measured at the grid origin
+        EastNorth origin = new EastNorth(ORIGIN_X.get(), ORIGIN_Y.get());
+        double unitsPerMetre = projectionUnitsPerMetre(mv.getProjection(), origin);
+        sx *= unitsPerMetre;
+        sy *= unitsPerMetre;
+        // thin out the grid until the cells are large enough on screen
+        double factor = thinningFactor(Math.min(sx, sy) / mv.getScale());
+        List<ProjectedGridLine> lines = getProjectedGridLines(mv.getProjectionBounds(), sx * factor, sy * factor,
+                ROTATION.get(), origin.east(), origin.north());
+        for (ProjectedGridLine line : lines) {
+            g.setColor(line.constantEast ? COLOR_EAST.get() : COLOR_NORTH.get());
+            g.draw(new Line2D.Double(mv.getPoint2D(line.start), mv.getPoint2D(line.end)));
+        }
+    }
+
+    private static void paintLatLonGrid(Graphics2D g, MapView mv) {
+        double sx = SPACING_X.get();
+        double sy = SPACING_Y.get();
+        if (sx <= 0 || sy <= 0) {
+            return;
+        }
+        Bounds view = mv.getRealBounds();
+        // pixel size of one cell at the center of the view
+        LatLon center = view.getCenter();
+        Point2D c = mv.getPoint2D(center);
+        Point2D cx = mv.getPoint2D(new LatLon(center.lat(), Math.min(180, center.lon() + sx)));
+        Point2D cy = mv.getPoint2D(new LatLon(Math.min(89, center.lat() + sy), center.lon()));
+        double factor = thinningFactor(Math.min(c.distance(cx), c.distance(cy)));
+        List<LatLonGridLine> lines = getLatLonGridLines(view, mv.getProjection().getWorldBoundsLatLon(),
+                sx * factor, sy * factor, ORIGIN_X.get(), ORIGIN_Y.get(), CURVE_SEGMENTS);
+        for (LatLonGridLine line : lines) {
+            g.setColor(line.meridian ? COLOR_EAST.get() : COLOR_NORTH.get());
+            Path2D.Double path = new Path2D.Double();
+            boolean first = true;
+            for (LatLon ll : line.points) {
+                Point2D p = mv.getPoint2D(ll);
+                if (first) {
+                    path.moveTo(p.getX(), p.getY());
+                    first = false;
+                } else {
+                    path.lineTo(p.getX(), p.getY());
+                }
+            }
+            g.draw(path);
+        }
+    }
+
+    /**
+     * Computes how many projection units correspond to one metre on the ground at the given position. For
+     * conformal projections (e.g. Mercator) this is the local scale factor, for a Mercator grid at 60° latitude
+     * one metre is two projection units.
+     * @param projection the projection
+     * @param at the position (projected coordinates)
+     * @return projection units per metre; 1 if it cannot be determined (position outside the world)
+     */
+    public static double projectionUnitsPerMetre(Projection projection, EastNorth at) {
+        try {
+            if (!projection.getWorldBoundsBoxEastNorth().contains(at)) {
+                return 1;
+            }
+            double step = 100;
+            LatLon a = projection.eastNorth2latlon(at);
+            LatLon b = projection.eastNorth2latlon(new EastNorth(at.east() + step, at.north()));
+            if (!a.isValid() || !b.isValid()) {
+                return 1;
+            }
+            double metres = a.greatCircleDistance((ILatLon) b);
+            return metres > 0 && Double.isFinite(metres) ? step / metres : 1;
+        } catch (IllegalArgumentException e) {
+            Logging.trace(e);
+            return 1;
+        }
+    }
+
+    /**
+     * Computes the factor (a power of 10) by which the spacing must be multiplied so that the lines are at least
+     * {@link #MIN_PIXEL_SPACING} apart.
+     * @param pixelSpacing the distance between two lines on screen, in pixels
+     * @return the factor (at least 1)
+     */
+    static double thinningFactor(double pixelSpacing) {
+        double min = Math.max(1, MIN_PIXEL_SPACING.get());
+        double factor = 1;
+        if (pixelSpacing <= 0 || Double.isNaN(pixelSpacing)) {
+            return factor;
+        }
+        while (pixelSpacing * factor < min && factor < 1e15) {
+            factor *= 10;
+        }
+        return factor;
+    }
+
+    /**
+     * Computes the lines of a (possibly rotated) grid in projected coordinates which cross the given area.
+     * @param area the area to cover
+     * @param spacingX distance between the lines running in the "north" direction of the grid (before rotation)
+     * @param spacingY distance between the lines running in the "east" direction of the grid (before rotation)
+     * @param rotationDegrees rotation of the grid, counter clockwise
+     * @param originX east coordinate of the grid origin
+     * @param originY north coordinate of the grid origin
+     * @return the lines; empty if the spacing is invalid or there would be too many lines
+     */
+    public static List<ProjectedGridLine> getProjectedGridLines(ProjectionBounds area, double spacingX, double spacingY,
+            double rotationDegrees, double originX, double originY) {
+        List<ProjectedGridLine> lines = new ArrayList<>();
+        if (!(spacingX > 0) || !(spacingY > 0)) {
+            return lines;
+        }
+        double angle = Math.toRadians(rotationDegrees);
+        double c = Math.cos(angle);
+        double s = Math.sin(angle);
+        // grid coordinates (u along the rotated east axis, v along the rotated north axis) of the area corners
+        double[] xs = {area.minEast, area.maxEast, area.maxEast, area.minEast};
+        double[] ys = {area.minNorth, area.minNorth, area.maxNorth, area.maxNorth};
+        double uMin = Double.POSITIVE_INFINITY;
+        double uMax = Double.NEGATIVE_INFINITY;
+        double vMin = Double.POSITIVE_INFINITY;
+        double vMax = Double.NEGATIVE_INFINITY;
+        for (int i = 0; i < 4; i++) {
+            double dx = xs[i] - originX;
+            double dy = ys[i] - originY;
+            double u = c * dx + s * dy;
+            double v = -s * dx + c * dy;
+            uMin = Math.min(uMin, u);
+            uMax = Math.max(uMax, u);
+            vMin = Math.min(vMin, v);
+            vMax = Math.max(vMax, v);
+        }
+        long kuMin = (long) Math.ceil(uMin / spacingX);
+        long kuMax = (long) Math.floor(uMax / spacingX);
+        long kvMin = (long) Math.ceil(vMin / spacingY);
+        long kvMax = (long) Math.floor(vMax / spacingY);
+        if (kuMax - kuMin > MAX_LINES || kvMax - kvMin > MAX_LINES) {
+            return lines;
+        }
+        // lines of constant u run along the v axis
+        for (long k = kuMin; k <= kuMax; k++) {
+            double u = k * spacingX;
+            lines.add(new ProjectedGridLine(gridToEastNorth(u, vMin, c, s, originX, originY),
+                    gridToEastNorth(u, vMax, c, s, originX, originY), true));
+        }
+        for (long k = kvMin; k <= kvMax; k++) {
+            double v = k * spacingY;
+            lines.add(new ProjectedGridLine(gridToEastNorth(uMin, v, c, s, originX, originY),
+                    gridToEastNorth(uMax, v, c, s, originX, originY), false));
+        }
+        return lines;
+    }
+
+    private static EastNorth gridToEastNorth(double u, double v, double c, double s, double originX, double originY) {
+        return new EastNorth(originX + c * u - s * v, originY + s * u + c * v);
+    }
+
+    /**
+     * Computes the lines of a latitude/longitude grid which cross the given area. Since these lines are curves in
+     * most projections, each line is returned as a polyline.
+     * @param area the area to cover
+     * @param world the bounds of the world in the current projection, the lines are clamped to it
+     * @param spacingLon distance between the meridians, in degrees
+     * @param spacingLat distance between the parallels, in degrees
+     * @param originLon longitude of a meridian of the grid
+     * @param originLat latitude of a parallel of the grid
+     * @param segments number of segments of each polyline
+     * @return the lines; empty if the spacing is invalid or there would be too many lines
+     */
+    public static List<LatLonGridLine> getLatLonGridLines(Bounds area, Bounds world, double spacingLon, double spacingLat,
+            double originLon, double originLat, int segments) {
+        List<LatLonGridLine> lines = new ArrayList<>();
+        if (!(spacingLon > 0) || !(spacingLat > 0) || segments < 1) {
+            return lines;
+        }
+        double minLat = Math.max(area.getMinLat(), world.getMinLat());
+        double maxLat = Math.min(area.getMaxLat(), world.getMaxLat());
+        double minLon = area.getMinLon();
+        double maxLon = area.getMaxLon();
+        if (maxLon < minLon) {
+            maxLon += 360; // the view crosses the antimeridian
+        }
+        if (minLat >= maxLat || minLon >= maxLon) {
+            return lines;
+        }
+        long kLonMin = (long) Math.ceil((minLon - originLon) / spacingLon);
+        long kLonMax = (long) Math.floor((maxLon - originLon) / spacingLon);
+        long kLatMin = (long) Math.ceil((minLat - originLat) / spacingLat);
+        long kLatMax = (long) Math.floor((maxLat - originLat) / spacingLat);
+        if (kLonMax - kLonMin > MAX_LINES || kLatMax - kLatMin > MAX_LINES) {
+            return lines;
+        }
+        // for a projection which does not span the whole globe, meridians outside its longitude range are
+        // skipped like the parallels below; a full range must not be checked since longitudes wrap around
+        boolean limitedLon = world.getMinLon() > -180 || world.getMaxLon() < 180;
+        for (long k = kLonMin; k <= kLonMax; k++) {
+            double lon = LatLon.toIntervalLon(originLon + k * spacingLon);
+            if (limitedLon && (lon < world.getMinLon() || lon > world.getMaxLon())) {
+                continue;
+            }
+            List<LatLon> line = new ArrayList<>(segments + 1);
+            for (int i = 0; i <= segments; i++) {
+                line.add(new LatLon(minLat + (maxLat - minLat) * i / segments, lon));
+            }
+            lines.add(new LatLonGridLine(line, true));
+        }
+        for (long k = kLatMin; k <= kLatMax; k++) {
+            double lat = originLat + k * spacingLat;
+            if (lat < world.getMinLat() || lat > world.getMaxLat()) {
+                continue;
+            }
+            List<LatLon> line = new ArrayList<>(segments + 1);
+            for (int i = 0; i <= segments; i++) {
+                line.add(new LatLon(lat, LatLon.toIntervalLon(minLon + (maxLon - minLon) * i / segments)));
+            }
+            lines.add(new LatLonGridLine(line, false));
+        }
+        return lines;
+    }
+
+    @Override
+    public void preferenceChanged(PreferenceChangeEvent e) {
+        if (e.getKey().startsWith(PREFIX) || e.getKey().equals(COLOR_EAST.getKey()) || e.getKey().equals(COLOR_NORTH.getKey())) {
+            invalidate();
+            if (mapView != null) {
+                mapView.repaint();
+            }
+        }
+    }
+
+    @Override
+    public void destroy() {
+        Config.getPref().removePreferenceChangeListener(this);
+        mapView = null;
+    }
+}
diff --git src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java
new file mode 100644
index 0000000000..849829d807
--- /dev/null
+++ src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java
@@ -0,0 +1,55 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Component;
+import java.awt.event.ActionEvent;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.JCheckBoxMenuItem;
+
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.gui.layer.Layer.LayerAction;
+
+/**
+ * Toggles the drawing of a thin border around each tile of an imagery layer.
+ * @since xxx
+ */
+public class ShowTileBordersAction extends AbstractAction implements LayerAction {
+
+    private final AbstractTileSourceLayer<?> layer;
+
+    /**
+     * Constructs a new {@code ShowTileBordersAction}.
+     * @param layer imagery layer
+     */
+    public ShowTileBordersAction(AbstractTileSourceLayer<?> layer) {
+        super(tr("Show tile borders"));
+        this.layer = layer;
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent ae) {
+        TileSourceDisplaySettings settings = layer.getDisplaySettings();
+        boolean show = !settings.isShowTileBorders();
+        settings.setShowTileBorders(show);
+        // remember the choice, so that it also applies to layers created later and after a restart. Only the
+        // action does this, not the setter, so that loading a session does not overwrite the preference.
+        TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.put(show);
+    }
+
+    @Override
+    public Component createMenuComponent() {
+        JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
+        item.setSelected(layer.getDisplaySettings().isShowTileBorders());
+        return item;
+    }
+
+    @Override
+    public boolean supportLayers(List<Layer> layers) {
+        return AbstractTileSourceLayer.actionSupportLayers(layers);
+    }
+}
diff --git src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
index 4496fb6027..13497a3ac6 100644
--- src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
+++ src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
@@ -45,6 +45,8 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
      */
     private static final String SHOW_ERRORS = "show-errors";
 
+    private static final String SHOW_TILE_BORDERS = "show-tile-borders";
+
     private static final String DISPLACEMENT = "displacement";
 
     private static final String PREFERENCE_PREFIX = "imagery.generic";
@@ -59,6 +61,14 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
      */
     public static final BooleanProperty PROP_AUTO_ZOOM = new BooleanProperty(PREFERENCE_PREFIX + ".default_autozoom", true);
 
+    /**
+     * The default tile borders property, remembered whenever the user toggles them, so that the choice also
+     * applies to layers created later and after a restart
+     * @since xxx
+     */
+    public static final BooleanProperty PROP_SHOW_TILE_BORDERS
+            = new BooleanProperty(PREFERENCE_PREFIX + ".default_showtileborders", false);
+
 
     /** if layers changes automatically, when user zooms in */
     private boolean autoZoom;
@@ -66,6 +76,8 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
     private boolean autoLoad;
     /** if layer should show errors on tiles */
     private boolean showErrors;
+    /** if layer should draw a border around each tile */
+    private boolean showTileBorders;
 
     private OffsetBookmark previousOffsetBookmark;
     private OffsetBookmark offsetBookmark;
@@ -97,15 +109,15 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
         autoZoom = getProperty(prefixes, "default_autozoom", PROP_AUTO_ZOOM.getDefaultValue());
         autoLoad = getProperty(prefixes, "default_autoload", PROP_AUTO_LOAD.getDefaultValue());
         showErrors = getProperty(prefixes, "default_showerrors", Boolean.TRUE);
+        showTileBorders = getProperty(prefixes, "default_showtileborders", PROP_SHOW_TILE_BORDERS.getDefaultValue());
     }
 
     private static boolean getProperty(String[] prefixes, String name, Boolean def) {
-        // iterate through all values to force the preferences to receive the default value.
-        // we only support a default value of true.
-        boolean value = true;
+        // iterate through all values to force the preferences to receive the default value
+        boolean value = def;
         for (String p : prefixes) {
             String key = p + "." + name;
-            boolean currentValue = Config.getPref().getBoolean(key, true);
+            boolean currentValue = Config.getPref().getBoolean(key, def);
             if (!Config.getPref().get(key, def.toString()).isEmpty()) {
                 value = currentValue;
             }
@@ -170,6 +182,26 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
         fireSettingsChange(SHOW_ERRORS);
     }
 
+    /**
+     * If the layer should draw a thin border around each tile.
+     * @return <code>true</code> to draw tile borders.
+     * @since xxx
+     */
+    public boolean isShowTileBorders() {
+        return showTileBorders;
+    }
+
+    /**
+     * Sets the show tile borders property. Fires a change event.
+     * @param showTileBorders {@code true} if the layer should draw a thin border around each tile
+     * @see #isShowTileBorders()
+     * @since xxx
+     */
+    public void setShowTileBorders(boolean showTileBorders) {
+        this.showTileBorders = showTileBorders;
+        fireSettingsChange(SHOW_TILE_BORDERS);
+    }
+
     /**
      * Gets the displacement in x (east) direction
      * @return The displacement.
@@ -290,6 +322,7 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
         data.put(AUTO_LOAD, Boolean.toString(autoLoad));
         data.put(AUTO_ZOOM, Boolean.toString(autoZoom));
         data.put(SHOW_ERRORS, Boolean.toString(showErrors));
+        data.put(SHOW_TILE_BORDERS, Boolean.toString(showTileBorders));
         return data;
     }
 
@@ -317,6 +350,11 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
             if (doShowErrors != null) {
                 setShowErrors(Boolean.parseBoolean(doShowErrors));
             }
+
+            String doShowTileBorders = data.get(SHOW_TILE_BORDERS);
+            if (doShowTileBorders != null) {
+                setShowTileBorders(Boolean.parseBoolean(doShowTileBorders));
+            }
         } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
             throw BugReport.intercept(e).put("data", data);
         }
@@ -324,7 +362,7 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
 
     @Override
     public int hashCode() {
-        return Objects.hash(autoLoad, autoZoom, showErrors);
+        return Objects.hash(autoLoad, autoZoom, showErrors, showTileBorders);
     }
 
     @Override
@@ -336,13 +374,14 @@ public class TileSourceDisplaySettings implements SessionAwareReadApply {
         TileSourceDisplaySettings other = (TileSourceDisplaySettings) obj;
         return autoLoad == other.autoLoad
             && autoZoom == other.autoZoom
-            && showErrors == other.showErrors;
+            && showErrors == other.showErrors
+            && showTileBorders == other.showTileBorders;
     }
 
     @Override
     public String toString() {
         return "TileSourceDisplaySettings [autoZoom=" + autoZoom + ", autoLoad=" + autoLoad + ", showErrors="
-                + showErrors + ']';
+                + showErrors + ", showTileBorders=" + showTileBorders + ']';
     }
 
     /**
diff --git src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java
index 61b8c82993..d1d8431b26 100644
--- src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java
+++ src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java
@@ -51,6 +51,7 @@ import org.openstreetmap.josm.gui.preferences.display.ColorPreference;
 import org.openstreetmap.josm.gui.preferences.display.DisplayPreference;
 import org.openstreetmap.josm.gui.preferences.display.DrawingPreference;
 import org.openstreetmap.josm.gui.preferences.display.GPXPreference;
+import org.openstreetmap.josm.gui.preferences.display.GridPreference;
 import org.openstreetmap.josm.gui.preferences.display.LafPreference;
 import org.openstreetmap.josm.gui.preferences.display.LanguagePreference;
 import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
@@ -610,6 +611,7 @@ public final class PreferenceTabbedPane extends JTabbedPane implements ExpertMod
         SETTINGS_FACTORIES.add(new ServerAccessPreference.Factory());
         SETTINGS_FACTORIES.add(new ProxyPreference.Factory());
         SETTINGS_FACTORIES.add(new ProjectionPreference.Factory());
+        SETTINGS_FACTORIES.add(new GridPreference.Factory());
         SETTINGS_FACTORIES.add(new MapPaintPreference.Factory());
         SETTINGS_FACTORIES.add(new TaggingPresetPreference.Factory());
         SETTINGS_FACTORIES.add(new BackupPreference.Factory());
diff --git src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java
new file mode 100644
index 0000000000..f2bc5198f3
--- /dev/null
+++ src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java
@@ -0,0 +1,216 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.display;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Component;
+import java.awt.GridBagLayout;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.ParseException;
+import java.util.Locale;
+import java.util.function.Function;
+
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+
+import org.openstreetmap.josm.data.preferences.DoubleProperty;
+import org.openstreetmap.josm.gui.draw.BlendComposite;
+import org.openstreetmap.josm.gui.help.HelpUtil;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
+import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
+import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
+import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.gui.widgets.JosmTextField;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Settings of the grid drawn over the map, see {@link MapGridPaintable}.
+ * @since xxx
+ */
+public class GridPreference extends DefaultTabPreferenceSetting {
+
+    /**
+     * Factory used to create a new {@code GridPreference}.
+     */
+    public static class Factory implements PreferenceSettingFactory {
+        @Override
+        public PreferenceSetting createPreferenceSetting() {
+            return new GridPreference();
+        }
+    }
+
+    private static final DecimalFormat FORMAT = new DecimalFormat("0.#########", DecimalFormatSymbols.getInstance(Locale.ROOT));
+
+    private final JCheckBox enabled = new JCheckBox(tr("Show grid"));
+    private final JosmComboBox<GridType> type = new JosmComboBox<>(GridType.values());
+    private final JosmTextField spacingX = new JosmTextField(10);
+    private final JosmTextField spacingY = new JosmTextField(10);
+    private final JosmTextField rotation = new JosmTextField(10);
+    private final JosmTextField originX = new JosmTextField(10);
+    private final JosmTextField originY = new JosmTextField(10);
+    private final JosmTextField minPixelSpacing = new JosmTextField(10);
+    private final JosmTextField lineWidth = new JosmTextField(10);
+    private final JosmComboBox<BlendComposite.Mode> blendMode = new JosmComboBox<>(BlendComposite.Mode.values());
+    private final JLabel spacingXLabel = new JLabel();
+    private final JLabel spacingYLabel = new JLabel();
+    private final JLabel originXLabel = new JLabel();
+    private final JLabel originYLabel = new JLabel();
+
+    GridPreference() {
+        super("preferences/grid", tr("Grid"), tr("Settings of the grid drawn over the map."));
+    }
+
+    @Override
+    public void addGui(PreferenceTabbedPane gui) {
+        JPanel panel = new JPanel(new GridBagLayout());
+
+        enabled.setSelected(Boolean.TRUE.equals(MapGridPaintable.ENABLED.get()));
+        enabled.setToolTipText(tr("Draw a grid over the map (View menu: Show grid). "
+                + "The grid is a visual aid only, there is no snapping to it."));
+        type.setSelectedItem(MapGridPaintable.TYPE.get());
+        type.setRenderer(new TranslatedRenderer<>(t -> t == GridType.PROJECTED ? tr("Projected coordinates") : tr("Latitude/longitude")));
+        type.setToolTipText("<html>" + tr("Latitude/longitude: lines of constant latitude and longitude, spacing in degrees.<br>"
+                + "Projected: lines of constant east/north coordinate of the map projection, "
+                + "spacing in metres, optionally rotated.") + "</html>");
+        type.addActionListener(e -> updateLabels());
+        set(spacingX, MapGridPaintable.SPACING_X);
+        set(spacingY, MapGridPaintable.SPACING_Y);
+        set(rotation, MapGridPaintable.ROTATION);
+        rotation.setToolTipText(tr("Rotation of a projected grid in degrees, counter clockwise. Ignored for a latitude/longitude grid."));
+        set(originX, MapGridPaintable.ORIGIN_X);
+        set(originY, MapGridPaintable.ORIGIN_Y);
+        set(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING);
+        set(lineWidth, MapGridPaintable.LINE_WIDTH);
+        lineWidth.setToolTipText(tr("Width of the grid lines in pixels. The colors of the east and north lines are set in the Colors tab."));
+        minPixelSpacing.setToolTipText(tr("When the grid lines get closer than this on screen, the spacing is multiplied by 10 "
+                + "so that the grid stays readable when zooming out."));
+        blendMode.setSelectedItem(MapGridPaintable.BLEND_MODE.get());
+        blendMode.setRenderer(new TranslatedRenderer<>(GridPreference::blendModeName));
+        blendMode.setToolTipText(tr("How the lines are combined with the map: normal transparency, multiply (darkens), "
+                + "burn, hard light, difference (visible on any background) or divide. The colors are set in the Colors tab."));
+        updateLabels();
+
+        panel.add(enabled, GBC.eol().insets(0, 0, 0, 10));
+        panel.add(new JLabel(tr("Grid type")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(type, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
+        panel.add(spacingXLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(spacingX, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(spacingYLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(spacingY, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Rotation (degrees)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(rotation, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(originXLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(originX, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(originYLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(originY, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Minimum line distance on screen (pixels)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(minPixelSpacing, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Line width (pixels)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(lineWidth, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Blend mode")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(blendMode, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
+        panel.add(GBC.glue(0, 0), GBC.eol().fill(GBC.BOTH));
+
+        createPreferenceTabWithScrollPane(gui, panel);
+    }
+
+    private static String blendModeName(BlendComposite.Mode mode) {
+        switch (mode) {
+        case MULTIPLY:
+            return tr("Multiply");
+        case BURN:
+            return tr("Burn");
+        case HARD_LIGHT:
+            return tr("Hard light");
+        case DIFFERENCE:
+            return tr("Difference");
+        case DIVIDE:
+            return tr("Divide");
+        case NORMAL:
+        default:
+            return tr("Normal");
+        }
+    }
+
+    /** Renders enum values with a translated name */
+    private static final class TranslatedRenderer<T> extends DefaultListCellRenderer {
+        private final Function<T, String> name;
+
+        TranslatedRenderer(Function<T, String> name) {
+            this.name = name;
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
+            String text = value == null ? "" : name.apply((T) value);
+            return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus);
+        }
+    }
+
+    private void updateLabels() {
+        boolean latlon = type.getSelectedItem() != GridType.PROJECTED;
+        spacingXLabel.setText(latlon ? tr("Longitude spacing (degrees)") : tr("East spacing (metres)"));
+        spacingYLabel.setText(latlon ? tr("Latitude spacing (degrees)") : tr("North spacing (metres)"));
+        String spacingTip = latlon
+                ? tr("Distance between the grid lines in degrees.")
+                : tr("Distance between the grid lines as a true distance in metres (as measured by the parallel way tool "
+                        + "and the status bar), measured at the grid origin. Place the origin in the area of interest.");
+        spacingX.setToolTipText(spacingTip);
+        spacingY.setToolTipText(spacingTip);
+        originXLabel.setText(latlon ? tr("Origin longitude") : tr("Origin east"));
+        originYLabel.setText(latlon ? tr("Origin latitude") : tr("Origin north"));
+        rotation.setEnabled(!latlon);
+    }
+
+    private static void set(JosmTextField field, DoubleProperty property) {
+        field.setText(FORMAT.format(property.get()));
+    }
+
+    private static void save(JosmTextField field, DoubleProperty property, boolean positive) {
+        try {
+            double value = FORMAT.parse(field.getText().trim()).doubleValue();
+            if (positive && !(value > 0)) {
+                Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
+                return;
+            }
+            property.put(value);
+        } catch (ParseException e) {
+            Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
+            Logging.trace(e);
+        }
+    }
+
+    @Override
+    public boolean ok() {
+        MapGridPaintable.ENABLED.put(enabled.isSelected());
+        MapGridPaintable.TYPE.put((GridType) type.getSelectedItem());
+        save(spacingX, MapGridPaintable.SPACING_X, true);
+        save(spacingY, MapGridPaintable.SPACING_Y, true);
+        save(rotation, MapGridPaintable.ROTATION, false);
+        save(originX, MapGridPaintable.ORIGIN_X, false);
+        save(originY, MapGridPaintable.ORIGIN_Y, false);
+        save(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING, true);
+        save(lineWidth, MapGridPaintable.LINE_WIDTH, true);
+        MapGridPaintable.BLEND_MODE.put((BlendComposite.Mode) blendMode.getSelectedItem());
+        return false;
+    }
+
+    @Override
+    public boolean isExpert() {
+        return false;
+    }
+
+    @Override
+    public String getHelpContext() {
+        return HelpUtil.ht("/Preferences/Grid");
+    }
+}
diff --git test/unit/org/openstreetmap/josm/actions/GridActionsTest.java test/unit/org/openstreetmap/josm/actions/GridActionsTest.java
new file mode 100644
index 0000000000..37e9d667c6
--- /dev/null
+++ test/unit/org/openstreetmap/josm/actions/GridActionsTest.java
@@ -0,0 +1,196 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.event.ActionEvent;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.Relation;
+import org.openstreetmap.josm.data.osm.RelationMember;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.gui.layer.OsmDataLayer;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.testutils.annotations.Main;
+import org.openstreetmap.josm.testutils.annotations.Projection;
+
+/**
+ * Unit tests of {@link SetGridOriginAction} and {@link AlignGridRotationAction}.
+ */
+@Main
+@Projection
+class GridActionsTest {
+
+    @AfterEach
+    void reset() {
+        for (String key : new String[] {"enabled", "type", "rotation", "origin-x", "origin-y"}) {
+            Config.getPref().put("draw.grid." + key, null);
+        }
+    }
+
+    /**
+     * The origin is the position of a single selected node, stored in the coordinates of the grid type, and the
+     * grid gets enabled. Without a selection the action is disabled.
+     */
+    @Test
+    void testSetOriginSingleNode() {
+        DataSet ds = new DataSet();
+        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
+        MainApplication.getLayerManager().addLayer(layer);
+        try {
+            Node n = new Node(new LatLon(50, 10));
+            ds.addPrimitive(n);
+
+            assertFalse(new SetGridOriginAction().isEnabled());
+
+            ds.setSelected(n);
+            EastNorth en = n.getEastNorth();
+            MapGridPaintable.TYPE.put(GridType.LATLON);
+            SetGridOriginAction action = new SetGridOriginAction();
+            assertTrue(action.isEnabled());
+            action.actionPerformed(new ActionEvent(this, 0, ""));
+            assertEquals(10, MapGridPaintable.ORIGIN_X.get(), 1e-7);
+            assertEquals(50, MapGridPaintable.ORIGIN_Y.get(), 1e-7);
+            assertTrue(MapGridPaintable.ENABLED.get());
+
+            MapGridPaintable.TYPE.put(GridType.PROJECTED);
+            action.actionPerformed(new ActionEvent(this, 0, ""));
+            assertEquals(en.east(), MapGridPaintable.ORIGIN_X.get(), 1e-6);
+            assertEquals(en.north(), MapGridPaintable.ORIGIN_Y.get(), 1e-6);
+        } finally {
+            MainApplication.getLayerManager().removeLayer(layer);
+        }
+    }
+
+    /**
+     * With more than one node reachable from the selection, the origin is the arithmetic mean of their positions:
+     * the centroid of two nodes of a way is their midpoint, not weighted by any polygon area.
+     */
+    @Test
+    void testSetOriginCentroid() {
+        DataSet ds = new DataSet();
+        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
+        MainApplication.getLayerManager().addLayer(layer);
+        try {
+            Node a = new Node(new EastNorth(0, 0));
+            Node b = new Node(new EastNorth(100, 0));
+            Way w = new Way();
+            w.addNode(a);
+            w.addNode(b);
+            ds.addPrimitive(a);
+            ds.addPrimitive(b);
+            ds.addPrimitive(w);
+
+            ds.setSelected(w);
+            MapGridPaintable.TYPE.put(GridType.PROJECTED);
+            SetGridOriginAction action = new SetGridOriginAction();
+            assertTrue(action.isEnabled());
+            action.actionPerformed(new ActionEvent(this, 0, ""));
+            assertEquals(50, MapGridPaintable.ORIGIN_X.get(), 1e-6);
+            assertEquals(0, MapGridPaintable.ORIGIN_Y.get(), 1e-6);
+
+            // a relation contributes its node members
+            Node c = new Node(new EastNorth(0, 100));
+            ds.addPrimitive(c);
+            Relation r = new Relation();
+            r.addMember(new RelationMember("", c));
+            ds.addPrimitive(r);
+            ds.setSelected(r);
+            action = new SetGridOriginAction();
+            assertTrue(action.isEnabled());
+            action.actionPerformed(new ActionEvent(this, 0, ""));
+            assertEquals(0, MapGridPaintable.ORIGIN_X.get(), 1e-6);
+            assertEquals(100, MapGridPaintable.ORIGIN_Y.get(), 1e-6);
+
+            ds.setSelected();
+            assertNull(SetGridOriginAction.getCentroid(ds));
+            assertFalse(new SetGridOriginAction().isEnabled());
+        } finally {
+            MainApplication.getLayerManager().removeLayer(layer);
+        }
+    }
+
+    /**
+     * The rotation is reduced to [0, 90) and is the same for all four directions of a square grid
+     */
+    @Test
+    void testRotationOf() {
+        EastNorth o = new EastNorth(0, 0);
+        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(10, 0)), 1e-9);
+        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(0, 10)), 1e-9);
+        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(-10, 0)), 1e-9);
+        assertEquals(45, AlignGridRotationAction.rotationOf(o, new EastNorth(10, 10)), 1e-9);
+        assertEquals(45, AlignGridRotationAction.rotationOf(o, new EastNorth(-10, 10)), 1e-9);
+        double c30 = Math.cos(Math.toRadians(30));
+        double s30 = Math.sin(Math.toRadians(30));
+        assertEquals(30, AlignGridRotationAction.rotationOf(o, new EastNorth(c30, s30)), 1e-9);
+        assertEquals(30, AlignGridRotationAction.rotationOf(o, new EastNorth(-c30, -s30)), 1e-9);
+        // a road at compass heading 115 degrees is a grid rotated by 65 degrees
+        double heading = Math.toRadians(115);
+        assertEquals(65, AlignGridRotationAction.rotationOf(o, new EastNorth(Math.sin(heading), Math.cos(heading))), 1e-9);
+    }
+
+    /**
+     * The direction comes from a single selected way or two selected nodes; anything else disables the action.
+     * The action reacts live to selection changes, since it now lives in a persistent menu.
+     */
+    @Test
+    void testAlignToSelection() {
+        DataSet ds = new DataSet();
+        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
+        MainApplication.getLayerManager().addLayer(layer);
+        try {
+            Node a = new Node(new LatLon(50, 10));
+            Node b = new Node(new LatLon(50.01, 10.01));
+            Node c = new Node(new LatLon(50.02, 10));
+            Way w = new Way();
+            w.addNode(a);
+            w.addNode(c);
+            w.addNode(b);
+            ds.addPrimitive(a);
+            ds.addPrimitive(b);
+            ds.addPrimitive(c);
+            ds.addPrimitive(w);
+
+            ds.setSelected();
+            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
+            AlignGridRotationAction action = new AlignGridRotationAction();
+            assertFalse(action.isEnabled());
+            ds.setSelected(a);
+            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
+            assertFalse(action.isEnabled());
+            ds.setSelected(a, b, c);
+            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
+            assertFalse(action.isEnabled());
+
+            ds.setSelected(a, b);
+            assertTrue(action.isEnabled());
+            MapGridPaintable.TYPE.put(GridType.LATLON);
+            action.actionPerformed(new ActionEvent(this, 0, ""));
+            double expected = AlignGridRotationAction.rotationOf(a.getEastNorth(), b.getEastNorth());
+            assertEquals(expected, MapGridPaintable.ROTATION.get(), 1e-9);
+            assertEquals(GridType.PROJECTED, MapGridPaintable.TYPE.get());
+            assertTrue(MapGridPaintable.ENABLED.get());
+
+            // the way: first to last node (a to b), not the first segment
+            ds.setSelected(w);
+            assertTrue(action.isEnabled());
+            EastNorth[] dir = AlignGridRotationAction.getSelectedDirection(ds);
+            assertEquals(a.getEastNorth(), dir[0]);
+            assertEquals(b.getEastNorth(), dir[1]);
+        } finally {
+            MainApplication.getLayerManager().removeLayer(layer);
+        }
+    }
+}
diff --git test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java
new file mode 100644
index 0000000000..9498b08f82
--- /dev/null
+++ test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java
@@ -0,0 +1,120 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.draw;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.gui.draw.BlendComposite.Mode;
+
+/**
+ * Unit tests of {@link BlendComposite}.
+ */
+class BlendCompositeTest {
+
+    private static int paint(int imageType, Color background, Color paint, Mode mode) {
+        BufferedImage img = new BufferedImage(4, 4, imageType);
+        Graphics2D g = img.createGraphics();
+        g.setColor(background);
+        g.fillRect(0, 0, 4, 4);
+        g.setComposite(BlendComposite.getInstance(mode));
+        g.setColor(paint);
+        g.fillRect(1, 1, 2, 2);
+        g.dispose();
+        // the untouched pixel keeps the background
+        assertEquals(background.getRGB() & 0xffffff, img.getRGB(0, 0) & 0xffffff);
+        return img.getRGB(1, 1) & 0xffffff;
+    }
+
+    /**
+     * Blend modes on an opaque source, on both an image without and with alpha channel.
+     * @param imageType image type
+     */
+    @ParameterizedTest
+    @ValueSource(ints = {BufferedImage.TYPE_3BYTE_BGR, BufferedImage.TYPE_INT_RGB, BufferedImage.TYPE_INT_ARGB})
+    void testModes(int imageType) {
+        Color bg = new Color(200, 100, 50);
+        Color fg = new Color(128, 255, 0);
+        assertEquals(0x80ff00, paint(imageType, bg, fg, Mode.NORMAL));
+        assertEquals(new Color(200 * 128 / 255, 100, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.MULTIPLY));
+        assertEquals(new Color(146, 100, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.BURN));
+        assertEquals(new Color(201, 255, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.HARD_LIGHT));
+        assertEquals(new Color(72, 155, 50).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.DIFFERENCE));
+        assertEquals(new Color(255, 100, 255).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.DIVIDE));
+    }
+
+    /**
+     * A translucent source only partially applies the blended color.
+     */
+    @Test
+    void testAlpha() {
+        Color bg = new Color(200, 200, 200);
+        // half transparent black, multiply: 200 -> 0 at full alpha -> 100 at half alpha
+        int rgb = paint(BufferedImage.TYPE_INT_RGB, bg, new Color(0, 0, 0, 128), Mode.MULTIPLY);
+        assertEquals(100, (rgb >> 16) & 0xff, 1);
+        assertEquals(100, rgb & 0xff, 1);
+        // fully transparent: nothing changes
+        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, new Color(0, 0, 0, 0), Mode.DIFFERENCE));
+    }
+
+    /**
+     * White is neutral for multiply, burn and divide; black is neutral for difference
+     */
+    @Test
+    void testNeutralColors() {
+        Color bg = new Color(12, 34, 56);
+        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.MULTIPLY));
+        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.BURN));
+        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.DIVIDE));
+        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.BLACK, Mode.DIFFERENCE));
+        // black burns everything to black, white hard light gives white
+        assertEquals(0, paint(BufferedImage.TYPE_INT_RGB, bg, Color.BLACK, Mode.BURN));
+        assertEquals(0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.HARD_LIGHT));
+    }
+
+    /**
+     * A translucent destination is composed with the "source over" rule: the result alpha is
+     * {@code as + ab*(1-as)} and the blend only applies where the backdrop is actually present.
+     */
+    @Test
+    void testTranslucentDestination() {
+        // opaque source over a half transparent destination: the source wins, the result is opaque
+        int argb = BlendComposite.composePixel(Mode.MULTIPLY, 0xff808080, 0x80ffffff);
+        assertEquals(0xff, argb >>> 24);
+        assertEquals(0x80, (argb >> 16) & 0xff, 1);
+
+        // half transparent source over a fully transparent destination: the source shows unblended
+        argb = BlendComposite.composePixel(Mode.MULTIPLY, 0x80123456, 0x00000000);
+        assertEquals(0x80, argb >>> 24);
+        assertEquals(0x12, (argb >> 16) & 0xff, 1);
+        assertEquals(0x34, (argb >> 8) & 0xff, 1);
+        assertEquals(0x56, argb & 0xff, 1);
+
+        // half transparent source over a half transparent destination: alpha is 128 + 128*(1-128/255)
+        argb = BlendComposite.composePixel(Mode.NORMAL, 0x80ffffff, 0x80000000);
+        assertEquals(128 + 128 * (255 - 128) / 255, argb >>> 24);
+
+        // a transparent source never changes the destination, whatever the mode
+        for (Mode mode : Mode.values()) {
+            assertEquals(0x8012ab34, BlendComposite.composePixel(mode, 0x00ffffff, 0x8012ab34), mode::toString);
+        }
+
+        // an opaque destination keeps its alpha and is only moved towards the blended color
+        assertEquals(0xff000000, BlendComposite.composePixel(Mode.MULTIPLY, 0xff000000, 0xffffffff));
+    }
+
+    /**
+     * Instances are shared per mode
+     */
+    @Test
+    void testInstances() {
+        assertSame(BlendComposite.getInstance(Mode.MULTIPLY), BlendComposite.getInstance(Mode.MULTIPLY));
+        assertEquals(Mode.DIVIDE, BlendComposite.getInstance(Mode.DIVIDE).getMode());
+    }
+}
diff --git test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java
new file mode 100644
index 0000000000..dc99e73d39
--- /dev/null
+++ test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java
@@ -0,0 +1,280 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.Color;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.image.BufferedImage;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.ProjectionBounds;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.draw.BlendComposite;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.LatLonGridLine;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.ProjectedGridLine;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.testutils.annotations.Main;
+import org.openstreetmap.josm.testutils.annotations.Projection;
+
+/**
+ * Unit tests of {@link MapGridPaintable}.
+ */
+@Main
+@Projection
+class MapGridPaintableTest {
+
+    private static long count(List<ProjectedGridLine> lines, boolean vertical) {
+        return lines.stream().filter(l -> vertical == (Math.abs(l.start.east() - l.end.east()) < 1e-9))
+                .peek(l -> assertEquals(vertical, l.constantEast, "wrong direction flag: " + l)).count();
+    }
+
+    /**
+     * An axis aligned grid covers the area with lines at multiples of the spacing.
+     */
+    @Test
+    void testProjectedGridAxisAligned() {
+        ProjectionBounds area = new ProjectionBounds(new EastNorth(-250, -120), new EastNorth(1010, 380));
+        List<ProjectedGridLine> lines = MapGridPaintable.getProjectedGridLines(area, 100, 50, 0, 0, 0);
+        // vertical lines at -200 .. 1000 (13), horizontal at -100 .. 350 (10)
+        assertEquals(13, count(lines, true));
+        assertEquals(10, count(lines, false));
+        assertEquals(23, lines.size());
+        for (ProjectedGridLine line : lines) {
+            if (line.constantEast) {
+                assertEquals(0, line.start.east() % 100, 1e-9, "vertical line not on the grid: " + line);
+                assertEquals(area.minNorth, Math.min(line.start.north(), line.end.north()), 1e-9);
+                assertEquals(area.maxNorth, Math.max(line.start.north(), line.end.north()), 1e-9);
+            } else {
+                assertEquals(0, line.start.north() % 50, 1e-9, "horizontal line not on the grid: " + line);
+                assertEquals(area.minEast, Math.min(line.start.east(), line.end.east()), 1e-9);
+                assertEquals(area.maxEast, Math.max(line.start.east(), line.end.east()), 1e-9);
+            }
+        }
+    }
+
+    /**
+     * The origin shifts the grid, the rotation turns it around the origin.
+     */
+    @Test
+    void testProjectedGridOriginAndRotation() {
+        ProjectionBounds area = new ProjectionBounds(new EastNorth(0, 0), new EastNorth(100, 100));
+        List<ProjectedGridLine> lines = MapGridPaintable.getProjectedGridLines(area, 30, 30, 0, 5, 10);
+        // vertical lines at 5, 35, 65, 95; horizontal at 10, 40, 70, 100
+        assertEquals(4, count(lines, true));
+        assertEquals(4, count(lines, false));
+        assertTrue(lines.stream().anyMatch(l -> Math.abs(l.start.east() - 95) < 1e-9 && Math.abs(l.end.east() - 95) < 1e-9));
+        assertTrue(lines.stream().anyMatch(l -> Math.abs(l.start.north() - 100) < 1e-9 && Math.abs(l.end.north() - 100) < 1e-9));
+
+        lines = MapGridPaintable.getProjectedGridLines(area, 30, 30, 45, 0, 0);
+        assertTrue(lines.size() > 4, lines.toString());
+        for (ProjectedGridLine line : lines) {
+            double dx = line.end.east() - line.start.east();
+            double dy = line.end.north() - line.start.north();
+            // every line runs at +45° or -45°
+            assertEquals(Math.abs(dx), Math.abs(dy), 1e-6, "line not rotated by 45°: " + line);
+            // the lines pass through the grid points (u, v) = (k*30, m*30) rotated by 45°: check the distance of the
+            // origin to the line is a multiple of 30
+            double len = Math.hypot(dx, dy);
+            double dist = Math.abs(dx * (0 - line.start.north()) - dy * (0 - line.start.east())) / len;
+            assertEquals(0, dist % 30 < 1e-6 ? 0 : Math.abs(dist % 30 - 30), 1e-6, "line not on the rotated grid: " + dist);
+        }
+    }
+
+    /**
+     * Invalid spacings and huge line counts yield nothing.
+     */
+    @Test
+    void testProjectedGridLimits() {
+        ProjectionBounds area = new ProjectionBounds(new EastNorth(0, 0), new EastNorth(100, 100));
+        assertTrue(MapGridPaintable.getProjectedGridLines(area, 0, 10, 0, 0, 0).isEmpty());
+        assertTrue(MapGridPaintable.getProjectedGridLines(area, 10, -1, 0, 0, 0).isEmpty());
+        assertTrue(MapGridPaintable.getProjectedGridLines(area, 0.01, 0.01, 0, 0, 0).isEmpty());
+    }
+
+    /**
+     * Latitude/longitude lines: multiples of the spacing (shifted by the origin), clamped to the world, curved
+     * lines with the requested number of segments.
+     */
+    @Test
+    void testLatLonGrid() {
+        Bounds world = new Bounds(-85, -180, 85, 180);
+        Bounds area = new Bounds(50.2, 7.9, 52.1, 10.3);
+        List<LatLonGridLine> gridLines = MapGridPaintable.getLatLonGridLines(area, world, 1, 0.5, 0, 0, 4);
+        // meridians 8, 9, 10; parallels 50.5, 51, 51.5, 52
+        assertEquals(7, gridLines.size());
+        assertEquals(3, gridLines.stream().filter(l -> l.meridian).count());
+        for (LatLonGridLine gridLine : gridLines) {
+            List<LatLon> line = gridLine.points;
+            assertEquals(5, line.size());
+            assertEquals(gridLine.meridian, line.get(0).lon() == line.get(4).lon());
+            if (gridLine.meridian) {
+                assertEquals(0, line.get(0).lon() % 1, 1e-9);
+                assertEquals(50.2, line.get(0).lat(), 1e-9);
+                assertEquals(52.1, line.get(4).lat(), 1e-9);
+            } else {
+                assertEquals(0, line.get(0).lat() % 0.5, 1e-9);
+                assertEquals(7.9, line.get(0).lon(), 1e-9);
+                assertEquals(10.3, line.get(4).lon(), 1e-9);
+            }
+        }
+        // origin offset
+        List<List<LatLon>> lines = points(MapGridPaintable.getLatLonGridLines(area, world, 1, 1, 0.5, 0.25, 1));
+        assertTrue(lines.stream().anyMatch(l -> l.get(0).lon() == 8.5 && l.get(1).lon() == 8.5), lines.toString());
+        assertTrue(lines.stream().anyMatch(l -> l.get(0).lat() == 51.25 && l.get(1).lat() == 51.25), lines.toString());
+
+        // clamped to the world bounds: no parallel at 90
+        lines = points(MapGridPaintable.getLatLonGridLines(new Bounds(80, 0, 89.9, 10), world, 10, 10, 0, 0, 1));
+        assertTrue(lines.stream().noneMatch(l -> l.get(0).lat() > 85), lines.toString());
+        assertTrue(lines.stream().anyMatch(l -> l.get(0).lat() == 80 && l.get(1).lat() == 80), lines.toString());
+
+        // across the antimeridian: meridians 170 .. 180, -170; longitudes stay in [-180, 180]
+        gridLines = MapGridPaintable.getLatLonGridLines(new Bounds(0, 165, 10, -165), world, 10, 90, 0, 0, 1);
+        assertEquals(3, gridLines.stream().filter(l -> l.meridian).count(), gridLines.toString());
+        assertTrue(gridLines.stream().allMatch(l -> l.points.stream().allMatch(ll -> ll.lon() >= -180 && ll.lon() <= 180)));
+    }
+
+    /**
+     * A projection which does not span the whole globe clamps the meridians to its longitude range, the same way
+     * the parallels are clamped to its latitude range.
+     */
+    @Test
+    void testLatLonGridLimitedWorld() {
+        // a projection valid for 6 degrees of longitude only, e.g. a UTM zone
+        Bounds world = new Bounds(0, 6, 84, 12);
+        Bounds area = new Bounds(45, 2, 50, 16);
+        List<LatLonGridLine> lines = MapGridPaintable.getLatLonGridLines(area, world, 2, 2, 0, 0, 1);
+        assertTrue(lines.stream().filter(l -> l.meridian).findAny().isPresent(), lines.toString());
+        assertTrue(lines.stream().filter(l -> l.meridian)
+                .allMatch(l -> l.points.get(0).lon() >= 6 && l.points.get(0).lon() <= 12), lines.toString());
+        // meridians 6, 8, 10, 12 are inside the projection, 2, 4, 14, 16 are not
+        assertEquals(4, lines.stream().filter(l -> l.meridian).count(), lines.toString());
+
+        // a world spanning all longitudes keeps every meridian, including across the antimeridian
+        Bounds whole = new Bounds(-85, -180, 85, 180);
+        assertEquals(8, MapGridPaintable.getLatLonGridLines(area, whole, 2, 2, 0, 0, 1)
+                .stream().filter(l -> l.meridian).count());
+    }
+
+    private static List<List<LatLon>> points(List<LatLonGridLine> lines) {
+        return lines.stream().map(l -> l.points).collect(Collectors.toList());
+    }
+
+    /**
+     * In Mercator one metre is 1/cos(latitude) projection units.
+     */
+    @Test
+    void testProjectionUnitsPerMetre() {
+        org.openstreetmap.josm.data.projection.Projection proj = ProjectionRegistry.getProjection();
+        assertEquals(1, MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(0, 10))), 1e-3);
+        assertEquals(1 / Math.cos(Math.toRadians(50)),
+                MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(50, 10))), 1e-3);
+        assertEquals(1 / Math.cos(Math.toRadians(20.6)),
+                MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(20.6, 87.8))), 1e-3);
+        // outside the world: no scaling
+        assertEquals(1, MapGridPaintable.projectionUnitsPerMetre(proj, new EastNorth(0, 1e12)), 1e-9);
+    }
+
+    /**
+     * The thinning factor is a power of ten bringing the spacing above the minimum.
+     */
+    @Test
+    void testThinningFactor() {
+        MapGridPaintable.MIN_PIXEL_SPACING.put(25.0);
+        try {
+            assertEquals(1, MapGridPaintable.thinningFactor(30));
+            assertEquals(1, MapGridPaintable.thinningFactor(25));
+            assertEquals(10, MapGridPaintable.thinningFactor(24.9));
+            assertEquals(100, MapGridPaintable.thinningFactor(0.3));
+            assertEquals(1, MapGridPaintable.thinningFactor(0));
+            assertEquals(1, MapGridPaintable.thinningFactor(Double.NaN));
+        } finally {
+            MapGridPaintable.MIN_PIXEL_SPACING.remove();
+        }
+    }
+
+    /**
+     * Painting on a map view draws something when enabled and nothing when disabled, for both grid types and
+     * with a blend mode.
+     */
+    @Test
+    void testPaint() {
+        SizedMapView mv = new SizedMapView();
+        mv.setBounds(new Rectangle(400, 300));
+        GuiHelper.runInEDTAndWait(() -> { /* let the component listener update the view state */ });
+        mv.updateState();
+        mv.zoomTo(new LatLon(50, 10));
+        assertTrue(mv.getRealBounds().getMaxLat() > mv.getRealBounds().getMinLat(), "map view has no size: " + mv.getRealBounds());
+        MapGridPaintable grid = new MapGridPaintable();
+        try {
+            MapGridPaintable.ENABLED.put(false);
+            assertEquals(0, paintedPixels(grid, mv), "grid drawn although disabled");
+            MapGridPaintable.ENABLED.put(true);
+            MapGridPaintable.TYPE.put(GridType.LATLON);
+            MapGridPaintable.SPACING_X.put(0.001);
+            MapGridPaintable.SPACING_Y.put(0.001);
+            assertTrue(paintedPixels(grid, mv) > 100, "lat/lon grid not drawn");
+            MapGridPaintable.TYPE.put(GridType.PROJECTED);
+            MapGridPaintable.SPACING_X.put(100.0);
+            MapGridPaintable.SPACING_Y.put(100.0);
+            MapGridPaintable.ROTATION.put(30.0);
+            MapGridPaintable.BLEND_MODE.put(BlendComposite.Mode.MULTIPLY);
+            assertTrue(paintedPixels(grid, mv) > 100, "projected grid not drawn");
+        } finally {
+            grid.destroy();
+            mv.destroy();
+            for (String key : new String[] {"enabled", "type", "spacing-x", "spacing-y", "rotation", "blend-mode"}) {
+                org.openstreetmap.josm.spi.preferences.Config.getPref().put("draw.grid." + key, null);
+            }
+        }
+    }
+
+    /** A map view which believes it is shown on screen, so that its view state gets a size */
+    private static final class SizedMapView extends MapView {
+        SizedMapView() {
+            super(MainApplication.getLayerManager(), null);
+        }
+
+        @Override
+        public Point getLocationOnScreen() {
+            return new Point(0, 0);
+        }
+
+        @Override
+        protected boolean isVisibleOnScreen() {
+            return true;
+        }
+
+        void updateState() {
+            updateLocationState();
+        }
+    }
+
+    private static int paintedPixels(MapGridPaintable grid, MapView mv) {
+        BufferedImage img = new BufferedImage(mv.getWidth(), mv.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
+        java.awt.Graphics2D g = img.createGraphics();
+        g.setColor(Color.WHITE);
+        g.fillRect(0, 0, img.getWidth(), img.getHeight());
+        grid.paint(g, mv, mv.getRealBounds());
+        g.dispose();
+        int count = 0;
+        for (int y = 0; y < img.getHeight(); y++) {
+            for (int x = 0; x < img.getWidth(); x++) {
+                if ((img.getRGB(x, y) & 0xffffff) != 0xffffff) {
+                    count++;
+                }
+            }
+        }
+        return count;
+    }
+}
diff --git test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java
new file mode 100644
index 0000000000..45c30cb2a0
--- /dev/null
+++ test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java
@@ -0,0 +1,72 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.event.ActionEvent;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.swing.JCheckBoxMenuItem;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.gui.layer.TMSLayer;
+import org.openstreetmap.josm.gui.layer.TMSLayerTest;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.testutils.annotations.Main;
+import org.openstreetmap.josm.testutils.annotations.Projection;
+
+/**
+ * Unit tests of {@link ShowTileBordersAction}.
+ */
+@Main
+@Projection
+class ShowTileBordersActionTest {
+
+    @AfterEach
+    void resetPreferences() {
+        Config.getPref().put("imagery.generic.default_showtileborders", null);
+    }
+
+    /**
+     * Toggling the tile borders remembers the choice, so that a layer created later - in this session or after
+     * a restart - shows them too. Non-regression test for the setting being forgotten between sessions.
+     */
+    @Test
+    void testTogglePersistsTheChoice() {
+        TMSLayer layer = TMSLayerTest.createTmsLayer();
+        assertFalse(layer.getDisplaySettings().isShowTileBorders());
+        ShowTileBordersAction action = new ShowTileBordersAction(layer);
+
+        action.actionPerformed(new ActionEvent(this, 0, ""));
+        assertTrue(layer.getDisplaySettings().isShowTileBorders());
+        assertTrue(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
+        // a layer created afterwards picks the choice up, which is what happens after a restart
+        assertTrue(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
+        assertTrue(new JCheckBoxMenuItem(action).getModel().isEnabled());
+        assertTrue(((JCheckBoxMenuItem) action.createMenuComponent()).isSelected());
+
+        action.actionPerformed(new ActionEvent(this, 0, ""));
+        assertFalse(layer.getDisplaySettings().isShowTileBorders());
+        assertFalse(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
+        assertFalse(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
+        assertFalse(((JCheckBoxMenuItem) action.createMenuComponent()).isSelected());
+    }
+
+    /**
+     * Loading a session applies the setting to that layer only, it must not change the remembered default.
+     */
+    @Test
+    void testSessionDoesNotOverwriteThePreference() {
+        TMSLayer layer = TMSLayerTest.createTmsLayer();
+        Map<String, String> session = new HashMap<>(Collections.singletonMap("show-tile-borders", "true"));
+        layer.getDisplaySettings().applyFromPropertiesMap(session);
+
+        assertTrue(layer.getDisplaySettings().isShowTileBorders());
+        assertFalse(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
+        assertFalse(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
+    }
+}
diff --git test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java
new file mode 100644
index 0000000000..6adbe9eaaa
--- /dev/null
+++ test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java
@@ -0,0 +1,77 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+
+/**
+ * Unit tests of {@link TileSourceDisplaySettings}.
+ */
+@BasicPreferences
+class TileSourceDisplaySettingsTest {
+
+    @AfterEach
+    void resetPreferences() {
+        Config.getPref().put("imagery.generic.default_showtileborders", null);
+        Config.getPref().put("imagery.tms.default_showtileborders", null);
+    }
+
+    /**
+     * The tile borders setting fires a change event and survives a round trip through the session properties.
+     */
+    @Test
+    void testShowTileBorders() {
+        TileSourceDisplaySettings settings = new TileSourceDisplaySettings();
+        assertFalse(settings.isShowTileBorders());
+        List<String> changes = new ArrayList<>();
+        settings.addSettingsChangeListener(e -> changes.add(e.getChangedSetting()));
+        settings.setShowTileBorders(true);
+        assertTrue(settings.isShowTileBorders());
+        assertEquals(1, changes.size());
+
+        Map<String, String> data = settings.toPropertiesMap();
+        assertEquals("true", data.get("show-tile-borders"));
+        TileSourceDisplaySettings copy = new TileSourceDisplaySettings();
+        assertNotEquals(settings, copy);
+        copy.applyFromPropertiesMap(data);
+        assertTrue(copy.isShowTileBorders());
+        assertEquals(settings, copy);
+        assertEquals(settings.hashCode(), copy.hashCode());
+
+        // a session without the setting keeps the current value
+        data.remove("show-tile-borders");
+        copy.applyFromPropertiesMap(data);
+        assertTrue(copy.isShowTileBorders());
+    }
+
+    /**
+     * The default of a new layer comes from the preferences, read with the same prefix logic as the sibling
+     * settings (auto load, auto zoom, show errors): an explicitly set value is honoured, and a layer specific
+     * prefix takes precedence over the generic one.
+     */
+    @Test
+    void testShowTileBordersDefault() {
+        assertFalse(new TileSourceDisplaySettings().isShowTileBorders());
+        assertFalse(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
+
+        Config.getPref().putBoolean("imagery.generic.default_showtileborders", true);
+        assertTrue(new TileSourceDisplaySettings().isShowTileBorders());
+
+        Config.getPref().putBoolean("imagery.tms.default_showtileborders", true);
+        assertTrue(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
+
+        Config.getPref().putBoolean("imagery.tms.default_showtileborders", false);
+        assertFalse(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
+    }
+}
diff --git test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java
new file mode 100644
index 0000000000..b52d26c915
--- /dev/null
+++ test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java
@@ -0,0 +1,43 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.display;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.preferences.PreferencesTestUtils;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+import org.openstreetmap.josm.testutils.annotations.Main;
+
+/**
+ * Unit tests of {@link GridPreference} class.
+ */
+@BasicPreferences
+@Main
+class GridPreferenceTest {
+    /**
+     * Unit test of {@link GridPreference#GridPreference}.
+     */
+    @Test
+    void testGridPreference() {
+        assertNotNull(new GridPreference.Factory().createPreferenceSetting());
+    }
+
+    /**
+     * Unit test of {@link GridPreference#addGui}: the settings survive a round trip through the panel.
+     */
+    @Test
+    void testAddGui() {
+        MapGridPaintable.SPACING_X.put(0.25);
+        MapGridPaintable.ROTATION.put(-12.5);
+        try {
+            PreferencesTestUtils.doTestPreferenceSettingAddGui(new GridPreference.Factory(), null);
+            assertEquals(0.25, MapGridPaintable.SPACING_X.get(), 1e-12);
+            assertEquals(-12.5, MapGridPaintable.ROTATION.get(), 1e-12);
+        } finally {
+            MapGridPaintable.SPACING_X.remove();
+            MapGridPaintable.ROTATION.remove();
+        }
+    }
+}
