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

Commit d3c94d7

Browse files
MultiPolygon rendering
1 parent 0ca4bd2 commit d3c94d7

7 files changed

Lines changed: 408 additions & 20 deletions

File tree

Alidade.Map/AlidadeGeoJsonConverter.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ private static void WriteGeometry(Utf8JsonWriter writer, Geometry geometry)
7171
writer.WritePropertyName("coordinates");
7272
writer.WriteStartArray();
7373
WriteCoordinateArray(writer, poly.ExteriorRing.Coordinates);
74+
foreach (Geometry hole in poly.InteriorRings)
75+
{
76+
WriteCoordinateArray(writer, hole.Coordinates);
77+
}
7478
writer.WriteEndArray();
7579
break;
7680

@@ -83,6 +87,10 @@ private static void WriteGeometry(Utf8JsonWriter writer, Geometry geometry)
8387
{
8488
writer.WriteStartArray();
8589
WriteCoordinateArray(writer, part.ExteriorRing.Coordinates);
90+
foreach (Geometry hole in part.InteriorRings)
91+
{
92+
WriteCoordinateArray(writer, hole.Coordinates);
93+
}
8694
writer.WriteEndArray();
8795
}
8896

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,17 @@ window.mapInterop = (() => {
280280
}
281281

282282
function addOsmLayers() {
283+
// Relation multipolygon fills (below way fills and strokes)
284+
map.addLayer({
285+
id: 'layer-relations-fill',
286+
type: 'fill',
287+
source: 'osm-relations',
288+
paint: {
289+
'fill-color': ['coalesce', ['get', 'fill'], '#aaa'],
290+
'fill-opacity': 0.2
291+
}
292+
});
293+
283294
// Ways, area fill first (below lines)
284295
map.addLayer({
285296
id: 'layer-ways-fill',
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
}

Alidade.Osm/Models/OsmWay.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ public bool IsArea
136136
return new Feature(geom, attrs);
137137
}
138138

139-
private static string WayStrokeColor(IReadOnlyDictionary<string, string> tags)
139+
/// <summary>
140+
/// Returns the stroke color hex string for a way with the given tags.
141+
/// </summary>
142+
/// <param name="tags">The way's or relation's tag dictionary.</param>
143+
/// <returns>A CSS hex color string.</returns>
144+
public static string WayStrokeColor(IReadOnlyDictionary<string, string> tags)
140145
{
141146
if (tags.TryGetValue("highway", out _))
142147
{
@@ -196,7 +201,12 @@ private static string ResolveOneway(IReadOnlyDictionary<string, string> tags)
196201
return "0";
197202
}
198203

199-
private static string WayFillColor(IReadOnlyDictionary<string, string> tags)
204+
/// <summary>
205+
/// Returns the fill color hex string for a way with the given tags.
206+
/// </summary>
207+
/// <param name="tags">The way's or relation's tag dictionary.</param>
208+
/// <returns>A CSS hex color string.</returns>
209+
public static string WayFillColor(IReadOnlyDictionary<string, string> tags)
200210
{
201211
if (tags.TryGetValue("building", out _))
202212
{

Alidade.Osm/Validators/Error/MissingRoleValidator.cs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
namespace Alidade.Osm.Validators.Error;
22

33
/// <summary>
4-
/// Reports an error when a member of a role-required relation type (multipolygon,
5-
/// boundary, route, public_transport, restriction) is missing its role string.
4+
/// Reports an error when a member of a multipolygon relation is missing its role string.
65
/// </summary>
76
public class MissingRoleValidator : ValidatorBase
87
{
9-
private static readonly HashSet<string> _requireRoles = [
10-
"multipolygon",
11-
"boundary",
12-
"route",
13-
"public_transport",
14-
"restriction"
15-
];
8+
private static readonly HashSet<string> _requireRoles = ["multipolygon"];
169

1710
/// <inheritdoc />
1811
public override string ValidatorName => "missing_role";

0 commit comments

Comments
 (0)