diff --git a/SatelliteProvider.DataAccess/Migrations/009_AddGeofencePolygonIndex.sql b/SatelliteProvider.DataAccess/Migrations/009_AddGeofencePolygonIndex.sql new file mode 100644 index 0000000..a800eb3 --- /dev/null +++ b/SatelliteProvider.DataAccess/Migrations/009_AddGeofencePolygonIndex.sql @@ -0,0 +1,2 @@ +ALTER TABLE route_regions ADD COLUMN geofence_polygon_index INTEGER; + diff --git a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs index 0fb6d03..ddde612 100644 --- a/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/IRouteRepository.cs @@ -10,9 +10,10 @@ public interface IRouteRepository Task InsertRoutePointsAsync(IEnumerable points); Task UpdateRouteAsync(RouteEntity route); Task DeleteRouteAsync(Guid id); - Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false); + Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false, int? geofencePolygonIndex = null); Task> GetRegionIdsByRouteAsync(Guid routeId); Task> GetGeofenceRegionIdsByRouteAsync(Guid routeId); + Task>> GetGeofenceRegionsByPolygonAsync(Guid routeId); Task> GetRoutesWithPendingMapsAsync(); } diff --git a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs index 35a357f..5e113d9 100644 --- a/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs +++ b/SatelliteProvider.DataAccess/Repositories/RouteRepository.cs @@ -121,15 +121,15 @@ public class RouteRepository : IRouteRepository return await connection.ExecuteAsync(sql, new { Id = id }); } - public async Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false) + public async Task LinkRouteToRegionAsync(Guid routeId, Guid regionId, bool isGeofence = false, int? geofencePolygonIndex = null) { using var connection = new NpgsqlConnection(_connectionString); const string sql = @" - INSERT INTO route_regions (route_id, region_id, is_geofence, created_at) - VALUES (@RouteId, @RegionId, @IsGeofence, @CreatedAt) + INSERT INTO route_regions (route_id, region_id, is_geofence, geofence_polygon_index, created_at) + VALUES (@RouteId, @RegionId, @IsGeofence, @GeofencePolygonIndex, @CreatedAt) ON CONFLICT (route_id, region_id) DO NOTHING"; - await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, IsGeofence = isGeofence, CreatedAt = DateTime.UtcNow }); + await connection.ExecuteAsync(sql, new { RouteId = routeId, RegionId = regionId, IsGeofence = isGeofence, GeofencePolygonIndex = geofencePolygonIndex, CreatedAt = DateTime.UtcNow }); } public async Task> GetRegionIdsByRouteAsync(Guid routeId) @@ -169,5 +169,29 @@ public class RouteRepository : IRouteRepository return await connection.QueryAsync(sql); } + + public async Task>> GetGeofenceRegionsByPolygonAsync(Guid routeId) + { + using var connection = new NpgsqlConnection(_connectionString); + const string sql = @" + SELECT region_id, geofence_polygon_index + FROM route_regions + WHERE route_id = @RouteId AND is_geofence = true AND geofence_polygon_index IS NOT NULL + ORDER BY geofence_polygon_index"; + + var results = await connection.QueryAsync<(Guid RegionId, int PolygonIndex)>(sql, new { RouteId = routeId }); + + var grouped = new Dictionary>(); + foreach (var (regionId, polygonIndex) in results) + { + if (!grouped.ContainsKey(polygonIndex)) + { + grouped[polygonIndex] = new List(); + } + grouped[polygonIndex].Add(regionId); + } + + return grouped; + } } diff --git a/SatelliteProvider.Services/RouteProcessingService.cs b/SatelliteProvider.Services/RouteProcessingService.cs index 491bd8b..1bb9683 100644 --- a/SatelliteProvider.Services/RouteProcessingService.cs +++ b/SatelliteProvider.Services/RouteProcessingService.cs @@ -196,7 +196,7 @@ public class RouteProcessingService : BackgroundService var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct(); var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList(); - await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, cancellationToken); + await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken); return; } @@ -246,6 +246,7 @@ public class RouteProcessingService : BackgroundService DataAccess.Models.RouteEntity route, IEnumerable regionIds, List geofenceRegionIds, + List routePoints, CancellationToken cancellationToken) { try @@ -326,11 +327,15 @@ public class RouteProcessingService : BackgroundService string? stitchedImagePath = null; if (route.RequestMaps) { - int? minX = null, minY = null, maxX = null, maxY = null; + var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>(); - if (geofenceRegionIds.Count > 0) + var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId); + + foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key)) { - foreach (var geofenceId in geofenceRegionIds) + int? minX = null, minY = null, maxX = null, maxY = null; + + foreach (var geofenceId in polygonRegionIds) { var region = await _regionRepository.GetByIdAsync(geofenceId); if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) @@ -357,18 +362,15 @@ public class RouteProcessingService : BackgroundService if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue) { - _logger.LogInformation("Route {RouteId}: Combined geofence tile bounds: X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", - routeId, minX.Value, maxX.Value, minY.Value, maxY.Value); + geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value)); + _logger.LogInformation("Route {RouteId}: Polygon {PolygonIndex} tile bounds: X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", + routeId, polygonIndex, minX.Value, maxX.Value, minY.Value, maxY.Value); } } stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); - - var geofenceBounds = (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue) - ? (minX.Value, minY.Value, maxX.Value, maxY.Value) - : ((int, int, int, int)?)null; - await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofenceBounds, cancellationToken); + await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken); } var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); @@ -455,7 +457,8 @@ public class RouteProcessingService : BackgroundService List tiles, string outputPath, int zoomLevel, - (int MinX, int MinY, int MaxX, int MaxY)? geofenceBounds, + List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds, + List routePoints, CancellationToken cancellationToken) { if (tiles.Count == 0) @@ -554,44 +557,66 @@ public class RouteProcessingService : BackgroundService } } - if (geofenceBounds.HasValue) + if (geofencePolygonBounds.Count > 0) { - var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofenceBounds.Value; + _logger.LogInformation("Drawing {Count} geofence polygon borders on image {Width}x{Height} (grid: minX={MinX}, minY={MinY})", + geofencePolygonBounds.Count, imageWidth, imageHeight, minX, minY); - _logger.LogInformation("Drawing geofence border on image {Width}x{Height} (grid: minX={MinX}, minY={MinY})", - imageWidth, imageHeight, minX, minY); - _logger.LogInformation("Geofence tile range - X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", - geoMinX, geoMaxX, geoMinY, geoMaxY); - - var x1 = (geoMinX - minX) * tileSizePixels; - var y1 = (geoMinY - minY) * tileSizePixels; - var x2 = (geoMaxX - minX + 1) * tileSizePixels - 1; - var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1; - - _logger.LogInformation("Geofence pixel coords before clipping - ({X1},{Y1}) to ({X2},{Y2})", - x1, y1, x2, y2); - - x1 = Math.Max(0, Math.Min(x1, imageWidth - 1)); - y1 = Math.Max(0, Math.Min(y1, imageHeight - 1)); - x2 = Math.Max(0, Math.Min(x2, imageWidth - 1)); - y2 = Math.Max(0, Math.Min(y2, imageHeight - 1)); - - if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1) + for (int i = 0; i < geofencePolygonBounds.Count; i++) { - _logger.LogInformation("Drawing geofence border at pixel coords ({X1},{Y1}) to ({X2},{Y2})", - x1, y1, x2, y2); + var (geoMinX, geoMinY, geoMaxX, geoMaxY) = geofencePolygonBounds[i]; - DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0)); + _logger.LogInformation("Polygon {Index}: Tile range - X=[{MinX}..{MaxX}], Y=[{MinY}..{MaxY}]", + i, geoMinX, geoMaxX, geoMinY, geoMaxY); - _logger.LogInformation("Successfully drew geofence border"); - } - else - { - _logger.LogWarning("Geofence border out of bounds or invalid - ({X1},{Y1}) to ({X2},{Y2}), image size: {Width}x{Height}", - x1, y1, x2, y2, imageWidth, imageHeight); + var x1 = (geoMinX - minX) * tileSizePixels; + var y1 = (geoMinY - minY + 1) * tileSizePixels; + var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1; + var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1; + + _logger.LogInformation("Polygon {Index}: Pixel coords before clipping - ({X1},{Y1}) to ({X2},{Y2})", + i, x1, y1, x2, y2); + + x1 = Math.Max(0, Math.Min(x1, imageWidth - 1)); + y1 = Math.Max(0, Math.Min(y1, imageHeight - 1)); + x2 = Math.Max(0, Math.Min(x2, imageWidth - 1)); + y2 = Math.Max(0, Math.Min(y2, imageHeight - 1)); + + if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1) + { + _logger.LogInformation("Polygon {Index}: Drawing border at pixel coords ({X1},{Y1}) to ({X2},{Y2})", + i, x1, y1, x2, y2); + + DrawRectangleBorder(stitchedImage, x1, y1, x2, y2, new Rgb24(255, 255, 0)); + + _logger.LogInformation("Polygon {Index}: Successfully drew geofence border", i); + } + else + { + _logger.LogWarning("Polygon {Index}: Border out of bounds or invalid - ({X1},{Y1}) to ({X2},{Y2}), image size: {Width}x{Height}", + i, x1, y1, x2, y2, imageWidth, imageHeight); + } } } + _logger.LogInformation("Drawing {Count} route point crosses on map", routePoints.Count); + + foreach (var point in routePoints) + { + var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude }; + var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel); + + var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2; + var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2; + + if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight) + { + DrawCross(stitchedImage, pixelX, pixelY, new Rgb24(255, 0, 0), 50); + } + } + + _logger.LogInformation("Finished drawing route point crosses"); + await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); var totalPossibleTiles = gridWidth * gridHeight; @@ -702,6 +727,34 @@ public class RouteProcessingService : BackgroundService } } } + + private static void DrawCross(Image image, int centerX, int centerY, Rgb24 color, int armLength) + { + const int thickness = 10; + int halfThickness = thickness / 2; + + for (int dx = -armLength; dx <= armLength; dx++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = centerX + dx; + int y = centerY + t; + if (x >= 0 && x < image.Width && y >= 0 && y < image.Height) + image[x, y] = color; + } + } + + for (int dy = -armLength; dy <= armLength; dy++) + { + for (int t = -halfThickness; t <= halfThickness; t++) + { + int x = centerX + t; + int y = centerY + dy; + if (x >= 0 && x < image.Width && y >= 0 && y < image.Height) + image[x, y] = color; + } + } + } } public class TileInfo diff --git a/SatelliteProvider.Services/RouteService.cs b/SatelliteProvider.Services/RouteService.cs index 95efe5f..49df97f 100644 --- a/SatelliteProvider.Services/RouteService.cs +++ b/SatelliteProvider.Services/RouteService.cs @@ -166,8 +166,10 @@ public class RouteService : IRouteService _logger.LogInformation("Route {RouteId}: Processing {GeofenceCount} geofence polygons", request.Id, request.Geofences.Polygons.Count); - foreach (var polygon in request.Geofences.Polygons) + for (int polygonIndex = 0; polygonIndex < request.Geofences.Polygons.Count; polygonIndex++) { + var polygon = request.Geofences.Polygons[polygonIndex]; + if (polygon.NorthWest is null || polygon.SouthEast is null) { throw new ArgumentException("Geofence polygon coordinates are required"); @@ -194,15 +196,15 @@ public class RouteService : IRouteService var geofenceRegions = CreateGeofenceRegionGrid(polygon.NorthWest, polygon.SouthEast, request.RegionSizeMeters); - _logger.LogInformation("Route {RouteId}: Created grid of {Count} regions to cover geofence area", - request.Id, geofenceRegions.Count); + _logger.LogInformation("Route {RouteId}: Polygon {PolygonIndex} - Created grid of {Count} regions to cover geofence area", + request.Id, polygonIndex, geofenceRegions.Count); foreach (var geofencePoint in geofenceRegions) { var geofenceRegionId = Guid.NewGuid(); - _logger.LogInformation("Route {RouteId}: Requesting geofence region {RegionId} at ({Lat}, {Lon}) with size {Size}m", - request.Id, geofenceRegionId, geofencePoint.Lat, geofencePoint.Lon, request.RegionSizeMeters); + _logger.LogInformation("Route {RouteId}: Polygon {PolygonIndex} - Requesting geofence region {RegionId} at ({Lat}, {Lon}) with size {Size}m", + request.Id, polygonIndex, geofenceRegionId, geofencePoint.Lat, geofencePoint.Lon, request.RegionSizeMeters); await _regionService.RequestRegionAsync( geofenceRegionId, @@ -212,7 +214,7 @@ public class RouteService : IRouteService request.ZoomLevel, stitchTiles: false); - await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true); + await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true, geofencePolygonIndex: polygonIndex); } } }