using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Imaging; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.Services.RouteManagement; // AZ-364 / C11: extracted from RouteProcessingService.StitchRouteTilesAsync. // Owns the route__stitched.jpg path computation, the per-tile filename // parsing into TilePlacement, the call into the shared TileGridStitcher // (Common), and the route-specific overlays (yellow geofence rectangles, // red 50-arm crosses on each route point). Behavior preserved verbatim per // AC-2 (pixel-for-pixel identical output for existing scenarios). public class RouteImageRenderer { private const int TileSizePixels = 256; private readonly StorageConfig _storageConfig; private readonly ILogger _logger; public RouteImageRenderer(IOptions storageConfig, ILogger logger) { ArgumentNullException.ThrowIfNull(storageConfig); _storageConfig = storageConfig.Value; _logger = logger; } public async Task RenderAsync( Guid routeId, IReadOnlyList tiles, int zoomLevel, IReadOnlyList<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds, IReadOnlyList routePoints, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(tiles); ArgumentNullException.ThrowIfNull(geofencePolygonBounds); ArgumentNullException.ThrowIfNull(routePoints); if (tiles.Count == 0) { _logger.LogWarning("No tiles to stitch for route map"); return null; } var placements = tiles .Select(t => { var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); return new TilePlacement(tileX, tileY, t.FilePath); }) .Where(p => p.TileX >= 0 && p.TileY >= 0) .ToList(); if (placements.Count == 0) { _logger.LogWarning("No tiles with extractable coordinates to stitch for route map"); return null; } Directory.CreateDirectory(_storageConfig.ReadyDirectory); var outputPath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_stitched.jpg"); var stitcher = new TileGridStitcher(); var result = await stitcher.StitchAsync( placements, TileSizePixels, deduplicateByTileCoords: true, swallowTileLoadErrors: true, cancellationToken); using var stitchedImage = result.Image; foreach (var missing in result.MissingTiles) { if (missing.Reason == MissingTileReason.LoadFailed) { _logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath); } else { _logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath); } } var minX = result.MinX; var minY = result.MinY; var imageWidth = result.ImageWidth; var imageHeight = result.ImageHeight; var yellow = new Rgb24(255, 255, 0); var red = new Rgb24(255, 0, 0); foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds) { 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; 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) { stitcher.DrawRectangleBorder( stitchedImage, new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), yellow); } } foreach (var point in routePoints) { var geoPoint = new GeoPoint(point.Latitude, 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) { stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50); } } await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); return outputPath; } // Parses tile____.jpg via StorageConfig.TryExtractTileCoordinates. // On parse failure, logs a warning and returns the (-1, -1) sentinel — the // RenderAsync pipeline filters those out before stitching. ArgumentNullException // on null input is propagated from StorageConfig. internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) { if (StorageConfig.TryExtractTileCoordinates(filePath, out var tileX, out var tileY)) { return (tileX, tileY); } _logger.LogWarning( "Could not extract tile coordinates from filename {FilePath}; expected pattern tile____", filePath); return (-1, -1); } }