From 8460921bfa12389ea029e698f424abc0b81b0aa4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 16 Aug 2026 17:09:16 +0300 Subject: [PATCH] Update README with project details and setup instructions; add data directories to .gitignore --- .gitignore | 10 + Directory.Build.props | 11 + README.md | 135 ++- TheLivingWorld.slnx | 12 + run.cmd | 30 + .../Endpoints/WorldEndpoints.cs | 96 ++ .../Generation/WorldGenerationService.cs | 228 +++++ src/TheLivingWorld.Api/Program.cs | 69 ++ .../Properties/launchSettings.json | 23 + .../Storage/WorldStorageOptions.cs | 9 + src/TheLivingWorld.Api/Storage/WorldStore.cs | 141 +++ .../TheLivingWorld.Api.csproj | 15 + .../appsettings.Development.json | 8 + src/TheLivingWorld.Api/appsettings.json | 23 + src/TheLivingWorld.AppHost/AppHost.cs | 13 + .../Properties/launchSettings.json | 31 + .../TheLivingWorld.AppHost.csproj | 19 + .../appsettings.Development.json | 8 + src/TheLivingWorld.AppHost/appsettings.json | 9 + src/TheLivingWorld.AppHost/aspire.config.json | 5 + .../Contracts/MapContracts.cs | 89 ++ src/TheLivingWorld.Core/Contracts/MapJson.cs | 28 + .../Contracts/WorldContracts.cs | 106 ++ src/TheLivingWorld.Core/Ecs/Components.cs | 32 + src/TheLivingWorld.Core/Ecs/FeatureKinds.cs | 99 ++ .../Export/ChunkExporter.cs | 204 ++++ src/TheLivingWorld.Core/Geo/GeoBounds.cs | 37 + src/TheLivingWorld.Core/Geo/GeoPoint.cs | 11 + .../Geo/GeometryClipper.cs | 168 ++++ .../Geo/LocalProjection.cs | 39 + src/TheLivingWorld.Core/Geo/Polygons.cs | 45 + src/TheLivingWorld.Core/Geo/RectBounds.cs | 70 ++ .../Systems/AssignChunksSystem.cs | 30 + .../Systems/ComputeBoundsSystem.cs | 31 + .../Systems/IWorldSystem.cs | 28 + .../TheLivingWorld.Core.csproj | 13 + src/TheLivingWorld.Core/Worlds/ChunkGrid.cs | 49 + src/TheLivingWorld.Core/Worlds/GameWorld.cs | 39 + src/TheLivingWorld.Core/Worlds/ShapeStore.cs | 36 + .../Worlds/WorldMetadata.cs | 44 + .../Import/OsmWorldBuilder.cs | 283 ++++++ .../Import/OsmWorldGenerator.cs | 134 +++ .../Import/RingAssembler.cs | 81 ++ .../Import/TagInterpreter.cs | 286 ++++++ src/TheLivingWorld.Osm/OsmOptions.cs | 31 + .../OsmServiceCollectionExtensions.cs | 29 + .../Overpass/OverpassClient.cs | 183 ++++ .../Overpass/OverpassModels.cs | 66 ++ .../Overpass/OverpassQuery.cs | 44 + .../TheLivingWorld.Osm.csproj | 22 + .../Extensions.cs | 127 +++ .../TheLivingWorld.ServiceDefaults.csproj | 22 + src/TheLivingWorld.Web/index.html | 55 ++ src/TheLivingWorld.Web/package-lock.json | 917 ++++++++++++++++++ src/TheLivingWorld.Web/package.json | 20 + src/TheLivingWorld.Web/src/api/client.ts | 65 ++ src/TheLivingWorld.Web/src/api/types.ts | 132 +++ src/TheLivingWorld.Web/src/main.ts | 204 ++++ src/TheLivingWorld.Web/src/map/camera.ts | 97 ++ .../src/map/chunkManager.ts | 207 ++++ .../src/map/chunkRenderer.ts | 228 +++++ src/TheLivingWorld.Web/src/map/mapView.ts | 243 +++++ src/TheLivingWorld.Web/src/map/style.ts | 169 ++++ src/TheLivingWorld.Web/src/styles.css | 276 ++++++ src/TheLivingWorld.Web/tsconfig.json | 20 + src/TheLivingWorld.Web/vite.config.ts | 40 + tests/TheLivingWorld.Tests/GeoTests.cs | 134 +++ tests/TheLivingWorld.Tests/GeometryTests.cs | 152 +++ .../ImportPipelineTests.cs | 236 +++++ .../OverpassClientTests.cs | 132 +++ .../TagInterpreterTests.cs | 159 +++ .../TheLivingWorld.Tests.csproj | 26 + the-living-world.sln | 66 ++ 73 files changed, 6978 insertions(+), 1 deletion(-) create mode 100644 Directory.Build.props create mode 100644 TheLivingWorld.slnx create mode 100644 run.cmd create mode 100644 src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs create mode 100644 src/TheLivingWorld.Api/Generation/WorldGenerationService.cs create mode 100644 src/TheLivingWorld.Api/Program.cs create mode 100644 src/TheLivingWorld.Api/Properties/launchSettings.json create mode 100644 src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs create mode 100644 src/TheLivingWorld.Api/Storage/WorldStore.cs create mode 100644 src/TheLivingWorld.Api/TheLivingWorld.Api.csproj create mode 100644 src/TheLivingWorld.Api/appsettings.Development.json create mode 100644 src/TheLivingWorld.Api/appsettings.json create mode 100644 src/TheLivingWorld.AppHost/AppHost.cs create mode 100644 src/TheLivingWorld.AppHost/Properties/launchSettings.json create mode 100644 src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj create mode 100644 src/TheLivingWorld.AppHost/appsettings.Development.json create mode 100644 src/TheLivingWorld.AppHost/appsettings.json create mode 100644 src/TheLivingWorld.AppHost/aspire.config.json create mode 100644 src/TheLivingWorld.Core/Contracts/MapContracts.cs create mode 100644 src/TheLivingWorld.Core/Contracts/MapJson.cs create mode 100644 src/TheLivingWorld.Core/Contracts/WorldContracts.cs create mode 100644 src/TheLivingWorld.Core/Ecs/Components.cs create mode 100644 src/TheLivingWorld.Core/Ecs/FeatureKinds.cs create mode 100644 src/TheLivingWorld.Core/Export/ChunkExporter.cs create mode 100644 src/TheLivingWorld.Core/Geo/GeoBounds.cs create mode 100644 src/TheLivingWorld.Core/Geo/GeoPoint.cs create mode 100644 src/TheLivingWorld.Core/Geo/GeometryClipper.cs create mode 100644 src/TheLivingWorld.Core/Geo/LocalProjection.cs create mode 100644 src/TheLivingWorld.Core/Geo/Polygons.cs create mode 100644 src/TheLivingWorld.Core/Geo/RectBounds.cs create mode 100644 src/TheLivingWorld.Core/Systems/AssignChunksSystem.cs create mode 100644 src/TheLivingWorld.Core/Systems/ComputeBoundsSystem.cs create mode 100644 src/TheLivingWorld.Core/Systems/IWorldSystem.cs create mode 100644 src/TheLivingWorld.Core/TheLivingWorld.Core.csproj create mode 100644 src/TheLivingWorld.Core/Worlds/ChunkGrid.cs create mode 100644 src/TheLivingWorld.Core/Worlds/GameWorld.cs create mode 100644 src/TheLivingWorld.Core/Worlds/ShapeStore.cs create mode 100644 src/TheLivingWorld.Core/Worlds/WorldMetadata.cs create mode 100644 src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs create mode 100644 src/TheLivingWorld.Osm/Import/OsmWorldGenerator.cs create mode 100644 src/TheLivingWorld.Osm/Import/RingAssembler.cs create mode 100644 src/TheLivingWorld.Osm/Import/TagInterpreter.cs create mode 100644 src/TheLivingWorld.Osm/OsmOptions.cs create mode 100644 src/TheLivingWorld.Osm/OsmServiceCollectionExtensions.cs create mode 100644 src/TheLivingWorld.Osm/Overpass/OverpassClient.cs create mode 100644 src/TheLivingWorld.Osm/Overpass/OverpassModels.cs create mode 100644 src/TheLivingWorld.Osm/Overpass/OverpassQuery.cs create mode 100644 src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj create mode 100644 src/TheLivingWorld.ServiceDefaults/Extensions.cs create mode 100644 src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj create mode 100644 src/TheLivingWorld.Web/index.html create mode 100644 src/TheLivingWorld.Web/package-lock.json create mode 100644 src/TheLivingWorld.Web/package.json create mode 100644 src/TheLivingWorld.Web/src/api/client.ts create mode 100644 src/TheLivingWorld.Web/src/api/types.ts create mode 100644 src/TheLivingWorld.Web/src/main.ts create mode 100644 src/TheLivingWorld.Web/src/map/camera.ts create mode 100644 src/TheLivingWorld.Web/src/map/chunkManager.ts create mode 100644 src/TheLivingWorld.Web/src/map/chunkRenderer.ts create mode 100644 src/TheLivingWorld.Web/src/map/mapView.ts create mode 100644 src/TheLivingWorld.Web/src/map/style.ts create mode 100644 src/TheLivingWorld.Web/src/styles.css create mode 100644 src/TheLivingWorld.Web/tsconfig.json create mode 100644 src/TheLivingWorld.Web/vite.config.ts create mode 100644 tests/TheLivingWorld.Tests/GeoTests.cs create mode 100644 tests/TheLivingWorld.Tests/GeometryTests.cs create mode 100644 tests/TheLivingWorld.Tests/ImportPipelineTests.cs create mode 100644 tests/TheLivingWorld.Tests/OverpassClientTests.cs create mode 100644 tests/TheLivingWorld.Tests/TagInterpreterTests.cs create mode 100644 tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj create mode 100644 the-living-world.sln diff --git a/.gitignore b/.gitignore index 7e2e97c..5332f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -414,3 +414,13 @@ FodyWeavers.xsd # Built Visual Studio Code Extensions *.vsix + +# ---> The Living World +# Generated worlds and cached Overpass responses; both are reproducible from coordinates. +data/ +src/TheLivingWorld.Api/data/ + +# Frontend +node_modules/ +dist/ +.vite/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6ebb106 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + + latest + enable + enable + true + false + + + diff --git a/README.md b/README.md index 082889f..e360d28 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ -# the-living-world +# The Living World +A web game whose map is a real place. The backend pulls a square of OpenStreetMap data once, turns it into an +ECS world, and serves it to a PixiJS client that renders it as a vector map. + +This is the first iteration: world generation and rendering only, no gameplay yet. + +## Stack + +| Piece | Choice | +| --- | --- | +| Backend | .NET 10, ASP.NET Core minimal APIs | +| World model | [Arch](https://github.com/genaray/Arch) ECS | +| Map data | OpenStreetMap via the Overpass API | +| Frontend | PixiJS 8 + TypeScript + Vite | +| Orchestration | .NET Aspire 13 | +| Storage | Plain files under `data/` | + +## Running it + +On Windows, double-click `run.cmd` or run it from a terminal: + +```bash +run.cmd +``` + +Anywhere else, or if you prefer the CLI directly: + +```bash +dotnet run --project src/TheLivingWorld.AppHost +``` + +Aspire starts the API, runs `npm install` for the client, launches the Vite dev server, and prints a dashboard +URL. Open the `web` endpoint from the dashboard, enter coordinates, and press **Generate world**. + +The default coordinates are Robert Lee, Texas (`31.8966010, -100.4858591`) — a small town that generates in a +few seconds. + +To run the two halves separately instead: + +```bash +dotnet run --project src/TheLivingWorld.Api +``` + +```bash +npm --prefix src/TheLivingWorld.Web run dev +``` + +Tests: + +```bash +dotnet test +``` + +## How a world is made + +1. **Fetch.** `OverpassClient` posts one bounding-box query to Overpass and streams the response into + `data/osm-cache/.json`. The hash covers the query text, so the same box is never downloaded twice and + editing the query invalidates the cache. Public mirrors are tried in order, with retries. +2. **Project.** `LocalProjection` flattens WGS84 onto a metric plane centred on the requested point: X east, + Y north, both in metres. Over a 20 km square the error stays under a metre, and distances are directly + usable as game units — which Web Mercator would not give. +3. **Import.** `OsmWorldBuilder` reads each element's tags, decides what it is, and creates one ECS entity per + feature. Multipolygon relations are stitched into rings by `RingAssembler`; everything is clipped to the + world square by `GeometryClipper`, so a highway crossing town does not drag geometry 40 km off the map. +4. **Systems.** `ComputeBoundsSystem` fills each entity's extent, `AssignChunksSystem` buckets it into the + chunk grid. +5. **Export.** `ChunkExporter` walks the ECS world and writes one JSON file per chunk, plus an index. + +### The ECS shape + +Geometry does not live in components. `ShapeStore` holds the vertex arrays and components carry an integer +handle, which keeps component data blittable and archetype chunks dense. + +| Component | Meaning | +| --- | --- | +| `OsmSource` | Which OSM element this came from | +| `Outline` / `Holes` | Closed ring and the rings cut out of it | +| `Polyline` | Open centreline, for roads and streams | +| `Bounds` / `InChunk` | Cached extent and spatial bucket, filled in by systems | +| `Building` | Kind, height, levels | +| `Road` | Class, width, lanes, bridge/tunnel/oneway flags | +| `AreaFeature` / `Water` | Land cover and water classification | +| `DisplayName` | The `name` tag | + +There are no simulation systems yet — the pipeline is the two passes above. Gameplay systems slot in beside +them without reworking the data model. + +## HTTP API + +| Endpoint | Purpose | +| --- | --- | +| `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"` | +| `GET /api/worlds` | List worlds, with live status for anything still generating | +| `GET /api/worlds/{id}` | Status of one world | +| `GET /api/worlds/{id}/map` | Metadata plus the chunk index | +| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry | +| `DELETE /api/worlds/{id}` | Remove a world and its chunks | + +Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client +polls for status. Only one generation runs at a time, to stay a good citizen on the shared Overpass mirrors. + +Geometry travels as flat `[x0, y0, x1, y1, …]` arrays of world metres, which is exactly what PixiJS +`Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written +in wire format and streamed straight from disk. + +## The client + +`MapView` owns one scaled container holding five layers — land cover, water, road casings, road fills, +buildings — so a building in one chunk never ends up under a park from the next. `Camera` is the only place +the Y flip lives; everything else thinks in map coordinates. + +`ChunkManager` fetches chunks as the camera reaches them and drops their graphics once they are well out of +view, keeping the parsed data cached so panning back is instant. Detail thins out as you zoom away: footpaths +disappear first, then small buildings, and stroke widths gain a floor so hairlines stay visible. + +## Configuration + +`src/TheLivingWorld.Api/appsettings.json`: + +- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`) +- `Osm:Endpoints` — Overpass mirrors, tried in order +- `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`) +- `Osm:QueryTimeoutSeconds` / `Osm:RequestTimeoutSeconds` — server-side and client-side budgets + +Relative paths resolve against the API's content root. Everything under `data/` is reproducible from +coordinates and is not committed. + +## Known limits + +- The Overpass response is parsed in one pass rather than streamed. Fine for the small towns this targets; a + dense 20 km city would want a streaming reader. +- Chunk assignment buckets a feature by the centre of its extent and never splits geometry, so a long road + overhangs its chunk. The exported chunk bounds are widened to match and the client culls against those. +- Buildings are flat footprints shaded by height. No 3D, no roofs. diff --git a/TheLivingWorld.slnx b/TheLivingWorld.slnx new file mode 100644 index 0000000..7e9afa4 --- /dev/null +++ b/TheLivingWorld.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/run.cmd b/run.cmd new file mode 100644 index 0000000..e921699 --- /dev/null +++ b/run.cmd @@ -0,0 +1,30 @@ +@echo off +rem Starts the whole stack through Aspire: the API, then npm install and the Vite dev server. +rem The dashboard URL (with its login token) is printed a few seconds after launch. + +setlocal +cd /d "%~dp0" + +where dotnet >nul 2>&1 || ( + echo The .NET SDK was not found on PATH. Install .NET 10 from https://dotnet.microsoft.com/download + goto :failed +) + +where node >nul 2>&1 || ( + echo Node.js was not found on PATH. The web client needs Node 20 or newer. + goto :failed +) + +dotnet run --project src\TheLivingWorld.AppHost %* +if errorlevel 1 goto :failed + +endlocal +exit /b 0 + +:failed +echo. +echo Startup failed. See the output above. +rem Keep the window open when this was launched from Explorer rather than a console. +if /i "%~1"=="" pause +endlocal +exit /b 1 diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs new file mode 100644 index 0000000..96f4a72 --- /dev/null +++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs @@ -0,0 +1,96 @@ +using TheLivingWorld.Api.Generation; +using TheLivingWorld.Api.Storage; +using TheLivingWorld.Core.Contracts; + +namespace TheLivingWorld.Api.Endpoints; + +public static class WorldEndpoints +{ + public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder app) + { + var worlds = app.MapGroup("/api/worlds").WithTags("worlds"); + + worlds.MapGet("/", ListWorlds); + worlds.MapPost("/", CreateWorld); + worlds.MapGet("/{id}", GetWorld); + worlds.MapGet("/{id}/map", GetMap); + worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk); + worlds.MapDelete("/{id}", DeleteWorld); + + return app; + } + + private static async Task ListWorlds( + WorldStore store, + WorldGenerationService generation, + CancellationToken cancellationToken) + { + var stored = await store.ListAsync(cancellationToken); + + // A world being generated right now has a fresher status in memory than on disk. + var merged = stored + .Select(summary => generation.GetInFlight(summary.Id) ?? summary) + .ToArray(); + + return Results.Ok(merged); + } + + private static async Task CreateWorld( + CreateWorldRequest request, + WorldGenerationService generation, + CancellationToken cancellationToken) + { + try + { + var summary = await generation.StartAsync(request, cancellationToken); + return Results.Created($"/api/worlds/{summary.Id}", summary); + } + catch (ArgumentException ex) + { + return Results.ValidationProblem(new Dictionary + { + ["request"] = [ex.Message], + }); + } + } + + private static async Task GetWorld( + string id, + WorldStore store, + WorldGenerationService generation, + CancellationToken cancellationToken) + { + if (!WorldStore.IsValidId(id)) return Results.NotFound(); + + if (generation.GetInFlight(id) is { } live) return Results.Ok(live); + + var summary = await store.GetSummaryAsync(id, cancellationToken); + return summary is null ? Results.NotFound() : Results.Ok(summary); + } + + private static async Task GetMap(string id, WorldStore store, CancellationToken cancellationToken) + { + if (!WorldStore.IsValidId(id)) return Results.NotFound(); + + var world = await store.GetWorldAsync(id, cancellationToken); + return world is null ? Results.NotFound() : Results.Ok(world); + } + + private static IResult GetChunk(string id, int x, int y, WorldStore store, HttpContext context) + { + if (!WorldStore.IsValidId(id)) return Results.NotFound(); + + var stream = store.OpenChunk(id, x, y); + if (stream is null) return Results.NotFound(); + + // World ids are unique per generation, so a chunk's contents can never change under a client. + context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, "application/json"); + } + + private static IResult DeleteWorld(string id, WorldStore store) + { + if (!WorldStore.IsValidId(id)) return Results.NotFound(); + return store.Delete(id) ? Results.NoContent() : Results.NotFound(); + } +} diff --git a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs new file mode 100644 index 0000000..98b93de --- /dev/null +++ b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs @@ -0,0 +1,228 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using TheLivingWorld.Api.Storage; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Export; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Worlds; +using TheLivingWorld.Osm.Import; + +namespace TheLivingWorld.Api.Generation; + +/// +/// Owns world generation. Generation takes tens of seconds - most of it waiting on Overpass - so the request +/// returns immediately with a pending world and the work continues in the background; the client polls the +/// world's status until it turns ready. +/// +public sealed class WorldGenerationService( + OsmWorldGenerator generator, + WorldStore store, + ChunkExporter exporter, + IHostApplicationLifetime lifetime, + ILogger logger) : IDisposable +{ + public const double MinSizeKm = 0.5; + public const double MaxSizeKm = 50.0; + + /// Overpass mirrors are shared infrastructure, so only one download runs at a time. + private readonly SemaphoreSlim _gate = new(1, 1); + + private readonly ConcurrentDictionary _inFlight = new(); + + public async Task StartAsync(CreateWorldRequest request, CancellationToken cancellationToken) + { + var origin = new GeoPoint(request.Latitude, request.Longitude); + if (!origin.IsValid) + throw new ArgumentException("Latitude must be within +/-90 and longitude within +/-180."); + + if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm) + throw new ArgumentException($"Size must be between {MinSizeKm} and {MaxSizeKm} km."); + + var name = string.IsNullOrWhiteSpace(request.Name) + ? $"{origin.Latitude:F4}, {origin.Longitude:F4}" + : request.Name.Trim(); + + var id = CreateId(name); + var summary = new WorldSummaryDto + { + Id = id, + Name = name, + Latitude = origin.Latitude, + Longitude = origin.Longitude, + SizeMeters = request.SizeKm * 1000.0, + Status = WorldStatus.Pending, + Stage = "Queued", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _inFlight[id] = summary; + await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false); + + // Detached on purpose: the caller gets the pending world back straight away. + _ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None); + + return summary; + } + + /// Returns the live status of a generation still in progress, if there is one. + public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id); + + private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh) + { + // Generation should stop when the host does, not drag shutdown out for minutes. + var cancellationToken = lifetime.ApplicationStopping; + + try + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + try + { + var progress = new CallbackProgress(stage => Publish(summary with + { + Status = WorldStatus.Generating, + Stage = stage, + })); + + Publish(summary with { Status = WorldStatus.Generating, Stage = "Starting" }); + + using var world = await generator.GenerateAsync( + new WorldGenerationRequest + { + Id = summary.Id, + Name = summary.Name, + Origin = new GeoPoint(summary.Latitude, summary.Longitude), + SizeMeters = summary.SizeMeters, + ForceRefresh = forceRefresh, + }, + progress, + cancellationToken).ConfigureAwait(false); + + progress.Report("Slicing into chunks"); + var chunks = exporter.Export(world); + + progress.Report($"Writing {chunks.Count:N0} chunks"); + await store.SaveWorldAsync(ToDto(world, chunks), chunks, cancellationToken).ConfigureAwait(false); + + var stats = world.Metadata.Stats; + var ready = summary with + { + Status = WorldStatus.Ready, + Stage = null, + Stats = new WorldStatsDto + { + Buildings = stats.Buildings, + Roads = stats.Roads, + Areas = stats.Areas, + Water = stats.Water, + Vertices = stats.Vertices, + }, + }; + + await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false); + Publish(ready); + _inFlight.TryRemove(summary.Id, out _); + } + catch (Exception ex) + { + logger.LogError(ex, "Generation of world {Id} failed", summary.Id); + + var failed = summary with + { + Status = WorldStatus.Failed, + Stage = null, + Error = ex.Message, + }; + + Publish(failed); + + try + { + await store.SaveSummaryAsync(failed, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception saveFailure) + { + logger.LogError(saveFailure, "Could not record the failure of world {Id}", summary.Id); + } + } + finally + { + _gate.Release(); + } + } + + private void Publish(WorldSummaryDto summary) => _inFlight[summary.Id] = summary; + + private static WorldDto ToDto(GameWorld world, IReadOnlyList chunks) + { + var metadata = world.Metadata; + var stats = metadata.Stats; + + return new WorldDto + { + Id = metadata.Id, + Name = metadata.Name, + Latitude = metadata.Origin.Latitude, + Longitude = metadata.Origin.Longitude, + SizeMeters = metadata.SizeMeters, + Bounds = new GeoBoundsDto( + metadata.Bounds.South, metadata.Bounds.West, metadata.Bounds.North, metadata.Bounds.East), + ChunkSizeMeters = metadata.ChunkSizeMeters, + ChunkCountX = metadata.ChunkCountX, + ChunkCountY = metadata.ChunkCountY, + GeneratedAt = metadata.GeneratedAt, + Stats = new WorldStatsDto + { + Buildings = stats.Buildings, + Roads = stats.Roads, + Areas = stats.Areas, + Water = stats.Water, + Vertices = stats.Vertices, + }, + Chunks = [.. chunks.Select(static chunk => chunk.Index)], + }; + } + + /// Builds a readable, filesystem-safe id: a slug of the name plus enough entropy to stay unique. + private static string CreateId(string name) + { + var slug = new StringBuilder(32); + var previousWasDash = false; + + foreach (var rune in name.Normalize(NormalizationForm.FormD)) + { + if (slug.Length >= 24) break; + if (CharUnicodeInfo.GetUnicodeCategory(rune) == UnicodeCategory.NonSpacingMark) continue; + + var lower = char.ToLowerInvariant(rune); + if (char.IsAsciiLetterLower(lower) || char.IsAsciiDigit(lower)) + { + slug.Append(lower); + previousWasDash = false; + } + else if (!previousWasDash && slug.Length > 0) + { + slug.Append('-'); + previousWasDash = true; + } + } + + var prefix = slug.ToString().Trim('-'); + if (prefix.Length == 0) prefix = "world"; + + return $"{prefix}-{Guid.NewGuid().ToString("n")[..8]}"; + } + + public void Dispose() => _gate.Dispose(); + + private sealed class CallbackProgress(Action callback) : IProgress + { + public void Report(string value) => callback(value); + } +} diff --git a/src/TheLivingWorld.Api/Program.cs b/src/TheLivingWorld.Api/Program.cs new file mode 100644 index 0000000..08483f6 --- /dev/null +++ b/src/TheLivingWorld.Api/Program.cs @@ -0,0 +1,69 @@ +using System.IO.Compression; +using Microsoft.AspNetCore.ResponseCompression; +using TheLivingWorld.Api.Endpoints; +using TheLivingWorld.Api.Generation; +using TheLivingWorld.Api.Storage; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Export; +using TheLivingWorld.Osm; + +const string WebClientCorsPolicy = "web-client"; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.ConfigureHttpJsonOptions(options => MapJson.Apply(options.SerializerOptions)); + +builder.Services.Configure( + builder.Configuration.GetSection(WorldStorageOptions.SectionName)); + +// Relative paths in configuration are meant to sit next to the app, not next to whatever the working +// directory happens to be when it is launched. +builder.Services.PostConfigure(options => + options.RootDirectory = RootPath(options.RootDirectory)); + +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is still marked experimental. +builder.Services.AddOsmImport(builder.Configuration) + // Overpass answers in minutes, not seconds; the resilience pipeline that AddServiceDefaults installs on + // every client would abandon the request long before that. OverpassClient retries across mirrors itself. + .RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + +builder.Services.PostConfigure(options => + options.CacheDirectory = RootPath(options.CacheDirectory)); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude. +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]); +}); +builder.Services.Configure(o => o.Level = CompressionLevel.Fastest); +builder.Services.Configure(o => o.Level = CompressionLevel.Fastest); + +// The Vite dev server proxies /api during `aspire run`, but the client can also be pointed at the API +// directly, which crosses an origin. +builder.Services.AddCors(options => options.AddPolicy(WebClientCorsPolicy, policy => policy + .SetIsOriginAllowed(origin => new Uri(origin).IsLoopback) + .AllowAnyHeader() + .AllowAnyMethod())); + +var app = builder.Build(); + +app.UseResponseCompression(); +app.UseCors(WebClientCorsPolicy); + +app.MapDefaultEndpoints(); +app.MapWorldEndpoints(); + +app.Run(); + +string RootPath(string path) => + Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, path)); diff --git a/src/TheLivingWorld.Api/Properties/launchSettings.json b/src/TheLivingWorld.Api/Properties/launchSettings.json new file mode 100644 index 0000000..2f82820 --- /dev/null +++ b/src/TheLivingWorld.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5195", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7162;http://localhost:5195", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs b/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs new file mode 100644 index 0000000..639559c --- /dev/null +++ b/src/TheLivingWorld.Api/Storage/WorldStorageOptions.cs @@ -0,0 +1,9 @@ +namespace TheLivingWorld.Api.Storage; + +public sealed class WorldStorageOptions +{ + public const string SectionName = "WorldStorage"; + + /// Where generated worlds live, relative to the content root unless rooted. + public string RootDirectory { get; set; } = "data/worlds"; +} diff --git a/src/TheLivingWorld.Api/Storage/WorldStore.cs b/src/TheLivingWorld.Api/Storage/WorldStore.cs new file mode 100644 index 0000000..16dd5f4 --- /dev/null +++ b/src/TheLivingWorld.Api/Storage/WorldStore.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Export; + +namespace TheLivingWorld.Api.Storage; + +/// +/// Persists generated worlds as plain files: one folder per world holding its state, its index, and one file +/// per chunk. Chunk files are written in the exact wire format so serving them is a byte copy. +/// +public sealed class WorldStore(IOptions options, ILogger logger) +{ + private const string StateFileName = "state.json"; + private const string IndexFileName = "world.json"; + private const string ChunksDirectoryName = "chunks"; + + private readonly string _root = options.Value.RootDirectory; + + /// + /// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that + /// reaches the filesystem goes through here. + /// + public static bool IsValidId(string? id) => + !string.IsNullOrEmpty(id) && + id.Length <= 64 && + id.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-'); + + public async Task> ListAsync(CancellationToken cancellationToken = default) + { + if (!Directory.Exists(_root)) return []; + + var summaries = new List(); + foreach (var directory in Directory.EnumerateDirectories(_root)) + { + var id = Path.GetFileName(directory); + if (!IsValidId(id)) continue; + + if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is { } summary) + summaries.Add(summary); + } + + summaries.Sort(static (a, b) => b.CreatedAt.CompareTo(a.CreatedAt)); + return summaries; + } + + public async Task GetSummaryAsync(string id, CancellationToken cancellationToken = default) + { + var path = Path.Combine(WorldDirectory(id), StateFileName); + if (!File.Exists(path)) return null; + + try + { + await using var stream = File.OpenRead(path); + return await JsonSerializer + .DeserializeAsync(stream, MapJson.Options, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + logger.LogWarning(ex, "Could not read state for world {Id}", id); + return null; + } + } + + public async Task SaveSummaryAsync(WorldSummaryDto summary, CancellationToken cancellationToken = default) + { + var directory = WorldDirectory(summary.Id); + Directory.CreateDirectory(directory); + await WriteJsonAsync(Path.Combine(directory, StateFileName), summary, cancellationToken).ConfigureAwait(false); + } + + public async Task GetWorldAsync(string id, CancellationToken cancellationToken = default) + { + var path = Path.Combine(WorldDirectory(id), IndexFileName); + if (!File.Exists(path)) return null; + + await using var stream = File.OpenRead(path); + return await JsonSerializer + .DeserializeAsync(stream, MapJson.Options, cancellationToken) + .ConfigureAwait(false); + } + + /// Opens a chunk file for streaming straight to the response. Null when the chunk is empty. + public Stream? OpenChunk(string id, int x, int y) + { + if (x < 0 || y < 0) return null; + + var path = Path.Combine(WorldDirectory(id), ChunksDirectoryName, $"{x}_{y}.json"); + return File.Exists(path) ? File.OpenRead(path) : null; + } + + public async Task SaveWorldAsync( + WorldDto world, + IReadOnlyList chunks, + CancellationToken cancellationToken = default) + { + var directory = WorldDirectory(world.Id); + var chunkDirectory = Path.Combine(directory, ChunksDirectoryName); + + // A regenerated world must not keep chunks that no longer exist. + if (Directory.Exists(chunkDirectory)) Directory.Delete(chunkDirectory, recursive: true); + Directory.CreateDirectory(chunkDirectory); + + foreach (var chunk in chunks) + { + var path = Path.Combine(chunkDirectory, $"{chunk.Coord.X}_{chunk.Coord.Y}.json"); + await WriteJsonAsync(path, chunk.Chunk, cancellationToken).ConfigureAwait(false); + } + + await WriteJsonAsync(Path.Combine(directory, IndexFileName), world, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Saved world {Id} with {Chunks:N0} chunks to {Directory}", world.Id, chunks.Count, directory); + } + + public bool Delete(string id) + { + var directory = WorldDirectory(id); + if (!Directory.Exists(directory)) return false; + + Directory.Delete(directory, recursive: true); + return true; + } + + private string WorldDirectory(string id) + { + if (!IsValidId(id)) throw new ArgumentException($"'{id}' is not a valid world id.", nameof(id)); + return Path.Combine(_root, id); + } + + private static async Task WriteJsonAsync(string path, T value, CancellationToken cancellationToken) + { + // Write beside the target and swap, so a reader never sees a half-written file. + var temporary = path + ".tmp"; + await using (var stream = File.Create(temporary)) + { + await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false); + } + + File.Move(temporary, path, overwrite: true); + } +} diff --git a/src/TheLivingWorld.Api/TheLivingWorld.Api.csproj b/src/TheLivingWorld.Api/TheLivingWorld.Api.csproj new file mode 100644 index 0000000..394d98d --- /dev/null +++ b/src/TheLivingWorld.Api/TheLivingWorld.Api.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/TheLivingWorld.Api/appsettings.Development.json b/src/TheLivingWorld.Api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/TheLivingWorld.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/TheLivingWorld.Api/appsettings.json b/src/TheLivingWorld.Api/appsettings.json new file mode 100644 index 0000000..95ee22a --- /dev/null +++ b/src/TheLivingWorld.Api/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "WorldStorage": { + "RootDirectory": "data/worlds" + }, + "Osm": { + "Endpoints": [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter" + ], + "QueryTimeoutSeconds": 180, + "RequestTimeoutSeconds": 240, + "MaxAttemptsPerEndpoint": 2, + "CacheDirectory": "data/osm-cache" + } +} diff --git a/src/TheLivingWorld.AppHost/AppHost.cs b/src/TheLivingWorld.AppHost/AppHost.cs new file mode 100644 index 0000000..0cb0c0d --- /dev/null +++ b/src/TheLivingWorld.AppHost/AppHost.cs @@ -0,0 +1,13 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var api = builder.AddProject("api") + .WithHttpHealthCheck("/health"); + +// The Vite dev server proxies /api to the address Aspire injects, so the browser only ever talks to one origin. +builder.AddViteApp("web", "../TheLivingWorld.Web") + .WithNpm() + .WithReference(api) + .WaitFor(api) + .WithExternalHttpEndpoints(); + +builder.Build().Run(); diff --git a/src/TheLivingWorld.AppHost/Properties/launchSettings.json b/src/TheLivingWorld.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..5cf49a2 --- /dev/null +++ b/src/TheLivingWorld.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17232;http://localhost:15180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21024", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23156", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22053" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15180", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19142", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18115", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20218" + } + } + } +} diff --git a/src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj b/src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj new file mode 100644 index 0000000..a5e85d6 --- /dev/null +++ b/src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + 2ffb5eff-6e16-47c4-a3f8-37b68224c539 + + + diff --git a/src/TheLivingWorld.AppHost/appsettings.Development.json b/src/TheLivingWorld.AppHost/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/TheLivingWorld.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/TheLivingWorld.AppHost/appsettings.json b/src/TheLivingWorld.AppHost/appsettings.json new file mode 100644 index 0000000..2185f95 --- /dev/null +++ b/src/TheLivingWorld.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/TheLivingWorld.AppHost/aspire.config.json b/src/TheLivingWorld.AppHost/aspire.config.json new file mode 100644 index 0000000..6da3cd5 --- /dev/null +++ b/src/TheLivingWorld.AppHost/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "TheLivingWorld.AppHost.csproj" + } +} diff --git a/src/TheLivingWorld.Core/Contracts/MapContracts.cs b/src/TheLivingWorld.Core/Contracts/MapContracts.cs new file mode 100644 index 0000000..2d15a8c --- /dev/null +++ b/src/TheLivingWorld.Core/Contracts/MapContracts.cs @@ -0,0 +1,89 @@ +using TheLivingWorld.Core.Ecs; + +namespace TheLivingWorld.Core.Contracts; + +/// +/// The wire format the client renders. Geometry travels as flat [x0, y0, x1, y1, ...] arrays of world +/// metres — that is exactly what PixiJS Graphics.poly() takes, so the client never has to reshape it. +/// X grows east, Y grows north; the client flips Y once at the container level. +/// +public sealed record ChunkDto +{ + public required int X { get; init; } + + public required int Y { get; init; } + + /// [minX, minY, maxX, maxY] widened to cover geometry overhanging the chunk's own square. + public required float[] Bounds { get; init; } + + public BuildingDto[] Buildings { get; init; } = []; + + public RoadDto[] Roads { get; init; } = []; + + public AreaDto[] Areas { get; init; } = []; + + public WaterDto[] Water { get; init; } = []; +} + +public sealed record BuildingDto +{ + public required long Id { get; init; } + + public required BuildingKind Kind { get; init; } + + public required float Height { get; init; } + + public required float[] Outline { get; init; } + + public float[][]? Holes { get; init; } + + public string? Name { get; init; } +} + +public sealed record RoadDto +{ + public required long Id { get; init; } + + public required RoadClass Class { get; init; } + + /// Carriageway width in metres; the client strokes the centreline at this width. + public required float Width { get; init; } + + public required float[] Path { get; init; } + + /// Bit field matching . + public byte Flags { get; init; } + + public string? Name { get; init; } +} + +public sealed record AreaDto +{ + public required long Id { get; init; } + + public required AreaKind Kind { get; init; } + + public required float[] Outline { get; init; } + + public float[][]? Holes { get; init; } + + public string? Name { get; init; } +} + +/// Water is either an area (a lake) or a line (a stream), never both. +public sealed record WaterDto +{ + public required long Id { get; init; } + + public required WaterKind Kind { get; init; } + + public float[]? Outline { get; init; } + + public float[][]? Holes { get; init; } + + public float[]? Path { get; init; } + + public float Width { get; init; } + + public string? Name { get; init; } +} diff --git a/src/TheLivingWorld.Core/Contracts/MapJson.cs b/src/TheLivingWorld.Core/Contracts/MapJson.cs new file mode 100644 index 0000000..986de3c --- /dev/null +++ b/src/TheLivingWorld.Core/Contracts/MapJson.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TheLivingWorld.Core.Contracts; + +/// +/// The single serializer configuration for everything on the wire. Chunk files are written to disk with these +/// options and later streamed straight to the client without re-serialization, so the store and the HTTP +/// endpoints must agree on them exactly. +/// +public static class MapJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Applies the same configuration to an options instance owned by someone else, such as ASP.NET Core's. + public static void Apply(JsonSerializerOptions options) + { + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.PropertyNameCaseInsensitive = true; + options.NumberHandling = Options.NumberHandling; + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + } +} diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs new file mode 100644 index 0000000..5ca27c8 --- /dev/null +++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs @@ -0,0 +1,106 @@ +namespace TheLivingWorld.Core.Contracts; + +public enum WorldStatus +{ + Pending, + Generating, + Ready, + Failed, +} + +/// Request body for POST /api/worlds. +public sealed record CreateWorldRequest +{ + public string? Name { get; init; } + + public required double Latitude { get; init; } + + public required double Longitude { get; init; } + + /// Side length of the square to generate, in kilometres. + public required double SizeKm { get; init; } + + /// Re-download from Overpass even if a cached response for this box exists. + public bool ForceRefresh { get; init; } +} + +/// A world as it appears in listings and while generation is still running. +public sealed record WorldSummaryDto +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required double Latitude { get; init; } + + public required double Longitude { get; init; } + + public required double SizeMeters { get; init; } + + public required WorldStatus Status { get; init; } + + /// Human-readable progress line while is . + public string? Stage { get; init; } + + public string? Error { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + + public WorldStatsDto? Stats { get; init; } +} + +/// Everything the client needs to set up its camera and decide which chunks to fetch. +public sealed record WorldDto +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required double Latitude { get; init; } + + public required double Longitude { get; init; } + + public required double SizeMeters { get; init; } + + public required GeoBoundsDto Bounds { get; init; } + + public required float ChunkSizeMeters { get; init; } + + public required int ChunkCountX { get; init; } + + public required int ChunkCountY { get; init; } + + public required DateTimeOffset GeneratedAt { get; init; } + + public required WorldStatsDto Stats { get; init; } + + /// Only chunks that actually hold features; empty ones are omitted. + public required ChunkIndexEntryDto[] Chunks { get; init; } +} + +public sealed record GeoBoundsDto(double South, double West, double North, double East); + +public sealed record ChunkIndexEntryDto +{ + public required int X { get; init; } + + public required int Y { get; init; } + + /// [minX, minY, maxX, maxY], widened to cover overhanging geometry. + public required float[] Bounds { get; init; } + + public required int Features { get; init; } +} + +public sealed record WorldStatsDto +{ + public required int Buildings { get; init; } + + public required int Roads { get; init; } + + public required int Areas { get; init; } + + public required int Water { get; init; } + + public required int Vertices { get; init; } +} diff --git a/src/TheLivingWorld.Core/Ecs/Components.cs b/src/TheLivingWorld.Core/Ecs/Components.cs new file mode 100644 index 0000000..d46ec2e --- /dev/null +++ b/src/TheLivingWorld.Core/Ecs/Components.cs @@ -0,0 +1,32 @@ +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Core.Ecs; + +/// Where this entity came from in OpenStreetMap. Kept so features stay traceable back to the source data. +public record struct OsmSource(long Id, OsmElementKind Kind); + +/// A closed ring: the entity's area footprint. Indexes into . +public record struct Outline(int ShapeId); + +/// Inner rings cut out of . ShapeIds is null for the common no-hole case. +public record struct Holes(int[]? ShapeIds); + +/// An open polyline: the entity's centreline. Indexes into . +public record struct Polyline(int ShapeId); + +/// Cached world-space extent, filled in by . +public record struct Bounds(RectBounds Value); + +/// Spatial bucket, filled in by . +public record struct InChunk(int X, int Y); + +public record struct Building(BuildingKind Kind, float HeightMeters, byte Levels); + +public record struct Road(RoadClass Class, float WidthMeters, byte Lanes, RoadFlags Flags); + +public record struct AreaFeature(AreaKind Kind); + +public record struct Water(WaterKind Kind, float WidthMeters); + +/// The name tag, or null. Always present so every feature archetype stays uniform. +public record struct DisplayName(string? Value); diff --git a/src/TheLivingWorld.Core/Ecs/FeatureKinds.cs b/src/TheLivingWorld.Core/Ecs/FeatureKinds.cs new file mode 100644 index 0000000..a5b8f22 --- /dev/null +++ b/src/TheLivingWorld.Core/Ecs/FeatureKinds.cs @@ -0,0 +1,99 @@ +namespace TheLivingWorld.Core.Ecs; + +public enum OsmElementKind : byte +{ + Node = 0, + Way = 1, + Relation = 2, +} + +public enum BuildingKind : byte +{ + Unknown = 0, + House, + Residential, + Apartments, + Commercial, + Retail, + Industrial, + Civic, + School, + Church, + Garage, + Shed, + Farm, + Ruins, +} + +public enum RoadClass : byte +{ + Unknown = 0, + Motorway, + Trunk, + Primary, + Secondary, + Tertiary, + Unclassified, + Residential, + LivingStreet, + Service, + Track, + Pedestrian, + Footway, + Cycleway, + Steps, + Path, + Railway, +} + +public enum AreaKind : byte +{ + Unknown = 0, + Forest, + Grass, + Meadow, + Farmland, + Orchard, + Scrub, + Heath, + Sand, + BareRock, + Wetland, + Park, + Garden, + Pitch, + Cemetery, + ResidentialZone, + IndustrialZone, + CommercialZone, + RetailZone, + Quarry, + Parking, + School, + Beach, +} + +public enum WaterKind : byte +{ + Unknown = 0, + Water, + Lake, + Pond, + Reservoir, + Riverbank, + River, + Stream, + Canal, + Ditch, + Drain, +} + +[Flags] +public enum RoadFlags : byte +{ + None = 0, + Bridge = 1 << 0, + Tunnel = 1 << 1, + Oneway = 1 << 2, + Unpaved = 1 << 3, +} diff --git a/src/TheLivingWorld.Core/Export/ChunkExporter.cs b/src/TheLivingWorld.Core/Export/ChunkExporter.cs new file mode 100644 index 0000000..012c60b --- /dev/null +++ b/src/TheLivingWorld.Core/Export/ChunkExporter.cs @@ -0,0 +1,204 @@ +using System.Numerics; +using Arch.Core; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Worlds; + +namespace TheLivingWorld.Core.Export; + +public sealed record ExportedChunk(ChunkCoord Coord, ChunkDto Chunk, ChunkIndexEntryDto Index); + +/// +/// Turns the ECS world into the per-chunk payloads the client fetches. Runs once per generation, right after +/// has bucketed every feature. +/// +public sealed class ChunkExporter +{ + private static readonly QueryDescription Buildings = + new QueryDescription().WithAll(); + + private static readonly QueryDescription Roads = + new QueryDescription().WithAll(); + + private static readonly QueryDescription Areas = + new QueryDescription().WithAll(); + + private static readonly QueryDescription WaterAreas = + new QueryDescription().WithAll(); + + private static readonly QueryDescription WaterLines = + new QueryDescription().WithAll(); + + public IReadOnlyList Export(GameWorld world) + { + var shapes = world.Shapes; + var grid = world.Grid; + var buckets = new Dictionary(); + + Bucket BucketFor(int x, int y) + { + var coord = new ChunkCoord(x, y); + if (!buckets.TryGetValue(coord, out var bucket)) + { + bucket = new Bucket(grid.BoundsOf(coord)); + buckets[coord] = bucket; + } + + return bucket; + } + + world.Ecs.Query(in Buildings, ( + ref OsmSource source, ref Building building, ref Outline outline, ref Holes holes, + ref Bounds bounds, ref InChunk chunk, ref DisplayName name) => + { + var bucket = BucketFor(chunk.X, chunk.Y); + bucket.Extend(bounds.Value); + bucket.Buildings.Add(new BuildingDto + { + Id = source.Id, + Kind = building.Kind, + Height = building.HeightMeters, + Outline = Flatten(shapes.Get(outline.ShapeId)), + Holes = FlattenHoles(shapes, holes), + Name = name.Value, + }); + }); + + world.Ecs.Query(in Roads, ( + ref OsmSource source, ref Road road, ref Polyline line, + ref Bounds bounds, ref InChunk chunk, ref DisplayName name) => + { + var bucket = BucketFor(chunk.X, chunk.Y); + bucket.Extend(bounds.Value); + bucket.Roads.Add(new RoadDto + { + Id = source.Id, + Class = road.Class, + Width = road.WidthMeters, + Path = Flatten(shapes.Get(line.ShapeId)), + Flags = (byte)road.Flags, + Name = name.Value, + }); + }); + + world.Ecs.Query(in Areas, ( + ref OsmSource source, ref AreaFeature area, ref Outline outline, ref Holes holes, + ref Bounds bounds, ref InChunk chunk, ref DisplayName name) => + { + var bucket = BucketFor(chunk.X, chunk.Y); + bucket.Extend(bounds.Value); + bucket.Areas.Add(new AreaDto + { + Id = source.Id, + Kind = area.Kind, + Outline = Flatten(shapes.Get(outline.ShapeId)), + Holes = FlattenHoles(shapes, holes), + Name = name.Value, + }); + }); + + world.Ecs.Query(in WaterAreas, ( + ref OsmSource source, ref Water water, ref Outline outline, ref Holes holes, + ref Bounds bounds, ref InChunk chunk, ref DisplayName name) => + { + var bucket = BucketFor(chunk.X, chunk.Y); + bucket.Extend(bounds.Value); + bucket.Water.Add(new WaterDto + { + Id = source.Id, + Kind = water.Kind, + Outline = Flatten(shapes.Get(outline.ShapeId)), + Holes = FlattenHoles(shapes, holes), + Name = name.Value, + }); + }); + + world.Ecs.Query(in WaterLines, ( + ref OsmSource source, ref Water water, ref Polyline line, + ref Bounds bounds, ref InChunk chunk, ref DisplayName name) => + { + var bucket = BucketFor(chunk.X, chunk.Y); + bucket.Extend(bounds.Value); + bucket.Water.Add(new WaterDto + { + Id = source.Id, + Kind = water.Kind, + Path = Flatten(shapes.Get(line.ShapeId)), + Width = water.WidthMeters, + Name = name.Value, + }); + }); + + var result = new List(buckets.Count); + foreach (var (coord, bucket) in buckets) + result.Add(bucket.Build(coord)); + + result.Sort(static (a, b) => a.Coord.Y != b.Coord.Y ? a.Coord.Y - b.Coord.Y : a.Coord.X - b.Coord.X); + return result; + } + + /// Packs points into [x0, y0, x1, y1, ...], rounded to the centimetre to keep JSON small. + private static float[] Flatten(ReadOnlySpan points) + { + var flat = new float[points.Length * 2]; + for (var i = 0; i < points.Length; i++) + { + flat[i * 2] = MathF.Round(points[i].X, 2); + flat[i * 2 + 1] = MathF.Round(points[i].Y, 2); + } + + return flat; + } + + private static float[][]? FlattenHoles(ShapeStore shapes, in Holes holes) + { + if (holes.ShapeIds is not { Length: > 0 } ids) return null; + + var flat = new float[ids.Length][]; + for (var i = 0; i < ids.Length; i++) + flat[i] = Flatten(shapes.Get(ids[i])); + + return flat; + } + + private sealed class Bucket(RectBounds square) + { + private RectBounds _bounds = square; + + public List Buildings { get; } = []; + + public List Roads { get; } = []; + + public List Areas { get; } = []; + + public List Water { get; } = []; + + public void Extend(in RectBounds featureBounds) => _bounds.Encapsulate(featureBounds); + + public ExportedChunk Build(ChunkCoord coord) + { + var bounds = _bounds.ToArray(); + var chunk = new ChunkDto + { + X = coord.X, + Y = coord.Y, + Bounds = bounds, + Buildings = [.. Buildings], + Roads = [.. Roads], + Areas = [.. Areas], + Water = [.. Water], + }; + + var index = new ChunkIndexEntryDto + { + X = coord.X, + Y = coord.Y, + Bounds = bounds, + Features = Buildings.Count + Roads.Count + Areas.Count + Water.Count, + }; + + return new ExportedChunk(coord, chunk, index); + } + } +} diff --git a/src/TheLivingWorld.Core/Geo/GeoBounds.cs b/src/TheLivingWorld.Core/Geo/GeoBounds.cs new file mode 100644 index 0000000..3a6d5f9 --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/GeoBounds.cs @@ -0,0 +1,37 @@ +using System.Globalization; + +namespace TheLivingWorld.Core.Geo; + +/// An axis-aligned WGS84 bounding box, in degrees. +public readonly record struct GeoBounds(double South, double West, double North, double East) +{ + /// + /// Builds the square that encloses of ground on a side, centred on + /// . The longitude span is widened by 1/cos(lat) so the box stays square in metres. + /// + public static GeoBounds FromCenter(GeoPoint center, double sizeMeters) + { + var half = sizeMeters / 2.0; + var latDelta = half / LocalProjection.MetersPerDegreeLatitude; + var cosLat = Math.Cos(center.Latitude * Math.PI / 180.0); + + // Guard the poles: cos(lat) collapses to zero there and the longitude span would explode. + var lonDelta = half / (LocalProjection.MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6)); + + return new GeoBounds( + South: center.Latitude - latDelta, + West: center.Longitude - lonDelta, + North: center.Latitude + latDelta, + East: center.Longitude + lonDelta); + } + + public GeoPoint Center => new((South + North) / 2.0, (West + East) / 2.0); + + public bool Contains(GeoPoint p) => + p.Latitude >= South && p.Latitude <= North && p.Longitude >= West && p.Longitude <= East; + + /// Overpass expects bounding boxes as (south,west,north,east). + public string ToOverpassBbox() => string.Create( + CultureInfo.InvariantCulture, + $"{South:F7},{West:F7},{North:F7},{East:F7}"); +} diff --git a/src/TheLivingWorld.Core/Geo/GeoPoint.cs b/src/TheLivingWorld.Core/Geo/GeoPoint.cs new file mode 100644 index 0000000..f7638b8 --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/GeoPoint.cs @@ -0,0 +1,11 @@ +namespace TheLivingWorld.Core.Geo; + +/// A WGS84 coordinate. Latitude and longitude are in degrees. +public readonly record struct GeoPoint(double Latitude, double Longitude) +{ + public bool IsValid => + double.IsFinite(Latitude) && double.IsFinite(Longitude) && + Latitude is >= -90 and <= 90 && Longitude is >= -180 and <= 180; + + public override string ToString() => $"{Latitude:F7}, {Longitude:F7}"; +} diff --git a/src/TheLivingWorld.Core/Geo/GeometryClipper.cs b/src/TheLivingWorld.Core/Geo/GeometryClipper.cs new file mode 100644 index 0000000..9ad122f --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/GeometryClipper.cs @@ -0,0 +1,168 @@ +using System.Numerics; + +namespace TheLivingWorld.Core.Geo; + +/// +/// Trims imported geometry to the generated square. OpenStreetMap returns whole ways whenever they touch the +/// query box, so a highway crossing town can reach tens of kilometres past the map edge; clipping keeps the +/// world's extents honest and stops one stray way from inflating a chunk's bounds. +/// +public static class GeometryClipper +{ + /// + /// Sutherland-Hodgman clip of a closed ring against the rectangle. Returns null when nothing survives. + /// The ring is expected without a repeated closing vertex, and comes back the same way. + /// + public static Vector2[]? ClipPolygon(ReadOnlySpan ring, in RectBounds rect) + { + if (ring.Length < 3) return null; + + var current = new List(ring.Length + 8); + current.AddRange(ring); + + var next = new List(ring.Length + 8); + + for (var edge = 0; edge < 4; edge++) + { + next.Clear(); + if (current.Count == 0) return null; + + for (var i = 0; i < current.Count; i++) + { + var a = current[i]; + var b = current[(i + 1) % current.Count]; + + var aInside = Inside(a, edge, rect); + var bInside = Inside(b, edge, rect); + + if (aInside) + { + next.Add(a); + if (!bInside) next.Add(IntersectEdge(a, b, edge, rect)); + } + else if (bInside) + { + next.Add(IntersectEdge(a, b, edge, rect)); + } + } + + (current, next) = (next, current); + } + + return current.Count >= 3 ? current.ToArray() : null; + } + + /// + /// Liang-Barsky clip of an open polyline. A line that leaves and re-enters the rectangle comes back as + /// several pieces, so the result is a list of runs rather than a single path. + /// + public static List ClipPolyline(ReadOnlySpan line, in RectBounds rect) + { + var runs = new List(); + if (line.Length < 2) return runs; + + var run = new List(); + + for (var i = 0; i < line.Length - 1; i++) + { + if (!ClipSegment(line[i], line[i + 1], rect, out var start, out var end)) + { + // The segment misses the box entirely, which breaks the current run. + FlushRun(run, runs); + continue; + } + + if (run.Count > 0 && !NearlyEqual(run[^1], start)) + FlushRun(run, runs); + + if (run.Count == 0) run.Add(start); + run.Add(end); + } + + FlushRun(run, runs); + return runs; + } + + private static void FlushRun(List run, List runs) + { + if (run.Count >= 2) runs.Add(run.ToArray()); + run.Clear(); + } + + private static bool NearlyEqual(Vector2 a, Vector2 b) => Vector2.DistanceSquared(a, b) < 1e-4f; + + private static bool ClipSegment(Vector2 a, Vector2 b, in RectBounds rect, out Vector2 start, out Vector2 end) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + var t0 = 0f; + var t1 = 1f; + + Span p = [-dx, dx, -dy, dy]; + Span q = [a.X - rect.MinX, rect.MaxX - a.X, a.Y - rect.MinY, rect.MaxY - a.Y]; + + for (var i = 0; i < 4; i++) + { + if (p[i] == 0f) + { + // Parallel to this boundary: outside it means the whole segment is rejected. + if (q[i] < 0f) + { + start = default; + end = default; + return false; + } + + continue; + } + + var t = q[i] / p[i]; + if (p[i] < 0f) + { + if (t > t1) { start = default; end = default; return false; } + if (t > t0) t0 = t; + } + else + { + if (t < t0) { start = default; end = default; return false; } + if (t < t1) t1 = t; + } + } + + start = new Vector2(a.X + t0 * dx, a.Y + t0 * dy); + end = new Vector2(a.X + t1 * dx, a.Y + t1 * dy); + return true; + } + + private static bool Inside(Vector2 p, int edge, in RectBounds rect) => edge switch + { + 0 => p.X >= rect.MinX, + 1 => p.X <= rect.MaxX, + 2 => p.Y >= rect.MinY, + _ => p.Y <= rect.MaxY, + }; + + private static Vector2 IntersectEdge(Vector2 a, Vector2 b, int edge, in RectBounds rect) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + + switch (edge) + { + case 0: + case 1: + { + var x = edge == 0 ? rect.MinX : rect.MaxX; + var t = dx == 0f ? 0f : (x - a.X) / dx; + return new Vector2(x, a.Y + t * dy); + } + + default: + { + var y = edge == 2 ? rect.MinY : rect.MaxY; + var t = dy == 0f ? 0f : (y - a.Y) / dy; + return new Vector2(a.X + t * dx, y); + } + } + } +} diff --git a/src/TheLivingWorld.Core/Geo/LocalProjection.cs b/src/TheLivingWorld.Core/Geo/LocalProjection.cs new file mode 100644 index 0000000..37eb0e8 --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/LocalProjection.cs @@ -0,0 +1,39 @@ +using System.Numerics; + +namespace TheLivingWorld.Core.Geo; + +/// +/// Flattens WGS84 coordinates onto a local metric plane centred on an origin: X grows east, Y grows north, +/// both in metres. This is an equirectangular projection with the scale factor frozen at the origin latitude, +/// which stays sub-metre accurate across the 5-20 km worlds this game generates and — unlike Web Mercator — +/// keeps distances usable directly as game units. +/// +public sealed class LocalProjection +{ + public const double EarthRadiusMeters = 6378137.0; + public const double MetersPerDegreeLatitude = EarthRadiusMeters * Math.PI / 180.0; + + private readonly double _metersPerDegreeLongitude; + + public LocalProjection(GeoPoint origin) + { + if (!origin.IsValid) + throw new ArgumentOutOfRangeException(nameof(origin), origin, "Origin is not a valid WGS84 coordinate."); + + Origin = origin; + var cosLat = Math.Cos(origin.Latitude * Math.PI / 180.0); + _metersPerDegreeLongitude = MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6); + } + + public GeoPoint Origin { get; } + + public Vector2 Project(GeoPoint point) => Project(point.Latitude, point.Longitude); + + public Vector2 Project(double latitude, double longitude) => new( + (float)((longitude - Origin.Longitude) * _metersPerDegreeLongitude), + (float)((latitude - Origin.Latitude) * MetersPerDegreeLatitude)); + + public GeoPoint Unproject(Vector2 local) => new( + Origin.Latitude + local.Y / MetersPerDegreeLatitude, + Origin.Longitude + local.X / _metersPerDegreeLongitude); +} diff --git a/src/TheLivingWorld.Core/Geo/Polygons.cs b/src/TheLivingWorld.Core/Geo/Polygons.cs new file mode 100644 index 0000000..fb40bbd --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/Polygons.cs @@ -0,0 +1,45 @@ +using System.Numerics; + +namespace TheLivingWorld.Core.Geo; + +public static class Polygons +{ + /// Twice the signed area of a ring: positive when the ring winds counter-clockwise. + public static float SignedDoubleArea(ReadOnlySpan ring) + { + if (ring.Length < 3) return 0f; + + var sum = 0f; + for (var i = 0; i < ring.Length; i++) + { + var a = ring[i]; + var b = ring[(i + 1) % ring.Length]; + sum += a.X * b.Y - b.X * a.Y; + } + + return sum; + } + + public static float Area(ReadOnlySpan ring) => MathF.Abs(SignedDoubleArea(ring)) * 0.5f; + + /// Standard even-odd ray cast. Points exactly on the boundary may land either way. + public static bool Contains(ReadOnlySpan ring, Vector2 point) + { + if (ring.Length < 3) return false; + + var inside = false; + for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++) + { + var a = ring[i]; + var b = ring[j]; + + if (a.Y > point.Y != b.Y > point.Y && + point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X) + { + inside = !inside; + } + } + + return inside; + } +} diff --git a/src/TheLivingWorld.Core/Geo/RectBounds.cs b/src/TheLivingWorld.Core/Geo/RectBounds.cs new file mode 100644 index 0000000..846a270 --- /dev/null +++ b/src/TheLivingWorld.Core/Geo/RectBounds.cs @@ -0,0 +1,70 @@ +using System.Numerics; + +namespace TheLivingWorld.Core.Geo; + +/// An axis-aligned bounding box in world metres. +public struct RectBounds +{ + public float MinX; + public float MinY; + public float MaxX; + public float MaxY; + + public RectBounds(float minX, float minY, float maxX, float maxY) + { + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + + /// An inverted box that swallows the first point it encapsulates. + public static RectBounds Empty => new(float.MaxValue, float.MaxValue, float.MinValue, float.MinValue); + + public readonly bool IsEmpty => MinX > MaxX || MinY > MaxY; + + public readonly float Width => MaxX - MinX; + + public readonly float Height => MaxY - MinY; + + public readonly Vector2 Center => new((MinX + MaxX) * 0.5f, (MinY + MaxY) * 0.5f); + + public void Encapsulate(Vector2 point) + { + if (point.X < MinX) MinX = point.X; + if (point.Y < MinY) MinY = point.Y; + if (point.X > MaxX) MaxX = point.X; + if (point.Y > MaxY) MaxY = point.Y; + } + + public void Encapsulate(in RectBounds other) + { + if (other.IsEmpty) return; + if (other.MinX < MinX) MinX = other.MinX; + if (other.MinY < MinY) MinY = other.MinY; + if (other.MaxX > MaxX) MaxX = other.MaxX; + if (other.MaxY > MaxY) MaxY = other.MaxY; + } + + public void Expand(float margin) + { + MinX -= margin; + MinY -= margin; + MaxX += margin; + MaxY += margin; + } + + public readonly bool Intersects(in RectBounds other) => + MinX <= other.MaxX && MaxX >= other.MinX && + MinY <= other.MaxY && MaxY >= other.MinY; + + public static RectBounds FromPoints(ReadOnlySpan points) + { + var bounds = Empty; + foreach (var point in points) + bounds.Encapsulate(point); + return bounds; + } + + public readonly float[] ToArray() => [MinX, MinY, MaxX, MaxY]; +} diff --git a/src/TheLivingWorld.Core/Systems/AssignChunksSystem.cs b/src/TheLivingWorld.Core/Systems/AssignChunksSystem.cs new file mode 100644 index 0000000..7c788e1 --- /dev/null +++ b/src/TheLivingWorld.Core/Systems/AssignChunksSystem.cs @@ -0,0 +1,30 @@ +using Arch.Core; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Core.Worlds; + +namespace TheLivingWorld.Core.Systems; + +/// +/// Buckets each feature into the chunk containing the centre of its extent. Features are never split, so a +/// long road stays whole and simply overhangs its chunk; the exported chunk bounds are widened to match, and +/// the client uses those widened bounds to decide what to fetch. +/// +public sealed class AssignChunksSystem : IWorldSystem +{ + private static readonly QueryDescription Placeable = new QueryDescription().WithAll(); + + public string Name => nameof(AssignChunksSystem); + + public void Execute(GameWorld world) + { + var grid = world.Grid; + + world.Ecs.Query(in Placeable, (ref Bounds bounds, ref InChunk chunk) => + { + if (bounds.Value.IsEmpty) return; + var coord = grid.CoordOf(bounds.Value.Center); + chunk.X = coord.X; + chunk.Y = coord.Y; + }); + } +} diff --git a/src/TheLivingWorld.Core/Systems/ComputeBoundsSystem.cs b/src/TheLivingWorld.Core/Systems/ComputeBoundsSystem.cs new file mode 100644 index 0000000..e09616b --- /dev/null +++ b/src/TheLivingWorld.Core/Systems/ComputeBoundsSystem.cs @@ -0,0 +1,31 @@ +using Arch.Core; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Worlds; + +namespace TheLivingWorld.Core.Systems; + +/// Fills every feature's cached world-space extent from the geometry it points at. +public sealed class ComputeBoundsSystem : IWorldSystem +{ + private static readonly QueryDescription AreaFeatures = new QueryDescription().WithAll(); + private static readonly QueryDescription LineFeatures = new QueryDescription().WithAll(); + + public string Name => nameof(ComputeBoundsSystem); + + public void Execute(GameWorld world) + { + var shapes = world.Shapes; + + // Holes are contained by their outline, so the outline alone determines the extent. + world.Ecs.Query(in AreaFeatures, (ref Outline outline, ref Bounds bounds) => + { + bounds.Value = RectBounds.FromPoints(shapes.Get(outline.ShapeId)); + }); + + world.Ecs.Query(in LineFeatures, (ref Polyline line, ref Bounds bounds) => + { + bounds.Value = RectBounds.FromPoints(shapes.Get(line.ShapeId)); + }); + } +} diff --git a/src/TheLivingWorld.Core/Systems/IWorldSystem.cs b/src/TheLivingWorld.Core/Systems/IWorldSystem.cs new file mode 100644 index 0000000..053eca6 --- /dev/null +++ b/src/TheLivingWorld.Core/Systems/IWorldSystem.cs @@ -0,0 +1,28 @@ +using TheLivingWorld.Core.Worlds; + +namespace TheLivingWorld.Core.Systems; + +/// A pass over the ECS world. Systems are ordered and run once per generation for now. +public interface IWorldSystem +{ + string Name { get; } + + void Execute(GameWorld world); +} + +/// Runs a fixed sequence of systems over a world. +public sealed class WorldPipeline(params IWorldSystem[] systems) +{ + public IReadOnlyList Systems { get; } = systems; + + public void Run(GameWorld world) + { + foreach (var system in Systems) + system.Execute(world); + } + + /// The passes every freshly built world needs before it can be sliced into chunks. + public static WorldPipeline CreateDefault() => new( + new ComputeBoundsSystem(), + new AssignChunksSystem()); +} diff --git a/src/TheLivingWorld.Core/TheLivingWorld.Core.csproj b/src/TheLivingWorld.Core/TheLivingWorld.Core.csproj new file mode 100644 index 0000000..72ba506 --- /dev/null +++ b/src/TheLivingWorld.Core/TheLivingWorld.Core.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/TheLivingWorld.Core/Worlds/ChunkGrid.cs b/src/TheLivingWorld.Core/Worlds/ChunkGrid.cs new file mode 100644 index 0000000..cfe572e --- /dev/null +++ b/src/TheLivingWorld.Core/Worlds/ChunkGrid.cs @@ -0,0 +1,49 @@ +using System.Numerics; +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Core.Worlds; + +public readonly record struct ChunkCoord(int X, int Y); + +/// +/// A uniform square grid over the world square, used to slice the map into separately fetchable pieces. +/// The world is centred on the projection origin, so it spans [-half, +half] on both axes. +/// +public sealed class ChunkGrid +{ + public ChunkGrid(double worldSizeMeters, float chunkSizeMeters) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(worldSizeMeters); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkSizeMeters); + + ChunkSizeMeters = chunkSizeMeters; + MinX = (float)(-worldSizeMeters / 2.0); + MinY = (float)(-worldSizeMeters / 2.0); + CountX = (int)Math.Ceiling(worldSizeMeters / chunkSizeMeters); + CountY = CountX; + } + + public float ChunkSizeMeters { get; } + + public float MinX { get; } + + public float MinY { get; } + + public int CountX { get; } + + public int CountY { get; } + + /// Locates the chunk containing , clamped to the grid. + public ChunkCoord CoordOf(Vector2 point) + { + var x = (int)Math.Floor((point.X - MinX) / ChunkSizeMeters); + var y = (int)Math.Floor((point.Y - MinY) / ChunkSizeMeters); + return new ChunkCoord(Math.Clamp(x, 0, CountX - 1), Math.Clamp(y, 0, CountY - 1)); + } + + public RectBounds BoundsOf(ChunkCoord coord) => new( + MinX + coord.X * ChunkSizeMeters, + MinY + coord.Y * ChunkSizeMeters, + MinX + (coord.X + 1) * ChunkSizeMeters, + MinY + (coord.Y + 1) * ChunkSizeMeters); +} diff --git a/src/TheLivingWorld.Core/Worlds/GameWorld.cs b/src/TheLivingWorld.Core/Worlds/GameWorld.cs new file mode 100644 index 0000000..0be7404 --- /dev/null +++ b/src/TheLivingWorld.Core/Worlds/GameWorld.cs @@ -0,0 +1,39 @@ +using Arch.Core; +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Core.Worlds; + +/// +/// A generated world: the Arch ECS world holding every map feature as an entity, the shape store its +/// components point into, and the projection/grid needed to interpret their coordinates. +/// +public sealed class GameWorld : IDisposable +{ + private bool _disposed; + + public GameWorld(WorldMetadata metadata) + { + Metadata = metadata; + Projection = new LocalProjection(metadata.Origin); + Grid = new ChunkGrid(metadata.SizeMeters, metadata.ChunkSizeMeters); + Shapes = new ShapeStore(); + Ecs = World.Create(); + } + + public World Ecs { get; } + + public ShapeStore Shapes { get; } + + public LocalProjection Projection { get; } + + public ChunkGrid Grid { get; } + + public WorldMetadata Metadata { get; set; } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + World.Destroy(Ecs); + } +} diff --git a/src/TheLivingWorld.Core/Worlds/ShapeStore.cs b/src/TheLivingWorld.Core/Worlds/ShapeStore.cs new file mode 100644 index 0000000..b54009b --- /dev/null +++ b/src/TheLivingWorld.Core/Worlds/ShapeStore.cs @@ -0,0 +1,36 @@ +using System.Numerics; + +namespace TheLivingWorld.Core.Worlds; + +/// +/// Side table holding every polyline and ring in the world. ECS components carry a plain integer handle into +/// this store instead of an array reference, which keeps component data blittable and keeps the archetype +/// chunks dense. +/// +public sealed class ShapeStore +{ + private readonly List _shapes = []; + + public int Count => _shapes.Count; + + /// Takes ownership of and returns its handle. + public int Add(Vector2[] points) + { + ArgumentNullException.ThrowIfNull(points); + _shapes.Add(points); + return _shapes.Count - 1; + } + + public ReadOnlySpan Get(int shapeId) => _shapes[shapeId]; + + public int VertexCount + { + get + { + var total = 0; + foreach (var shape in _shapes) + total += shape.Length; + return total; + } + } +} diff --git a/src/TheLivingWorld.Core/Worlds/WorldMetadata.cs b/src/TheLivingWorld.Core/Worlds/WorldMetadata.cs new file mode 100644 index 0000000..942d2c2 --- /dev/null +++ b/src/TheLivingWorld.Core/Worlds/WorldMetadata.cs @@ -0,0 +1,44 @@ +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Core.Worlds; + +/// Everything needed to describe a generated world without loading its geometry. +public sealed record WorldMetadata +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + /// The point the player asked for. It is the projection origin, so it sits at world (0, 0). + public required GeoPoint Origin { get; init; } + + /// Side length of the generated square, in metres. + public required double SizeMeters { get; init; } + + public required GeoBounds Bounds { get; init; } + + public required float ChunkSizeMeters { get; init; } + + public required int ChunkCountX { get; init; } + + public required int ChunkCountY { get; init; } + + public required DateTimeOffset GeneratedAt { get; init; } + + public WorldStats Stats { get; init; } = new(); +} + +public sealed record WorldStats +{ + public int Buildings { get; init; } + + public int Roads { get; init; } + + public int Areas { get; init; } + + public int Water { get; init; } + + public int Vertices { get; init; } + + public int Total => Buildings + Roads + Areas + Water; +} diff --git a/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs b/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs new file mode 100644 index 0000000..520feb9 --- /dev/null +++ b/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs @@ -0,0 +1,283 @@ +using System.Numerics; +using Microsoft.Extensions.Logging; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Worlds; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Osm.Import; + +/// +/// Turns raw Overpass elements into ECS entities. Every feature becomes one entity whose components describe +/// what it is; the geometry itself lives in the world's and is referenced by handle. +/// +public sealed class OsmWorldBuilder(ILogger logger) +{ + /// Rings smaller than this are noise at any zoom the client offers. + private const float MinimumAreaSquareMeters = 1.0f; + + public WorldStats Populate(GameWorld world, IEnumerable elements) + { + var half = (float)(world.Metadata.SizeMeters / 2.0); + var clip = new RectBounds(-half, -half, half, half); + var counters = new Counters(); + + foreach (var element in elements) + { + try + { + if (element.IsWay) AddWay(world, element, clip, counters); + else if (element.IsRelation) AddRelation(world, element, clip, counters); + } + catch (Exception ex) + { + // One malformed element must not abort a whole world. + logger.LogWarning(ex, "Skipped {Type}/{Id} while importing", element.Type, element.Id); + } + } + + return new WorldStats + { + Buildings = counters.Buildings, + Roads = counters.Roads, + Areas = counters.Areas, + Water = counters.Water, + Vertices = world.Shapes.VertexCount, + }; + } + + private void AddWay(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters) + { + if (element.Geometry is not { Count: >= 2 } geometry) return; + + var points = Project(world.Projection, geometry); + var closed = IsClosed(points); + var ring = closed ? TrimClosingVertex(points) : null; + var name = TagInterpreter.ReadName(element); + var source = new OsmSource(element.Id, OsmElementKind.Way); + + if (element.HasTag("building") || element.HasTag("building:part")) + { + if (ring is null) return; + AddBuilding(world, source, TagInterpreter.ReadBuilding(element), ring, holes: null, name, clip, counters); + return; + } + + if (TagInterpreter.ReadWater(element) is { } water) + { + if (ring is not null) AddWaterArea(world, source, water, ring, holes: null, name, clip, counters); + else AddWaterLine(world, source, water, points, name, clip, counters); + return; + } + + if (TagInterpreter.IsRoad(element)) + { + AddRoad(world, source, TagInterpreter.ReadRoad(element), points, name, clip, counters); + return; + } + + if (TagInterpreter.ReadAreaKind(element) is { } areaKind && ring is not null) + AddArea(world, source, areaKind, ring, holes: null, name, clip, counters); + } + + private void AddRelation(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters) + { + if (element.Members is not { Count: > 0 } members) return; + + var outerRings = AssembleRings(world.Projection, members, "outer"); + if (outerRings.Count == 0) return; + + var innerRings = AssembleRings(world.Projection, members, "inner"); + var name = TagInterpreter.ReadName(element); + var source = new OsmSource(element.Id, OsmElementKind.Relation); + + var isBuilding = element.HasTag("building"); + var water = TagInterpreter.ReadWater(element); + var areaKind = TagInterpreter.ReadAreaKind(element); + + if (!isBuilding && water is null && areaKind is null) return; + + var building = isBuilding ? TagInterpreter.ReadBuilding(element) : default; + + foreach (var outer in outerRings) + { + var holes = MatchHoles(outer, innerRings, outerRings.Count); + + if (isBuilding) AddBuilding(world, source, building, outer, holes, name, clip, counters); + else if (water is { } w) AddWaterArea(world, source, w, outer, holes, name, clip, counters); + else if (areaKind is { } kind) AddArea(world, source, kind, outer, holes, name, clip, counters); + } + } + + /// + /// Assigns inner rings to the outer ring that encloses them. With a single outer ring the containment + /// test is skipped - every inner ring must belong to it. + /// + private static List? MatchHoles(Vector2[] outer, List innerRings, int outerCount) + { + if (innerRings.Count == 0) return null; + if (outerCount == 1) return innerRings; + + List? matched = null; + foreach (var inner in innerRings) + { + if (!Polygons.Contains(outer, inner[0])) continue; + matched ??= []; + matched.Add(inner); + } + + return matched; + } + + private static List AssembleRings(LocalProjection projection, List members, string role) + { + var segments = new List>(); + + foreach (var member in members) + { + if (member.Type != "way") continue; + + // An empty role means "outer" by the multipolygon spec. + var memberRole = string.IsNullOrEmpty(member.Role) ? "outer" : member.Role; + if (memberRole != role) continue; + + if (member.Geometry is { Count: >= 2 } geometry) + segments.Add(Project(projection, geometry)); + } + + return segments.Count == 0 ? [] : RingAssembler.Assemble(segments); + } + + private void AddBuilding( + GameWorld world, OsmSource source, Building building, Vector2[] ring, + List? holes, string? name, in RectBounds clip, Counters counters) + { + if (ClipRing(ring, clip) is not { } clipped) return; + + world.Ecs.Create( + source, + building, + new Outline(world.Shapes.Add(clipped)), + new Holes(StoreHoles(world, holes, clip)), + new Bounds(), + new InChunk(), + new DisplayName(name)); + + counters.Buildings++; + } + + private void AddArea( + GameWorld world, OsmSource source, AreaKind kind, Vector2[] ring, + List? holes, string? name, in RectBounds clip, Counters counters) + { + if (ClipRing(ring, clip) is not { } clipped) return; + + world.Ecs.Create( + source, + new AreaFeature(kind), + new Outline(world.Shapes.Add(clipped)), + new Holes(StoreHoles(world, holes, clip)), + new Bounds(), + new InChunk(), + new DisplayName(name)); + + counters.Areas++; + } + + private void AddWaterArea( + GameWorld world, OsmSource source, Water water, Vector2[] ring, + List? holes, string? name, in RectBounds clip, Counters counters) + { + if (ClipRing(ring, clip) is not { } clipped) return; + + world.Ecs.Create( + source, + water, + new Outline(world.Shapes.Add(clipped)), + new Holes(StoreHoles(world, holes, clip)), + new Bounds(), + new InChunk(), + new DisplayName(name)); + + counters.Water++; + } + + private void AddRoad( + GameWorld world, OsmSource source, Road road, Vector2[] points, + string? name, in RectBounds clip, Counters counters) + { + foreach (var run in GeometryClipper.ClipPolyline(points, clip)) + { + world.Ecs.Create( + source, + road, + new Polyline(world.Shapes.Add(run)), + new Bounds(), + new InChunk(), + new DisplayName(name)); + + counters.Roads++; + } + } + + private void AddWaterLine( + GameWorld world, OsmSource source, Water water, Vector2[] points, + string? name, in RectBounds clip, Counters counters) + { + foreach (var run in GeometryClipper.ClipPolyline(points, clip)) + { + world.Ecs.Create( + source, + water, + new Polyline(world.Shapes.Add(run)), + new Bounds(), + new InChunk(), + new DisplayName(name)); + + counters.Water++; + } + } + + private static int[]? StoreHoles(GameWorld world, List? holes, in RectBounds clip) + { + if (holes is null || holes.Count == 0) return null; + + List? ids = null; + foreach (var hole in holes) + { + if (ClipRing(hole, clip) is not { } clipped) continue; + ids ??= []; + ids.Add(world.Shapes.Add(clipped)); + } + + return ids?.ToArray(); + } + + private static Vector2[]? ClipRing(Vector2[] ring, in RectBounds clip) + { + var clipped = GeometryClipper.ClipPolygon(ring, clip); + return clipped is not null && Polygons.Area(clipped) >= MinimumAreaSquareMeters ? clipped : null; + } + + private static Vector2[] Project(LocalProjection projection, List geometry) + { + var points = new Vector2[geometry.Count]; + for (var i = 0; i < geometry.Count; i++) + points[i] = projection.Project(geometry[i].Lat, geometry[i].Lon); + + return points; + } + + private static bool IsClosed(Vector2[] points) => + points.Length >= 4 && Vector2.DistanceSquared(points[0], points[^1]) < 0.0025f; + + private static Vector2[] TrimClosingVertex(Vector2[] points) => points[..^1]; + + private sealed class Counters + { + public int Buildings; + public int Roads; + public int Areas; + public int Water; + } +} diff --git a/src/TheLivingWorld.Osm/Import/OsmWorldGenerator.cs b/src/TheLivingWorld.Osm/Import/OsmWorldGenerator.cs new file mode 100644 index 0000000..cad74a3 --- /dev/null +++ b/src/TheLivingWorld.Osm/Import/OsmWorldGenerator.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Systems; +using TheLivingWorld.Core.Worlds; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Osm.Import; + +public sealed record WorldGenerationRequest +{ + public required string Id { get; init; } + + public required string Name { get; init; } + + public required GeoPoint Origin { get; init; } + + public required double SizeMeters { get; init; } + + public float ChunkSizeMeters { get; init; } = 512f; + + public bool ForceRefresh { get; init; } +} + +/// +/// The one-shot world generation pass: pull the box from Overpass, import it into an ECS world, then run the +/// systems that give every feature its extent and chunk. +/// +public sealed class OsmWorldGenerator( + OverpassClient client, + OsmWorldBuilder builder, + ILogger logger) +{ + private static readonly JsonSerializerOptions ParseOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly WorldPipeline _pipeline = WorldPipeline.CreateDefault(); + + public async Task GenerateAsync( + WorldGenerationRequest request, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var bounds = GeoBounds.FromCenter(request.Origin, request.SizeMeters); + logger.LogInformation( + "Generating world {Id} at {Origin} spanning {Size:N0} m", + request.Id, request.Origin, request.SizeMeters); + + var cachePath = await client + .FetchAsync(bounds, request.ForceRefresh, progress, cancellationToken) + .ConfigureAwait(false); + + progress?.Report("Parsing OpenStreetMap response"); + var response = await ReadResponseAsync(cachePath, cancellationToken).ConfigureAwait(false); + + // Overpass reports an aborted query as a remark inside an otherwise valid 200 response. Caching that + // would poison every later run for this box, so the file goes and the caller sees the reason. + if (response.Remark is { Length: > 0 } remark && response.Elements.Count == 0) + { + TryDelete(cachePath); + throw new OverpassException($"Overpass could not complete the query: {remark}"); + } + + logger.LogInformation("Parsed {Count:N0} OSM elements", response.Elements.Count); + + var world = new GameWorld(new WorldMetadata + { + Id = request.Id, + Name = request.Name, + Origin = request.Origin, + SizeMeters = request.SizeMeters, + Bounds = bounds, + ChunkSizeMeters = request.ChunkSizeMeters, + ChunkCountX = 0, + ChunkCountY = 0, + GeneratedAt = DateTimeOffset.UtcNow, + }); + + try + { + progress?.Report($"Importing {response.Elements.Count:N0} elements"); + var stats = builder.Populate(world, response.Elements); + + progress?.Report("Computing bounds and chunks"); + _pipeline.Run(world); + + world.Metadata = world.Metadata with + { + ChunkCountX = world.Grid.CountX, + ChunkCountY = world.Grid.CountY, + Stats = stats, + }; + + logger.LogInformation( + "World {Id} built: {Buildings:N0} buildings, {Roads:N0} roads, {Areas:N0} areas, {Water:N0} water", + request.Id, stats.Buildings, stats.Roads, stats.Areas, stats.Water); + + return world; + } + catch + { + world.Dispose(); + throw; + } + } + + /// + /// Reads the cached response in one pass. Small-town boxes are a few megabytes of JSON; streaming the + /// element array would be the next step if this ever has to swallow a dense metropolis. + /// + private static async Task ReadResponseAsync(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var response = await JsonSerializer + .DeserializeAsync(stream, ParseOptions, cancellationToken) + .ConfigureAwait(false); + + return response ?? throw new OverpassException($"Cached Overpass response '{path}' is empty or malformed."); + } + + private void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Could not remove the unusable cached response {Path}", path); + } + } +} diff --git a/src/TheLivingWorld.Osm/Import/RingAssembler.cs b/src/TheLivingWorld.Osm/Import/RingAssembler.cs new file mode 100644 index 0000000..a47debc --- /dev/null +++ b/src/TheLivingWorld.Osm/Import/RingAssembler.cs @@ -0,0 +1,81 @@ +using System.Numerics; + +namespace TheLivingWorld.Osm.Import; + +/// +/// Stitches the member ways of an OpenStreetMap multipolygon into closed rings. Members arrive as arbitrary +/// fragments in arbitrary order and direction, so rings are grown by repeatedly attaching whichever fragment +/// shares an endpoint with the open end. +/// +public static class RingAssembler +{ + /// Endpoints come from identical OSM nodes, so they match to well under a centimetre. + private const float ToleranceMeters = 0.05f; + + private const float ToleranceSquared = ToleranceMeters * ToleranceMeters; + + /// + /// Assembles into closed rings. Fragments that never close are dropped - + /// broken multipolygons exist in the wild and a half-open ring cannot be filled. + /// + public static List Assemble(IEnumerable> segments) + { + var pending = new List>(); + foreach (var segment in segments) + { + if (segment.Count >= 2) pending.Add([.. segment]); + } + + var rings = new List(); + + while (pending.Count > 0) + { + var ring = pending[^1]; + pending.RemoveAt(pending.Count - 1); + + while (!IsClosed(ring) && TryTakeConnecting(pending, ring[^1], out var next)) + { + // The first vertex of `next` duplicates the open end, so skip it when appending. + for (var i = 1; i < next.Count; i++) + ring.Add(next[i]); + } + + if (!IsClosed(ring)) continue; + + ring.RemoveAt(ring.Count - 1); + if (ring.Count >= 3) rings.Add(ring.ToArray()); + } + + return rings; + } + + private static bool IsClosed(List ring) => + ring.Count >= 4 && Vector2.DistanceSquared(ring[0], ring[^1]) <= ToleranceSquared; + + /// Removes and returns the fragment touching , reversed if needed. + private static bool TryTakeConnecting(List> pending, Vector2 openEnd, out List segment) + { + for (var i = 0; i < pending.Count; i++) + { + var candidate = pending[i]; + + if (Vector2.DistanceSquared(candidate[0], openEnd) <= ToleranceSquared) + { + pending.RemoveAt(i); + segment = candidate; + return true; + } + + if (Vector2.DistanceSquared(candidate[^1], openEnd) <= ToleranceSquared) + { + pending.RemoveAt(i); + candidate.Reverse(); + segment = candidate; + return true; + } + } + + segment = []; + return false; + } +} diff --git a/src/TheLivingWorld.Osm/Import/TagInterpreter.cs b/src/TheLivingWorld.Osm/Import/TagInterpreter.cs new file mode 100644 index 0000000..a5b8e3b --- /dev/null +++ b/src/TheLivingWorld.Osm/Import/TagInterpreter.cs @@ -0,0 +1,286 @@ +using System.Globalization; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Osm.Import; + +/// +/// Collapses OpenStreetMap's open-ended tagging into the small closed set of kinds the game renders and will +/// later simulate. Anything unrecognised falls back to Unknown rather than being dropped, so a feature +/// still shows up on the map even if the game does not yet understand it. +/// +public static class TagInterpreter +{ + private const float MetersPerLevel = 3.0f; + + private static readonly string[] UnpavedSurfaces = + ["unpaved", "gravel", "dirt", "ground", "grass", "sand", "compacted", "fine_gravel", "earth", "mud"]; + + public static Building ReadBuilding(OverpassElement element) + { + var kind = ReadBuildingKind(element.Tag("building") ?? element.Tag("building:part")); + var levels = ReadLevels(element); + var height = ReadHeight(element, kind, levels); + return new Building(kind, height, levels); + } + + private static BuildingKind ReadBuildingKind(string? value) => value switch + { + "house" or "detached" or "semidetached_house" or "bungalow" or "cabin" or "static_caravan" => BuildingKind.House, + "residential" or "terrace" or "dormitory" => BuildingKind.Residential, + "apartments" or "flats" => BuildingKind.Apartments, + "commercial" or "office" or "hotel" or "kiosk" => BuildingKind.Commercial, + "retail" or "supermarket" or "shop" or "mall" => BuildingKind.Retail, + "industrial" or "warehouse" or "factory" or "manufacture" or "silo" or "storage_tank" => BuildingKind.Industrial, + "civic" or "public" or "government" or "hospital" or "fire_station" or "train_station" => BuildingKind.Civic, + "school" or "university" or "college" or "kindergarten" => BuildingKind.School, + "church" or "chapel" or "cathedral" or "mosque" or "temple" or "synagogue" or "religious" => BuildingKind.Church, + "garage" or "garages" or "carport" => BuildingKind.Garage, + "shed" or "hut" or "roof" or "container" or "service" => BuildingKind.Shed, + "farm" or "barn" or "farm_auxiliary" or "stable" or "greenhouse" or "cowshed" => BuildingKind.Farm, + "ruins" or "collapsed" => BuildingKind.Ruins, + _ => BuildingKind.Unknown, + }; + + private static byte ReadLevels(OverpassElement element) + { + var raw = element.Tag("building:levels") ?? element.Tag("levels"); + if (TryParseLeadingNumber(raw, out var levels) && levels is > 0 and < 200) + return (byte)Math.Round(levels); + + return 0; + } + + private static float ReadHeight(OverpassElement element, BuildingKind kind, byte levels) + { + if (TryParseLeadingNumber(element.Tag("height"), out var height) && height is > 0 and < 1000) + return (float)height; + + if (levels > 0) return levels * MetersPerLevel; + + // A sensible silhouette beats no height at all; the client shades buildings by it. + return kind switch + { + BuildingKind.Shed or BuildingKind.Garage => 3f, + BuildingKind.House => 5f, + BuildingKind.Farm => 7f, + BuildingKind.Apartments => 15f, + BuildingKind.Industrial => 9f, + BuildingKind.Church => 12f, + _ => 6f, + }; + } + + public static bool IsRoad(OverpassElement element) => + element.HasTag("highway") || element.HasTag("railway"); + + public static Road ReadRoad(OverpassElement element) + { + if (element.Tag("highway") is not { } highway) + return new Road(RoadClass.Railway, 3f, 0, ReadRoadFlags(element)); + + var roadClass = ReadRoadClass(highway); + var lanes = ReadLanes(element); + var width = ReadRoadWidth(element, roadClass, lanes); + return new Road(roadClass, width, lanes, ReadRoadFlags(element)); + } + + private static RoadClass ReadRoadClass(string highway) + { + // motorway_link and friends carry the same traffic role as their parent class. + var trimmed = highway.EndsWith("_link", StringComparison.Ordinal) ? highway[..^5] : highway; + + return trimmed switch + { + "motorway" => RoadClass.Motorway, + "trunk" => RoadClass.Trunk, + "primary" => RoadClass.Primary, + "secondary" => RoadClass.Secondary, + "tertiary" => RoadClass.Tertiary, + "unclassified" or "road" => RoadClass.Unclassified, + "residential" => RoadClass.Residential, + "living_street" => RoadClass.LivingStreet, + "service" => RoadClass.Service, + "track" => RoadClass.Track, + "pedestrian" => RoadClass.Pedestrian, + "footway" or "sidewalk" => RoadClass.Footway, + "cycleway" => RoadClass.Cycleway, + "steps" => RoadClass.Steps, + "path" or "bridleway" => RoadClass.Path, + _ => RoadClass.Unknown, + }; + } + + private static byte ReadLanes(OverpassElement element) => + TryParseLeadingNumber(element.Tag("lanes"), out var lanes) && lanes is > 0 and <= 16 + ? (byte)Math.Round(lanes) + : (byte)0; + + private static float ReadRoadWidth(OverpassElement element, RoadClass roadClass, byte lanes) + { + if (TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 100) + return (float)width; + + if (lanes > 0) return lanes * 3.4f; + + return roadClass switch + { + RoadClass.Motorway => 14f, + RoadClass.Trunk => 12f, + RoadClass.Primary => 10f, + RoadClass.Secondary => 9f, + RoadClass.Tertiary => 8f, + RoadClass.Residential or RoadClass.Unclassified => 6.5f, + RoadClass.LivingStreet => 5.5f, + RoadClass.Pedestrian => 5f, + RoadClass.Service => 4f, + RoadClass.Track => 3f, + RoadClass.Cycleway => 2f, + RoadClass.Footway or RoadClass.Path or RoadClass.Steps => 1.5f, + RoadClass.Railway => 3f, + _ => 5f, + }; + } + + private static RoadFlags ReadRoadFlags(OverpassElement element) + { + var flags = RoadFlags.None; + + if (element.Tag("bridge") is { } bridge && bridge != "no") flags |= RoadFlags.Bridge; + if (element.Tag("tunnel") is { } tunnel && tunnel != "no") flags |= RoadFlags.Tunnel; + if (element.Tag("oneway") is "yes" or "1" or "-1") flags |= RoadFlags.Oneway; + + if (element.Tag("surface") is { } surface && Array.IndexOf(UnpavedSurfaces, surface) >= 0) + flags |= RoadFlags.Unpaved; + + return flags; + } + + /// Recognises water bodies and watercourses. Returns null when the element is not water. + public static Water? ReadWater(OverpassElement element) + { + if (element.Tag("natural") == "water") + return new Water(ReadWaterBodyKind(element.Tag("water")), 0f); + + if (element.Tag("landuse") is "reservoir") + return new Water(WaterKind.Reservoir, 0f); + + if (element.Tag("landuse") is "basin") + return new Water(WaterKind.Water, 0f); + + if (element.Tag("waterway") is not { } waterway) return null; + + return waterway switch + { + "riverbank" => new Water(WaterKind.Riverbank, 0f), + "river" => new Water(WaterKind.River, ReadWaterwayWidth(element, 12f)), + "stream" => new Water(WaterKind.Stream, ReadWaterwayWidth(element, 3f)), + "canal" => new Water(WaterKind.Canal, ReadWaterwayWidth(element, 8f)), + "ditch" => new Water(WaterKind.Ditch, ReadWaterwayWidth(element, 1.5f)), + "drain" => new Water(WaterKind.Drain, ReadWaterwayWidth(element, 1.5f)), + // dam, weir, lock_gate and the like are structures, not water. + _ => null, + }; + } + + private static WaterKind ReadWaterBodyKind(string? water) => water switch + { + "lake" => WaterKind.Lake, + "pond" => WaterKind.Pond, + "reservoir" or "basin" => WaterKind.Reservoir, + "river" => WaterKind.Riverbank, + _ => WaterKind.Water, + }; + + private static float ReadWaterwayWidth(OverpassElement element, float fallback) => + TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 500 + ? (float)width + : fallback; + + /// Recognises land cover polygons. Returns null when the element is not one. + public static AreaKind? ReadAreaKind(OverpassElement element) + { + if (element.Tag("landuse") is { } landuse) + { + var kind = landuse switch + { + "forest" => AreaKind.Forest, + "grass" or "village_green" or "greenfield" => AreaKind.Grass, + "meadow" => AreaKind.Meadow, + "farmland" or "farmyard" or "allotments" => AreaKind.Farmland, + "orchard" or "vineyard" or "plant_nursery" => AreaKind.Orchard, + "residential" => AreaKind.ResidentialZone, + "industrial" or "railway" or "landfill" => AreaKind.IndustrialZone, + "commercial" => AreaKind.CommercialZone, + "retail" => AreaKind.RetailZone, + "quarry" => AreaKind.Quarry, + "cemetery" => AreaKind.Cemetery, + "recreation_ground" => AreaKind.Park, + _ => (AreaKind?)null, + }; + + if (kind is not null) return kind; + } + + if (element.Tag("natural") is { } natural) + { + var kind = natural switch + { + "wood" or "tree_row" => AreaKind.Forest, + "scrub" => AreaKind.Scrub, + "heath" => AreaKind.Heath, + "grassland" => AreaKind.Grass, + "sand" or "dune" => AreaKind.Sand, + "bare_rock" or "rock" or "scree" or "cliff" => AreaKind.BareRock, + "wetland" or "marsh" => AreaKind.Wetland, + "beach" => AreaKind.Beach, + _ => (AreaKind?)null, + }; + + if (kind is not null) return kind; + } + + if (element.Tag("leisure") is { } leisure) + { + var kind = leisure switch + { + "park" or "nature_reserve" or "common" => AreaKind.Park, + "garden" => AreaKind.Garden, + "pitch" or "playground" or "track" or "sports_centre" or "stadium" => AreaKind.Pitch, + "golf_course" => AreaKind.Grass, + _ => (AreaKind?)null, + }; + + if (kind is not null) return kind; + } + + return element.Tag("amenity") switch + { + "parking" => AreaKind.Parking, + "school" or "college" or "university" or "kindergarten" => AreaKind.School, + "grave_yard" => AreaKind.Cemetery, + _ => null, + }; + } + + public static string? ReadName(OverpassElement element) => + element.Tag("name") is { Length: > 0 } name ? name : null; + + /// + /// Parses the numeric prefix of a tag value. OpenStreetMap allows units and stray text, so + /// "12 m" and "3.5" both need to work while "tall" must not. + /// + internal static bool TryParseLeadingNumber(string? value, out double number) + { + number = 0; + if (string.IsNullOrWhiteSpace(value)) return false; + + var span = value.AsSpan().TrimStart(); + var length = 0; + while (length < span.Length && (char.IsAsciiDigit(span[length]) || span[length] is '.' or '-' or '+')) + length++; + + return length > 0 && + double.TryParse(span[..length], NumberStyles.Float, CultureInfo.InvariantCulture, out number); + } +} diff --git a/src/TheLivingWorld.Osm/OsmOptions.cs b/src/TheLivingWorld.Osm/OsmOptions.cs new file mode 100644 index 0000000..b7ae1b7 --- /dev/null +++ b/src/TheLivingWorld.Osm/OsmOptions.cs @@ -0,0 +1,31 @@ +namespace TheLivingWorld.Osm; + +public sealed class OsmOptions +{ + public const string SectionName = "Osm"; + + /// + /// Overpass mirrors, tried in order. The public instances are shared infrastructure and rate limited, so + /// responses are cached on disk and a world is only ever downloaded once. + /// + public string[] Endpoints { get; set; } = + [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", + ]; + + /// Server-side query budget, passed to Overpass as [timeout:...]. + public int QueryTimeoutSeconds { get; set; } = 180; + + /// Client-side ceiling; must exceed to let the server reply first. + public int RequestTimeoutSeconds { get; set; } = 240; + + public int MaxAttemptsPerEndpoint { get; set; } = 2; + + /// Overpass asks that clients identify themselves so abusive traffic can be traced. + public string UserAgent { get; set; } = "TheLivingWorld/0.1 (map generator; contact: repository owner)"; + + /// Where raw Overpass responses are cached, relative to the content root unless rooted. + public string CacheDirectory { get; set; } = "data/osm-cache"; +} diff --git a/src/TheLivingWorld.Osm/OsmServiceCollectionExtensions.cs b/src/TheLivingWorld.Osm/OsmServiceCollectionExtensions.cs new file mode 100644 index 0000000..a4f49a8 --- /dev/null +++ b/src/TheLivingWorld.Osm/OsmServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TheLivingWorld.Osm.Import; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Osm; + +public static class OsmServiceCollectionExtensions +{ + /// + /// Registers the OpenStreetMap import pipeline. The returned builder is the Overpass HTTP client, which + /// callers usually need to adjust: Overpass queries run for minutes and do not fit default HTTP policies. + /// + public static IHttpClientBuilder AddOsmImport(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(OsmOptions.SectionName)); + + services.AddSingleton(); + services.AddSingleton(); + + return services.AddHttpClient(OverpassClient.HttpClientName, static (provider, client) => + { + var options = provider.GetRequiredService>().Value; + client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds); + client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent); + }); + } +} diff --git a/src/TheLivingWorld.Osm/Overpass/OverpassClient.cs b/src/TheLivingWorld.Osm/Overpass/OverpassClient.cs new file mode 100644 index 0000000..f68d672 --- /dev/null +++ b/src/TheLivingWorld.Osm/Overpass/OverpassClient.cs @@ -0,0 +1,183 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Osm.Overpass; + +/// +/// Downloads a bounding box of OpenStreetMap data through Overpass and keeps the raw response on disk. +/// A world is generated once, so the cache means re-running generation for the same box costs nothing and +/// the public Overpass mirrors are not hit twice for the same data. +/// +public sealed class OverpassClient( + HttpClient http, + IOptions options, + ILogger logger) +{ + public const string HttpClientName = "overpass"; + + private static readonly HttpStatusCode[] RetryableStatuses = + [ + HttpStatusCode.TooManyRequests, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout, + ]; + + private readonly OsmOptions _options = options.Value; + + /// + /// Returns the path of the file holding the raw Overpass JSON for , downloading + /// it first if it is not already cached. + /// + public async Task FetchAsync( + GeoBounds bounds, + bool forceRefresh, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var query = OverpassQuery.Build(bounds, _options.QueryTimeoutSeconds); + var cachePath = ResolveCachePath(query); + + if (!forceRefresh && File.Exists(cachePath)) + { + logger.LogInformation("Using cached Overpass response {Path}", cachePath); + progress?.Report("Using cached OpenStreetMap data"); + return cachePath; + } + + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + + // Download to a temporary file first: a half-written response must never end up in the cache. + var tempPath = cachePath + ".partial"; + Exception? lastFailure = null; + + foreach (var endpoint in _options.Endpoints) + { + for (var attempt = 1; attempt <= _options.MaxAttemptsPerEndpoint; attempt++) + { + try + { + progress?.Report($"Querying OpenStreetMap ({new Uri(endpoint).Host}, attempt {attempt})"); + await DownloadAsync(endpoint, query, tempPath, cancellationToken).ConfigureAwait(false); + + File.Move(tempPath, cachePath, overwrite: true); + logger.LogInformation( + "Downloaded {Bytes:N0} bytes of OSM data from {Endpoint}", + new FileInfo(cachePath).Length, + endpoint); + + return cachePath; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryDeleteTemp(tempPath); + throw; + } + catch (Exception ex) + { + lastFailure = ex; + TryDeleteTemp(tempPath); + logger.LogWarning(ex, "Overpass request to {Endpoint} failed (attempt {Attempt})", endpoint, attempt); + + if (attempt < _options.MaxAttemptsPerEndpoint) + await Task.Delay(TimeSpan.FromSeconds(3 * attempt), cancellationToken).ConfigureAwait(false); + } + } + } + + // The message surfaces in the UI, so it carries the last reason rather than pointing at a log. + throw new OverpassException( + $"Every Overpass endpoint failed. Last error: {lastFailure?.Message ?? "unknown"}", + lastFailure); + } + + private async Task DownloadAsync(string endpoint, string query, string destination, CancellationToken cancellationToken) + { + // HttpClient.Timeout stops applying once the headers are in, and a busy Overpass instance will answer + // 200 immediately and then sit on the body indefinitely. This deadline covers the whole download. + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(_options.RequestTimeoutSeconds)); + var token = deadline.Token; + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = new StringContent(query, Encoding.UTF8, "text/plain"), + }; + + using var response = await http + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var message = $"Overpass returned {(int)response.StatusCode} {response.StatusCode}: " + + await ReadSnippetAsync(response, token).ConfigureAwait(false); + + throw Array.IndexOf(RetryableStatuses, response.StatusCode) >= 0 + ? new OverpassTransientException(message) + : new OverpassException(message); + } + + // An overloaded instance reports "the server is probably too busy" as an HTML page under a 200. + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (mediaType is not null && !mediaType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + throw new OverpassTransientException( + $"Overpass returned {mediaType} instead of JSON: " + + await ReadSnippetAsync(response, token).ConfigureAwait(false)); + } + + await using var source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + await using var target = File.Create(destination); + await source.CopyToAsync(target, token).ConfigureAwait(false); + } + + /// Overpass error pages are verbose HTML; only the first few hundred characters are useful. + private static async Task ReadSnippetAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var text = System.Text.RegularExpressions.Regex.Replace(body, "<[^>]*>", " "); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim(); + return text.Length > 300 ? text[..300] : text; + } + catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException) + { + return "(response body unavailable)"; + } + } + + /// The cache key is the query itself, so any change to the box or the query invalidates it. + private string ResolveCachePath(string query) + { + var payload = $"v{OverpassQuery.Version}\n{query}"; + var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payload))); + return Path.Combine(_options.CacheDirectory, $"{hash}.json"); + } + + private static void TryDeleteTemp(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) + { + // A leftover .partial file is harmless; it is overwritten on the next attempt. + } + } +} + +public class OverpassException : Exception +{ + public OverpassException(string message) : base(message) { } + + public OverpassException(string message, Exception? inner) : base(message, inner) { } +} + +public sealed class OverpassTransientException(string message) : OverpassException(message); diff --git a/src/TheLivingWorld.Osm/Overpass/OverpassModels.cs b/src/TheLivingWorld.Osm/Overpass/OverpassModels.cs new file mode 100644 index 0000000..f8ec013 --- /dev/null +++ b/src/TheLivingWorld.Osm/Overpass/OverpassModels.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; + +namespace TheLivingWorld.Osm.Overpass; + +/// Shape of an Overpass [out:json] response requested with out geom;. +public sealed class OverpassResponse +{ + [JsonPropertyName("elements")] + public List Elements { get; set; } = []; + + /// Set when Overpass aborts mid-query, typically on a timeout or memory limit. + [JsonPropertyName("remark")] + public string? Remark { get; set; } +} + +public sealed class OverpassElement +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("tags")] + public Dictionary? Tags { get; set; } + + /// Present on ways when the query used out geom. + [JsonPropertyName("geometry")] + public List? Geometry { get; set; } + + /// Present on relations; each member carries its own geometry under out geom. + [JsonPropertyName("members")] + public List? Members { get; set; } + + public bool IsWay => Type == "way"; + + public bool IsRelation => Type == "relation"; + + public string? Tag(string key) => Tags is not null && Tags.TryGetValue(key, out var value) ? value : null; + + public bool HasTag(string key) => Tags is not null && Tags.ContainsKey(key); +} + +public sealed class OverpassMember +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("ref")] + public long Ref { get; set; } + + [JsonPropertyName("role")] + public string? Role { get; set; } + + [JsonPropertyName("geometry")] + public List? Geometry { get; set; } +} + +public readonly struct OverpassNode +{ + [JsonPropertyName("lat")] + public double Lat { get; init; } + + [JsonPropertyName("lon")] + public double Lon { get; init; } +} diff --git a/src/TheLivingWorld.Osm/Overpass/OverpassQuery.cs b/src/TheLivingWorld.Osm/Overpass/OverpassQuery.cs new file mode 100644 index 0000000..e5e5337 --- /dev/null +++ b/src/TheLivingWorld.Osm/Overpass/OverpassQuery.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using TheLivingWorld.Core.Geo; + +namespace TheLivingWorld.Osm.Overpass; + +public static class OverpassQuery +{ + /// + /// Bumped whenever changes shape. It is mixed into the cache key so an edited query + /// never silently reuses a response fetched under the old one. + /// + public const int Version = 1; + + /// + /// Everything the map renderer draws: building footprints, the road and rail network, water, and the + /// land-cover polygons underneath them. out geom inlines coordinates into each way and relation + /// member, so no second pass is needed to resolve node references. + /// + public static string Build(GeoBounds bounds, int timeoutSeconds) + { + var bbox = bounds.ToOverpassBbox(); + var timeout = timeoutSeconds.ToString(CultureInfo.InvariantCulture); + + return $""" + [out:json][timeout:{timeout}]; + ( + way["building"]({bbox}); + relation["building"]["type"="multipolygon"]({bbox}); + way["building:part"]({bbox}); + way["highway"]({bbox}); + way["railway"~"^(rail|light_rail|narrow_gauge|tram|subway|disused|abandoned)$"]({bbox}); + way["waterway"]({bbox}); + way["natural"]({bbox}); + relation["natural"]["type"="multipolygon"]({bbox}); + way["landuse"]({bbox}); + relation["landuse"]["type"="multipolygon"]({bbox}); + way["leisure"]({bbox}); + relation["leisure"]["type"="multipolygon"]({bbox}); + way["amenity"~"^(parking|school|grave_yard)$"]({bbox}); + ); + out geom; + """; + } +} diff --git a/src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj b/src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj new file mode 100644 index 0000000..e1a11ae --- /dev/null +++ b/src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + + + + + diff --git a/src/TheLivingWorld.ServiceDefaults/Extensions.cs b/src/TheLivingWorld.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..bf34b04 --- /dev/null +++ b/src/TheLivingWorld.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj b/src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj new file mode 100644 index 0000000..865f26a --- /dev/null +++ b/src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html new file mode 100644 index 0000000..708e85a --- /dev/null +++ b/src/TheLivingWorld.Web/index.html @@ -0,0 +1,55 @@ + + + + + + The Living World + + + +
+ + + +
+ + + + diff --git a/src/TheLivingWorld.Web/package-lock.json b/src/TheLivingWorld.Web/package-lock.json new file mode 100644 index 0000000..5b3d1cf --- /dev/null +++ b/src/TheLivingWorld.Web/package-lock.json @@ -0,0 +1,917 @@ +{ + "name": "the-living-world-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "the-living-world-web", + "version": "0.1.0", + "dependencies": { + "pixi.js": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "^5.9.3", + "vite": "^8.2.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", + "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixi.js": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz", + "integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.13", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/src/TheLivingWorld.Web/package.json b/src/TheLivingWorld.Web/package.json new file mode 100644 index 0000000..c5cc95a --- /dev/null +++ b/src/TheLivingWorld.Web/package.json @@ -0,0 +1,20 @@ +{ + "name": "the-living-world-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "pixi.js": "^8.19.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "^5.9.3", + "vite": "^8.2.1" + } +} diff --git a/src/TheLivingWorld.Web/src/api/client.ts b/src/TheLivingWorld.Web/src/api/client.ts new file mode 100644 index 0000000..d46e954 --- /dev/null +++ b/src/TheLivingWorld.Web/src/api/client.ts @@ -0,0 +1,65 @@ +import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types'; + +const BASE = '/api/worlds'; + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + + if (!response.ok) { + throw new Error(await describeFailure(response)); + } + + return (await response.json()) as T; +} + +async function describeFailure(response: Response): Promise { + try { + const problem = (await response.json()) as { title?: string; errors?: Record }; + const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined; + if (details) return details; + if (problem.title) return problem.title; + } catch { + // Not a problem-details body; fall through to the status line. + } + + return `${response.status} ${response.statusText}`; +} + +export const api = { + listWorlds: () => request(BASE), + + createWorld: (body: CreateWorldRequest) => + request(BASE, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + + getWorld: (id: string) => request(`${BASE}/${id}`), + + getMap: (id: string) => request(`${BASE}/${id}/map`), + + getChunk: (id: string, x: number, y: number, signal?: AbortSignal) => + request(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined), + + deleteWorld: async (id: string): Promise => { + const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' }); + if (!response.ok) throw new Error(await describeFailure(response)); + }, +}; + +/** Polls a world until generation finishes, reporting each stage change as it happens. */ +export async function waitForWorld( + id: string, + onProgress: (summary: WorldSummary) => void, + intervalMs = 1500, +): Promise { + for (;;) { + const summary = await api.getWorld(id); + onProgress(summary); + + if (summary.status === 'ready' || summary.status === 'failed') return summary; + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts new file mode 100644 index 0000000..de9cf30 --- /dev/null +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -0,0 +1,132 @@ +/** Mirrors the DTOs in TheLivingWorld.Core.Contracts. Keep both sides in step. */ + +export type WorldStatus = 'pending' | 'generating' | 'ready' | 'failed'; + +export interface WorldStats { + buildings: number; + roads: number; + areas: number; + water: number; + vertices: number; +} + +export interface WorldSummary { + id: string; + name: string; + latitude: number; + longitude: number; + sizeMeters: number; + status: WorldStatus; + stage?: string; + error?: string; + createdAt: string; + stats?: WorldStats; +} + +export interface CreateWorldRequest { + name?: string; + latitude: number; + longitude: number; + sizeKm: number; + forceRefresh?: boolean; +} + +/** [minX, minY, maxX, maxY] in world metres. */ +export type BoundsArray = [number, number, number, number]; + +export interface ChunkIndexEntry { + x: number; + y: number; + bounds: BoundsArray; + features: number; +} + +export interface WorldMap { + id: string; + name: string; + latitude: number; + longitude: number; + sizeMeters: number; + bounds: { south: number; west: number; north: number; east: number }; + chunkSizeMeters: number; + chunkCountX: number; + chunkCountY: number; + generatedAt: string; + stats: WorldStats; + chunks: ChunkIndexEntry[]; +} + +export type BuildingKind = + | 'unknown' | 'house' | 'residential' | 'apartments' | 'commercial' | 'retail' | 'industrial' + | 'civic' | 'school' | 'church' | 'garage' | 'shed' | 'farm' | 'ruins'; + +export type RoadClass = + | 'unknown' | 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'unclassified' + | 'residential' | 'livingStreet' | 'service' | 'track' | 'pedestrian' | 'footway' | 'cycleway' + | 'steps' | 'path' | 'railway'; + +export type AreaKind = + | 'unknown' | 'forest' | 'grass' | 'meadow' | 'farmland' | 'orchard' | 'scrub' | 'heath' | 'sand' + | 'bareRock' | 'wetland' | 'park' | 'garden' | 'pitch' | 'cemetery' | 'residentialZone' + | 'industrialZone' | 'commercialZone' | 'retailZone' | 'quarry' | 'parking' | 'school' | 'beach'; + +export type WaterKind = + | 'unknown' | 'water' | 'lake' | 'pond' | 'reservoir' | 'riverbank' | 'river' | 'stream' + | 'canal' | 'ditch' | 'drain'; + +/** Bit values matching RoadFlags on the server. */ +export const RoadFlags = { + bridge: 1, + tunnel: 2, + oneway: 4, + unpaved: 8, +} as const; + +/** Geometry travels as flat [x0, y0, x1, y1, ...] arrays, ready for Graphics.poly(). */ +export type FlatPoints = number[]; + +export interface BuildingFeature { + id: number; + kind: BuildingKind; + height: number; + outline: FlatPoints; + holes?: FlatPoints[]; + name?: string; +} + +export interface RoadFeature { + id: number; + class: RoadClass; + width: number; + path: FlatPoints; + flags?: number; + name?: string; +} + +export interface AreaFeature { + id: number; + kind: AreaKind; + outline: FlatPoints; + holes?: FlatPoints[]; + name?: string; +} + +export interface WaterFeature { + id: number; + kind: WaterKind; + outline?: FlatPoints; + holes?: FlatPoints[]; + path?: FlatPoints; + width?: number; + name?: string; +} + +export interface MapChunk { + x: number; + y: number; + bounds: BoundsArray; + buildings: BuildingFeature[]; + roads: RoadFeature[]; + areas: AreaFeature[]; + water: WaterFeature[]; +} diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts new file mode 100644 index 0000000..0b62874 --- /dev/null +++ b/src/TheLivingWorld.Web/src/main.ts @@ -0,0 +1,204 @@ +import './styles.css'; +import { api, waitForWorld } from './api/client'; +import type { WorldSummary } from './api/types'; +import { MapView, type MapStatus } from './map/mapView'; + +const LAST_WORLD_KEY = 'the-living-world:last-world'; + +const elements = { + stage: required('stage'), + form: required('generate-form'), + name: required('field-name'), + latitude: required('field-lat'), + longitude: required('field-lon'), + size: required('field-size'), + sizeValue: required('field-size-value'), + generate: required('generate-button'), + worldList: required('world-list'), + status: required('status'), + hud: required('hud'), +}; + +const view = new MapView(); +let activeWorldId: string | null = null; + +function required(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element #${id}`); + return element as T; +} + +function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): void { + elements.status.textContent = message; + elements.status.dataset.tone = tone; +} + +function renderHud(status: MapStatus): void { + const scale = status.metersPerPixel >= 10 + ? `${Math.round(status.metersPerPixel)} m/px` + : `${status.metersPerPixel.toFixed(1)} m/px`; + + const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`; + const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`; + + elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`; +} + +async function refreshWorldList(): Promise { + const worlds = await api.listWorlds(); + elements.worldList.replaceChildren(...worlds.map(renderWorldItem)); + return worlds; +} + +function renderWorldItem(world: WorldSummary): HTMLLIElement { + const item = document.createElement('li'); + item.className = 'world'; + item.dataset.status = world.status; + if (world.id === activeWorldId) item.dataset.active = 'true'; + + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'world__open'; + open.disabled = world.status !== 'ready'; + + const title = document.createElement('span'); + title.className = 'world__name'; + title.textContent = world.name; + + const detail = document.createElement('span'); + detail.className = 'world__detail'; + detail.textContent = describeWorld(world); + + open.append(title, detail); + open.addEventListener('click', () => { + void openWorld(world.id); + }); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'world__delete'; + remove.title = 'Delete world'; + remove.textContent = '×'; + remove.addEventListener('click', () => { + void deleteWorld(world); + }); + + item.append(open, remove); + return item; +} + +function describeWorld(world: WorldSummary): string { + if (world.status === 'failed') return world.error ?? 'Generation failed'; + if (world.status !== 'ready') return world.stage ?? world.status; + + const size = `${(world.sizeMeters / 1000).toFixed(0)} km`; + if (!world.stats) return size; + + return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`; +} + +async function openWorld(id: string): Promise { + try { + setStatus('Loading map…', 'busy'); + const map = await api.getMap(id); + + activeWorldId = id; + localStorage.setItem(LAST_WORLD_KEY, id); + + view.showWorld(map); + setStatus(`${map.name} — ${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`); + await refreshWorldList(); + } catch (error) { + setStatus(`Could not open world: ${message(error)}`, 'error'); + } +} + +async function deleteWorld(world: WorldSummary): Promise { + if (!confirm(`Delete "${world.name}"?`)) return; + + try { + await api.deleteWorld(world.id); + + if (activeWorldId === world.id) { + activeWorldId = null; + localStorage.removeItem(LAST_WORLD_KEY); + view.clear(); + elements.hud.textContent = ''; + } + + await refreshWorldList(); + setStatus(`Deleted ${world.name}`); + } catch (error) { + setStatus(`Could not delete world: ${message(error)}`, 'error'); + } +} + +async function generate(event: SubmitEvent): Promise { + event.preventDefault(); + + const latitude = Number(elements.latitude.value); + const longitude = Number(elements.longitude.value); + const sizeKm = Number(elements.size.value); + + elements.generate.disabled = true; + setStatus('Requesting world…', 'busy'); + + try { + const created = await api.createWorld({ + name: elements.name.value.trim() || undefined, + latitude, + longitude, + sizeKm, + }); + + await refreshWorldList(); + + const finished = await waitForWorld(created.id, (summary) => { + setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy'); + void refreshWorldList(); + }); + + if (finished.status === 'failed') { + setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error'); + return; + } + + await openWorld(finished.id); + } catch (error) { + setStatus(`Generation failed: ${message(error)}`, 'error'); + } finally { + elements.generate.disabled = false; + void refreshWorldList(); + } +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function start(): Promise { + elements.size.addEventListener('input', () => { + elements.sizeValue.textContent = `${elements.size.value} km`; + }); + elements.form.addEventListener('submit', (event) => { + void generate(event); + }); + + view.onStatusChange = renderHud; + await view.init(elements.stage); + + try { + const worlds = await refreshWorldList(); + const remembered = localStorage.getItem(LAST_WORLD_KEY); + const target = + worlds.find((world) => world.id === remembered && world.status === 'ready') ?? + worlds.find((world) => world.status === 'ready'); + + if (target) await openWorld(target.id); + else setStatus('No worlds yet — generate one to get started.'); + } catch (error) { + setStatus(`Could not reach the API: ${message(error)}`, 'error'); + } +} + +void start(); diff --git a/src/TheLivingWorld.Web/src/map/camera.ts b/src/TheLivingWorld.Web/src/map/camera.ts new file mode 100644 index 0000000..f574dbc --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/camera.ts @@ -0,0 +1,97 @@ +export interface Viewport { + width: number; + height: number; +} + +export interface WorldRect { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Maps world metres to screen pixels. World Y grows north, screen Y grows down, so the vertical scale is + * negative — the one place the flip is expressed, and every other module can think in map coordinates. + */ +export class Camera { + /** Camera centre, in world metres. */ + x = 0; + y = 0; + + /** Screen pixels per world metre. */ + zoom = 0.1; + + minZoom = 0.005; + maxZoom = 6; + + /** Frames the whole world square with a little breathing room and pins the zoom-out limit to it. */ + fit(worldSizeMeters: number, viewport: Viewport): void { + const usable = Math.min(viewport.width, viewport.height); + this.zoom = usable > 0 ? (usable / worldSizeMeters) * 0.92 : 0.1; + this.minZoom = this.zoom * 0.35; + this.maxZoom = 6; + this.x = 0; + this.y = 0; + } + + translateBy(screenDx: number, screenDy: number): void { + this.x -= screenDx / this.zoom; + this.y += screenDy / this.zoom; + } + + /** Zooms about a screen anchor so the world point under the cursor stays put. */ + zoomAt(factor: number, screenX: number, screenY: number, viewport: Viewport): void { + const before = this.screenToWorld(screenX, screenY, viewport); + this.zoom = clamp(this.zoom * factor, this.minZoom, this.maxZoom); + const after = this.screenToWorld(screenX, screenY, viewport); + + this.x += before.x - after.x; + this.y += before.y - after.y; + } + + /** Keeps the centre inside the world square so the map cannot be flung off screen. */ + clampToWorld(worldSizeMeters: number): void { + const half = worldSizeMeters / 2; + this.x = clamp(this.x, -half, half); + this.y = clamp(this.y, -half, half); + } + + screenToWorld(screenX: number, screenY: number, viewport: Viewport): { x: number; y: number } { + return { + x: this.x + (screenX - viewport.width / 2) / this.zoom, + y: this.y - (screenY - viewport.height / 2) / this.zoom, + }; + } + + /** The world rectangle currently on screen, optionally grown by a fraction of its size. */ + visibleRect(viewport: Viewport, padding = 0): WorldRect { + const halfWidth = viewport.width / 2 / this.zoom; + const halfHeight = viewport.height / 2 / this.zoom; + const padX = halfWidth * padding; + const padY = halfHeight * padding; + + return { + minX: this.x - halfWidth - padX, + minY: this.y - halfHeight - padY, + maxX: this.x + halfWidth + padX, + maxY: this.y + halfHeight + padY, + }; + } + + /** Container offset that places the camera centre at the middle of the viewport. */ + containerPosition(viewport: Viewport): { x: number; y: number } { + return { + x: viewport.width / 2 - this.x * this.zoom, + y: viewport.height / 2 + this.y * this.zoom, + }; + } +} + +export function rectsIntersect(a: WorldRect, b: WorldRect): boolean { + return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; +} + +function clamp(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} diff --git a/src/TheLivingWorld.Web/src/map/chunkManager.ts b/src/TheLivingWorld.Web/src/map/chunkManager.ts new file mode 100644 index 0000000..e591c39 --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/chunkManager.ts @@ -0,0 +1,207 @@ +import { Container } from 'pixi.js'; +import { api } from '../api/client'; +import type { ChunkIndexEntry, MapChunk } from '../api/types'; +import { rectsIntersect, type WorldRect } from './camera'; +import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer'; + +export interface MapLayers { + areas: Container; + water: Container; + roadCasings: Container; + roads: Container; + buildings: Container; +} + +interface ChunkState { + entry: ChunkIndexEntry; + rect: WorldRect; + data?: MapChunk; + graphics?: ChunkGraphics; + /** Profile the current graphics were built for; a mismatch means they need redrawing. */ + drawnWith?: string; + request?: AbortController; + failed?: boolean; +} + +/** How far past the viewport chunks are fetched, as a fraction of the viewport size. */ +const PREFETCH_PADDING = 0.35; + +/** Graphics survive a little further out than that, so small pans do not thrash the renderer. */ +const RETAIN_PADDING = 1.0; + +const MAX_CONCURRENT_REQUESTS = 6; + +/** + * Keeps the visible slice of a world on screen: fetches chunks as the camera reaches them, draws them into + * the shared layers, and drops their graphics once they are well out of view. Chunk data itself stays cached, + * so panning back is instant. + */ +export class ChunkManager { + private readonly states = new Map(); + private worldId: string | null = null; + private inFlight = 0; + private pending: ChunkState[] = []; + + constructor(private readonly layers: MapLayers) {} + + get loadedCount(): number { + let count = 0; + for (const state of this.states.values()) if (state.data) count++; + return count; + } + + get totalCount(): number { + return this.states.size; + } + + get isBusy(): boolean { + return this.inFlight > 0 || this.pending.length > 0; + } + + setWorld(worldId: string, index: ChunkIndexEntry[]): void { + this.clear(); + this.worldId = worldId; + + for (const entry of index) { + const [minX, minY, maxX, maxY] = entry.bounds; + this.states.set(key(entry.x, entry.y), { + entry, + rect: { minX, minY, maxX, maxY }, + }); + } + } + + clear(): void { + for (const state of this.states.values()) { + state.request?.abort(); + if (state.graphics) { + detach(state.graphics); + destroyChunkGraphics(state.graphics); + } + } + + this.states.clear(); + this.pending = []; + this.inFlight = 0; + this.worldId = null; + } + + /** Reconciles what is on screen with what should be, given the camera's current view and zoom. */ + update(visible: WorldRect, profile: RenderProfile): void { + if (!this.worldId) return; + + const prefetch = grow(visible, PREFETCH_PADDING); + const retain = grow(visible, RETAIN_PADDING); + + this.pending = []; + + for (const state of this.states.values()) { + const wanted = rectsIntersect(state.rect, prefetch); + + if (wanted && !state.data && !state.request && !state.failed) { + this.pending.push(state); + continue; + } + + if (!state.data) continue; + + if (wanted) { + if (!state.graphics || state.drawnWith !== profile.key) this.draw(state, profile); + } else if (state.graphics && !rectsIntersect(state.rect, retain)) { + detach(state.graphics); + destroyChunkGraphics(state.graphics); + state.graphics = undefined; + state.drawnWith = undefined; + } + } + + // Nearest chunks first: the middle of the screen is what the player is looking at. + const centerX = (visible.minX + visible.maxX) / 2; + const centerY = (visible.minY + visible.maxY) / 2; + this.pending.sort((a, b) => distanceSquared(a.rect, centerX, centerY) - distanceSquared(b.rect, centerX, centerY)); + + this.pump(profile); + } + + private pump(profile: RenderProfile): void { + while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) { + const state = this.pending.shift()!; + void this.fetch(state, profile); + } + } + + private async fetch(state: ChunkState, profile: RenderProfile): Promise { + const worldId = this.worldId; + if (!worldId) return; + + const controller = new AbortController(); + state.request = controller; + this.inFlight++; + + try { + const chunk = await api.getChunk(worldId, state.entry.x, state.entry.y, controller.signal); + + // The world may have been swapped while this request was in the air. + if (this.worldId !== worldId) return; + + state.data = chunk; + this.draw(state, profile); + } catch (error) { + if (!controller.signal.aborted) { + state.failed = true; + console.error(`Failed to load chunk ${state.entry.x},${state.entry.y}`, error); + } + } finally { + this.inFlight--; + state.request = undefined; + this.pump(profile); + } + } + + private draw(state: ChunkState, profile: RenderProfile): void { + if (!state.data) return; + + if (state.graphics) { + detach(state.graphics); + destroyChunkGraphics(state.graphics); + } + + const graphics = renderChunk(state.data, profile); + state.graphics = graphics; + state.drawnWith = profile.key; + + this.layers.areas.addChild(graphics.areas); + this.layers.water.addChild(graphics.water); + this.layers.roadCasings.addChild(graphics.roadCasings); + this.layers.roads.addChild(graphics.roads); + this.layers.buildings.addChild(graphics.buildings); + } +} + +function detach(graphics: ChunkGraphics): void { + for (const layer of Object.values(graphics)) { + layer.removeFromParent(); + } +} + +function key(x: number, y: number): string { + return `${x},${y}`; +} + +function grow(rect: WorldRect, fraction: number): WorldRect { + const padX = (rect.maxX - rect.minX) * fraction * 0.5; + const padY = (rect.maxY - rect.minY) * fraction * 0.5; + + return { + minX: rect.minX - padX, + minY: rect.minY - padY, + maxX: rect.maxX + padX, + maxY: rect.maxY + padY, + }; +} + +function distanceSquared(rect: WorldRect, x: number, y: number): number { + const dx = (rect.minX + rect.maxX) / 2 - x; + const dy = (rect.minY + rect.maxY) / 2 - y; + return dx * dx + dy * dy; +} diff --git a/src/TheLivingWorld.Web/src/map/chunkRenderer.ts b/src/TheLivingWorld.Web/src/map/chunkRenderer.ts new file mode 100644 index 0000000..2519e0d --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/chunkRenderer.ts @@ -0,0 +1,228 @@ +import { Graphics } from 'pixi.js'; +import type { AreaFeature, BuildingFeature, FlatPoints, MapChunk, RoadFeature, WaterFeature } from '../api/types'; +import { + AREA_COLORS, + BUILDING_EDGE_COLOR, + ROAD_CASING_COLORS, + ROAD_COLORS, + ROAD_MIN_DETAIL, + ROAD_RANK, + WATER_COLOR, + WATER_EDGE_COLOR, + WATER_LINE_COLORS, + buildingColor, +} from './style'; + +/** 0 = whole town in view, 1 = neighbourhood, 2 = street level. */ +export type DetailLevel = 0 | 1 | 2; + +export interface RenderProfile { + /** Changes exactly when the geometry needs redrawing, and not otherwise. */ + key: string; + detail: DetailLevel; + /** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */ + minStrokeMeters: number; +} + +/** Strokes thinner than this fade into the background. */ +const MIN_STROKE_PIXELS = 1.3; + +const MIN_BUILDING_AREA: Record = { 0: 150, 1: 30, 2: 0 }; + +export function profileForZoom(zoom: number): RenderProfile { + const detail: DetailLevel = zoom >= 0.35 ? 2 : zoom >= 0.12 ? 1 : 0; + + // Quantising to powers of two means a slow zoom redraws a handful of times rather than every frame. + const step = Math.floor(Math.log2(zoom)); + const minStrokeMeters = MIN_STROKE_PIXELS / 2 ** step; + + return { key: `${detail}@${step}`, detail, minStrokeMeters }; +} + +export interface ChunkGraphics { + areas: Graphics; + water: Graphics; + roadCasings: Graphics; + roads: Graphics; + buildings: Graphics; +} + +export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics { + return { + areas: renderAreas(chunk.areas), + water: renderWater(chunk.water, profile), + roadCasings: renderRoads(chunk.roads, profile, true), + roads: renderRoads(chunk.roads, profile, false), + buildings: renderBuildings(chunk.buildings, profile), + }; +} + +export function destroyChunkGraphics(graphics: ChunkGraphics): void { + for (const layer of Object.values(graphics)) { + layer.destroy(); + } +} + +interface Ring { + outline: FlatPoints; + holes?: FlatPoints[]; +} + +interface EdgeStyle { + width: number; + color: number; +} + +function renderAreas(areas: AreaFeature[]): Graphics { + const g = new Graphics(); + + // Big landuse blocks first, so the parks and pitches sitting inside them stay visible. + const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline)); + + forEachRun( + sorted, + (area) => AREA_COLORS[area.kind] ?? AREA_COLORS.unknown, + (run, color) => fillRings(g, run, color), + ); + + return g; +} + +function renderWater(water: WaterFeature[], profile: RenderProfile): Graphics { + const g = new Graphics(); + + const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline); + fillRings(g, bodies, WATER_COLOR, { width: profile.minStrokeMeters, color: WATER_EDGE_COLOR }); + + for (const stream of water) { + if (!stream.path) continue; + + g.poly(stream.path, false).stroke({ + width: Math.max(stream.width ?? 2, profile.minStrokeMeters), + color: WATER_LINE_COLORS[stream.kind] ?? WATER_COLOR, + cap: 'round', + join: 'round', + }); + } + + return g; +} + +function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics { + const g = new Graphics(); + + const visible = roads + .filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail) + .sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width); + + // The casing is a slightly wider stroke drawn underneath; it is what gives roads their outline. + const casingMargin = Math.max(1.4, profile.minStrokeMeters * 0.7); + + // Sorted by class then width, so a run shares both and can be stroked in a single call. + forEachRun( + visible, + (road) => `${road.class}:${road.width}`, + (run) => { + const first = run[0]!; + for (const road of run) g.poly(road.path, false); + + const width = Math.max(first.width, profile.minStrokeMeters); + g.stroke({ + width: casing ? width + casingMargin : width, + color: casing ? ROAD_CASING_COLORS[first.class] : ROAD_COLORS[first.class], + cap: 'round', + join: 'round', + }); + }, + ); + + return g; +} + +function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics { + const g = new Graphics(); + const minArea = MIN_BUILDING_AREA[profile.detail]; + const edge: EdgeStyle | undefined = + profile.detail === 2 ? { width: profile.minStrokeMeters * 0.8, color: BUILDING_EDGE_COLOR } : undefined; + + // Footprints never overlap, so they can be regrouped by colour without disturbing the picture. + const byColor = new Map(); + for (const building of buildings) { + if (minArea > 0 && boundingArea(building.outline) < minArea) continue; + + const color = buildingColor(building.kind, building.height); + const group = byColor.get(color); + if (group) group.push(building); + else byColor.set(color, [building]); + } + + for (const [color, group] of byColor) { + fillRings(g, group, color, edge); + } + + return g; +} + +/** + * Fills a batch of rings. Rings without holes share one draw call; a ring with holes needs its own, because + * each cut applies to the shape that precedes it. + */ +function fillRings(g: Graphics, rings: Ring[], color: number, edge?: EdgeStyle): void { + let solidCount = 0; + + for (const ring of rings) { + if (ring.holes?.length) continue; + g.poly(ring.outline); + solidCount++; + } + + if (solidCount > 0) { + g.fill({ color }); + if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 }); + } + + for (const ring of rings) { + if (!ring.holes?.length) continue; + + g.poly(ring.outline); + for (const hole of ring.holes) { + g.poly(hole); + g.cut(); + } + + g.fill({ color }); + if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 }); + } +} + +/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */ +function boundingArea(points: FlatPoints): number { + if (points.length < 6) return 0; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let i = 0; i < points.length; i += 2) { + const x = points[i]!; + const y = points[i + 1]!; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + return (maxX - minX) * (maxY - minY); +} + +/** Walks a sorted list, handing over each maximal run of items that share a key. */ +function forEachRun(items: T[], keyOf: (item: T) => K, draw: (run: T[], key: K) => void): void { + let index = 0; + while (index < items.length) { + const key = keyOf(items[index]!); + const start = index; + while (index < items.length && keyOf(items[index]!) === key) index++; + draw(items.slice(start, index), key); + } +} diff --git a/src/TheLivingWorld.Web/src/map/mapView.ts b/src/TheLivingWorld.Web/src/map/mapView.ts new file mode 100644 index 0000000..1f1c829 --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/mapView.ts @@ -0,0 +1,243 @@ +import { Application, Container, Graphics } from 'pixi.js'; +import type { WorldMap } from '../api/types'; +import { Camera, type Viewport } from './camera'; +import { ChunkManager, type MapLayers } from './chunkManager'; +import { profileForZoom, type RenderProfile } from './chunkRenderer'; +import { LAND_COLOR } from './style'; + +const OUTSIDE_COLOR = 0xd5d9d2; +const BORDER_COLOR = 0xb4bab2; + +/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */ +const CHUNK_UPDATE_INTERVAL_MS = 90; + +export interface MapStatus { + zoom: number; + metersPerPixel: number; + center: { x: number; y: number }; + loadedChunks: number; + totalChunks: number; + loading: boolean; +} + +/** + * The PixiJS side of the map: one scaled container holding the layer stack, a camera driving it, and the + * pointer handling that lets the player move around. + */ +export class MapView { + private readonly app = new Application(); + private readonly root = new Container(); + private readonly background = new Graphics(); + private readonly border = new Graphics(); + private readonly camera = new Camera(); + + private readonly layers: MapLayers = { + areas: new Container(), + water: new Container(), + roadCasings: new Container(), + roads: new Container(), + buildings: new Container(), + }; + + private readonly chunks = new ChunkManager(this.layers); + + private host: HTMLElement | null = null; + private worldSizeMeters = 0; + private profile: RenderProfile = profileForZoom(0.1); + private cameraDirty = true; + private lastChunkUpdate = 0; + private readonly activePointers = new Map(); + private pinchDistance = 0; + + onStatusChange: ((status: MapStatus) => void) | null = null; + + async init(host: HTMLElement): Promise { + this.host = host; + + await this.app.init({ + background: OUTSIDE_COLOR, + antialias: true, + resizeTo: host, + resolution: window.devicePixelRatio || 1, + autoDensity: true, + preference: 'webgl', + }); + + host.appendChild(this.app.canvas); + + // Painter's order: land cover, then water, then the road casings their fills sit on, then buildings. + this.root.addChild( + this.background, + this.layers.areas, + this.layers.water, + this.layers.roadCasings, + this.layers.roads, + this.layers.buildings, + this.border, + ); + this.app.stage.addChild(this.root); + + this.attachInput(this.app.canvas); + + // Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow. + new ResizeObserver(() => { + this.cameraDirty = true; + }).observe(host); + + this.app.ticker.add(() => this.tick()); + } + + showWorld(map: WorldMap): void { + this.chunks.clear(); + this.worldSizeMeters = map.sizeMeters; + + const half = map.sizeMeters / 2; + this.background.clear().rect(-half, -half, map.sizeMeters, map.sizeMeters).fill({ color: LAND_COLOR }); + + this.camera.fit(map.sizeMeters, this.viewport); + this.chunks.setWorld(map.id, map.chunks); + this.cameraDirty = true; + this.lastChunkUpdate = 0; + } + + clear(): void { + this.chunks.clear(); + this.background.clear(); + this.border.clear(); + this.worldSizeMeters = 0; + } + + destroy(): void { + this.chunks.clear(); + this.app.destroy(true, { children: true }); + } + + private get viewport(): Viewport { + return { + width: this.host?.clientWidth || this.app.screen.width, + height: this.host?.clientHeight || this.app.screen.height, + }; + } + + private tick(): void { + if (this.worldSizeMeters === 0) return; + + const viewport = this.viewport; + + if (this.cameraDirty) { + this.camera.clampToWorld(this.worldSizeMeters); + + const position = this.camera.containerPosition(viewport); + this.root.position.set(position.x, position.y); + this.root.scale.set(this.camera.zoom, -this.camera.zoom); + + this.profile = profileForZoom(this.camera.zoom); + this.drawBorder(); + this.cameraDirty = false; + } + + const now = performance.now(); + if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) { + this.lastChunkUpdate = now; + this.chunks.update(this.camera.visibleRect(viewport), this.profile); + this.publishStatus(); + } + } + + private drawBorder(): void { + const half = this.worldSizeMeters / 2; + this.border + .clear() + .rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters) + .stroke({ width: 2 / this.camera.zoom, color: BORDER_COLOR, alignment: 1 }); + } + + private publishStatus(): void { + this.onStatusChange?.({ + zoom: this.camera.zoom, + metersPerPixel: 1 / this.camera.zoom, + center: { x: this.camera.x, y: this.camera.y }, + loadedChunks: this.chunks.loadedCount, + totalChunks: this.chunks.totalCount, + loading: this.chunks.isBusy, + }); + } + + private attachInput(canvas: HTMLCanvasElement): void { + canvas.style.touchAction = 'none'; + + canvas.addEventListener('pointerdown', (event) => { + canvas.setPointerCapture(event.pointerId); + this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + this.pinchDistance = this.currentPinchDistance(); + }); + + canvas.addEventListener('pointermove', (event) => { + const previous = this.activePointers.get(event.pointerId); + if (!previous) return; + + this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + + if (this.activePointers.size >= 2) { + this.handlePinch(); + return; + } + + this.camera.translateBy(event.clientX - previous.x, event.clientY - previous.y); + this.cameraDirty = true; + }); + + const release = (event: PointerEvent) => { + this.activePointers.delete(event.pointerId); + this.pinchDistance = this.currentPinchDistance(); + }; + + canvas.addEventListener('pointerup', release); + canvas.addEventListener('pointercancel', release); + canvas.addEventListener('pointerleave', release); + + canvas.addEventListener( + 'wheel', + (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + // A gentle exponential keeps trackpads and mouse wheels feeling alike. + this.camera.zoomAt( + Math.exp(-event.deltaY * 0.0015), + event.clientX - rect.left, + event.clientY - rect.top, + this.viewport, + ); + this.cameraDirty = true; + }, + { passive: false }, + ); + } + + private handlePinch(): void { + const distance = this.currentPinchDistance(); + if (this.pinchDistance === 0 || distance === 0) { + this.pinchDistance = distance; + return; + } + + const midpoint = this.pointerMidpoint(); + const rect = this.app.canvas.getBoundingClientRect(); + this.camera.zoomAt(distance / this.pinchDistance, midpoint.x - rect.left, midpoint.y - rect.top, this.viewport); + + this.pinchDistance = distance; + this.cameraDirty = true; + } + + private currentPinchDistance(): number { + if (this.activePointers.size < 2) return 0; + + const [first, second] = [...this.activePointers.values()]; + return Math.hypot(second!.x - first!.x, second!.y - first!.y); + } + + private pointerMidpoint(): { x: number; y: number } { + const [first, second] = [...this.activePointers.values()]; + return { x: (first!.x + second!.x) / 2, y: (first!.y + second!.y) / 2 }; + } +} diff --git a/src/TheLivingWorld.Web/src/map/style.ts b/src/TheLivingWorld.Web/src/map/style.ts new file mode 100644 index 0000000..68fd287 --- /dev/null +++ b/src/TheLivingWorld.Web/src/map/style.ts @@ -0,0 +1,169 @@ +import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types'; + +/** + * A quiet daytime cartography palette: muted land cover, white roads, warm building fills. Everything the + * renderer draws gets its colour from here so the map reads as one coherent style. + */ + +export const LAND_COLOR = 0xeceee7; + +export const AREA_COLORS: Record = { + unknown: 0xe6e6e0, + forest: 0xc6ddb8, + grass: 0xd8e9c6, + meadow: 0xdfeecd, + farmland: 0xece7c8, + orchard: 0xd9e7bd, + scrub: 0xd9e6c9, + heath: 0xdfe4cb, + sand: 0xf0e9cf, + bareRock: 0xdedcd5, + wetland: 0xd2e2d7, + park: 0xd2eac0, + garden: 0xd9edc6, + pitch: 0xc9e6b4, + cemetery: 0xd6e0cd, + residentialZone: 0xe6e3de, + industrialZone: 0xe3dee2, + commercialZone: 0xeae0dd, + retailZone: 0xeddfd7, + quarry: 0xdfdad2, + parking: 0xe7e4dd, + school: 0xeae3d7, + beach: 0xf4ead1, +}; + +export const WATER_COLOR = 0x9fc9e0; +export const WATER_EDGE_COLOR = 0x84b4cf; + +export const WATER_LINE_COLORS: Record = { + unknown: WATER_COLOR, + water: WATER_COLOR, + lake: WATER_COLOR, + pond: WATER_COLOR, + reservoir: WATER_COLOR, + riverbank: WATER_COLOR, + river: WATER_COLOR, + stream: 0xa9d0e4, + canal: WATER_COLOR, + ditch: 0xb5d7e8, + drain: 0xb5d7e8, +}; + +export const ROAD_COLORS: Record = { + unknown: 0xf7f6f4, + motorway: 0xf3ae68, + trunk: 0xf7c489, + primary: 0xfad89b, + secondary: 0xfae9b8, + tertiary: 0xfdfaf0, + unclassified: 0xffffff, + residential: 0xffffff, + livingStreet: 0xf7f7f5, + service: 0xfbfbfa, + track: 0xe6d9bd, + pedestrian: 0xf1eee9, + footway: 0xecc9b3, + cycleway: 0xd2dcef, + steps: 0xe4b39a, + path: 0xe8cbb6, + railway: 0xb2aca6, +}; + +export const ROAD_CASING_COLORS: Record = { + unknown: 0xd8d4cd, + motorway: 0xd48f45, + trunk: 0xd9a163, + primary: 0xdcb872, + secondary: 0xdfcb8c, + tertiary: 0xd2cec6, + unclassified: 0xd2cec6, + residential: 0xd2cec6, + livingStreet: 0xd2cec6, + service: 0xdad6cf, + track: 0xc7b795, + pedestrian: 0xd6d1c9, + footway: 0xd0a488, + cycleway: 0xafbcd6, + steps: 0xcb9077, + path: 0xcfab8f, + railway: 0x8d8781, +}; + +/** Draw order within the road layer: bigger roads sit on top of the network they feed. */ +export const ROAD_RANK: Record = { + path: 0, + steps: 0, + footway: 1, + cycleway: 1, + track: 2, + service: 3, + pedestrian: 3, + railway: 4, + livingStreet: 5, + residential: 6, + unclassified: 6, + unknown: 6, + tertiary: 7, + secondary: 8, + primary: 9, + trunk: 10, + motorway: 11, +}; + +/** Minor paths vanish first as the camera pulls back. */ +export const ROAD_MIN_DETAIL: Record = { + path: 2, + steps: 2, + footway: 2, + cycleway: 2, + track: 1, + service: 1, + pedestrian: 1, + railway: 0, + livingStreet: 0, + residential: 0, + unclassified: 0, + unknown: 1, + tertiary: 0, + secondary: 0, + primary: 0, + trunk: 0, + motorway: 0, +}; + +const BUILDING_BASE = 0xdfd7cc; +const BUILDING_TALL = 0xc4b8a8; + +export const BUILDING_EDGE_COLOR = 0xbdb2a3; + +const BUILDING_TINTS: Partial> = { + church: 0xd9cfc4, + school: 0xdcd6c4, + civic: 0xd8d2c6, + industrial: 0xd7d1cd, + retail: 0xe2d5cc, + commercial: 0xe0d6cd, + shed: 0xe3ddd4, + garage: 0xe3ddd4, +}; + +/** + * Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark. + * Anything above ~30 m is already at the darkest end of the ramp. + */ +export function buildingColor(kind: BuildingKind, height: number): number { + const base = BUILDING_TINTS[kind] ?? BUILDING_BASE; + return mix(base, BUILDING_TALL, clamp01((height - 3) / 27)); +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function mix(from: number, to: number, t: number): number { + const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t); + const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t); + const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t); + return (r << 16) | (g << 8) | b; +} diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css new file mode 100644 index 0000000..39b692f --- /dev/null +++ b/src/TheLivingWorld.Web/src/styles.css @@ -0,0 +1,276 @@ +:root { + --panel-bg: rgba(252, 252, 250, 0.94); + --panel-border: #d9d6cf; + --text: #2b2a27; + --text-muted: #75726b; + --accent: #3d6b4a; + --accent-hover: #325a3d; + --error: #a63d33; + font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + overflow: hidden; + color: var(--text); + background: #d5d9d2; +} + +#stage { + position: fixed; + inset: 0; +} + +#stage canvas { + display: block; + cursor: grab; +} + +#stage canvas:active { + cursor: grabbing; +} + +.panel { + position: fixed; + top: 16px; + left: 16px; + width: 320px; + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + gap: 18px; + padding: 18px; + overflow-y: auto; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 12px; + box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14); + backdrop-filter: blur(6px); +} + +.panel__header h1 { + margin: 0; + font-size: 17px; + letter-spacing: 0.01em; +} + +.panel__subtitle { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-muted); +} + +.form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--text-muted); +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.field input[type='text'], +.field input[type='number'] { + padding: 7px 9px; + font: inherit; + font-size: 13px; + color: var(--text); + background: #fff; + border: 1px solid var(--panel-border); + border-radius: 7px; +} + +.field input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.field input[type='range'] { + accent-color: var(--accent); +} + +.field output { + float: right; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.button { + padding: 9px 12px; + font: inherit; + font-size: 13px; + font-weight: 600; + color: #fff; + background: var(--accent); + border: none; + border-radius: 7px; + cursor: pointer; +} + +.button:hover:not(:disabled) { + background: var(--accent-hover); +} + +.button:disabled { + opacity: 0.55; + cursor: progress; +} + +.worlds h2 { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.world-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.world { + display: flex; + align-items: stretch; + gap: 4px; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.world[data-active='true'] { + border-color: var(--accent); + box-shadow: inset 2px 0 0 var(--accent); +} + +.world[data-status='failed'] .world__detail { + color: var(--error); +} + +.world__open { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + font: inherit; + text-align: left; + background: none; + border: none; + cursor: pointer; +} + +.world__open:disabled { + cursor: default; + opacity: 0.75; +} + +.world__open:hover:not(:disabled) { + background: #f3f5f1; +} + +.world__name { + font-size: 13px; + font-weight: 600; +} + +.world__detail { + font-size: 11px; + color: var(--text-muted); +} + +.world__delete { + padding: 0 10px; + font-size: 16px; + line-height: 1; + color: var(--text-muted); + background: none; + border: none; + border-left: 1px solid var(--panel-border); + cursor: pointer; +} + +.world__delete:hover { + color: var(--error); + background: #faf1f0; +} + +.status { + min-height: 16px; + font-size: 12px; + color: var(--text-muted); +} + +.status[data-tone='error'] { + color: var(--error); +} + +.status[data-tone='busy']::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background: var(--accent); + animation: pulse 1.1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.25; + } + 50% { + opacity: 1; + } +} + +.hud { + position: fixed; + right: 16px; + bottom: 16px; + padding: 6px 10px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 7px; + pointer-events: none; +} + +@media (max-width: 640px) { + .panel { + top: auto; + bottom: 12px; + left: 12px; + right: 12px; + width: auto; + max-height: 55vh; + } +} diff --git a/src/TheLivingWorld.Web/tsconfig.json b/src/TheLivingWorld.Web/tsconfig.json new file mode 100644 index 0000000..44e8680 --- /dev/null +++ b/src/TheLivingWorld.Web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/src/TheLivingWorld.Web/vite.config.ts b/src/TheLivingWorld.Web/vite.config.ts new file mode 100644 index 0000000..fd64b3a --- /dev/null +++ b/src/TheLivingWorld.Web/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; + +/** + * Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as + * `services__api____`. Outside Aspire the fallback lets a plain `npm run dev` still work. + */ +function resolveApiUrl(): string { + const fromAspire = Object.keys(process.env) + .filter((key) => key.startsWith('services__api__')) + // Ascending order puts `http` ahead of `https`; plain HTTP is the simpler hop for a local proxy. + .sort() + .map((key) => process.env[key]) + .find((value): value is string => !!value); + + // Matches the `http` launch profile of TheLivingWorld.Api. + return fromAspire ?? process.env.API_URL ?? 'http://localhost:5195'; +} + +export default defineConfig(() => { + const apiUrl = resolveApiUrl(); + + return { + server: { + port: process.env.PORT ? Number(process.env.PORT) : 5173, + strictPort: true, + proxy: { + '/api': { + target: apiUrl, + changeOrigin: true, + // Aspire issues a development certificate the proxy has no reason to distrust. + secure: false, + }, + }, + }, + build: { + target: 'es2022', + sourcemap: true, + }, + }; +}); diff --git a/tests/TheLivingWorld.Tests/GeoTests.cs b/tests/TheLivingWorld.Tests/GeoTests.cs new file mode 100644 index 0000000..07dd275 --- /dev/null +++ b/tests/TheLivingWorld.Tests/GeoTests.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Core.Worlds; + +namespace TheLivingWorld.Tests; + +public class LocalProjectionTests +{ + // Robert Lee, Texas - the town the first worlds were generated around. + private static readonly GeoPoint Origin = new(31.8966010, -100.4858591); + + [Fact] + public void Origin_projects_to_world_zero() + { + var projection = new LocalProjection(Origin); + + var projected = projection.Project(Origin); + + Assert.Equal(0f, projected.X, 3); + Assert.Equal(0f, projected.Y, 3); + } + + [Fact] + public void One_kilometre_north_lands_one_kilometre_up() + { + var projection = new LocalProjection(Origin); + var north = new GeoPoint(Origin.Latitude + 1000.0 / LocalProjection.MetersPerDegreeLatitude, Origin.Longitude); + + var projected = projection.Project(north); + + Assert.Equal(0f, projected.X, 2); + Assert.Equal(1000f, projected.Y, 1); + } + + [Fact] + public void Unproject_reverses_project() + { + var projection = new LocalProjection(Origin); + var point = new Vector2(-4321.5f, 6789.25f); + + var roundTripped = projection.Project(projection.Unproject(point)); + + Assert.Equal(point.X, roundTripped.X, 1); + Assert.Equal(point.Y, roundTripped.Y, 1); + } + + [Fact] + public void Rejects_coordinates_outside_the_globe() + { + Assert.Throws(() => new LocalProjection(new GeoPoint(91, 0))); + } +} + +public class GeoBoundsTests +{ + [Theory] + [InlineData(31.8966010, -100.4858591, 10_000)] + [InlineData(60.0, 30.3, 20_000)] + [InlineData(-33.87, 151.21, 5_000)] + public void FromCenter_produces_a_square_of_the_requested_size(double lat, double lon, double sizeMeters) + { + var center = new GeoPoint(lat, lon); + var bounds = GeoBounds.FromCenter(center, sizeMeters); + var projection = new LocalProjection(center); + + var southWest = projection.Project(bounds.South, bounds.West); + var northEast = projection.Project(bounds.North, bounds.East); + + // Half a metre of slack over a 20 km span is the float rounding in the projection. + Assert.Equal(sizeMeters, northEast.X - southWest.X, 0.5); + Assert.Equal(sizeMeters, northEast.Y - southWest.Y, 0.5); + } + + [Fact] + public void Overpass_bbox_is_south_west_north_east() + { + var bounds = new GeoBounds(South: 1, West: 2, North: 3, East: 4); + + Assert.Equal("1.0000000,2.0000000,3.0000000,4.0000000", bounds.ToOverpassBbox()); + } +} + +public class ChunkGridTests +{ + [Fact] + public void Covers_the_whole_world_square() + { + var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512); + + Assert.Equal(4, grid.CountX); + Assert.Equal(4, grid.CountY); + Assert.Equal(-1024f, grid.MinX); + } + + [Fact] + public void Rounds_the_count_up_when_the_size_does_not_divide_evenly() + { + var grid = new ChunkGrid(worldSizeMeters: 10_000, chunkSizeMeters: 512); + + Assert.Equal(20, grid.CountX); + } + + [Fact] + public void Maps_points_to_the_chunk_containing_them() + { + var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512); + + Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-1024, -1024))); + Assert.Equal(new ChunkCoord(2, 2), grid.CoordOf(new Vector2(1, 1))); + Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(1023, 1023))); + } + + [Fact] + public void Clamps_points_that_fall_outside_the_world() + { + var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512); + + Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-99_999, -99_999))); + Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(99_999, 99_999))); + } + + [Fact] + public void Chunk_bounds_line_up_with_the_grid() + { + var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512); + + var bounds = grid.BoundsOf(new ChunkCoord(1, 2)); + + Assert.Equal(-512f, bounds.MinX); + Assert.Equal(0f, bounds.MinY); + Assert.Equal(0f, bounds.MaxX); + Assert.Equal(512f, bounds.MaxY); + } +} diff --git a/tests/TheLivingWorld.Tests/GeometryTests.cs b/tests/TheLivingWorld.Tests/GeometryTests.cs new file mode 100644 index 0000000..a0f14a5 --- /dev/null +++ b/tests/TheLivingWorld.Tests/GeometryTests.cs @@ -0,0 +1,152 @@ +using System.Numerics; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Osm.Import; + +namespace TheLivingWorld.Tests; + +public class GeometryClipperTests +{ + private static readonly RectBounds Unit = new(0, 0, 100, 100); + + [Fact] + public void Leaves_a_polygon_that_is_already_inside_alone() + { + Vector2[] square = [new(10, 10), new(90, 10), new(90, 90), new(10, 90)]; + + var clipped = GeometryClipper.ClipPolygon(square, Unit); + + Assert.NotNull(clipped); + Assert.Equal(4, clipped.Length); + Assert.Equal(Polygons.Area(square), Polygons.Area(clipped), 1); + } + + [Fact] + public void Trims_a_polygon_that_hangs_over_the_edge() + { + Vector2[] overhanging = [new(50, 50), new(200, 50), new(200, 90), new(50, 90)]; + + var clipped = GeometryClipper.ClipPolygon(overhanging, Unit); + + Assert.NotNull(clipped); + Assert.All(clipped, point => Assert.InRange(point.X, 0, 100)); + Assert.Equal(50f * 40f, Polygons.Area(clipped), 1); + } + + [Fact] + public void Drops_a_polygon_that_misses_the_box_entirely() + { + Vector2[] elsewhere = [new(500, 500), new(600, 500), new(600, 600)]; + + Assert.Null(GeometryClipper.ClipPolygon(elsewhere, Unit)); + } + + [Fact] + public void Keeps_the_part_of_a_line_that_crosses_the_box() + { + Vector2[] line = [new(-50, 50), new(150, 50)]; + + var runs = GeometryClipper.ClipPolyline(line, Unit); + + var run = Assert.Single(runs); + Assert.Equal(new Vector2(0, 50), run[0]); + Assert.Equal(new Vector2(100, 50), run[^1]); + } + + [Fact] + public void Splits_a_line_that_leaves_and_comes_back() + { + Vector2[] line = [new(10, 50), new(150, 50), new(150, 20), new(10, 20)]; + + var runs = GeometryClipper.ClipPolyline(line, Unit); + + Assert.Equal(2, runs.Count); + Assert.All(runs, run => Assert.All(run, point => Assert.InRange(point.X, 0, 100))); + } + + [Fact] + public void Drops_a_line_that_never_touches_the_box() + { + Vector2[] line = [new(-50, -50), new(-10, -20)]; + + Assert.Empty(GeometryClipper.ClipPolyline(line, Unit)); + } +} + +public class PolygonsTests +{ + [Fact] + public void Computes_the_area_of_a_square() + { + Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]; + + Assert.Equal(100f, Polygons.Area(square)); + } + + [Fact] + public void Detects_containment() + { + Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]; + + Assert.True(Polygons.Contains(square, new Vector2(5, 5))); + Assert.False(Polygons.Contains(square, new Vector2(15, 5))); + } +} + +public class RingAssemblerTests +{ + [Fact] + public void Joins_fragments_into_one_ring_whatever_their_order_or_direction() + { + // A 10x10 square split into three fragments; the middle one runs backwards. + List> fragments = + [ + [new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)], + [new Vector2(0, 0), new Vector2(10, 0)], + [new Vector2(10, 10), new Vector2(10, 0)], + ]; + + var rings = RingAssembler.Assemble(fragments); + + var ring = Assert.Single(rings); + Assert.Equal(4, ring.Length); + Assert.Equal(100f, Polygons.Area(ring)); + } + + [Fact] + public void Accepts_a_way_that_is_already_closed() + { + List> fragments = + [ + [new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)], + ]; + + var ring = Assert.Single(RingAssembler.Assemble(fragments)); + + // The repeated closing vertex is dropped: a ring is stored open. + Assert.Equal(4, ring.Length); + } + + [Fact] + public void Drops_fragments_that_never_close() + { + List> fragments = + [ + [new Vector2(0, 0), new Vector2(10, 0)], + [new Vector2(10, 0), new Vector2(10, 10)], + ]; + + Assert.Empty(RingAssembler.Assemble(fragments)); + } + + [Fact] + public void Separates_two_independent_rings() + { + List> fragments = + [ + [new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)], + [new Vector2(50, 50), new Vector2(60, 50), new Vector2(60, 60), new Vector2(50, 50)], + ]; + + Assert.Equal(2, RingAssembler.Assemble(fragments).Count); + } +} diff --git a/tests/TheLivingWorld.Tests/ImportPipelineTests.cs b/tests/TheLivingWorld.Tests/ImportPipelineTests.cs new file mode 100644 index 0000000..d5ca893 --- /dev/null +++ b/tests/TheLivingWorld.Tests/ImportPipelineTests.cs @@ -0,0 +1,236 @@ +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; + +/// +/// 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. +/// +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() + { + using var world = Build(WayWithGeometry( + id: 7, + tags: new() { ["highway"] = "residential", ["name"] = "Main Street" }, + metres: [new Vector2(-200, 0), new Vector2(200, 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 road = Assert.Single(Export(world)[0].Chunk.Roads); + var maxX = 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 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 { ["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_geometry_that_overhangs() + { + // A road whose centre sits in one chunk but which reaches well into its neighbours. + using var world = Build(WayWithGeometry( + id: 12, + tags: new() { ["highway"] = "primary" }, + metres: [new Vector2(-900, 0), new Vector2(900, 0)])); + + 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.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.Instance).Populate(world, elements); + } + + private static IReadOnlyList 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 tags, Vector2[] metres) => new() + { + Type = "way", + Id = id, + Tags = tags, + Geometry = Metres(metres), + }; + + /// Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send. + private static List 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; + } +} diff --git a/tests/TheLivingWorld.Tests/OverpassClientTests.cs b/tests/TheLivingWorld.Tests/OverpassClientTests.cs new file mode 100644 index 0000000..9e487d5 --- /dev/null +++ b/tests/TheLivingWorld.Tests/OverpassClientTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TheLivingWorld.Core.Geo; +using TheLivingWorld.Osm; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Tests; + +public class OverpassClientTests : IDisposable +{ + private static readonly GeoBounds Bounds = GeoBounds.FromCenter(new GeoPoint(31.8966010, -100.4858591), 1000); + + private readonly string _cacheDirectory = + Path.Combine(Path.GetTempPath(), $"tlw-osm-cache-{Guid.NewGuid():n}"); + + [Fact] + public async Task Caches_the_response_and_serves_it_again_without_a_second_request() + { + var handler = new StubHandler((_, _) => Json("""{"elements":[]}""")); + var client = CreateClient(handler); + + var first = await client.FetchAsync(Bounds, forceRefresh: false); + var second = await client.FetchAsync(Bounds, forceRefresh: false); + + Assert.Equal(first, second); + Assert.Equal(1, handler.Requests); + Assert.Equal("""{"elements":[]}""", await File.ReadAllTextAsync(first)); + } + + [Fact] + public async Task Refetches_when_asked_to_refresh() + { + var handler = new StubHandler((_, _) => Json("""{"elements":[]}""")); + var client = CreateClient(handler); + + await client.FetchAsync(Bounds, forceRefresh: false); + await client.FetchAsync(Bounds, forceRefresh: true); + + Assert.Equal(2, handler.Requests); + } + + [Fact] + public async Task Falls_back_to_the_next_mirror_after_a_retryable_failure() + { + var handler = new StubHandler((request, _) => request.RequestUri!.Host == "first.example" + ? new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("busy") } + : Json("""{"elements":[]}""")); + + var client = CreateClient(handler, "https://first.example/api", "https://second.example/api"); + + var path = await client.FetchAsync(Bounds, forceRefresh: false); + + Assert.True(File.Exists(path)); + Assert.Equal(2, handler.Requests); + } + + [Fact] + public async Task Rejects_an_html_error_page_served_under_a_200() + { + // An overloaded Overpass instance answers "the server is probably too busy" as HTML with status 200. + var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("too busy", System.Text.Encoding.UTF8, "text/html"), + }); + + var client = CreateClient(handler); + + var failure = await Assert.ThrowsAsync(() => client.FetchAsync(Bounds, forceRefresh: false)); + + Assert.Contains("text/html", failure.Message); + Assert.False(Directory.EnumerateFiles(_cacheDirectory, "*.json").Any()); + } + + [Fact] + public async Task Reports_the_last_error_when_every_mirror_fails() + { + var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("

gateway is down

"), + }); + + var client = CreateClient(handler); + + var failure = await Assert.ThrowsAsync(() => client.FetchAsync(Bounds, forceRefresh: false)); + + Assert.Contains("gateway is down", failure.Message); + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"), + }; + + private OverpassClient CreateClient(StubHandler handler, params string[] endpoints) + { + var options = new OsmOptions + { + CacheDirectory = _cacheDirectory, + MaxAttemptsPerEndpoint = 1, + RequestTimeoutSeconds = 5, + }; + + if (endpoints.Length > 0) options.Endpoints = endpoints; + else options.Endpoints = ["https://only.example/api"]; + + return new OverpassClient( + new HttpClient(handler), + Options.Create(options), + NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(_cacheDirectory)) Directory.Delete(_cacheDirectory, recursive: true); + GC.SuppressFinalize(this); + } + + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + public int Requests { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests++; + return Task.FromResult(respond(request, cancellationToken)); + } + } +} diff --git a/tests/TheLivingWorld.Tests/TagInterpreterTests.cs b/tests/TheLivingWorld.Tests/TagInterpreterTests.cs new file mode 100644 index 0000000..99e32c7 --- /dev/null +++ b/tests/TheLivingWorld.Tests/TagInterpreterTests.cs @@ -0,0 +1,159 @@ +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Osm.Import; +using TheLivingWorld.Osm.Overpass; + +namespace TheLivingWorld.Tests; + +public class TagInterpreterTests +{ + private static OverpassElement Way(params string[] tags) + { + var map = new Dictionary(); + for (var i = 0; i < tags.Length; i += 2) + map[tags[i]] = tags[i + 1]; + + return new OverpassElement { Type = "way", Id = 1, Tags = map }; + } + + [Fact] + public void Reads_an_explicit_height_including_its_unit() + { + var building = TagInterpreter.ReadBuilding(Way("building", "yes", "height", "12.5 m")); + + Assert.Equal(12.5f, building.HeightMeters); + } + + [Fact] + public void Derives_height_from_levels_when_none_is_tagged() + { + var building = TagInterpreter.ReadBuilding(Way("building", "apartments", "building:levels", "4")); + + Assert.Equal(4, building.Levels); + Assert.Equal(12f, building.HeightMeters); + } + + [Fact] + public void Falls_back_to_a_height_that_suits_the_kind() + { + var shed = TagInterpreter.ReadBuilding(Way("building", "shed")); + var house = TagInterpreter.ReadBuilding(Way("building", "house")); + + Assert.Equal(BuildingKind.Shed, shed.Kind); + Assert.True(shed.HeightMeters < house.HeightMeters); + } + + [Fact] + public void Ignores_a_height_that_is_not_a_number() + { + var building = TagInterpreter.ReadBuilding(Way("building", "house", "height", "tall")); + + Assert.Equal(5f, building.HeightMeters); + } + + [Theory] + [InlineData("motorway", RoadClass.Motorway)] + [InlineData("motorway_link", RoadClass.Motorway)] + [InlineData("residential", RoadClass.Residential)] + [InlineData("living_street", RoadClass.LivingStreet)] + [InlineData("nonsense", RoadClass.Unknown)] + public void Maps_highway_values_to_road_classes(string highway, RoadClass expected) + { + Assert.Equal(expected, TagInterpreter.ReadRoad(Way("highway", highway)).Class); + } + + [Fact] + public void Prefers_a_tagged_width_over_lane_count() + { + var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "2", "width", "9")); + + Assert.Equal(9f, road.WidthMeters); + } + + [Fact] + public void Derives_width_from_lanes_when_no_width_is_tagged() + { + var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "4")); + + Assert.Equal(4, road.Lanes); + Assert.Equal(13.6f, road.WidthMeters, 3); + } + + [Fact] + public void Reads_road_flags() + { + var road = TagInterpreter.ReadRoad(Way("highway", "track", "bridge", "yes", "surface", "gravel")); + + Assert.True(road.Flags.HasFlag(RoadFlags.Bridge)); + Assert.True(road.Flags.HasFlag(RoadFlags.Unpaved)); + Assert.False(road.Flags.HasFlag(RoadFlags.Tunnel)); + } + + [Fact] + public void Treats_railways_without_a_highway_tag_as_rail() + { + Assert.True(TagInterpreter.IsRoad(Way("railway", "rail"))); + Assert.Equal(RoadClass.Railway, TagInterpreter.ReadRoad(Way("railway", "rail")).Class); + } + + [Theory] + [InlineData("river", WaterKind.River, 12f)] + [InlineData("stream", WaterKind.Stream, 3f)] + public void Reads_watercourses_with_a_default_width(string waterway, WaterKind kind, float width) + { + var water = TagInterpreter.ReadWater(Way("waterway", waterway)); + + Assert.NotNull(water); + Assert.Equal(kind, water.Value.Kind); + Assert.Equal(width, water.Value.WidthMeters); + } + + [Fact] + public void Reads_a_lake_as_a_water_body() + { + var water = TagInterpreter.ReadWater(Way("natural", "water", "water", "lake")); + + Assert.Equal(WaterKind.Lake, water!.Value.Kind); + } + + [Fact] + public void Does_not_treat_a_dam_as_water() + { + Assert.Null(TagInterpreter.ReadWater(Way("waterway", "dam"))); + } + + [Theory] + [InlineData("landuse", "forest", AreaKind.Forest)] + [InlineData("natural", "wood", AreaKind.Forest)] + [InlineData("leisure", "park", AreaKind.Park)] + [InlineData("amenity", "parking", AreaKind.Parking)] + public void Maps_land_cover_tags_to_area_kinds(string key, string value, AreaKind expected) + { + Assert.Equal(expected, TagInterpreter.ReadAreaKind(Way(key, value))); + } + + [Fact] + public void Returns_no_area_kind_for_an_unrelated_element() + { + Assert.Null(TagInterpreter.ReadAreaKind(Way("highway", "residential"))); + } + + [Theory] + [InlineData("12", 12.0)] + [InlineData("12 m", 12.0)] + [InlineData("3.5", 3.5)] + [InlineData("-2", -2.0)] + public void Parses_numeric_prefixes(string raw, double expected) + { + Assert.True(TagInterpreter.TryParseLeadingNumber(raw, out var value)); + Assert.Equal(expected, value, 5); + } + + [Theory] + [InlineData("")] + [InlineData("tall")] + [InlineData(null)] + public void Rejects_values_without_a_number(string? raw) + { + Assert.False(TagInterpreter.TryParseLeadingNumber(raw, out _)); + } +} diff --git a/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj b/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj new file mode 100644 index 0000000..07966ea --- /dev/null +++ b/tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/the-living-world.sln b/the-living-world.sln new file mode 100644 index 0000000..8cf7a4f --- /dev/null +++ b/the-living-world.sln @@ -0,0 +1,66 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Api", "src\TheLivingWorld.Api\TheLivingWorld.Api.csproj", "{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.AppHost", "src\TheLivingWorld.AppHost\TheLivingWorld.AppHost.csproj", "{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Core", "src\TheLivingWorld.Core\TheLivingWorld.Core.csproj", "{0C8158F7-A44A-2243-729F-7287A7A00520}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Osm", "src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj", "{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.ServiceDefaults", "src\TheLivingWorld.ServiceDefaults\TheLivingWorld.ServiceDefaults.csproj", "{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Tests", "tests\TheLivingWorld.Tests\TheLivingWorld.Tests.csproj", "{9E0B47BE-A768-D9DB-AD74-01E7397587CC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Release|Any CPU.Build.0 = Release|Any CPU + {85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Release|Any CPU.Build.0 = Release|Any CPU + {0C8158F7-A44A-2243-729F-7287A7A00520}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C8158F7-A44A-2243-729F-7287A7A00520}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C8158F7-A44A-2243-729F-7287A7A00520}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C8158F7-A44A-2243-729F-7287A7A00520}.Release|Any CPU.Build.0 = Release|Any CPU + {AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Release|Any CPU.Build.0 = Release|Any CPU + {A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Release|Any CPU.Build.0 = Release|Any CPU + {9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {85E45975-AB22-8AF2-BC2D-7A352F60EFDD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0C8158F7-A44A-2243-729F-7287A7A00520} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {AC728E4B-71A3-F367-5920-D6B8E8BD0DF5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9E0B47BE-A768-D9DB-AD74-01E7397587CC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2485AB6C-35FD-4D36-B8DD-1167BDC206BD} + EndGlobalSection +EndGlobal