diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt b/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt index e52d76fb..58b72f6b 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt @@ -24,13 +24,13 @@ import com.google.android.play.core.review.ReviewManagerFactory import com.google.android.play.core.review.model.ReviewErrorCode import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.scottishtecharmy.soundscape.geoengine.PROTOMAPS_SERVER_BASE +import org.scottishtecharmy.soundscape.geoengine.PROTOMAPS_SERVER_PATH import org.scottishtecharmy.soundscape.screens.home.HomeRoutes import org.scottishtecharmy.soundscape.screens.home.HomeScreen import org.scottishtecharmy.soundscape.screens.home.Navigator import org.scottishtecharmy.soundscape.services.SoundscapeService import org.scottishtecharmy.soundscape.ui.theme.SoundscapeTheme -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.PROTOMAPS_SERVER_PATH -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.PROTOMAPS_SERVER_BASE import org.scottishtecharmy.soundscape.utils.extractAssets import java.io.File import javax.inject.Inject diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/database/local/RealmConfiguration.kt b/app/src/main/java/org/scottishtecharmy/soundscape/database/local/RealmConfiguration.kt index 21fbc98e..d39fec00 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/database/local/RealmConfiguration.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/database/local/RealmConfiguration.kt @@ -8,7 +8,7 @@ import io.realm.kotlin.RealmConfiguration import org.scottishtecharmy.soundscape.database.local.model.Location import org.scottishtecharmy.soundscape.database.local.model.RouteData import org.scottishtecharmy.soundscape.database.local.model.RoutePoint -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.SOUNDSCAPE_TILE_BACKEND +import org.scottishtecharmy.soundscape.geoengine.SOUNDSCAPE_TILE_BACKEND object RealmConfiguration { private var tileDataRealm: Realm? = null diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/Configuration.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/Configuration.kt new file mode 100644 index 00000000..0704350a --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/Configuration.kt @@ -0,0 +1,42 @@ +package org.scottishtecharmy.soundscape.geoengine + +/** + * This file contains the various configuration options for the GeoEngine. + */ + +/** + * The zoom level and grid size are constant. When using soundscape-backend these will be + * 16 and 3, but if we switch to using protobuf tiles they will be 15 and 2. + */ +const val SOUNDSCAPE_TILE_BACKEND = false +val ZOOM_LEVEL = if(SOUNDSCAPE_TILE_BACKEND) 16 else 15 +var GRID_SIZE = if(SOUNDSCAPE_TILE_BACKEND) 3 else 2 + +/** + * The default tile server is the one out in the cloud where the tile JSON is at: + * https://server/protomaps.json + * + * and the tiles are at + * https://server/protomaps/{z}/{x}/{y}.mvt + */ +const val PROTOMAPS_SERVER_BASE = "https://d1wzlzgah5gfol.cloudfront.net" +const val PROTOMAPS_SERVER_PATH = "protomaps" +const val PROTOMAPS_SUFFIX = "mvt" + +/** + * It's also useful to be able to use tiles served up locally when testing. When I + * test locally I'm serving up the file like this: + * + * tileserver-gl-light --file europe.pmtiles -b 192.168.86.39 + * + * With this configuration the tile JSON descriptor appears at: + * http://192.168.86.39:8080/data/v3.json + * + * and the tiles within it are at: + * http://192.168.86.39:8080/data/v3/{z}/{x}/{y}.pbf + * + */ +//const val PROTOMAPS_SERVER_BASE = "http://192.168.86.39:8080" +//const val PROTOMAPS_SERVER_PATH = "data/v3" +//const val PROTOMAPS_SUFFIX = "pbf" + diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt index ff646044..740eddf1 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt @@ -43,8 +43,6 @@ import org.scottishtecharmy.soundscape.network.TileClient import org.scottishtecharmy.soundscape.geoengine.mvttranslation.InterpolatedPointsJoiner import org.scottishtecharmy.soundscape.geoengine.utils.RelativeDirections import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.SOUNDSCAPE_TILE_BACKEND -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.ZOOM_LEVEL import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.getTileGrid import org.scottishtecharmy.soundscape.geoengine.utils.checkIntersection import org.scottishtecharmy.soundscape.geoengine.utils.cleanTileGeoJSON diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/EntranceMatching.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/EntranceMatching.kt new file mode 100644 index 00000000..6bec4364 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/EntranceMatching.kt @@ -0,0 +1,108 @@ +package org.scottishtecharmy.soundscape.geoengine.mvttranslation + +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature +import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point + +class EntranceMatching { + + /** + * buildingNodes is a sparse map which maps from a location within the tile to a list of + * building polygons which have nodes at that point. Every node on any `POI` polygon will appear + * in the map along with any entrance. After processing it should be straightforward to match + * up entrances to their POI polygons. + */ + private val buildingNodes : HashMap< Int, ArrayList> = hashMapOf() + + /** + * addLine is called for any line feature that is being added to the FeatureCollection. + * @param line is a new `transportation` layer line to add to the map + * @param details describes the line that is being added. + * + */ + fun addPolygon(line : ArrayList>, + details : EntranceDetails + ) { + for (point in line) { + if((point.first < 0) || (point.first > 4095) || + (point.second < 0) || (point.second > 4095)) { + continue + } + + // Rather than have a 2D sparse array, turn the coordinates into a single int so that we + // can have a 1D sparse array instead. + val coordinateKey = point.first.shl(12) + point.second + if (buildingNodes[coordinateKey] == null) { + buildingNodes[coordinateKey] = arrayListOf(details.copy()) + } + else { + buildingNodes[coordinateKey]?.add(details.copy()) + } + } + } + + /** + * generateIntersections goes through our hash map and adds an intersection feature to the + * collection wherever it finds out. + * @param collection is where the new intersection features are added + * @param tileX the tile x coordinate so that the tile relative location of the intersection can + * be turned into a latitude/longitude + * @param tileY the tile y coordinate so that the tile relative location of the intersection can + * * be turned into a latitude/longitude + */ + fun generateEntrances(collection: FeatureCollection, tileX : Int, tileY : Int, tileZoom : Int) { + // Add points for the intersections that we found + for ((key, nodes) in buildingNodes) { + + // Generate an entrance with a matching POI polygon + var entranceDetails : EntranceDetails? = null + var poiDetails : EntranceDetails? = null + for(node in nodes) { + if(!node.poi) { + // We have an entrance! + entranceDetails = node + } else { + poiDetails = node + } + } + + // If we have an entrance at this point then we generate a feature to represent it + // using the POI that it is coincident with if there is one. + if(entranceDetails != null) { + // Turn our coordinate key back into tile relative x,y coordinates + val x = key.shr(12) + val y = key.and(0xfff) + // Convert the tile relative coordinate into a LatLngAlt + val point = arrayListOf(Pair(x, y)) + val coordinates = convertGeometry(tileX, tileY, tileZoom, point) + + // Create our entrance feature to match those from soundscape-backend + val entrance = Feature() + entrance.geometry = + Point(coordinates[0].longitude, coordinates[0].latitude) + entrance.foreign = HashMap() + entrance.foreign!!["feature_type"] = "entrance" + entrance.foreign!!["feature_value"] = entranceDetails.entranceType + val osmIds = arrayListOf() + osmIds.add(entranceDetails.osmId) + entrance.foreign!!["osm_ids"] = osmIds + + entrance.properties = HashMap() + entrance.properties!!["name"] = entranceDetails.name + if(entranceDetails.name == null) + entrance.properties!!["name"] = poiDetails?.name + + collection.addFeature(entrance) + +// println("Entrance: ${poiDetails?.name} ${entranceDetails.entranceType} ") + } + } + } +} + +data class EntranceDetails( + val name : String?, + val entranceType : String?, + val poi: Boolean, + val osmId : Double, +) \ No newline at end of file diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/InterpolatedPointsJoiner.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/InterpolatedPointsJoiner.kt new file mode 100644 index 00000000..3f8f52d2 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/InterpolatedPointsJoiner.kt @@ -0,0 +1,270 @@ +package org.scottishtecharmy.soundscape.geoengine.mvttranslation + +import org.scottishtecharmy.soundscape.geoengine.utils.distance +import org.scottishtecharmy.soundscape.geoengine.utils.getLatLonTileWithOffset +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature +import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Geometry +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LineString +import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt + +class InterpolatedPointsJoiner { + + private val interpolatedPoints: HashMap> = hashMapOf() + + fun addInterpolatedPoints(feature: Feature): Boolean { + // We add all edgePoint coordinates to our HashMap of interpolated points by OSM id + if (feature.properties?.containsKey("class")!!) { + if (feature.properties!!["class"] == "edgePoint") { + val geometry = feature.geometry as Geometry + val osmId = feature.foreign!!["osm_id"] as Double + if (!interpolatedPoints.containsKey(osmId)) { + interpolatedPoints[osmId] = mutableListOf() + } + for (point in geometry.coordinates) { + // Add the point + interpolatedPoints[osmId]!!.add(point) + } + return false + } + } + return true + } + + fun addJoiningLines(featureCollection : FeatureCollection) { + for (entries in interpolatedPoints) { + if (entries.value.size > 1) { + // We want to find points that we can join together. Go through the list of points + // for the OSM id comparing against the other members in the list to see if any are + // almost at the same point. + for ((index1, point1) in entries.value.withIndex()) { + for ((index2, point2) in entries.value.withIndex()) { + if (index1 != index2) { + if (distance( + point1.latitude, + point1.longitude, + point2.latitude, + point2.longitude + ) < 0.1 + ) { + // If the points are within 10cm of each other, then join their + // LineStrings together. + val joining = Feature() + val foreign: HashMap = hashMapOf() + val osmIds = arrayListOf() + osmIds.add(entries.key) + foreign["osm_ids"] = entries.key + joining.foreign = foreign + joining.geometry = LineString(point1, point2) + joining.properties = foreign + + featureCollection.addFeature(joining) + } + } + } + } + } else { + // This is point must be on the outer edge or our grid, so we need do nothing + } + } + } +} + +/** + * convertGeometryAndClipLineToTile takes a line and converts it into a List of LineStrings. In the + * simplest case, the points are all within the tile and so there will just be a single LineString + * output. However, if the line goes off and on the tile (bouncing around in the buffer region) then + * there can be multiple segments returned. + * We also store all of the interpolated points that we've been created so that we can more easily + * connect them to the adjacent tiles in the grid. + */ +fun convertGeometryAndClipLineToTile( + tileX: Int, + tileY: Int, + tileZoom: Int, + line: ArrayList>, + interpolatedNodes: MutableList +) : List { + val returnList = mutableListOf() + + if(line.isEmpty()) { + return returnList + } + + // We want to iterate through the line detecting when it goes off/on tile and creating line + // segments for each. The ends of the line as it goes off tile need to be in LatLng as we want + // to interpolate as precisely as possible so that the line end is at the same point on adjacent + // tiles. The only other thing to bear in mind is that it's possible for two points to be off + // tile but the line between them to cross through the tile. + var offTile = pointIsOffTile(line[0].first, line[0].second) + val segment = arrayListOf() + var lastPoint = line[0] + for(point in line) { + if(pointIsOffTile(point.first, point.second) != offTile){ + if(offTile) { + // We started off tile and this point is now on tile + // Add interpolated point from lastPoint to this point + val interpolatedPoint = getTileCrossingPoint(lastPoint, point) + val interpolatedLatLon = getLatLonTileWithOffset( + tileX, + tileY, + tileZoom, + interpolatedPoint[0].first / 4096.0, + interpolatedPoint[0].second / 4096.0 + ) + segment.add(interpolatedLatLon) + interpolatedNodes.add(interpolatedLatLon) + + // Add the new point + segment.add( + getLatLonTileWithOffset( + tileX, + tileY, + tileZoom, + point.first.toDouble() / 4096.0, + point.second.toDouble() / 4096.0 + ) + ) + } else { + // We started on tile and this point is now off tile + // Add interpolated point from lastPoint to this point + val interpolatedPoint = getTileCrossingPoint(lastPoint, point) + val interpolatedLatLon = getLatLonTileWithOffset( + tileX, + tileY, + tileZoom, + interpolatedPoint[0].first / 4096.0, + interpolatedPoint[0].second / 4096.0 + ) + + segment.add(interpolatedLatLon) + interpolatedNodes.add(interpolatedLatLon) + returnList.add(LineString(ArrayList(segment))) + segment.clear() + } + + // Update the current point state + offTile = offTile.xor(true) + } + else if(!offTile) { + segment.add( + getLatLonTileWithOffset( + tileX, + tileY, + tileZoom, + point.first.toDouble() / 4096.0, + point.second.toDouble() / 4096.0 + ) + ) + } else { + // We're continuing off tile, but we need to check if the line between the two off tile + // points crossed over the tile. + val interpolatedPoints = getTileCrossingPoint(lastPoint, point) + for(ip in interpolatedPoints) { + val interpolatedLatLon = getLatLonTileWithOffset( + tileX, + tileY, + tileZoom, + ip.first / 4096.0, + ip.second / 4096.0 + ) + segment.add(interpolatedLatLon) + interpolatedNodes.add(interpolatedLatLon) + } + if(segment.isNotEmpty()) { + returnList.add(LineString(ArrayList(segment))) + segment.clear() + } + } + + lastPoint = point + } + if(segment.isNotEmpty()) { + returnList.add(LineString(segment)) + } + return returnList +} + +/** getTileCrossingPoint returns the point at which the line connecting lastPoint and point crosses + * the tile boundary. If both points are outside the tile there can be two intersection points + * returned. Otherwise there can only be a single intersection point. + * @param point1 Point on line that might cross tile boundary + * @param point2 Another point on the line that might cross the tile boundary + * + * @return The coordinates at which the line crosses the tile boundary as a list of pairs of Doubles + * to give us the best precision. + */ +fun getTileCrossingPoint(point1 : Pair, point2 : Pair) : List> { + + // Extract the coordinates of the points and square boundaries + val x1 = point1.first.toDouble() + val y1 = point1.second.toDouble() + val x2 = point2.first.toDouble() + val y2 = point2.second.toDouble() + + val intersections = mutableListOf>() + + // Check intersections with the four sides of the square + + // Left side (x = 0) + intersectVertical(0.0, y1, y2, x1, x2)?.let { yIntersection -> + if (yIntersection in 0.0..4096.0) { + intersections.add(Pair(0.0, yIntersection)) + } + } + + // Right side (x = 4096) + intersectVertical(4096.0, y1, y2, x1, x2)?.let { yIntersection -> + if (yIntersection in 0.0..4096.0) { + intersections.add(Pair(4096.0, yIntersection)) + } + } + + // Bottom side (y = 0.0) + intersectHorizontal(0.0, x1, x2, y1, y2)?.let { xIntersection -> + if (xIntersection in 0.0..4096.0) { + intersections.add(Pair(xIntersection, 0.0)) + } + } + + // Top side (y = 4096) + intersectHorizontal(4096.0, x1, x2, y1, y2)?.let { xIntersection -> + if (xIntersection in 0.0..4096.0) { + intersections.add(Pair(xIntersection, 4096.0)) + } + } + + // Return any intersections that we found + return intersections +} + +fun calculateSlope(aConst: Double, a1: Double, a2: Double) : Double? { + if (a1 == a2) { + // Parallel lines, so no intersection + return null + } + val t = (aConst - a1) / (a2 - a1) + if (t < 0.0 || t > 1.0) { + // Intersection point is outside the segment + return null + } + return t +} + +// Function to calculate the intersection with a vertical line (x = constant) +fun intersectVertical(xConst: Double, y1: Double, y2: Double, x1: Double, x2: Double): Double? { + val t = calculateSlope(xConst, x1, x2) + if(t != null) { + return y1 + t * (y2 - y1) + } + return null +} + +// Function to calculate the intersection with a horizontal line (y = constant) +fun intersectHorizontal(yConst: Double, x1: Double, x2: Double, y1: Double, y2: Double): Double? { + val t = calculateSlope(yConst, y1, y2) + if(t != null) { + return x1 + t * (x2 - x1) + } + return null +} \ No newline at end of file diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/IntersectionDetection.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/IntersectionDetection.kt new file mode 100644 index 00000000..bc0ad181 --- /dev/null +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/IntersectionDetection.kt @@ -0,0 +1,127 @@ +package org.scottishtecharmy.soundscape.geoengine.mvttranslation + +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature +import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection +import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point + +class IntersectionDetection { + + /** + * highwayPoints is a sparse map which maps from a location within the tile to a list of + * lines which have nodes at that point. Every node on any `transportation` line will appear in the + * map and if after processing all of the lines there's an intersection at that point, the map + * entry will have information for more than one line. + */ + private val highwayNodes : HashMap< Int, ArrayList> = hashMapOf() + + /** + * addLine is called for any line feature that is being added to the FeatureCollection. + * @param line is a new `transportation` layer line to add to the map + * @param details describes the line that is being added. + * + */ + fun addLine(line : ArrayList>, + details : IntersectionDetails + ) { + for (point in line) { + if((point.first < 0) || (point.first > 4095) || + (point.second < 0) || (point.second > 4095)) { + continue + } + + // Rather than have a 2D sparse array, turn the coordinates into a single int so that we + // can have a 1D sparse array instead. + val coordinateKey = point.first.shl(12) + point.second + val detailsCopy = details.copy() + detailsCopy.lineEnd = ((point == line.first()) || (point == line.last())) + if (highwayNodes[coordinateKey] == null) { + highwayNodes[coordinateKey] = arrayListOf(detailsCopy) + } + else { + highwayNodes[coordinateKey]?.add(detailsCopy) + } + } + } + + /** + * generateIntersections goes through our hash map and adds an intersection feature to the + * collection wherever it finds out. + * @param collection is where the new intersection features are added + * @param tileX the tile x coordinate so that the tile relative location of the intersection can + * be turned into a latitude/longitude + * @param tileY the tile y coordinate so that the tile relative location of the intersection can + * * be turned into a latitude/longitude + */ + fun generateIntersections(collection: FeatureCollection, tileX : Int, tileY : Int, tileZoom : Int) { + // Add points for the intersections that we found + for ((key, intersections) in highwayNodes) { + + // An intersection exists where there are nodes from multiple line at the same location + if (intersections.size > 1) { + + if(intersections.size == 2) { + // An intersection with only 2 lines might just be the same line but it's been + // drawn in two separate segments. It's an an intersection if both lines aren't + // ending (i.e. one line is joining half way along the other line) or the type + // of line changes, or the name changes etc. Check these and don't add the + // intersection if we don't believe it meets the criteria. + val line1 = intersections[0] + val line2 = intersections[1] + if((line1.type == line2.type) && + (line1.name == line2.name) && + (line1.brunnel == line2.brunnel) && + (line1.subClass == line2.subClass) && + line1.lineEnd && line2.lineEnd) { + // This isn't an intersection, simply two line segments with the same + // properties joining at a point. + continue + } + + } + + // Turn our coordinate key back into tile relative x,y coordinates + val x = key.shr(12) + val y = key.and(0xfff) + // Convert the tile relative coordinate into a LatLngAlt + val point = arrayListOf(Pair(x, y)) + val coordinates = convertGeometry(tileX, tileY, tileZoom, point) + + // Create our intersection feature to match those from soundscape-backend + val intersection = Feature() + intersection.geometry = + Point(coordinates[0].longitude, coordinates[0].latitude) + intersection.foreign = HashMap() + intersection.foreign!!["feature_type"] = "highway" + intersection.foreign!!["feature_value"] = "gd_intersection" + var name = "" + val osmIds = arrayListOf() + for (road in intersections) { + if(name.isNotEmpty()) { + name += "/" + } + if (road.brunnel != "null") + name += road.brunnel + else if (road.subClass != "null") + name += road.subClass + else + name += road.name + + osmIds.add(road.id) + } + intersection.foreign!!["osm_ids"] = osmIds + intersection.properties = HashMap() + intersection.properties!!["name"] = name + collection.addFeature(intersection) + } + } + } +} + +data class IntersectionDetails( + val name : String, + val type : String, + val subClass : String, + val brunnel : String, + val id : Double, + var lineEnd : Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/MvtToGeoJson.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/MvtToGeoJson.kt index a17a0b75..6ea235b2 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/MvtToGeoJson.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/mvttranslation/MvtToGeoJson.kt @@ -1,16 +1,13 @@ package org.scottishtecharmy.soundscape.geoengine.mvttranslation -import org.scottishtecharmy.soundscape.geoengine.utils.distance +import org.scottishtecharmy.soundscape.geoengine.ZOOM_LEVEL import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection import org.scottishtecharmy.soundscape.geojsonparser.geojson.GeoJsonObject -import org.scottishtecharmy.soundscape.geojsonparser.geojson.Geometry -import org.scottishtecharmy.soundscape.geojsonparser.geojson.LineString import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.geojsonparser.geojson.MultiPoint import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point import org.scottishtecharmy.soundscape.geojsonparser.geojson.Polygon -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.ZOOM_LEVEL import org.scottishtecharmy.soundscape.geoengine.utils.getLatLonTileWithOffset import vector_tile.VectorTile @@ -99,7 +96,7 @@ private fun parseGeometry( return results } -private fun convertGeometry(tileX : Int, tileY : Int, tileZoom : Int, geometry: ArrayList>) : ArrayList { +fun convertGeometry(tileX : Int, tileY : Int, tileZoom : Int, geometry: ArrayList>) : ArrayList { val results = arrayListOf() for(point in geometry) { results.add( @@ -113,481 +110,6 @@ private fun convertGeometry(tileX : Int, tileY : Int, tileZoom : Int, geometry: return results } -data class IntersectionDetails( - val name : String, - val type : String, - val subClass : String, - val brunnel : String, - val id : Double, - var lineEnd : Boolean = false -) - -class InterpolatedPointsJoiner { - - private val interpolatedPoints: HashMap> = hashMapOf() - - fun addInterpolatedPoints(feature: Feature): Boolean { - // We add all edgePoint coordinates to our HashMap of interpolated points by OSM id - if (feature.properties?.containsKey("class")!!) { - if (feature.properties!!["class"] == "edgePoint") { - val geometry = feature.geometry as Geometry - val osmId = feature.foreign!!["osm_id"] as Double - if (!interpolatedPoints.containsKey(osmId)) { - interpolatedPoints[osmId] = mutableListOf() - } - for (point in geometry.coordinates) { - // Add the point - interpolatedPoints[osmId]!!.add(point) - } - return false - } - } - return true - } - - fun addJoiningLines(featureCollection : FeatureCollection) { - for (entries in interpolatedPoints) { - if (entries.value.size > 1) { - // We want to find points that we can join together. Go through the list of points - // for the OSM id comparing against the other members in the list to see if any are - // almost at the same point. - for ((index1, point1) in entries.value.withIndex()) { - for ((index2, point2) in entries.value.withIndex()) { - if (index1 != index2) { - if (distance( - point1.latitude, - point1.longitude, - point2.latitude, - point2.longitude - ) < 0.1 - ) { - // If the points are within 10cm of each other, then join their - // LineStrings together. - val joining = Feature() - val foreign: HashMap = hashMapOf() - val osmIds = arrayListOf() - osmIds.add(entries.key) - foreign["osm_ids"] = entries.key - joining.foreign = foreign - joining.geometry = LineString(point1, point2) - joining.properties = foreign - - featureCollection.addFeature(joining) - } - } - } - } - } else { - // This is point must be on the outer edge or our grid, so we need do nothing - } - } - } -} - -class IntersectionDetection { - - /** - * highwayPoints is a sparse map which maps from a location within the tile to a list of - * lines which have nodes at that point. Every node on any `transportation` line will appear in the - * map and if after processing all of the lines there's an intersection at that point, the map - * entry will have information for more than one line. - */ - private val highwayNodes : HashMap< Int, ArrayList> = hashMapOf() - - /** - * addLine is called for any line feature that is being added to the FeatureCollection. - * @param line is a new `transportation` layer line to add to the map - * @param details describes the line that is being added. - * - */ - fun addLine(line : ArrayList>, - details : IntersectionDetails - ) { - for (point in line) { - if((point.first < 0) || (point.first > 4095) || - (point.second < 0) || (point.second > 4095)) { - continue - } - - // Rather than have a 2D sparse array, turn the coordinates into a single int so that we - // can have a 1D sparse array instead. - val coordinateKey = point.first.shl(12) + point.second - val detailsCopy = details.copy() - detailsCopy.lineEnd = ((point == line.first()) || (point == line.last())) - if (highwayNodes[coordinateKey] == null) { - highwayNodes[coordinateKey] = arrayListOf(detailsCopy) - } - else { - highwayNodes[coordinateKey]?.add(detailsCopy) - } - } - } - - /** - * generateIntersections goes through our hash map and adds an intersection feature to the - * collection wherever it finds out. - * @param collection is where the new intersection features are added - * @param tileX the tile x coordinate so that the tile relative location of the intersection can - * be turned into a latitude/longitude - * @param tileY the tile y coordinate so that the tile relative location of the intersection can - * * be turned into a latitude/longitude - */ - fun generateIntersections(collection: FeatureCollection, tileX : Int, tileY : Int, tileZoom : Int) { - // Add points for the intersections that we found - for ((key, intersections) in highwayNodes) { - - // An intersection exists where there are nodes from multiple line at the same location - if (intersections.size > 1) { - - if(intersections.size == 2) { - // An intersection with only 2 lines might just be the same line but it's been - // drawn in two separate segments. It's an an intersection if both lines aren't - // ending (i.e. one line is joining half way along the other line) or the type - // of line changes, or the name changes etc. Check these and don't add the - // intersection if we don't believe it meets the criteria. - val line1 = intersections[0] - val line2 = intersections[1] - if((line1.type == line2.type) && - (line1.name == line2.name) && - (line1.brunnel == line2.brunnel) && - (line1.subClass == line2.subClass) && - line1.lineEnd && line2.lineEnd) { - // This isn't an intersection, simply two line segments with the same - // properties joining at a point. - continue - } - - } - - // Turn our coordinate key back into tile relative x,y coordinates - val x = key.shr(12) - val y = key.and(0xfff) - // Convert the tile relative coordinate into a LatLngAlt - val point = arrayListOf(Pair(x, y)) - val coordinates = convertGeometry(tileX, tileY, tileZoom, point) - - // Create our intersection feature to match those from soundscape-backend - val intersection = Feature() - intersection.geometry = - Point(coordinates[0].longitude, coordinates[0].latitude) - intersection.foreign = HashMap() - intersection.foreign!!["feature_type"] = "highway" - intersection.foreign!!["feature_value"] = "gd_intersection" - var name = "" - val osmIds = arrayListOf() - for (road in intersections) { - if(name.isNotEmpty()) { - name += "/" - } - if (road.brunnel != "null") - name += road.brunnel - else if (road.subClass != "null") - name += road.subClass - else - name += road.name - - osmIds.add(road.id) - } - intersection.foreign!!["osm_ids"] = osmIds - intersection.properties = HashMap() - intersection.properties!!["name"] = name - collection.addFeature(intersection) - } - } - } -} - -data class EntranceDetails( - val name : String?, - val entranceType : String?, - val poi: Boolean, - val osmId : Double, -) -class EntranceMatching { - - /** - * buildingNodes is a sparse map which maps from a location within the tile to a list of - * building polygons which have nodes at that point. Every node on any `POI` polygon will appear - * in the map along with any entrance. After processing it should be straightforward to match - * up entrances to their POI polygons. - */ - private val buildingNodes : HashMap< Int, ArrayList> = hashMapOf() - - /** - * addLine is called for any line feature that is being added to the FeatureCollection. - * @param line is a new `transportation` layer line to add to the map - * @param details describes the line that is being added. - * - */ - fun addPolygon(line : ArrayList>, - details : EntranceDetails - ) { - for (point in line) { - if((point.first < 0) || (point.first > 4095) || - (point.second < 0) || (point.second > 4095)) { - continue - } - - // Rather than have a 2D sparse array, turn the coordinates into a single int so that we - // can have a 1D sparse array instead. - val coordinateKey = point.first.shl(12) + point.second - if (buildingNodes[coordinateKey] == null) { - buildingNodes[coordinateKey] = arrayListOf(details.copy()) - } - else { - buildingNodes[coordinateKey]?.add(details.copy()) - } - } - } - - /** - * generateIntersections goes through our hash map and adds an intersection feature to the - * collection wherever it finds out. - * @param collection is where the new intersection features are added - * @param tileX the tile x coordinate so that the tile relative location of the intersection can - * be turned into a latitude/longitude - * @param tileY the tile y coordinate so that the tile relative location of the intersection can - * * be turned into a latitude/longitude - */ - fun generateEntrances(collection: FeatureCollection, tileX : Int, tileY : Int, tileZoom : Int) { - // Add points for the intersections that we found - for ((key, nodes) in buildingNodes) { - - // Generate an entrance with a matching POI polygon - var entranceDetails : EntranceDetails? = null - var poiDetails : EntranceDetails? = null - for(node in nodes) { - if(!node.poi) { - // We have an entrance! - entranceDetails = node - } else { - poiDetails = node - } - } - - // If we have an entrance at this point then we generate a feature to represent it - // using the POI that it is coincident with if there is one. - if(entranceDetails != null) { - // Turn our coordinate key back into tile relative x,y coordinates - val x = key.shr(12) - val y = key.and(0xfff) - // Convert the tile relative coordinate into a LatLngAlt - val point = arrayListOf(Pair(x, y)) - val coordinates = convertGeometry(tileX, tileY, tileZoom, point) - - // Create our entrance feature to match those from soundscape-backend - val entrance = Feature() - entrance.geometry = - Point(coordinates[0].longitude, coordinates[0].latitude) - entrance.foreign = HashMap() - entrance.foreign!!["feature_type"] = "entrance" - entrance.foreign!!["feature_value"] = entranceDetails.entranceType - val osmIds = arrayListOf() - osmIds.add(entranceDetails.osmId) - entrance.foreign!!["osm_ids"] = osmIds - - entrance.properties = HashMap() - entrance.properties!!["name"] = entranceDetails.name - if(entranceDetails.name == null) - entrance.properties!!["name"] = poiDetails?.name - - collection.addFeature(entrance) - -// println("Entrance: ${poiDetails?.name} ${entranceDetails.entranceType} ") - } - } - } -} - -fun calculateSlope(aConst: Double, a1: Double, a2: Double) : Double? { - if (a1 == a2) { - // Parallel lines, so no intersection - return null - } - val t = (aConst - a1) / (a2 - a1) - if (t < 0.0 || t > 1.0) { - // Intersection point is outside the segment - return null - } - return t -} - -// Function to calculate the intersection with a vertical line (x = constant) -fun intersectVertical(xConst: Double, y1: Double, y2: Double, x1: Double, x2: Double): Double? { - val t = calculateSlope(xConst, x1, x2) - if(t != null) { - return y1 + t * (y2 - y1) - } - return null -} - -// Function to calculate the intersection with a horizontal line (y = constant) -fun intersectHorizontal(yConst: Double, x1: Double, x2: Double, y1: Double, y2: Double): Double? { - val t = calculateSlope(yConst, y1, y2) - if(t != null) { - return x1 + t * (x2 - x1) - } - return null -} - -/** getTileCrossingPoint returns the point at which the line connecting lastPoint and point crosses - * the tile boundary. If both points are outside the tile there can be two intersection points - * returned. Otherwise there can only be a single intersection point. - * @param point1 Point on line that might cross tile boundary - * @param point2 Another point on the line that might cross the tile boundary - * - * @return The coordinates at which the line crosses the tile boundary as a list of pairs of Doubles - * to give us the best precision. - */ -fun getTileCrossingPoint(point1 : Pair, point2 : Pair) : List> { - - // Extract the coordinates of the points and square boundaries - val x1 = point1.first.toDouble() - val y1 = point1.second.toDouble() - val x2 = point2.first.toDouble() - val y2 = point2.second.toDouble() - - val intersections = mutableListOf>() - - // Check intersections with the four sides of the square - - // Left side (x = 0) - intersectVertical(0.0, y1, y2, x1, x2)?.let { yIntersection -> - if (yIntersection in 0.0..4096.0) { - intersections.add(Pair(0.0, yIntersection)) - } - } - - // Right side (x = 4096) - intersectVertical(4096.0, y1, y2, x1, x2)?.let { yIntersection -> - if (yIntersection in 0.0..4096.0) { - intersections.add(Pair(4096.0, yIntersection)) - } - } - - // Bottom side (y = 0.0) - intersectHorizontal(0.0, x1, x2, y1, y2)?.let { xIntersection -> - if (xIntersection in 0.0..4096.0) { - intersections.add(Pair(xIntersection, 0.0)) - } - } - - // Top side (y = 4096) - intersectHorizontal(4096.0, x1, x2, y1, y2)?.let { xIntersection -> - if (xIntersection in 0.0..4096.0) { - intersections.add(Pair(xIntersection, 4096.0)) - } - } - - // Return any intersections that we found - return intersections -} - -/** - * convertGeometryAndClipLineToTile takes a line and converts it into a List of LineStrings. In the - * simplest case, the points are all within the tile and so there will just be a single LineString - * output. However, if the line goes off and on the tile (bouncing around in the buffer region) then - * there can be multiple segments returned. - * We also store all of the interpolated points that we've been created so that we can more easily - * connect them to the adjacent tiles in the grid. - */ -fun convertGeometryAndClipLineToTile( - tileX: Int, - tileY: Int, - tileZoom: Int, - line: ArrayList>, - interpolatedNodes: MutableList -) : List { - val returnList = mutableListOf() - - if(line.isEmpty()) { - return returnList - } - - // We want to iterate through the line detecting when it goes off/on tile and creating line - // segments for each. The ends of the line as it goes off tile need to be in LatLng as we want - // to interpolate as precisely as possible so that the line end is at the same point on adjacent - // tiles. The only other thing to bear in mind is that it's possible for two points to be off - // tile but the line between them to cross through the tile. - var offTile = pointIsOffTile(line[0].first, line[0].second) - val segment = arrayListOf() - var lastPoint = line[0] - for(point in line) { - if(pointIsOffTile(point.first, point.second) != offTile){ - if(offTile) { - // We started off tile and this point is now on tile - // Add interpolated point from lastPoint to this point - val interpolatedPoint = getTileCrossingPoint(lastPoint, point) - val interpolatedLatLon = getLatLonTileWithOffset(tileX, - tileY, - tileZoom, - interpolatedPoint[0].first/4096.0, - interpolatedPoint[0].second/4096.0) - segment.add(interpolatedLatLon) - interpolatedNodes.add(interpolatedLatLon) - - // Add the new point - segment.add( - getLatLonTileWithOffset(tileX, - tileY, - tileZoom, - point.first.toDouble()/4096.0, - point.second.toDouble()/4096.0) - ) - } else { - // We started on tile and this point is now off tile - // Add interpolated point from lastPoint to this point - val interpolatedPoint = getTileCrossingPoint(lastPoint, point) - val interpolatedLatLon = getLatLonTileWithOffset(tileX, - tileY, - tileZoom, - interpolatedPoint[0].first/4096.0, - interpolatedPoint[0].second/4096.0) - - segment.add(interpolatedLatLon) - interpolatedNodes.add(interpolatedLatLon) - returnList.add(LineString(ArrayList(segment))) - segment.clear() - } - - // Update the current point state - offTile = offTile.xor(true) - } - else if(!offTile) { - segment.add( - getLatLonTileWithOffset(tileX, - tileY, - tileZoom, - point.first.toDouble()/4096.0, - point.second.toDouble()/4096.0) - ) - } else { - // We're continuing off tile, but we need to check if the line between the two off tile - // points crossed over the tile. - val interpolatedPoints = getTileCrossingPoint(lastPoint, point) - for(ip in interpolatedPoints) { - val interpolatedLatLon = getLatLonTileWithOffset(tileX, - tileY, - tileZoom, - ip.first/4096.0, - ip.second/4096.0) - segment.add(interpolatedLatLon) - interpolatedNodes.add(interpolatedLatLon) - } - if(segment.isNotEmpty()) { - returnList.add(LineString(ArrayList(segment))) - segment.clear() - } - } - - lastPoint = point - } - if(segment.isNotEmpty()) { - returnList.add(LineString(segment)) - } - return returnList -} - /** * vectorTileToGeoJson generates a GeoJSON FeatureCollection from a Mapbox Vector Tile. * @param tileX is the x coordinate of the tile diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/utils/TileGridUtils.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/utils/TileGridUtils.kt index 543cef6e..70f32fcb 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/utils/TileGridUtils.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/utils/TileGridUtils.kt @@ -2,6 +2,8 @@ package org.scottishtecharmy.soundscape.geoengine.utils import org.scottishtecharmy.soundscape.dto.BoundingBox import org.scottishtecharmy.soundscape.dto.Tile +import org.scottishtecharmy.soundscape.geoengine.GRID_SIZE +import org.scottishtecharmy.soundscape.geoengine.ZOOM_LEVEL import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature import org.scottishtecharmy.soundscape.geojsonparser.geojson.FeatureCollection import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt @@ -53,42 +55,6 @@ class TileGrid(newTiles : MutableList, newCentralBoundingBox : BoundingBox } companion object { - /** - * The zoom level and grid size are constant. When using soundscape-backend these will be - * 16 and 3, but if we switch to using protobuf tiles they will be 15 and 2. - */ - const val SOUNDSCAPE_TILE_BACKEND = false - val ZOOM_LEVEL = if(SOUNDSCAPE_TILE_BACKEND) 16 else 15 - var GRID_SIZE = if(SOUNDSCAPE_TILE_BACKEND) 3 else 2 - - /** - * The default tile server is the one out in the cloud where the tile JSON is at: - * https://server/protomaps.json - * - * and the tiles are at - * https://server/protomaps/{z}/{x}/{y}.mvt - */ - const val PROTOMAPS_SERVER_BASE = "https://d1wzlzgah5gfol.cloudfront.net" - const val PROTOMAPS_SERVER_PATH = "protomaps" - const val PROTOMAPS_SUFFIX = "mvt" - - /** - * It's also useful to be able to use tiles served up locally when testing. When I - * test locally I'm serving up the file like this: - * - * tileserver-gl-light --file europe.pmtiles -b 192.168.86.39 - * - * With this configuration the tile JSON descriptor appears at: - * http://192.168.86.39:8080/data/v3.json - * - * and the tiles within it are at: - * http://192.168.86.39:8080/data/v3/{z}/{x}/{y}.pbf - * - */ - //const val PROTOMAPS_SERVER_BASE = "http://192.168.86.39:8080" - //const val PROTOMAPS_SERVER_PATH = "data/v3" - //const val PROTOMAPS_SUFFIX = "pbf" - /** * Given a location it calculates the set of tiles (VectorTiles) that cover a * 3 x 3 grid around the specified location. diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/network/ITileDAO.kt b/app/src/main/java/org/scottishtecharmy/soundscape/network/ITileDAO.kt index 73ca8d7d..21534335 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/network/ITileDAO.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/network/ITileDAO.kt @@ -1,7 +1,7 @@ package org.scottishtecharmy.soundscape.network -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.PROTOMAPS_SERVER_PATH -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.PROTOMAPS_SUFFIX +import org.scottishtecharmy.soundscape.geoengine.PROTOMAPS_SERVER_PATH +import org.scottishtecharmy.soundscape.geoengine.PROTOMAPS_SUFFIX import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/network/ProtomapsTileClient.kt b/app/src/main/java/org/scottishtecharmy/soundscape/network/ProtomapsTileClient.kt index f967fd15..052fccea 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/network/ProtomapsTileClient.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/network/ProtomapsTileClient.kt @@ -1,7 +1,7 @@ package org.scottishtecharmy.soundscape.network import android.app.Application -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.PROTOMAPS_SERVER_BASE +import org.scottishtecharmy.soundscape.geoengine.PROTOMAPS_SERVER_BASE import retrofit2.Retrofit import retrofit2.converter.protobuf.ProtoConverterFactory diff --git a/app/src/test/java/org/scottishtecharmy/soundscape/TileUtilsTest.kt b/app/src/test/java/org/scottishtecharmy/soundscape/TileUtilsTest.kt index fcd7e28c..8b6d45fd 100644 --- a/app/src/test/java/org/scottishtecharmy/soundscape/TileUtilsTest.kt +++ b/app/src/test/java/org/scottishtecharmy/soundscape/TileUtilsTest.kt @@ -28,9 +28,9 @@ import org.scottishtecharmy.soundscape.geoengine.utils.polygonContainsCoordinate import com.squareup.moshi.Moshi import org.junit.Assert import org.junit.Test +import org.scottishtecharmy.soundscape.geoengine.GRID_SIZE import org.scottishtecharmy.soundscape.geojsonparser.geojson.Feature import org.scottishtecharmy.soundscape.geojsonparser.geojson.LineString -import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.GRID_SIZE import org.scottishtecharmy.soundscape.geoengine.utils.TileGrid.Companion.getTileGrid import org.scottishtecharmy.soundscape.geoengine.utils.createTriangleFOV import org.scottishtecharmy.soundscape.geoengine.utils.explodeLineString