diff --git src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java
index 00aa032f3..5e4ee9b9c 100644
--- src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java
+++ src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java
@@ -33,19 +33,22 @@ public class TemplatedTMSTileSource extends TMSTileSource implements TemplatedTi
     private Random rand;
     private String[] randomParts;
     private final Map<String, String> headers = new HashMap<>();
+    private boolean inverse_zoom = false;
+    private int zoom_offset = 0;
 
     // CHECKSTYLE.OFF: SingleSpaceSeparator
     private static final String COOKIE_HEADER   = "Cookie";
-    private static final String PATTERN_ZOOM    = "\\{(?:(\\d+)-)?z(?:oom)?([+-]\\d+)?\\}";
-    private static final String PATTERN_X       = "\\{x\\}";
-    private static final String PATTERN_Y       = "\\{y\\}";
-    private static final String PATTERN_Y_YAHOO = "\\{!y\\}";
-    private static final String PATTERN_NEG_Y   = "\\{-y\\}";
-    private static final String PATTERN_SWITCH  = "\\{switch:([^}]+)\\}";
-    private static final String PATTERN_HEADER  = "\\{header\\(([^,]+),([^}]+)\\)\\}";
+    private static final Pattern PATTERN_ZOOM    = Pattern.compile("\\{(?:(\\d+)-)?z(?:oom)?([+-]\\d+)?\\}");
+    private static final Pattern PATTERN_X       = Pattern.compile("\\{x\\}");
+    private static final Pattern PATTERN_Y       = Pattern.compile("\\{y\\}");
+    private static final Pattern PATTERN_Y_YAHOO = Pattern.compile("\\{!y\\}");
+    private static final Pattern PATTERN_NEG_Y   = Pattern.compile("\\{-y\\}");
+    private static final Pattern PATTERN_SWITCH  = Pattern.compile("\\{switch:([^}]+)\\}");
+    private static final Pattern PATTERN_HEADER  = Pattern.compile("\\{header\\(([^,]+),([^}]+)\\)\\}");
+    private static final Pattern PATTERN_PARAM  = Pattern.compile("\\{((?:\\d+-)?z(?:oom)?(:?[+-]\\d+)?|x|y|!y|-y|switch:([^}]+))\\}");
     // CHECKSTYLE.ON: SingleSpaceSeparator
 
-    private static final String[] ALL_PATTERNS = {
+    private static final Pattern[] ALL_PATTERNS = {
         PATTERN_HEADER, PATTERN_ZOOM, PATTERN_X, PATTERN_Y, PATTERN_Y_YAHOO, PATTERN_NEG_Y, PATTERN_SWITCH
     };
 
@@ -64,20 +67,33 @@ public class TemplatedTMSTileSource extends TMSTileSource implements TemplatedTi
 
     private void handleTemplate() {
         // Capturing group pattern on switch values
-        Matcher m = Pattern.compile(".*"+PATTERN_SWITCH+".*").matcher(baseUrl);
-        if (m.matches()) {
+        Matcher m = PATTERN_SWITCH.matcher(baseUrl);
+        if (m.find()) {
             rand = new Random();
             randomParts = m.group(1).split(",");
         }
-        Pattern pattern = Pattern.compile(PATTERN_HEADER);
         StringBuffer output = new StringBuffer();
-        Matcher matcher = pattern.matcher(baseUrl);
+        Matcher matcher = PATTERN_HEADER.matcher(baseUrl);
         while (matcher.find()) {
             headers.put(matcher.group(1), matcher.group(2));
             matcher.appendReplacement(output, "");
         }
         matcher.appendTail(output);
         baseUrl = output.toString();
+        m = PATTERN_ZOOM.matcher(this.baseUrl);
+        if (m.find()) {
+            if (m.group(1) != null) {
+                inverse_zoom = true;
+                zoom_offset = Integer.parseInt(m.group(1));
+            }
+            if (m.group(2) != null) {
+                String ofs = m.group(2);
+                if (ofs.startsWith("+"))
+                    ofs = ofs.substring(1);
+                zoom_offset += Integer.parseInt(ofs);
+            }
+        }
+
     }
 
     @Override
@@ -87,29 +103,44 @@ public class TemplatedTMSTileSource extends TMSTileSource implements TemplatedTi
 
     @Override
     public String getTileUrl(int zoom, int tilex, int tiley) {
-        int finalZoom = zoom;
-        Matcher m = Pattern.compile(".*"+PATTERN_ZOOM+".*").matcher(this.baseUrl);
-        if (m.matches()) {
-            if (m.group(1) != null) {
-                finalZoom = Integer.parseInt(m.group(1))-zoom;
-            }
-            if (m.group(2) != null) {
-                String ofs = m.group(2);
-                if (ofs.startsWith("+"))
-                    ofs = ofs.substring(1);
-                finalZoom += Integer.parseInt(ofs);
+        StringBuffer url = new StringBuffer(baseUrl.length());
+        Matcher matcher = PATTERN_PARAM.matcher(baseUrl);
+        while (matcher.find()) {
+            String replacement = "replace";
+            switch (matcher.group(1)) {
+            case "z": // PATTERN_ZOOM
+            case "zoom":
+                replacement = Integer.toString((inverse_zoom ? -1 * zoom : zoom) + zoom_offset);
+                break;
+            case "x": // PATTERN_X
+                replacement = Integer.toString(tilex);
+                break;
+            case "y": // PATTERN_Y
+                replacement = Integer.toString(tiley);
+                break;
+            case "!y": // PATTERN_Y_YAHOO
+                replacement = Integer.toString((int) Math.pow(2, zoom-1)-1-tiley);
+                break;
+            case "-y": // PATTERN_NEG_Y
+                replacement = Integer.toString((int) Math.pow(2, zoom)-1-tiley);
+                break;
+            case "switch:":
+                replacement = randomParts[rand.nextInt(randomParts.length)];
+                break;
+            default:
+                // handle switch/zoom here, as group will contain parameters and switch will not work
+                if (PATTERN_ZOOM.matcher("{" + matcher.group(1) + "}").matches()) {
+                    replacement = Integer.toString((inverse_zoom ? -1 * zoom : zoom) + zoom_offset);
+                } else if (PATTERN_SWITCH.matcher("{" + matcher.group(1) + "}").matches()) {
+                    replacement = randomParts[rand.nextInt(randomParts.length)];
+                } else {
+                    replacement = '{' + matcher.group(1) + '}';
+                }
             }
+            matcher.appendReplacement(url, replacement);
         }
-        String r = this.baseUrl
-            .replaceAll(PATTERN_ZOOM, Integer.toString(finalZoom))
-            .replaceAll(PATTERN_X, Integer.toString(tilex))
-            .replaceAll(PATTERN_Y, Integer.toString(tiley))
-            .replaceAll(PATTERN_Y_YAHOO, Integer.toString((int) Math.pow(2, zoom-1)-1-tiley))
-            .replaceAll(PATTERN_NEG_Y, Integer.toString((int) Math.pow(2, zoom)-1-tiley));
-        if (rand != null) {
-            r = r.replaceAll(PATTERN_SWITCH, randomParts[rand.nextInt(randomParts.length)]);
-        }
-        return r;
+        matcher.appendTail(url);
+        return url.toString().replace(" ", "%20");
     }
 
     /**
@@ -121,8 +152,8 @@ public class TemplatedTMSTileSource extends TMSTileSource implements TemplatedTi
         Matcher m = Pattern.compile("\\{[^}]*\\}").matcher(url);
         while (m.find()) {
             boolean isSupportedPattern = false;
-            for (String pattern : ALL_PATTERNS) {
-                if (m.group().matches(pattern)) {
+            for (Pattern pattern : ALL_PATTERNS) {
+                if (pattern.matcher(m.group()).matches()) {
                     isSupportedPattern = true;
                     break;
                 }
diff --git src/org/openstreetmap/josm/data/imagery/AbstractWMSTileSource.java src/org/openstreetmap/josm/data/imagery/AbstractWMSTileSource.java
index 5f668e5f8..b57591494 100644
--- src/org/openstreetmap/josm/data/imagery/AbstractWMSTileSource.java
+++ src/org/openstreetmap/josm/data/imagery/AbstractWMSTileSource.java
@@ -225,14 +225,20 @@ public abstract class AbstractWMSTileSource extends TMSTileSource {
         double s = se.getY();
         double e = se.getX();
 
-        return (
-                switchLatLon ?
-                        String.format("%s,%s,%s,%s",
-                                LATLON_FORMAT.format(s), LATLON_FORMAT.format(w), LATLON_FORMAT.format(n), LATLON_FORMAT.format(e))
-                        :
-                        String.format("%s,%s,%s,%s",
-                                LATLON_FORMAT.format(w), LATLON_FORMAT.format(s), LATLON_FORMAT.format(e), LATLON_FORMAT.format(n))
+        return switchLatLon ?
+                getBboxstr(s, w, n, e)
+                : getBboxstr(w, s, e, n);
+    }
 
-                );
+    private final String getBboxstr(double x1, double x2, double x3, double x4) {
+        return new StringBuilder(64)
+                .append(LATLON_FORMAT.format(x1))
+                .append(',')
+                .append(LATLON_FORMAT.format(x2))
+                .append(',')
+                .append(LATLON_FORMAT.format(x3))
+                .append(',')
+                .append(LATLON_FORMAT.format(x4))
+                .toString();
     }
 }
diff --git src/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSource.java src/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSource.java
index cfe64156f..80e82164b 100644
--- src/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSource.java
+++ src/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSource.java
@@ -50,6 +50,7 @@ public class TemplatedWMSTileSource extends AbstractWMSTileSource implements Tem
         PATTERN_HEADER, PATTERN_PROJ, PATTERN_WKID, PATTERN_BBOX, PATTERN_W, PATTERN_S, PATTERN_E, PATTERN_N, PATTERN_WIDTH, PATTERN_HEIGHT
     };
 
+    private final boolean switchLatLon;
     /**
      * Creates a tile source based on imagery info
      * @param info imagery info
@@ -61,6 +62,31 @@ public class TemplatedWMSTileSource extends AbstractWMSTileSource implements Tem
         this.headers.putAll(info.getCustomHttpHeaders());
         handleTemplate();
         initProjection();
+        // Bounding box coordinates have to be switched for WMS 1.3.0 EPSG:4326.
+        //
+        // Background:
+        //
+        // bbox=x_min,y_min,x_max,y_max
+        //
+        //      SRS=... is WMS 1.1.1
+        //      CRS=... is WMS 1.3.0
+        //
+        // The difference:
+        //      For SRS x is east-west and y is north-south
+        //      For CRS x and y are as specified by the EPSG
+        //          E.g. [1] lists lat as first coordinate axis and lot as second, so it is switched for EPSG:4326.
+        //          For most other EPSG code there seems to be no difference.
+        // CHECKSTYLE.OFF: LineLength
+        // [1] https://www.epsg-registry.org/report.htm?type=selection&entity=urn:ogc:def:crs:EPSG::4326&reportDetail=short&style=urn:uuid:report-style:default-with-code&style_name=OGP%20Default%20With%20Code&title=EPSG:4326
+        // CHECKSTYLE.ON: LineLength
+        if (baseUrl.toLowerCase(Locale.US).contains("crs=epsg:4326")) {
+            switchLatLon = true;
+        } else if (baseUrl.toLowerCase(Locale.US).contains("crs=")) {
+            // assume WMS 1.3.0
+            switchLatLon = ProjectionRegistry.getProjection().switchXY();
+        } else {
+            switchLatLon = false;
+        }
     }
 
     @Override
@@ -85,32 +111,6 @@ public class TemplatedWMSTileSource extends AbstractWMSTileSource implements Tem
             myProjCode = "CRS:84";
         }
 
-        // Bounding box coordinates have to be switched for WMS 1.3.0 EPSG:4326.
-        //
-        // Background:
-        //
-        // bbox=x_min,y_min,x_max,y_max
-        //
-        //      SRS=... is WMS 1.1.1
-        //      CRS=... is WMS 1.3.0
-        //
-        // The difference:
-        //      For SRS x is east-west and y is north-south
-        //      For CRS x and y are as specified by the EPSG
-        //          E.g. [1] lists lat as first coordinate axis and lot as second, so it is switched for EPSG:4326.
-        //          For most other EPSG code there seems to be no difference.
-        // CHECKSTYLE.OFF: LineLength
-        // [1] https://www.epsg-registry.org/report.htm?type=selection&entity=urn:ogc:def:crs:EPSG::4326&reportDetail=short&style=urn:uuid:report-style:default-with-code&style_name=OGP%20Default%20With%20Code&title=EPSG:4326
-        // CHECKSTYLE.ON: LineLength
-        boolean switchLatLon = false;
-        if (baseUrl.toLowerCase(Locale.US).contains("crs=epsg:4326")) {
-            switchLatLon = true;
-        } else if (baseUrl.toLowerCase(Locale.US).contains("crs=")) {
-            // assume WMS 1.3.0
-            switchLatLon = ProjectionRegistry.getProjection().switchXY();
-        }
-        String bbox = getBbox(zoom, tilex, tiley, switchLatLon);
-
         // Using StringBuffer and generic PATTERN_PARAM matcher gives 2x performance improvement over replaceAll
         StringBuffer url = new StringBuffer(baseUrl.length());
         Matcher matcher = PATTERN_PARAM.matcher(baseUrl);
@@ -124,7 +124,7 @@ public class TemplatedWMSTileSource extends AbstractWMSTileSource implements Tem
                 replacement = myProjCode.startsWith("EPSG:") ? myProjCode.substring(5) : myProjCode;
                 break;
             case "bbox":
-                replacement = bbox;
+                replacement = getBbox(zoom, tilex, tiley, switchLatLon);
                 break;
             case "w":
                 replacement = LATLON_FORMAT.format(w);
diff --git src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java
index bf635b64f..008fc553c 100644
--- src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java
+++ src/org/openstreetmap/josm/data/imagery/WMSEndpointTileSource.java
@@ -59,8 +59,6 @@ public class WMSEndpointTileSource extends AbstractWMSTileSource implements Temp
 
     @Override
     public String getTileUrl(int zoom, int tilex, int tiley) {
-        String bbox = getBbox(zoom, tilex, tiley, !wmsi.belowWMS130() && getTileProjection().switchXY());
-
         // Using StringBuffer and generic PATTERN_PARAM matcher gives 2x performance improvement over replaceAll
         StringBuffer url = new StringBuffer(urlPattern.length());
         Matcher matcher = PATTERN_PARAM.matcher(urlPattern);
@@ -71,7 +69,7 @@ public class WMSEndpointTileSource extends AbstractWMSTileSource implements Temp
                 replacement = getServerCRS();
                 break;
             case "bbox":
-                replacement = bbox;
+                replacement = getBbox(zoom, tilex, tiley, !wmsi.belowWMS130() && getTileProjection().switchXY());
                 break;
             case "width":
             case "height":
diff --git test/performance/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourcePerformanceTest.java test/performance/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourcePerformanceTest.java
new file mode 100644
index 000000000..f7423e545
--- /dev/null
+++ test/performance/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourcePerformanceTest.java
@@ -0,0 +1,46 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.gui.jmapviewer.tilesources;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.openstreetmap.josm.JOSMFixture;
+import org.openstreetmap.josm.PerformanceTestUtils;
+import org.openstreetmap.josm.PerformanceTestUtils.PerformanceTestTimer;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+
+public class TemplatedTMSTileSourcePerformanceTest {
+
+    private static final int TEST_RUNS = 1;
+    private final static int TIMES = 10_000;
+
+    /**
+     * Prepare the test.
+     */
+    @BeforeClass
+    public static void createJOSMFixture() {
+        JOSMFixture.createPerformanceTestFixture().init(true);
+    }
+
+    @Test
+    public void testGetTileUrl() {
+        ImageryInfo testImageryTMS = new ImageryInfo("test imagery",
+                "https://maps{switch:1,2,3,4}.wien.gv.at/basemap/geolandbasemap/normal/google3857/{zoom}/{y}/{x}.png",
+                "tms", null, null);
+        TemplatedTMSTileSource tmsTs = new TemplatedTMSTileSource(testImageryTMS);
+
+        for (int testRun = 0; testRun < TEST_RUNS; testRun++) {
+            PerformanceTestTimer tmsTimer = PerformanceTestUtils.startTimer("TemplatedTMSTileSource#getUrl(String)");
+            for (int i = 0; i < TIMES ; i++) {
+                tmsTs.getTileUrl(i % 20, i, i);
+            }
+            tmsTimer.done();
+        }
+        System.exit(0);
+
+    }
+
+    public static void main(String args[]) {
+        createJOSMFixture();
+        new TemplatedTMSTileSourcePerformanceTest().testGetTileUrl();
+    }
+}
diff --git test/performance/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourcePerformanceTest.java test/performance/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourcePerformanceTest.java
new file mode 100644
index 000000000..5f663f262
--- /dev/null
+++ test/performance/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourcePerformanceTest.java
@@ -0,0 +1,52 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.imagery;
+
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.openstreetmap.josm.JOSMFixture;
+import org.openstreetmap.josm.PerformanceTestUtils;
+import org.openstreetmap.josm.PerformanceTestUtils.PerformanceTestTimer;
+import org.openstreetmap.josm.data.projection.Projections;
+
+public class TemplatedWMSTileSourcePerformanceTest {
+
+    private static final int TEST_RUNS = 1;
+    private final static int TIMES = 10_000;
+
+    /**
+     * Prepare the test.
+     */
+    @BeforeClass
+    public static void createJOSMFixture() {
+        JOSMFixture.createPerformanceTestFixture().init(true);
+    }
+
+    @Test
+    public void testGetTileUrl() {
+        ImageryInfo testImageryWMS = new ImageryInfo("test imagery",
+                "https://services.slip.wa.gov.au/public/services/SLIP_Public_Services/Transport/MapServer/WMSServer?LAYERS=8&"
+                + "TRANSPARENT=TRUE&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=&FORMAT=image%2Fpng&SRS={proj}&BBOX={bbox}&"
+                + "WIDTH={width}&HEIGHT={height}",
+                "wms",
+                null,
+                null);
+        TemplatedWMSTileSource wmsTs = new TemplatedWMSTileSource(testImageryWMS, Projections.getProjectionByCode("EPSG:3857"));
+
+        for (int testRun = 0; testRun < TEST_RUNS; testRun++) {
+            PerformanceTestTimer wmsTimer = PerformanceTestUtils.startTimer("TemplatedWMSTileSource#getUrl(String)");
+            for (int i = 0; i < TIMES ; i++) {
+                wmsTs.getTileUrl(i % 20, i, i);
+            }
+            wmsTimer.done();
+        }
+
+        System.exit(0);
+    }
+
+    public static void main(String args[]) {
+        createJOSMFixture();
+        new TemplatedWMSTileSourcePerformanceTest().testGetTileUrl();
+    }
+
+
+}
diff --git test/unit/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java test/unit/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java
new file mode 100644
index 000000000..4f15b3a60
--- /dev/null
+++ test/unit/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java
@@ -0,0 +1,198 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.gui.jmapviewer.tilesources;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.tuple.Triple;
+import org.junit.Test;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+
+
+/**
+ *
+ * Tests for TemplaedTMSTileSource
+ */
+public class TemplatedTMSTileSourceTest {
+
+    private final static Collection<String> TMS_IMAGERIES = Arrays.asList(new String[]{
+            "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z={zoom}&x={x}&y={-y}",
+            /*
+             *  generate for example with:
+             *  $ curl https://josm.openstreetmap.de/maps | \
+             *    xmlstarlet sel -N 'josm=http://josm.openstreetmap.de/maps-1.0' -t -v "//josm:entry[josm:type='tms']/josm:url" -n  | \
+             *    sed -e 's/\&amp;/\&/g' -e 's/^/"/' -e 's/$/",/'
+             */
+    });
+
+    /**
+     * triple of:
+     *  * baseUrl
+     *  * expected tile url for zoom=1, x=2, y=3
+     *  * expected tile url for zoom=3, x=2, y=1
+     */
+    @SuppressWarnings("unchecked")
+    private Collection<Triple<String, String, String>> TEST_DATA = Arrays.asList(new Triple[] {
+            Triple.of("http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z={zoom}&x={x}&y={-y}", "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z=1&x=2&y=-2", "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z=3&x=2&y=6"),
+            /*
+             * generate with main method below once TMS_IMAGERIES is filled in
+             */
+    });
+
+    /**
+     * Check standard template
+     */
+    @Test
+    public void testGetTileUrl() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{y}",
+                "http://localhost/1/2/3",
+                "http://localhost/3/1/2"
+                );
+    }
+
+
+    /**
+     * Check template with positive zoom index
+     */
+    @Test
+    public void testGetTileUrl_positive_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{zoom+5}/{x}/{y}",
+                "http://localhost/6/2/3",
+                "http://localhost/8/1/2"
+                );
+    }
+
+    /**
+     * Check template with negative zoom index
+     */
+    @Test
+    public void testGetTileUrl_negative_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{zoom-5}/{x}/{y}",
+                "http://localhost/-4/2/3",
+                "http://localhost/-2/1/2"
+                );
+    }
+
+    /**
+     * Check template with inverse zoom index
+     */
+    @Test
+    public void testGetTileUrl_inverse_negative_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{5-zoom}/{x}/{y}",
+                "http://localhost/4/2/3",
+                "http://localhost/2/1/2"
+                );
+    }
+
+    /**
+     * Check template with inverse zoom index and negative zoom index
+     */
+    @Test
+    public void testGetTileUrl_both_offsets() {
+        checkGetTileUrl(
+                "http://localhost/{10-zoom-5}/{x}/{y}",
+                "http://localhost/4/2/3",
+                "http://localhost/2/1/2"
+                );
+    }
+
+    /**
+     * Test template with switch
+     */
+    @Test
+    public void testGetTileUrl_switch() {
+        ImageryInfo testImageryTMS = new ImageryInfo("test imagery", "http://{switch:a,b,c}.localhost/{10-zoom-5}/{x}/{y}", "tms", null, null);
+        TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/4/2/3",
+                        "http://b.localhost/4/2/3",
+                        "http://c.localhost/4/2/3"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(1, 2, 3)))
+                );
+
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/3/3/4",
+                        "http://b.localhost/3/3/4",
+                        "http://c.localhost/3/3/4"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(2, 3, 4)))
+                );
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/2/4/5",
+                        "http://b.localhost/2/4/5",
+                        "http://c.localhost/2/4/5"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(3, 4, 5)))
+                );
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/1/5/6",
+                        "http://b.localhost/1/5/6",
+                        "http://c.localhost/1/5/6"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(4, 5, 6)))
+                );
+    }
+
+    @Test
+    public void testGetTileUrl_yahoo() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{!y}",
+                "http://localhost/1/2/-3",
+                "http://localhost/3/1/1"
+                );
+
+    }
+
+    @Test
+    public void testGetTileUrl_negative_y() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{-y}",
+                "http://localhost/1/2/-2",
+                "http://localhost/3/1/5"
+                );
+
+    }
+
+    private void checkGetTileUrl(String url, String expected123, String expected312) {
+        ImageryInfo testImageryTMS = new ImageryInfo("test imagery", url, "tms", null, null);
+        TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+        assertEquals(expected123, ts.getTileUrl(1, 2, 3));
+        assertEquals(expected312, ts.getTileUrl(3, 1, 2));
+    }
+    /**
+     * Tests all entries in TEST_DATA. This test will fail if {switch:...} template is used
+     */
+    @Test
+    public void testAllUrls() {
+        for(Triple<String, String, String> test: TEST_DATA) {
+            ImageryInfo testImageryTMS = new ImageryInfo("test imagery", test.getLeft(), "tms", null, null);
+            TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+            assertEquals(test.getMiddle(), ts.getTileUrl(1, 2, 3));
+            assertEquals(test.getRight(), ts.getTileUrl(3, 2, 1));
+        }
+    }
+
+    public static void main(String[] args) {
+        for(String url: TMS_IMAGERIES) {
+            ImageryInfo testImageryTMS = new ImageryInfo("test imagery", url, "tms", null, null);
+            TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+            System.out.println(MessageFormat.format("Triple.of(\"{0}\", \"{1}\", \"{2}\"),", url, ts.getTileUrl(1, 2, 3), ts.getTileUrl(3, 2, 1)));
+        }
+    }
+
+}
diff --git test/unit/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourceTest.java test/unit/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourceTest.java
index 89b29d045..02910cf97 100644
--- test/unit/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourceTest.java
+++ test/unit/org/openstreetmap/josm/data/imagery/TemplatedWMSTileSourceTest.java
@@ -151,6 +151,47 @@ public class TemplatedWMSTileSourceTest {
         verifyLocation(source, new LatLon(60, 18.1));
     }
 
+    /**
+     * Test getTileUrl
+     */
+    @Test
+    public void testGetTileUrl() {
+        // "https://maps.six.nsw.gov.au/arcgis/services/public/NSW_Imagery_Dates/MapServer/WMSServer?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&CRS={proj}&BBOX={bbox}&WIDTH={width}&HEIGHT={height}&LAYERS=0&STYLES=&FORMAT=image/png32&DPI=96&MAP_RESOLUTION=96&FORMAT_OPTIONS=dpi:96&TRANSPARENT=TRUE",
+        Projection projection = Projections.getProjectionByCode("EPSG:4326");
+        ProjectionRegistry.setProjection(projection);
+        ImageryInfo testImageryWMS = new ImageryInfo("test imagery",
+                "https://maps.six.nsw.gov.au/arcgis/services/public/NSW_Imagery_Dates/MapServer/WMSServer?SERVICE=WMS&VERSION=1.3.0&"
+                + "REQUEST=GetMap&CRS={proj}&BBOX={bbox}&WIDTH={width}&HEIGHT={height}&LAYERS=0&STYLES=&FORMAT=image/png32&DPI=96&"
+                + "MAP_RESOLUTION=96&FORMAT_OPTIONS=dpi:96&TRANSPARENT=TRUE",
+                "wms",
+                null,
+                null);
+        TemplatedWMSTileSource ts = new TemplatedWMSTileSource(testImageryWMS, projection);
+        assertEquals("https://maps.six.nsw.gov.au/arcgis/services/public/NSW_Imagery_Dates/MapServer/WMSServer?SERVICE=WMS&"
+                + "VERSION=1.3.0&REQUEST=GetMap&CRS=EPSG:4326&BBOX=-1349.9999381,539.9999691,-989.9999536,899.9999536&WIDTH=512&"
+                + "HEIGHT=512&LAYERS=0&STYLES=&FORMAT=image/png32&DPI=96&MAP_RESOLUTION=96&FORMAT_OPTIONS=dpi:96&TRANSPARENT=TRUE",
+                ts.getTileUrl(1, 2, 3));
+        assertEquals("https://maps.six.nsw.gov.au/arcgis/services/public/NSW_Imagery_Dates/MapServer/WMSServer?SERVICE=WMS&"
+                + "VERSION=1.3.0&REQUEST=GetMap&CRS=EPSG:4326&BBOX=-89.9999923,-0.0000077,0.0000039,89.9999884&WIDTH=512&HEIGHT=512&"
+                + "LAYERS=0&STYLES=&FORMAT=image/png32&DPI=96&MAP_RESOLUTION=96&FORMAT_OPTIONS=dpi:96&TRANSPARENT=TRUE",
+                ts.getTileUrl(3, 2, 1));
+        testImageryWMS = new ImageryInfo("test imagery",
+                "https://services.slip.wa.gov.au/public/services/SLIP_Public_Services/Transport/MapServer/WMSServer?LAYERS=8&"
+                + "TRANSPARENT=TRUE&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=&FORMAT=image%2Fpng&SRS={proj}&BBOX={bbox}&"
+                + "WIDTH={width}&HEIGHT={height}",
+                "wms",
+                null,
+                null);
+        ts = new TemplatedWMSTileSource(testImageryWMS, projection);
+        assertEquals("https://services.slip.wa.gov.au/public/services/SLIP_Public_Services/Transport/MapServer/WMSServer?LAYERS=8&"
+                + "TRANSPARENT=TRUE&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=&FORMAT=image%2Fpng&SRS=EPSG:4326&"
+                + "BBOX=539.9999691,-1349.9999381,899.9999536,-989.9999536&WIDTH=512&HEIGHT=512",
+                ts.getTileUrl(1, 2, 3));
+        assertEquals("https://services.slip.wa.gov.au/public/services/SLIP_Public_Services/Transport/MapServer/WMSServer?LAYERS=8&"
+                + "TRANSPARENT=TRUE&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=&FORMAT=image%2Fpng&SRS=EPSG:4326&"
+                + "BBOX=-0.0000077,-89.9999923,89.9999884,0.0000039&WIDTH=512&HEIGHT=512", ts.getTileUrl(3, 2, 1));
+    }
+
     private void verifyMercatorTile(TemplatedWMSTileSource source, int x, int y, int z) {
         TemplatedTMSTileSource verifier = new TemplatedTMSTileSource(testImageryTMS);
         LatLon result = getTileLatLon(source, x, y, z);
