diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java index e1472b4..a0b42e7 100644 --- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java @@ -26,8 +26,6 @@ import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.stream.IntStream; -import java.util.stream.Stream; import org.openstreetmap.gui.jmapviewer.interfaces.TileSource; import org.openstreetmap.josm.data.Bounds; @@ -86,114 +84,6 @@ import jakarta.json.stream.JsonParser; * @author Taylor Smock */ public class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader { - private record TileXYZ(int x, int y, int z) { - /** - * Checks to see if the given bounds are functionally equal to this tile - * - * @param left left - * @param bottom bottom - * @param right right - * @param top top - */ - boolean checkBounds(double left, double bottom, double right, double top) { - final var thisLeft = xToLongitude(this.x, this.z); - final var thisRight = xToLongitude(this.x + 1, this.z); - final var thisBottom = yToLatitude(this.y + 1, this.z); - final var thisTop = yToLatitude(this.y, this.z); - return equalsEpsilon(thisLeft, left, this.z) && equalsEpsilon(thisRight, right, this.z) - && equalsEpsilon(thisBottom, bottom, this.z) && equalsEpsilon(thisTop, top, this.z); - } - - private static boolean equalsEpsilon(double first, double second, int z) { - // 0.1% of tile size is considered to be "equal" - final var maxDiff = (360 / Math.pow(2, z)) / 1000; - final var diff = Math.abs(first - second); - return diff <= maxDiff; - } - - private static double xToLongitude(int x, int z) { - return (x / Math.pow(2, z)) * 360 - 180; - } - - private static double yToLatitude(int y, int z) { - var t = Math.PI - 2 * Math.PI * y / Math.pow(2, z); - return 180 / Math.PI * Math.atan((Math.exp(t) - Math.exp(-t)) / 2); - } - - /** - * Convert bounds to tiles - * - * @param zoom The zoom level to use - * @param bounds The bounds to convert to tiles - * @return A stream of tiles for the bounds at the given zoom level - */ - private static Stream tilesFromBBox(int zoom, Bounds bounds) { - final var left = bounds.getMinLon(); - final var bottom = bounds.getMinLat(); - final var right = bounds.getMaxLon(); - final var top = bounds.getMaxLat(); - final var tile1 = tileFromLatLonZoom(left, bottom, zoom); - final var tile2 = tileFromLatLonZoom(right, top, zoom); - return IntStream.rangeClosed(tile1.x, tile2.x) - .mapToObj(x -> IntStream.rangeClosed(tile2.y, tile1.y).mapToObj(y -> new TileXYZ(x, y, zoom))) - .flatMap(stream -> stream); - } - - /** - * Checks to see if the given bounds are functionally equal to this tile - * - * @param left left lon - * @param bottom bottom lat - * @param right right lon - * @param top top lat - */ - private static TileXYZ tileFromBBox(double left, double bottom, double right, double top) { - var zoom = 18; - while (zoom > 0) { - final var tile1 = tileFromLatLonZoom(left, bottom, zoom); - final var tile2 = tileFromLatLonZoom(right, top, zoom); - if (tile1.equals(tile2)) { - return tile1; - } else if (tile1.checkBounds(left, bottom, right, top)) { - return tile1; - } else if (tile2.checkBounds(left, bottom, right, top)) { - return tile2; - // Just in case the coordinates are _barely_ in other tiles and not the "common" - // tile - } else if (Math.abs(tile1.x() - tile2.x()) <= 2 && Math.abs(tile1.y() - tile2.y()) <= 2) { - final var tileT = new TileXYZ((tile1.x() + tile2.x()) / 2, (tile1.y() + tile2.y()) / 2, zoom); - if (tileT.checkBounds(left, bottom, right, top)) { - return tileT; - } - } - zoom--; - } - return new TileXYZ(0, 0, 0); - } - - private static TileXYZ tileFromLatLonZoom(double lon, double lat, int zoom) { - var xCoordinate = Math.toIntExact(Math.round(Math.floor(Math.pow(2, zoom) * (180 + lon) / 360))); - var yCoordinate = Math.toIntExact(Math.round(Math.floor(Math.pow(2, zoom) - * (1 - (Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI)) - / 2))); - return new TileXYZ(xCoordinate, yCoordinate, zoom); - } - - /** - * Extends a bounds object to contain this tile - * - * @param currentBounds The bounds to extend - */ - private void expandBounds(Bounds currentBounds) { - final var thisLeft = xToLongitude(this.x, this.z); - final var thisRight = xToLongitude(this.x + 1, this.z); - final var thisBottom = yToLatitude(this.y + 1, this.z); - final var thisTop = yToLatitude(this.y, this.z); - currentBounds.extend(thisBottom, thisLeft); - currentBounds.extend(thisTop, thisRight); - } - } - private final String url; private final boolean crop; private final int start; diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZ.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZ.java new file mode 100644 index 0000000..1e6df34 --- /dev/null +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZ.java @@ -0,0 +1,121 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.plugins.mapwithai.backend; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.openstreetmap.josm.data.Bounds; + +/** + * Create a tile + * + * @param x The x coordinate of the tile + * @param y The y coordinate of the tile + * @param z The zoom level + */ +record TileXYZ(int x, int y, int z) { + /** + * Checks to see if the given bounds are functionally equal to this tile + * + * @param left left + * @param bottom bottom + * @param right right + * @param top top + */ + boolean checkBounds(double left, double bottom, double right, double top) { + final var thisLeft = xToLongitude(this.x, this.z); + final var thisRight = xToLongitude(this.x + 1, this.z); + final var thisBottom = yToLatitude(this.y + 1, this.z); + final var thisTop = yToLatitude(this.y, this.z); + return equalsEpsilon(thisLeft, left, this.z) && equalsEpsilon(thisRight, right, this.z) + && equalsEpsilon(thisBottom, bottom, this.z) && equalsEpsilon(thisTop, top, this.z); + } + + private static boolean equalsEpsilon(double first, double second, int z) { + // 0.1% of tile size is considered to be "equal" + final var maxDiff = (360 / Math.pow(2, z)) / 1000; + final var diff = Math.abs(first - second); + return diff <= maxDiff; + } + + private static double xToLongitude(int x, int z) { + return (x / Math.pow(2, z)) * 360 - 180; + } + + private static double yToLatitude(int y, int z) { + var t = Math.PI - 2 * Math.PI * y / Math.pow(2, z); + return 180 / Math.PI * Math.atan((Math.exp(t) - Math.exp(-t)) / 2); + } + + /** + * Convert bounds to tiles + * + * @param zoom The zoom level to use + * @param bounds The bounds to convert to tiles + * @return A stream of tiles for the bounds at the given zoom level + */ + static Stream tilesFromBBox(int zoom, Bounds bounds) { + final var left = bounds.getMinLon(); + final var bottom = bounds.getMinLat(); + final var right = bounds.getMaxLon(); + final var top = bounds.getMaxLat(); + final var tile1 = tileFromLatLonZoom(left, bottom, zoom); + final var tile2 = tileFromLatLonZoom(right, top, zoom); + return IntStream.rangeClosed(tile1.x, tile2.x) + .mapToObj(x -> IntStream.rangeClosed(tile2.y, tile1.y).mapToObj(y -> new TileXYZ(x, y, zoom))) + .flatMap(stream -> stream); + } + + /** + * Checks to see if the given bounds are functionally equal to this tile + * + * @param left left lon + * @param bottom bottom lat + * @param right right lon + * @param top top lat + */ + static TileXYZ tileFromBBox(double left, double bottom, double right, double top) { + var zoom = 18; + while (zoom > 0) { + final var tile1 = tileFromLatLonZoom(left, bottom, zoom); + final var tile2 = tileFromLatLonZoom(right, top, zoom); + if (tile1.equals(tile2)) { + return tile1; + } else if (tile1.checkBounds(left, bottom, right, top)) { + return tile1; + } else if (tile2.checkBounds(left, bottom, right, top)) { + return tile2; + // Just in case the coordinates are _barely_ in other tiles and not the "common" + // tile + } else if (Math.abs(tile1.x() - tile2.x()) <= 2 && Math.abs(tile1.y() - tile2.y()) <= 2) { + final var tileT = new TileXYZ((tile1.x() + tile2.x()) / 2, (tile1.y() + tile2.y()) / 2, zoom); + if (tileT.checkBounds(left, bottom, right, top)) { + return tileT; + } + } + zoom--; + } + return new TileXYZ(0, 0, 0); + } + + static TileXYZ tileFromLatLonZoom(double lon, double lat, int zoom) { + var xCoordinate = Math.toIntExact(Math.round(Math.floor(Math.pow(2, zoom) * (180 + lon) / 360))); + var yCoordinate = Math.toIntExact(Math.round(Math.floor(Math.pow(2, zoom) + * (1 - (Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI)) / 2))); + return new TileXYZ(xCoordinate, yCoordinate, zoom); + } + + /** + * Extends a bounds object to contain this tile + * + * @param currentBounds The bounds to extend + */ + void expandBounds(Bounds currentBounds) { + final var thisLeft = xToLongitude(this.x, this.z); + final var thisRight = xToLongitude(this.x + 1, this.z); + final var thisBottom = yToLatitude(this.y + 1, this.z); + final var thisTop = yToLatitude(this.y, this.z); + currentBounds.extend(thisBottom, thisLeft); + currentBounds.extend(thisTop, thisRight); + } +} diff --git a/src/test/unit/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZTest.java b/src/test/unit/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZTest.java new file mode 100644 index 0000000..5451714 --- /dev/null +++ b/src/test/unit/org/openstreetmap/josm/plugins/mapwithai/backend/TileXYZTest.java @@ -0,0 +1,46 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.plugins.mapwithai.backend; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.openstreetmap.josm.data.Bounds; + +/** + * Test class for {@link TileXYZ} + */ +class TileXYZTest { + static Stream testTileCalculations() { + return Stream.of(Arguments.of(39.07035, -108.5709286, 52013, 100120, 18), + Arguments.of(39.0643941, -108.5610312, 52020, 100125, 18), + Arguments.of(39.0643941, -108.5709286, 52013, 100125, 18), + Arguments.of(39.07035, -108.5610312, 52020, 100120, 18)); + } + + @ParameterizedTest + @MethodSource + void testTileCalculations(double lat, double lon, int x, int y, int z) { + final var tile = TileXYZ.tileFromLatLonZoom(lon, lat, z); + assertAll(() -> assertEquals(x, tile.x()), () -> assertEquals(y, tile.y()), () -> assertEquals(z, tile.z())); + } + + /** + * Check that the tiles calculated for a bbox are correct + */ + @Test + void testNonRegressionGH44() { + final var tiles = TileXYZ.tilesFromBBox(18, new Bounds(39.0643941, -108.5709286, 39.07035, -108.5610312)) + .toArray(TileXYZ[]::new); + assertAll(() -> assertEquals(100125, Arrays.stream(tiles).mapToInt(TileXYZ::y).max().orElse(0)), + () -> assertEquals(100120, Arrays.stream(tiles).mapToInt(TileXYZ::y).min().orElse(0)), + () -> assertEquals(52013, Arrays.stream(tiles).mapToInt(TileXYZ::x).min().orElse(0)), + () -> assertEquals(52020, Arrays.stream(tiles).mapToInt(TileXYZ::x).max().orElse(0))); + assertEquals(48, tiles.length, "Should be 6x8 tiles (rangeClosed)"); + } +}