276 lines
10 KiB
C#
276 lines
10 KiB
C#
using System.Numerics;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using TheLivingWorld.Core.Ecs;
|
|
using TheLivingWorld.Core.Export;
|
|
using TheLivingWorld.Core.Geo;
|
|
using TheLivingWorld.Core.Systems;
|
|
using TheLivingWorld.Core.Worlds;
|
|
using TheLivingWorld.Osm.Import;
|
|
using TheLivingWorld.Osm.Overpass;
|
|
|
|
namespace TheLivingWorld.Tests;
|
|
|
|
/// <summary>
|
|
/// Drives the whole import path - Overpass elements in, chunk payloads out - against hand-built geometry, so
|
|
/// the tests do not need a network call to exercise it.
|
|
/// </summary>
|
|
public class ImportPipelineTests
|
|
{
|
|
private const double WorldSizeMeters = 2048;
|
|
private static readonly GeoPoint Origin = new(31.8966010, -100.4858591);
|
|
|
|
[Fact]
|
|
public void Imports_a_building_way_as_an_entity_with_a_footprint()
|
|
{
|
|
using var world = Build(WayWithGeometry(
|
|
id: 42,
|
|
tags: new() { ["building"] = "house", ["name"] = "Old Post Office" },
|
|
metres: Square(100, 100, 20, closed: true)));
|
|
|
|
var chunk = Assert.Single(Export(world)).Chunk;
|
|
var building = Assert.Single(chunk.Buildings);
|
|
|
|
Assert.Equal(42, building.Id);
|
|
Assert.Equal(BuildingKind.House, building.Kind);
|
|
Assert.Equal("Old Post Office", building.Name);
|
|
|
|
// Four corners, flattened to [x, y] pairs, with the repeated closing vertex dropped.
|
|
Assert.Equal(8, building.Outline.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void Places_features_in_the_chunk_that_contains_them()
|
|
{
|
|
using var world = Build(
|
|
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(-900, -900, 20, closed: true)),
|
|
WayWithGeometry(2, new() { ["building"] = "yes" }, Square(900, 900, 20, closed: true)));
|
|
|
|
var chunks = Export(world);
|
|
|
|
Assert.Equal(2, chunks.Count);
|
|
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(0, 0));
|
|
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(3, 3));
|
|
}
|
|
|
|
[Fact]
|
|
public void Imports_a_highway_as_a_road_with_a_width()
|
|
{
|
|
// Kept inside a single chunk so this test is about the attributes, not the splitting.
|
|
using var world = Build(WayWithGeometry(
|
|
id: 7,
|
|
tags: new() { ["highway"] = "residential", ["name"] = "Main Street" },
|
|
metres: [new Vector2(100, 0), new Vector2(300, 0)]));
|
|
|
|
var road = Assert.Single(Export(world)[0].Chunk.Roads);
|
|
|
|
Assert.Equal(RoadClass.Residential, road.Class);
|
|
Assert.Equal("Main Street", road.Name);
|
|
Assert.Equal(6.5f, road.Width);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clips_geometry_that_reaches_beyond_the_world_square()
|
|
{
|
|
// A highway running far past the eastern edge of the generated square.
|
|
using var world = Build(WayWithGeometry(
|
|
id: 8,
|
|
tags: new() { ["highway"] = "primary" },
|
|
metres: [new Vector2(0, 0), new Vector2(50_000, 0)]));
|
|
|
|
var maxX = Export(world)
|
|
.SelectMany(chunk => chunk.Chunk.Roads)
|
|
.SelectMany(road => road.Path.Where((_, index) => index % 2 == 0))
|
|
.Max();
|
|
|
|
Assert.True(maxX <= WorldSizeMeters / 2 + 0.5, $"Road reached {maxX} m, past the world edge.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Splits_a_long_road_into_one_piece_per_chunk_it_crosses()
|
|
{
|
|
// Spans the full width of the world at y = 0, which crosses all four chunk columns of row 2.
|
|
using var world = Build(WayWithGeometry(
|
|
id: 12,
|
|
tags: new() { ["highway"] = "primary" },
|
|
metres: [new Vector2(-900, 0), new Vector2(900, 0)]));
|
|
|
|
var chunks = Export(world);
|
|
|
|
Assert.Equal(4, chunks.Count);
|
|
Assert.All(chunks, chunk => Assert.Equal(2, chunk.Coord.Y));
|
|
Assert.Equal([0, 1, 2, 3], chunks.Select(chunk => chunk.Coord.X));
|
|
|
|
// Every piece now sits inside its own chunk, so the chunk's bounds no longer stretch across the map.
|
|
foreach (var chunk in chunks)
|
|
{
|
|
var square = world.Grid.BoundsOf(chunk.Coord);
|
|
Assert.True(chunk.Index.Bounds[0] >= square.MinX - 2f);
|
|
Assert.True(chunk.Index.Bounds[2] <= square.MaxX + 2f);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Keeps_a_short_road_whole()
|
|
{
|
|
using var world = Build(WayWithGeometry(
|
|
id: 13,
|
|
tags: new() { ["highway"] = "service" },
|
|
metres: [new Vector2(10, 10), new Vector2(60, 40)]));
|
|
|
|
Assert.Single(Export(world));
|
|
Assert.Single(Export(world)[0].Chunk.Roads);
|
|
}
|
|
|
|
[Fact]
|
|
public void Assembles_a_multipolygon_relation_and_keeps_its_holes()
|
|
{
|
|
// A forest with a clearing cut out of it, split into two member ways as OSM usually stores them.
|
|
var outerWest = Metres([new(-300, -300), new(-300, 300), new(300, 300)]);
|
|
var outerEast = Metres([new(300, 300), new(300, -300), new(-300, -300)]);
|
|
var clearing = Metres([new(-50, -50), new(50, -50), new(50, 50), new(-50, 50), new(-50, -50)]);
|
|
|
|
var relation = new OverpassElement
|
|
{
|
|
Type = "relation",
|
|
Id = 99,
|
|
Tags = new Dictionary<string, string> { ["type"] = "multipolygon", ["landuse"] = "forest" },
|
|
Members =
|
|
[
|
|
new OverpassMember { Type = "way", Ref = 1, Role = "outer", Geometry = outerWest },
|
|
new OverpassMember { Type = "way", Ref = 2, Role = "outer", Geometry = outerEast },
|
|
new OverpassMember { Type = "way", Ref = 3, Role = "inner", Geometry = clearing },
|
|
],
|
|
};
|
|
|
|
using var world = Build(relation);
|
|
|
|
var area = Assert.Single(Export(world)[0].Chunk.Areas);
|
|
|
|
Assert.Equal(AreaKind.Forest, area.Kind);
|
|
Assert.Equal(4, area.Outline.Length / 2);
|
|
Assert.NotNull(area.Holes);
|
|
Assert.Single(area.Holes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Splits_water_into_bodies_and_watercourses()
|
|
{
|
|
using var world = Build(
|
|
WayWithGeometry(10, new() { ["natural"] = "water", ["water"] = "pond" }, Square(0, 0, 60, closed: true)),
|
|
WayWithGeometry(11, new() { ["waterway"] = "stream" }, [new Vector2(-400, -400), new Vector2(-300, -350)]));
|
|
|
|
var water = Export(world).SelectMany(chunk => chunk.Chunk.Water).ToList();
|
|
|
|
var pond = Assert.Single(water, feature => feature.Kind == WaterKind.Pond);
|
|
Assert.NotNull(pond.Outline);
|
|
Assert.Null(pond.Path);
|
|
|
|
var stream = Assert.Single(water, feature => feature.Kind == WaterKind.Stream);
|
|
Assert.NotNull(stream.Path);
|
|
Assert.Null(stream.Outline);
|
|
Assert.Equal(3f, stream.Width);
|
|
}
|
|
|
|
[Fact]
|
|
public void Counts_what_it_imported()
|
|
{
|
|
var stats = BuildWithStats(
|
|
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(0, 0, 20, closed: true)),
|
|
WayWithGeometry(2, new() { ["highway"] = "service" }, [new Vector2(0, 0), new Vector2(50, 0)]),
|
|
WayWithGeometry(3, new() { ["landuse"] = "meadow" }, Square(200, 200, 100, closed: true)));
|
|
|
|
Assert.Equal(1, stats.Buildings);
|
|
Assert.Equal(1, stats.Roads);
|
|
Assert.Equal(1, stats.Areas);
|
|
Assert.Equal(3, stats.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public void Chunk_bounds_stretch_to_cover_areas_that_overhang()
|
|
{
|
|
// Areas are still bucketed whole by the centre of their extent, so a large one overhangs its chunk.
|
|
using var world = Build(WayWithGeometry(
|
|
id: 14,
|
|
tags: new() { ["landuse"] = "forest" },
|
|
metres: Square(0, 0, 1500, closed: true)));
|
|
|
|
var exported = Assert.Single(Export(world));
|
|
var square = world.Grid.BoundsOf(exported.Coord);
|
|
|
|
Assert.True(exported.Index.Bounds[0] < square.MinX);
|
|
Assert.True(exported.Index.Bounds[2] > square.MaxX);
|
|
}
|
|
|
|
[Fact]
|
|
public void Skips_elements_it_does_not_understand()
|
|
{
|
|
var stats = BuildWithStats(
|
|
WayWithGeometry(1, new() { ["barrier"] = "fence" }, [new Vector2(0, 0), new Vector2(10, 0)]),
|
|
WayWithGeometry(2, new() { ["waterway"] = "dam" }, [new Vector2(0, 0), new Vector2(10, 0)]));
|
|
|
|
Assert.Equal(0, stats.Total);
|
|
}
|
|
|
|
private static GameWorld Build(params OverpassElement[] elements)
|
|
{
|
|
var world = NewWorld();
|
|
new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance).Populate(world, elements);
|
|
WorldPipeline.CreateDefault().Run(world);
|
|
return world;
|
|
}
|
|
|
|
private static WorldStats BuildWithStats(params OverpassElement[] elements)
|
|
{
|
|
using var world = NewWorld();
|
|
return new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance).Populate(world, elements);
|
|
}
|
|
|
|
private static IReadOnlyList<ExportedChunk> Export(GameWorld world) => new ChunkExporter().Export(world);
|
|
|
|
private static GameWorld NewWorld() => new(new WorldMetadata
|
|
{
|
|
Id = "test",
|
|
Name = "Test",
|
|
Origin = Origin,
|
|
SizeMeters = WorldSizeMeters,
|
|
Bounds = GeoBounds.FromCenter(Origin, WorldSizeMeters),
|
|
ChunkSizeMeters = 512,
|
|
ChunkCountX = 4,
|
|
ChunkCountY = 4,
|
|
GeneratedAt = DateTimeOffset.UnixEpoch,
|
|
});
|
|
|
|
private static OverpassElement WayWithGeometry(long id, Dictionary<string, string> tags, Vector2[] metres) => new()
|
|
{
|
|
Type = "way",
|
|
Id = id,
|
|
Tags = tags,
|
|
Geometry = Metres(metres),
|
|
};
|
|
|
|
/// <summary>Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send.</summary>
|
|
private static List<OverpassNode> Metres(Vector2[] points)
|
|
{
|
|
var projection = new LocalProjection(Origin);
|
|
return [.. points.Select(point =>
|
|
{
|
|
var geo = projection.Unproject(point);
|
|
return new OverpassNode { Lat = geo.Latitude, Lon = geo.Longitude };
|
|
})];
|
|
}
|
|
|
|
private static Vector2[] Square(float centerX, float centerY, float size, bool closed)
|
|
{
|
|
var half = size / 2;
|
|
Vector2[] corners =
|
|
[
|
|
new(centerX - half, centerY - half),
|
|
new(centerX + half, centerY - half),
|
|
new(centerX + half, centerY + half),
|
|
new(centerX - half, centerY + half),
|
|
];
|
|
|
|
return closed ? [.. corners, corners[0]] : corners;
|
|
}
|
|
}
|