Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit c39e3e8

Browse files
Circularize refactor
1 parent 9997e52 commit c39e3e8

23 files changed

Lines changed: 924 additions & 132 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
namespace Alidade.Map.Consts;
2+
3+
/// <summary>
4+
/// MapLibre GeoJSON source IDs used by the C# interop layer.
5+
/// All values here must match the names registered in <c>addOsmSources()</c> in
6+
/// <c>map-interop.js</c>.
7+
/// </summary>
8+
public static class MapSourceNames
9+
{
10+
/// <summary>
11+
/// The source for OSM node point features.
12+
/// </summary>
13+
public const string Nodes = "osm-nodes";
14+
15+
/// <summary>
16+
/// The source for OSM way line and area features.
17+
/// </summary>
18+
public const string Ways = "osm-ways";
19+
20+
/// <summary>
21+
/// The source for OSM relation features.
22+
/// </summary>
23+
public const string Relations = "osm-relations";
24+
25+
/// <summary>
26+
/// The source for the selected-element highlight overlay.
27+
/// </summary>
28+
public const string Selected = "osm-selected";
29+
30+
/// <summary>
31+
/// The source for the hovered-element highlight overlay.
32+
/// </summary>
33+
public const string Hover = "osm-hover";
34+
35+
/// <summary>
36+
/// The source for way-vertex dot markers shown during editing.
37+
/// </summary>
38+
public const string Vertices = "osm-vertices";
39+
40+
/// <summary>
41+
/// The source for OSM note markers.
42+
/// </summary>
43+
public const string Notes = "osm-notes";
44+
45+
/// <summary>
46+
/// The source for the GPX track overlay.
47+
/// </summary>
48+
public const string Gpx = "osm-gpx";
49+
50+
/// <summary>
51+
/// The source for the draw-tool rubber-band geometry preview.
52+
/// </summary>
53+
public const string DrawPreview = "osm-draw-preview";
54+
55+
/// <summary>
56+
/// The source for tool geometry previews (circularize, gridify, etc.).
57+
/// </summary>
58+
public const string Preview = "osm-preview";
59+
60+
/// <summary>
61+
/// The source for validation-error highlight overlays.
62+
/// </summary>
63+
public const string Invalid = "osm-invalid";
64+
65+
/// <summary>
66+
/// The source for snap-segment hit targets populated during a node drag.
67+
/// </summary>
68+
public const string SnapSegments = "osm-snap-segments";
69+
}

Alidade.Map/wwwroot/assets/js/map-interop.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ window.mapInterop = (() => {
266266
'osm-selected', 'osm-hover', 'osm-vertices',
267267
'osm-notes', 'osm-gpx',
268268
'osm-draw-preview',
269-
'osm-gridify-preview',
269+
'osm-preview',
270270
'osm-invalid',
271271
'osm-snap-segments'
272272
];
@@ -522,18 +522,33 @@ window.mapInterop = (() => {
522522
paint: { 'line-color': '#c00', 'line-width': 2 }
523523
});
524524

525-
// Gridify preview: orange dashed grid overlay shown while the gridify dialog is open
525+
// Tool preview: orange dashed outline shared by gridify, circularize, and similar panels
526526
map.addLayer({
527-
id: 'layer-gridify-preview-line',
527+
id: 'layer-preview-line',
528528
type: 'line',
529-
source: 'osm-gridify-preview',
529+
source: 'osm-preview',
530+
filter: ['==', ['geometry-type'], 'LineString'],
530531
paint: {
531532
'line-color': '#ff6600',
532533
'line-width': 1.5,
533534
'line-dasharray': [4, 3]
534535
}
535536
});
536537

538+
// Tool preview: vertex dots at each proposed node position
539+
map.addLayer({
540+
id: 'layer-preview-circle',
541+
type: 'circle',
542+
source: 'osm-preview',
543+
filter: ['==', ['geometry-type'], 'Point'],
544+
paint: {
545+
'circle-radius': 4,
546+
'circle-color': '#ff6600',
547+
'circle-stroke-width': 1,
548+
'circle-stroke-color': '#fff'
549+
}
550+
});
551+
537552
// Draw preview, hover highlight — same style as layer-hover, shown on mouseover
538553
map.addLayer({
539554
id: 'layer-draw-preview-hover',

Alidade.Osm/Handlers/Editing/CircularizeNodes.cs

Lines changed: 0 additions & 34 deletions
This file was deleted.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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

Comments
 (0)