using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Common.Imaging; // AZ-367 / C14: shared tile-grid stitcher. // Both region and route map generation previously implemented the same placement // loop (min/max of TileX/TileY, allocate Image, foreach tile load + DrawImage // at (x-minX, y-minY) * tileSizePixels). The overlays (region center cross / route // geofence rectangles + route point crosses) differ and stay at the call sites. public sealed class TileGridStitcher { public async Task StitchAsync( IEnumerable tiles, int tileSizePixels, bool deduplicateByTileCoords, bool swallowTileLoadErrors, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(tiles); if (tileSizePixels <= 0) { throw new ArgumentOutOfRangeException(nameof(tileSizePixels), tileSizePixels, "Tile size must be positive."); } var sourceList = tiles.ToList(); if (sourceList.Count == 0) { throw new InvalidOperationException("No tiles to stitch."); } var orderedTiles = deduplicateByTileCoords ? sourceList .GroupBy(t => (t.TileX, t.TileY)) .Select(g => g.First()) .OrderBy(t => t.TileY) .ThenBy(t => t.TileX) .ToList() : sourceList; var minX = orderedTiles.Min(t => t.TileX); var maxX = orderedTiles.Max(t => t.TileX); var minY = orderedTiles.Min(t => t.TileY); var maxY = orderedTiles.Max(t => t.TileY); var gridWidth = maxX - minX + 1; var gridHeight = maxY - minY + 1; var imageWidth = gridWidth * tileSizePixels; var imageHeight = gridHeight * tileSizePixels; var stitchedImage = new Image(imageWidth, imageHeight); var placed = new List(orderedTiles.Count); var missing = new List(); try { foreach (var tile in orderedTiles) { cancellationToken.ThrowIfCancellationRequested(); if (!File.Exists(tile.FilePath)) { missing.Add(new MissingTile(tile, MissingTileReason.FileNotFound, Error: null)); continue; } Image? tileImage = null; try { tileImage = await Image.LoadAsync(tile.FilePath, cancellationToken); var destX = (tile.TileX - minX) * tileSizePixels; var destY = (tile.TileY - minY) * tileSizePixels; stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f)); placed.Add(tile); } catch (Exception ex) when (swallowTileLoadErrors && ex is not OperationCanceledException) { missing.Add(new MissingTile(tile, MissingTileReason.LoadFailed, ex)); } finally { tileImage?.Dispose(); } } } catch { stitchedImage.Dispose(); throw; } return new StitchResult( Image: stitchedImage, MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY, TileSizePixels: tileSizePixels, ImageWidth: imageWidth, ImageHeight: imageHeight, PlacedTiles: placed, MissingTiles: missing); } public void DrawCross(Image image, Point center, Rgb24 color, int armLength, int thickness = 10) { ArgumentNullException.ThrowIfNull(image); if (armLength < 0 || thickness < 1) { return; } var halfThickness = thickness / 2; var width = image.Width; var height = image.Height; for (int dx = -armLength; dx <= armLength; dx++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = center.X + dx; int y = center.Y + t; if (x >= 0 && x < width && y >= 0 && y < height) { image[x, y] = color; } } } for (int dy = -armLength; dy <= armLength; dy++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = center.X + t; int y = center.Y + dy; if (x >= 0 && x < width && y >= 0 && y < height) { image[x, y] = color; } } } } public void DrawRectangleBorder(Image image, Rectangle rect, Rgb24 color, int thickness = 5) { ArgumentNullException.ThrowIfNull(image); if (thickness < 1) { return; } var x1 = rect.X; var y1 = rect.Y; var x2 = rect.X + rect.Width - 1; var y2 = rect.Y + rect.Height - 1; var width = image.Width; var height = image.Height; for (int t = 0; t < thickness; t++) { for (int x = x1; x <= x2; x++) { int topY = y1 + t; int bottomY = y2 - t; if (x >= 0 && x < width && topY >= 0 && topY < height) { image[x, topY] = color; } if (x >= 0 && x < width && bottomY >= 0 && bottomY < height) { image[x, bottomY] = color; } } for (int y = y1; y <= y2; y++) { int leftX = x1 + t; int rightX = x2 - t; if (leftX >= 0 && leftX < width && y >= 0 && y < height) { image[leftX, y] = color; } if (rightX >= 0 && rightX < width && y >= 0 && y < height) { image[rightX, y] = color; } } } } } public sealed record TilePlacement(int TileX, int TileY, string FilePath); public sealed record StitchResult( Image Image, int MinX, int MinY, int MaxX, int MaxY, int TileSizePixels, int ImageWidth, int ImageHeight, IReadOnlyList PlacedTiles, IReadOnlyList MissingTiles); public sealed record MissingTile(TilePlacement Tile, MissingTileReason Reason, Exception? Error); public enum MissingTileReason { FileNotFound, LoadFailed }