|
| 1 | +using NetTopologySuite.Features; |
| 2 | +using NetTopologySuite.Geometries; |
| 3 | + |
| 4 | +namespace Alidade.Osm.Handlers.Geom; |
| 5 | + |
| 6 | +/// <inheritdoc /> |
| 7 | +public sealed class AssembleMultiPolygon(EditBufferStateService editBufferState, GeometryFactory factory) |
| 8 | + : IRequestHandler<AssembleMultiPolygon.Query, QueryResult<Feature>> |
| 9 | +{ |
| 10 | + /// <summary> |
| 11 | + /// Assembles a GeoJSON <see cref="Feature"/> with a <see cref="Polygon"/> or |
| 12 | + /// <see cref="MultiPolygon"/> geometry from the way members of a multipolygon relation. |
| 13 | + /// Outer and inner way members are chained into rings; when a connecting way is absent |
| 14 | + /// from the buffer (e.g. outside the current viewport), a straight-line bridge is |
| 15 | + /// inserted so that a renderable polygon can still be produced. The assembled geometry |
| 16 | + /// is used for rendering only and is never written back to the edit buffer. |
| 17 | + /// </summary> |
| 18 | + /// <param name="Ref">Typed reference to the relation to assemble.</param> |
| 19 | + /// <returns> |
| 20 | + /// A success result containing the assembled feature, or a failure result when the |
| 21 | + /// relation is not a multipolygon, has no outer way members, or produces a degenerate |
| 22 | + /// geometry. |
| 23 | + /// </returns> |
| 24 | + public record Query(OsmElementRef Ref) : IRequest<QueryResult<Feature>>; |
| 25 | + |
| 26 | + /// <inheritdoc /> |
| 27 | + public Task<QueryResult<Feature>> Handle(Query request, CancellationToken cancellationToken) |
| 28 | + { |
| 29 | + EditBufferState buf = editBufferState.State; |
| 30 | + |
| 31 | + if (!buf.Relations.TryGetValue(request.Ref.Id, out OsmRelation? relation)) |
| 32 | + { |
| 33 | + return Task.FromResult(QueryResult<Feature>.Fail("Relation not found.")); |
| 34 | + } |
| 35 | + |
| 36 | + if (!relation.Tags.TryGetValue("type", out string? relType) || relType != "multipolygon") |
| 37 | + { |
| 38 | + return Task.FromResult(QueryResult<Feature>.Fail("Not a multipolygon relation.")); |
| 39 | + } |
| 40 | + |
| 41 | + List<OsmWay> outerWays = []; |
| 42 | + List<OsmWay> innerWays = []; |
| 43 | + |
| 44 | + foreach (OsmMember member in relation.Members) |
| 45 | + { |
| 46 | + if (member.Type != OsmElementTypes.Way) |
| 47 | + { |
| 48 | + continue; |
| 49 | + } |
| 50 | + |
| 51 | + if (!buf.Ways.TryGetValue(member.Ref, out OsmWay? way)) |
| 52 | + { |
| 53 | + continue; |
| 54 | + } |
| 55 | + |
| 56 | + if (member.Role == "outer") |
| 57 | + { |
| 58 | + outerWays.Add(way); |
| 59 | + } |
| 60 | + else if (member.Role == "inner") |
| 61 | + { |
| 62 | + innerWays.Add(way); |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + if (outerWays.Count == 0) |
| 67 | + { |
| 68 | + return Task.FromResult(QueryResult<Feature>.Fail("No outer way members found.")); |
| 69 | + } |
| 70 | + |
| 71 | + if (!TryChainIntoRings(outerWays, buf.Nodes, out List<Coordinate[]> outerRings) |
| 72 | + || outerRings.Count == 0) |
| 73 | + { |
| 74 | + return Task.FromResult(QueryResult<Feature>.Fail("Could not chain outer ways into rings.")); |
| 75 | + } |
| 76 | + |
| 77 | + TryChainIntoRings(innerWays, buf.Nodes, out List<Coordinate[]> innerRings); |
| 78 | + |
| 79 | + Geometry geom; |
| 80 | + try |
| 81 | + { |
| 82 | + if (outerRings.Count == 1) |
| 83 | + { |
| 84 | + LinearRing exterior = factory.CreateLinearRing(outerRings[0]); |
| 85 | + LinearRing[] holes = [.. innerRings.Select(r => factory.CreateLinearRing(r))]; |
| 86 | + geom = factory.CreatePolygon(exterior, holes); |
| 87 | + } |
| 88 | + else |
| 89 | + { |
| 90 | + List<Polygon> polygons = [.. outerRings.Select(r => |
| 91 | + factory.CreatePolygon(factory.CreateLinearRing(r)))]; |
| 92 | + |
| 93 | + foreach (Coordinate[] innerRing in innerRings) |
| 94 | + { |
| 95 | + Polygon holeGeom = factory.CreatePolygon(factory.CreateLinearRing(innerRing)); |
| 96 | + Polygon? containing = polygons.FirstOrDefault(p => p.Contains(holeGeom)); |
| 97 | + if (containing is null) |
| 98 | + { |
| 99 | + continue; |
| 100 | + } |
| 101 | + |
| 102 | + int idx = polygons.IndexOf(containing); |
| 103 | + LinearRing[] existingHoles = [.. containing.InteriorRings.Select(r => factory.CreateLinearRing(r.Coordinates)), factory.CreateLinearRing(innerRing)]; |
| 104 | + polygons[idx] = factory.CreatePolygon(factory.CreateLinearRing(containing.ExteriorRing.Coordinates), existingHoles); |
| 105 | + } |
| 106 | + |
| 107 | + geom = factory.CreateMultiPolygon([.. polygons]); |
| 108 | + } |
| 109 | + } |
| 110 | + catch (ArgumentException) |
| 111 | + { |
| 112 | + return Task.FromResult(QueryResult<Feature>.Fail("Degenerate ring geometry.")); |
| 113 | + } |
| 114 | + |
| 115 | + AttributesTable attrs = new() |
| 116 | + { |
| 117 | + { "id", relation.Id.ToString() }, |
| 118 | + { "type", "relation" }, |
| 119 | + { "version", relation.Version }, |
| 120 | + { "area", "yes" }, |
| 121 | + { "fill", OsmWay.WayFillColor(relation.Tags) }, |
| 122 | + { "stroke", OsmWay.WayStrokeColor(relation.Tags) } |
| 123 | + }; |
| 124 | + |
| 125 | + foreach ((string key, string value) in relation.Tags) |
| 126 | + { |
| 127 | + attrs.Add("tag:" + key, value); |
| 128 | + } |
| 129 | + |
| 130 | + return Task.FromResult(QueryResult<Feature>.Pass(new Feature(geom, attrs))); |
| 131 | + } |
| 132 | + |
| 133 | + private static bool TryChainIntoRings( |
| 134 | + List<OsmWay> wayMembers, |
| 135 | + IReadOnlyDictionary<long, OsmNode> nodes, |
| 136 | + out List<Coordinate[]> rings) |
| 137 | + { |
| 138 | + rings = []; |
| 139 | + if (wayMembers.Count == 0) |
| 140 | + { |
| 141 | + return true; |
| 142 | + } |
| 143 | + |
| 144 | + List<OsmWay> remaining = [.. wayMembers]; |
| 145 | + |
| 146 | + while (remaining.Count > 0) |
| 147 | + { |
| 148 | + OsmWay startWay = remaining[0]; |
| 149 | + remaining.RemoveAt(0); |
| 150 | + |
| 151 | + long chainStartNodeId = startWay.NodeIds[0]; |
| 152 | + List<Coordinate> ringCoords = []; |
| 153 | + |
| 154 | + if (!AppendWayCoords(startWay, reversed: false, nodes, ringCoords)) |
| 155 | + { |
| 156 | + return false; |
| 157 | + } |
| 158 | + |
| 159 | + long currentEndNodeId = startWay.NodeIds[^1]; |
| 160 | + |
| 161 | + while (currentEndNodeId != chainStartNodeId) |
| 162 | + { |
| 163 | + int exactIdx = remaining.FindIndex(w => |
| 164 | + w.NodeIds[0] == currentEndNodeId || |
| 165 | + w.NodeIds[^1] == currentEndNodeId); |
| 166 | + |
| 167 | + OsmWay nextWay; |
| 168 | + bool reversed; |
| 169 | + |
| 170 | + if (exactIdx >= 0) |
| 171 | + { |
| 172 | + nextWay = remaining[exactIdx]; |
| 173 | + remaining.RemoveAt(exactIdx); |
| 174 | + reversed = nextWay.NodeIds[0] != currentEndNodeId; |
| 175 | + } |
| 176 | + else if (remaining.Count > 0) |
| 177 | + { |
| 178 | + (int idx, bool rev) = FindClosestEndpoint(remaining, ringCoords[^1], nodes); |
| 179 | + nextWay = remaining[idx]; |
| 180 | + remaining.RemoveAt(idx); |
| 181 | + reversed = rev; |
| 182 | + } |
| 183 | + else |
| 184 | + { |
| 185 | + break; |
| 186 | + } |
| 187 | + |
| 188 | + if (!AppendWayCoords(nextWay, reversed, nodes, ringCoords)) |
| 189 | + { |
| 190 | + return false; |
| 191 | + } |
| 192 | + |
| 193 | + currentEndNodeId = reversed ? nextWay.NodeIds[0] : nextWay.NodeIds[^1]; |
| 194 | + } |
| 195 | + |
| 196 | + if (!ringCoords[^1].Equals2D(ringCoords[0])) |
| 197 | + { |
| 198 | + ringCoords.Add(new Coordinate(ringCoords[0].X, ringCoords[0].Y)); |
| 199 | + } |
| 200 | + |
| 201 | + if (ringCoords.Count >= 4) |
| 202 | + { |
| 203 | + rings.Add([.. ringCoords]); |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + return true; |
| 208 | + } |
| 209 | + |
| 210 | + private static bool AppendWayCoords( |
| 211 | + OsmWay way, |
| 212 | + bool reversed, |
| 213 | + IReadOnlyDictionary<long, OsmNode> nodes, |
| 214 | + List<Coordinate> ringCoords) |
| 215 | + { |
| 216 | + IEnumerable<long> nodeIds = reversed |
| 217 | + ? ((IEnumerable<long>)way.NodeIds).Reverse() |
| 218 | + : way.NodeIds; |
| 219 | + |
| 220 | + foreach (long nodeId in nodeIds) |
| 221 | + { |
| 222 | + if (!nodes.TryGetValue(nodeId, out OsmNode? node)) |
| 223 | + { |
| 224 | + return false; |
| 225 | + } |
| 226 | + |
| 227 | + Coordinate coord = new(node.Lon, node.Lat); |
| 228 | + if (ringCoords.Count == 0 || !ringCoords[^1].Equals2D(coord)) |
| 229 | + { |
| 230 | + ringCoords.Add(coord); |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + return true; |
| 235 | + } |
| 236 | + |
| 237 | + private static (int Index, bool Reversed) FindClosestEndpoint( |
| 238 | + List<OsmWay> remaining, |
| 239 | + Coordinate currentEnd, |
| 240 | + IReadOnlyDictionary<long, OsmNode> nodes) |
| 241 | + { |
| 242 | + int bestIndex = 0; |
| 243 | + bool bestReversed = false; |
| 244 | + double bestDistSquared = double.MaxValue; |
| 245 | + |
| 246 | + for (int i = 0; i < remaining.Count; i++) |
| 247 | + { |
| 248 | + OsmWay way = remaining[i]; |
| 249 | + |
| 250 | + if (nodes.TryGetValue(way.NodeIds[0], out OsmNode? first)) |
| 251 | + { |
| 252 | + double dx = first.Lon - currentEnd.X; |
| 253 | + double dy = first.Lat - currentEnd.Y; |
| 254 | + double dist = dx * dx + dy * dy; |
| 255 | + if (dist < bestDistSquared) |
| 256 | + { |
| 257 | + bestDistSquared = dist; |
| 258 | + bestIndex = i; |
| 259 | + bestReversed = false; |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + if (nodes.TryGetValue(way.NodeIds[^1], out OsmNode? last)) |
| 264 | + { |
| 265 | + double dx = last.Lon - currentEnd.X; |
| 266 | + double dy = last.Lat - currentEnd.Y; |
| 267 | + double dist = dx * dx + dy * dy; |
| 268 | + if (dist < bestDistSquared) |
| 269 | + { |
| 270 | + bestDistSquared = dist; |
| 271 | + bestIndex = i; |
| 272 | + bestReversed = true; |
| 273 | + } |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + return (bestIndex, bestReversed); |
| 278 | + } |
| 279 | +} |
0 commit comments