|
| 1 | +using Alidade.Osm.Models.Tools.Circularize; |
| 2 | +using NetTopologySuite.Geometries; |
| 3 | + |
| 4 | +namespace Alidade.Osm.Handlers.Tools.Circularize; |
| 5 | + |
| 6 | +/// <inheritdoc /> |
| 7 | +public sealed class CircularizeWay(EditBufferStateService editBufferState) |
| 8 | + : IRequestHandler<CircularizeWay.Query, QueryResult<CircularizeResult>> |
| 9 | +{ |
| 10 | + /// <summary> |
| 11 | + /// Computes node moves that fit a closed way to its best-fit circle, adding new nodes |
| 12 | + /// between existing ones when the arc segments would otherwise exceed the target spacing. |
| 13 | + /// Key nodes — those shared with another way or carrying tags — define arc boundaries and |
| 14 | + /// are snapped to the circle at their existing angle; fill nodes in each arc are |
| 15 | + /// distributed at uniform angular spacing. |
| 16 | + /// Returns a <see cref="CircularizeResult"/> containing the moves to commit. |
| 17 | + /// </summary> |
| 18 | + /// <param name="WayRef">The element reference of the closed way to circularize.</param> |
| 19 | + /// <param name="VertexCount"> |
| 20 | + /// Target number of vertices for the output circle. When <see langword="null"/>, the count |
| 21 | + /// is derived from the radius using iD's MAX_SEGMENT_LENGTH formula (4 m segments, clamped |
| 22 | + /// to 12–32 vertices). |
| 23 | + /// </param> |
| 24 | + public record Query(OsmElementRef WayRef, int? VertexCount = null) : IRequest<QueryResult<CircularizeResult>>; |
| 25 | + |
| 26 | + // Target arc segment length in meters, matching iD's MAX_SEGMENT_LENGTH. |
| 27 | + private const double MaxSegmentLength = 4.0; |
| 28 | + |
| 29 | + // Minimum vertex count, matching iD's MIN_VERTICES. |
| 30 | + private const int MinVertices = 12; |
| 31 | + |
| 32 | + // Maximum vertex count, matching iD's MAX_VERTICES. |
| 33 | + private const int MaxVertices = 32; |
| 34 | + |
| 35 | + /// <inheritdoc /> |
| 36 | + public Task<QueryResult<CircularizeResult>> Handle(Query request, CancellationToken cancellationToken) |
| 37 | + { |
| 38 | + EditBufferState buf = editBufferState.State; |
| 39 | + CircularizeResult result = Compute(request.WayRef.Id, buf.Ways, buf.Nodes, request.VertexCount); |
| 40 | + return result.Moves.Count == 0 |
| 41 | + ? Task.FromResult(QueryResult<CircularizeResult>.Fail("The way cannot be circularized.")) |
| 42 | + : Task.FromResult(QueryResult<CircularizeResult>.Pass(result)); |
| 43 | + } |
| 44 | + |
| 45 | + private static CircularizeResult Compute(long wayId, ImmutableDictionary<long, OsmWay> ways, ImmutableDictionary<long, OsmNode> nodes, int? vertexCount = null) |
| 46 | + { |
| 47 | + if (!ways.TryGetValue(wayId, out OsmWay? way) || !way.IsClosed) |
| 48 | + { |
| 49 | + return CircularizeResult.Empty; |
| 50 | + } |
| 51 | + |
| 52 | + List<long> nodeIds = [.. way.NodeIds.Take(way.NodeIds.Count - 1)]; |
| 53 | + List<OsmNode> nodeList = [.. nodeIds.Where(nodes.ContainsKey).Select(id => nodes[id])]; |
| 54 | + if (nodeList.Count < 3) |
| 55 | + { |
| 56 | + return CircularizeResult.Empty; |
| 57 | + } |
| 58 | + |
| 59 | + double latRef = nodeList.Average(n => n.Lat); |
| 60 | + List<Coordinate> pts = [.. nodeList.Select(n => GeometryService.Project(new Coordinate(n.Lon, n.Lat), latRef))]; |
| 61 | + |
| 62 | + double cx = pts.Average(p => p.X); |
| 63 | + double cy = pts.Average(p => p.Y); |
| 64 | + double r = pts.Average(p => Math.Sqrt((p.X - cx) * (p.X - cx) + (p.Y - cy) * (p.Y - cy))); |
| 65 | + if (r < 1e-6) |
| 66 | + { |
| 67 | + return CircularizeResult.Empty; |
| 68 | + } |
| 69 | + |
| 70 | + int targetCount = vertexCount ?? GetTargetVertexCount(r); |
| 71 | + double maxAngle = 2.0 * Math.PI / (targetCount - 1); |
| 72 | + |
| 73 | + // Winding: positive signed area (Y-up flat projection) = CCW = sign 1; negative = CW = sign -1. |
| 74 | + double signedArea = 0; |
| 75 | + for (int i = 0; i < pts.Count; i++) |
| 76 | + { |
| 77 | + Coordinate a = pts[i]; |
| 78 | + Coordinate b = pts[(i + 1) % pts.Count]; |
| 79 | + signedArea += a.X * b.Y - b.X * a.Y; |
| 80 | + } |
| 81 | + double sign = signedArea >= 0 ? 1.0 : -1.0; |
| 82 | + |
| 83 | + // Key nodes: shared with another way in the edit buffer, or carrying tags. |
| 84 | + HashSet<long> sharedNodeIds = FindSharedNodeIds(ways, wayId, nodeList); |
| 85 | + List<int> keyIndices = [.. Enumerable.Range(0, nodeList.Count) |
| 86 | + .Where(i => sharedNodeIds.Contains(nodeList[i].Id) || nodeList[i].Tags.Count > 0)]; |
| 87 | + |
| 88 | + if (keyIndices.Count == 0) |
| 89 | + { |
| 90 | + keyIndices.Add(0); |
| 91 | + } |
| 92 | + if (keyIndices.Count == 1) |
| 93 | + { |
| 94 | + keyIndices.Add((keyIndices[0] + nodeList.Count / 2) % nodeList.Count); |
| 95 | + } |
| 96 | + |
| 97 | + List<(long, Coordinate?, Coordinate)> moves = []; |
| 98 | + List<CircularizeNodeRef> finalWayNodeList = []; |
| 99 | + int newNodeCount = 0; |
| 100 | + |
| 101 | + for (int i = 0; i < keyIndices.Count; i++) |
| 102 | + { |
| 103 | + int startIdx = keyIndices[i]; |
| 104 | + int endIdx = keyIndices[(i + 1) % keyIndices.Count]; |
| 105 | + int indexRange = (endIdx - startIdx + nodeList.Count) % nodeList.Count; |
| 106 | + if (indexRange == 0) |
| 107 | + { |
| 108 | + indexRange = nodeList.Count; |
| 109 | + } |
| 110 | + |
| 111 | + double startAngle = Math.Atan2(pts[startIdx].Y - cy, pts[startIdx].X - cx); |
| 112 | + double endAngle = Math.Atan2(pts[endIdx].Y - cy, pts[endIdx].X - cx); |
| 113 | + double totalAngle = endAngle - startAngle; |
| 114 | + |
| 115 | + // Ensure totalAngle goes in the polygon's winding direction. In Y-up coordinates, |
| 116 | + // CCW arcs have positive totalAngle and CW arcs have negative. Flip when the raw |
| 117 | + // difference crosses ±π in the wrong direction (opposite of iD's Y-down check). |
| 118 | + if (totalAngle * sign < 0) |
| 119 | + { |
| 120 | + totalAngle = sign * (2.0 * Math.PI - Math.Abs(totalAngle)); |
| 121 | + } |
| 122 | + |
| 123 | + // Find the minimum number of additional nodes so every arc segment ≤ maxAngle. |
| 124 | + int numberNewNodes = 0; |
| 125 | + double eachAngle; |
| 126 | + do |
| 127 | + { |
| 128 | + eachAngle = totalAngle / (indexRange + numberNewNodes); |
| 129 | + if (Math.Abs(eachAngle) <= maxAngle) |
| 130 | + { |
| 131 | + break; |
| 132 | + } |
| 133 | + numberNewNodes++; |
| 134 | + } while (numberNewNodes <= MaxVertices); |
| 135 | + |
| 136 | + // Snap the start key node onto the circle (preserve angle, force distance = r). |
| 137 | + OsmNode startNode = nodeList[startIdx]; |
| 138 | + Coordinate snappedStart = GeometryService.Unproject( |
| 139 | + new Coordinate(cx + r * Math.Cos(startAngle), cy + r * Math.Sin(startAngle)), latRef); |
| 140 | + moves.Add((startNode.Id, new Coordinate(startNode.Lon, startNode.Lat), snappedStart)); |
| 141 | + finalWayNodeList.Add(CircularizeNodeRef.Existing(startNode.Id)); |
| 142 | + |
| 143 | + // Redistribute existing in-between nodes at uniform angular spacing. |
| 144 | + for (int j = 1; j < indexRange; j++) |
| 145 | + { |
| 146 | + double angle = startAngle + j * eachAngle; |
| 147 | + Coordinate newPos = GeometryService.Unproject( |
| 148 | + new Coordinate(cx + r * Math.Cos(angle), cy + r * Math.Sin(angle)), latRef); |
| 149 | + OsmNode mid = nodeList[(startIdx + j) % nodeList.Count]; |
| 150 | + moves.Add((mid.Id, new Coordinate(mid.Lon, mid.Lat), newPos)); |
| 151 | + finalWayNodeList.Add(CircularizeNodeRef.Existing(mid.Id)); |
| 152 | + } |
| 153 | + |
| 154 | + // Insert new fill nodes to close any remaining angular gap. |
| 155 | + for (int j = 0; j < numberNewNodes; j++) |
| 156 | + { |
| 157 | + double angle = startAngle + (indexRange + j) * eachAngle; |
| 158 | + Coordinate newPos = GeometryService.Unproject( |
| 159 | + new Coordinate(cx + r * Math.Cos(angle), cy + r * Math.Sin(angle)), latRef); |
| 160 | + moves.Add((0L, null, newPos)); |
| 161 | + finalWayNodeList.Add(CircularizeNodeRef.New(newNodeCount++)); |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + // Close the way by repeating the first node reference. |
| 166 | + if (finalWayNodeList.Count > 0) |
| 167 | + { |
| 168 | + finalWayNodeList.Add(finalWayNodeList[0]); |
| 169 | + } |
| 170 | + |
| 171 | + // Only return FinalWayNodeList when new nodes were inserted; a plain moves-only result |
| 172 | + // is sufficient otherwise and avoids unnecessarily rewriting the way's node list. |
| 173 | + return new CircularizeResult(moves, newNodeCount > 0 ? finalWayNodeList : []); |
| 174 | + } |
| 175 | + |
| 176 | + /// <summary> |
| 177 | + /// Returns the target vertex count for a circle of the given radius in approximate meters, |
| 178 | + /// using iD's formula clamped to [<see cref="MinVertices"/>, <see cref="MaxVertices"/>]. |
| 179 | + /// </summary> |
| 180 | + /// <param name="radiusMeters">The radius in approximate meters (from the flat-Earth projection).</param> |
| 181 | + /// <returns>The target number of vertices.</returns> |
| 182 | + private static int GetTargetVertexCount(double radiusMeters) |
| 183 | + => Math.Clamp((int)Math.Round(radiusMeters * Math.PI / MaxSegmentLength) * 2, MinVertices, MaxVertices); |
| 184 | + |
| 185 | + private static HashSet<long> FindSharedNodeIds( |
| 186 | + ImmutableDictionary<long, OsmWay> ways, long wayId, List<OsmNode> nodeList) |
| 187 | + { |
| 188 | + HashSet<long> wayNodeIds = [.. nodeList.Select(n => n.Id)]; |
| 189 | + HashSet<long> shared = []; |
| 190 | + |
| 191 | + foreach (OsmWay other in ways.Values) |
| 192 | + { |
| 193 | + if (other.Id == wayId) |
| 194 | + { |
| 195 | + continue; |
| 196 | + } |
| 197 | + foreach (long nodeId in other.NodeIds) |
| 198 | + { |
| 199 | + if (wayNodeIds.Contains(nodeId)) |
| 200 | + { |
| 201 | + shared.Add(nodeId); |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + return shared; |
| 207 | + } |
| 208 | +} |
0 commit comments