Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
+10
@@ -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/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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/<hash>.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.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/TheLivingWorld.Api/TheLivingWorld.Api.csproj" />
|
||||
<Project Path="src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj" />
|
||||
<Project Path="src/TheLivingWorld.Core/TheLivingWorld.Core.csproj" />
|
||||
<Project Path="src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj" />
|
||||
<Project Path="src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -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
|
||||
@@ -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<IResult> 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<IResult> 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<string, string[]>
|
||||
{
|
||||
["request"] = [ex.Message],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class WorldGenerationService(
|
||||
OsmWorldGenerator generator,
|
||||
WorldStore store,
|
||||
ChunkExporter exporter,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<WorldGenerationService> logger) : IDisposable
|
||||
{
|
||||
public const double MinSizeKm = 0.5;
|
||||
public const double MaxSizeKm = 50.0;
|
||||
|
||||
/// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
|
||||
|
||||
public async Task<WorldSummaryDto> 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;
|
||||
}
|
||||
|
||||
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
|
||||
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<ExportedChunk> 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)],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Builds a readable, filesystem-safe id: a slug of the name plus enough entropy to stay unique.</summary>
|
||||
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<string> callback) : IProgress<string>
|
||||
{
|
||||
public void Report(string value) => callback(value);
|
||||
}
|
||||
}
|
||||
@@ -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<WorldStorageOptions>(
|
||||
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<WorldStorageOptions>(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<OsmOptions>(options =>
|
||||
options.CacheDirectory = RootPath(options.CacheDirectory));
|
||||
|
||||
builder.Services.AddSingleton<WorldStore>();
|
||||
builder.Services.AddSingleton<ChunkExporter>();
|
||||
builder.Services.AddSingleton<WorldGenerationService>();
|
||||
|
||||
// Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude.
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.Providers.Add<BrotliCompressionProvider>();
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(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));
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TheLivingWorld.Api.Storage;
|
||||
|
||||
public sealed class WorldStorageOptions
|
||||
{
|
||||
public const string SectionName = "WorldStorage";
|
||||
|
||||
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
|
||||
public string RootDirectory { get; set; } = "data/worlds";
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Export;
|
||||
|
||||
namespace TheLivingWorld.Api.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<WorldStore> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that
|
||||
/// reaches the filesystem goes through here.
|
||||
/// </summary>
|
||||
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<IReadOnlyList<WorldSummaryDto>> ListAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Directory.Exists(_root)) return [];
|
||||
|
||||
var summaries = new List<WorldSummaryDto>();
|
||||
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<WorldSummaryDto?> 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<WorldSummaryDto>(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<WorldDto?> 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<WorldDto>(stream, MapJson.Options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Opens a chunk file for streaming straight to the response. Null when the chunk is empty.</summary>
|
||||
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<ExportedChunk> 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<T>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||
<ProjectReference Include="..\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
|
||||
<ProjectReference Include="..\TheLivingWorld.ServiceDefaults\TheLivingWorld.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var api = builder.AddProject<Projects.TheLivingWorld_Api>("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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TheLivingWorld.Api\TheLivingWorld.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>2ffb5eff-6e16-47c4-a3f8-37b68224c539</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"appHost": {
|
||||
"path": "TheLivingWorld.AppHost.csproj"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
|
||||
namespace TheLivingWorld.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// The wire format the client renders. Geometry travels as flat <c>[x0, y0, x1, y1, ...]</c> arrays of world
|
||||
/// metres — that is exactly what PixiJS <c>Graphics.poly()</c> takes, so the client never has to reshape it.
|
||||
/// X grows east, Y grows north; the client flips Y once at the container level.
|
||||
/// </summary>
|
||||
public sealed record ChunkDto
|
||||
{
|
||||
public required int X { get; init; }
|
||||
|
||||
public required int Y { get; init; }
|
||||
|
||||
/// <summary>[minX, minY, maxX, maxY] widened to cover geometry overhanging the chunk's own square.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Carriageway width in metres; the client strokes the centreline at this width.</summary>
|
||||
public required float Width { get; init; }
|
||||
|
||||
public required float[] Path { get; init; }
|
||||
|
||||
/// <summary>Bit field matching <see cref="RoadFlags"/>.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Water is either an area (a lake) or a line (a stream), never both.</summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TheLivingWorld.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class MapJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>Applies the same configuration to an options instance owned by someone else, such as ASP.NET Core's.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
namespace TheLivingWorld.Core.Contracts;
|
||||
|
||||
public enum WorldStatus
|
||||
{
|
||||
Pending,
|
||||
Generating,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>Request body for <c>POST /api/worlds</c>.</summary>
|
||||
public sealed record CreateWorldRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
|
||||
public required double Latitude { get; init; }
|
||||
|
||||
public required double Longitude { get; init; }
|
||||
|
||||
/// <summary>Side length of the square to generate, in kilometres.</summary>
|
||||
public required double SizeKm { get; init; }
|
||||
|
||||
/// <summary>Re-download from Overpass even if a cached response for this box exists.</summary>
|
||||
public bool ForceRefresh { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A world as it appears in listings and while generation is still running.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Human-readable progress line while <see cref="Status"/> is <see cref="WorldStatus.Generating"/>.</summary>
|
||||
public string? Stage { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public WorldStatsDto? Stats { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Everything the client needs to set up its camera and decide which chunks to fetch.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Only chunks that actually hold features; empty ones are omitted.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>[minX, minY, maxX, maxY], widened to cover overhanging geometry.</summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using TheLivingWorld.Core.Geo;
|
||||
|
||||
namespace TheLivingWorld.Core.Ecs;
|
||||
|
||||
/// <summary>Where this entity came from in OpenStreetMap. Kept so features stay traceable back to the source data.</summary>
|
||||
public record struct OsmSource(long Id, OsmElementKind Kind);
|
||||
|
||||
/// <summary>A closed ring: the entity's area footprint. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
|
||||
public record struct Outline(int ShapeId);
|
||||
|
||||
/// <summary>Inner rings cut out of <see cref="Outline"/>. <c>ShapeIds</c> is null for the common no-hole case.</summary>
|
||||
public record struct Holes(int[]? ShapeIds);
|
||||
|
||||
/// <summary>An open polyline: the entity's centreline. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
|
||||
public record struct Polyline(int ShapeId);
|
||||
|
||||
/// <summary>Cached world-space extent, filled in by <see cref="Systems.ComputeBoundsSystem"/>.</summary>
|
||||
public record struct Bounds(RectBounds Value);
|
||||
|
||||
/// <summary>Spatial bucket, filled in by <see cref="Systems.AssignChunksSystem"/>.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>The <c>name</c> tag, or null. Always present so every feature archetype stays uniform.</summary>
|
||||
public record struct DisplayName(string? Value);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Turns the ECS world into the per-chunk payloads the client fetches. Runs once per generation, right after
|
||||
/// <see cref="Systems.AssignChunksSystem"/> has bucketed every feature.
|
||||
/// </summary>
|
||||
public sealed class ChunkExporter
|
||||
{
|
||||
private static readonly QueryDescription Buildings =
|
||||
new QueryDescription().WithAll<OsmSource, Building, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||
|
||||
private static readonly QueryDescription Roads =
|
||||
new QueryDescription().WithAll<OsmSource, Road, Polyline, Bounds, InChunk, DisplayName>();
|
||||
|
||||
private static readonly QueryDescription Areas =
|
||||
new QueryDescription().WithAll<OsmSource, AreaFeature, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||
|
||||
private static readonly QueryDescription WaterAreas =
|
||||
new QueryDescription().WithAll<OsmSource, Water, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||
|
||||
private static readonly QueryDescription WaterLines =
|
||||
new QueryDescription().WithAll<OsmSource, Water, Polyline, Bounds, InChunk, DisplayName>();
|
||||
|
||||
public IReadOnlyList<ExportedChunk> Export(GameWorld world)
|
||||
{
|
||||
var shapes = world.Shapes;
|
||||
var grid = world.Grid;
|
||||
var buckets = new Dictionary<ChunkCoord, Bucket>();
|
||||
|
||||
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<ExportedChunk>(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;
|
||||
}
|
||||
|
||||
/// <summary>Packs points into <c>[x0, y0, x1, y1, ...]</c>, rounded to the centimetre to keep JSON small.</summary>
|
||||
private static float[] Flatten(ReadOnlySpan<Vector2> 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<BuildingDto> Buildings { get; } = [];
|
||||
|
||||
public List<RoadDto> Roads { get; } = [];
|
||||
|
||||
public List<AreaDto> Areas { get; } = [];
|
||||
|
||||
public List<WaterDto> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
/// <summary>An axis-aligned WGS84 bounding box, in degrees.</summary>
|
||||
public readonly record struct GeoBounds(double South, double West, double North, double East)
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the square that encloses <paramref name="sizeMeters"/> of ground on a side, centred on
|
||||
/// <paramref name="center"/>. The longitude span is widened by 1/cos(lat) so the box stays square in metres.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Overpass expects bounding boxes as <c>(south,west,north,east)</c>.</summary>
|
||||
public string ToOverpassBbox() => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{South:F7},{West:F7},{North:F7},{East:F7}");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
/// <summary>A WGS84 coordinate. Latitude and longitude are in degrees.</summary>
|
||||
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}";
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class GeometryClipper
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Vector2[]? ClipPolygon(ReadOnlySpan<Vector2> ring, in RectBounds rect)
|
||||
{
|
||||
if (ring.Length < 3) return null;
|
||||
|
||||
var current = new List<Vector2>(ring.Length + 8);
|
||||
current.AddRange(ring);
|
||||
|
||||
var next = new List<Vector2>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static List<Vector2[]> ClipPolyline(ReadOnlySpan<Vector2> line, in RectBounds rect)
|
||||
{
|
||||
var runs = new List<Vector2[]>();
|
||||
if (line.Length < 2) return runs;
|
||||
|
||||
var run = new List<Vector2>();
|
||||
|
||||
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<Vector2> run, List<Vector2[]> 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<float> p = [-dx, dx, -dy, dy];
|
||||
Span<float> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
public static class Polygons
|
||||
{
|
||||
/// <summary>Twice the signed area of a ring: positive when the ring winds counter-clockwise.</summary>
|
||||
public static float SignedDoubleArea(ReadOnlySpan<Vector2> 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<Vector2> ring) => MathF.Abs(SignedDoubleArea(ring)) * 0.5f;
|
||||
|
||||
/// <summary>Standard even-odd ray cast. Points exactly on the boundary may land either way.</summary>
|
||||
public static bool Contains(ReadOnlySpan<Vector2> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Core.Geo;
|
||||
|
||||
/// <summary>An axis-aligned bounding box in world metres.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>An inverted box that swallows the first point it encapsulates.</summary>
|
||||
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<Vector2> points)
|
||||
{
|
||||
var bounds = Empty;
|
||||
foreach (var point in points)
|
||||
bounds.Encapsulate(point);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
public readonly float[] ToArray() => [MinX, MinY, MaxX, MaxY];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Arch.Core;
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
using TheLivingWorld.Core.Worlds;
|
||||
|
||||
namespace TheLivingWorld.Core.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class AssignChunksSystem : IWorldSystem
|
||||
{
|
||||
private static readonly QueryDescription Placeable = new QueryDescription().WithAll<Bounds, InChunk>();
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Arch.Core;
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
using TheLivingWorld.Core.Worlds;
|
||||
|
||||
namespace TheLivingWorld.Core.Systems;
|
||||
|
||||
/// <summary>Fills every feature's cached world-space extent from the geometry it points at.</summary>
|
||||
public sealed class ComputeBoundsSystem : IWorldSystem
|
||||
{
|
||||
private static readonly QueryDescription AreaFeatures = new QueryDescription().WithAll<Outline, Bounds>();
|
||||
private static readonly QueryDescription LineFeatures = new QueryDescription().WithAll<Polyline, Bounds>();
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using TheLivingWorld.Core.Worlds;
|
||||
|
||||
namespace TheLivingWorld.Core.Systems;
|
||||
|
||||
/// <summary>A pass over the ECS world. Systems are ordered and run once per generation for now.</summary>
|
||||
public interface IWorldSystem
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void Execute(GameWorld world);
|
||||
}
|
||||
|
||||
/// <summary>Runs a fixed sequence of systems over a world.</summary>
|
||||
public sealed class WorldPipeline(params IWorldSystem[] systems)
|
||||
{
|
||||
public IReadOnlyList<IWorldSystem> Systems { get; } = systems;
|
||||
|
||||
public void Run(GameWorld world)
|
||||
{
|
||||
foreach (var system in Systems)
|
||||
system.Execute(world);
|
||||
}
|
||||
|
||||
/// <summary>The passes every freshly built world needs before it can be sliced into chunks.</summary>
|
||||
public static WorldPipeline CreateDefault() => new(
|
||||
new ComputeBoundsSystem(),
|
||||
new AssignChunksSystem());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Arch" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Numerics;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
|
||||
namespace TheLivingWorld.Core.Worlds;
|
||||
|
||||
public readonly record struct ChunkCoord(int X, int Y);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Locates the chunk containing <paramref name="point"/>, clamped to the grid.</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Arch.Core;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
|
||||
namespace TheLivingWorld.Core.Worlds;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Core.Worlds;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ShapeStore
|
||||
{
|
||||
private readonly List<Vector2[]> _shapes = [];
|
||||
|
||||
public int Count => _shapes.Count;
|
||||
|
||||
/// <summary>Takes ownership of <paramref name="points"/> and returns its handle.</summary>
|
||||
public int Add(Vector2[] points)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(points);
|
||||
_shapes.Add(points);
|
||||
return _shapes.Count - 1;
|
||||
}
|
||||
|
||||
public ReadOnlySpan<Vector2> Get(int shapeId) => _shapes[shapeId];
|
||||
|
||||
public int VertexCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var shape in _shapes)
|
||||
total += shape.Length;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using TheLivingWorld.Core.Geo;
|
||||
|
||||
namespace TheLivingWorld.Core.Worlds;
|
||||
|
||||
/// <summary>Everything needed to describe a generated world without loading its geometry.</summary>
|
||||
public sealed record WorldMetadata
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>The point the player asked for. It is the projection origin, so it sits at world (0, 0).</summary>
|
||||
public required GeoPoint Origin { get; init; }
|
||||
|
||||
/// <summary>Side length of the generated square, in metres.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ShapeStore"/> and is referenced by handle.
|
||||
/// </summary>
|
||||
public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||
{
|
||||
/// <summary>Rings smaller than this are noise at any zoom the client offers.</summary>
|
||||
private const float MinimumAreaSquareMeters = 1.0f;
|
||||
|
||||
public WorldStats Populate(GameWorld world, IEnumerable<OverpassElement> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<Vector2[]>? MatchHoles(Vector2[] outer, List<Vector2[]> innerRings, int outerCount)
|
||||
{
|
||||
if (innerRings.Count == 0) return null;
|
||||
if (outerCount == 1) return innerRings;
|
||||
|
||||
List<Vector2[]>? matched = null;
|
||||
foreach (var inner in innerRings)
|
||||
{
|
||||
if (!Polygons.Contains(outer, inner[0])) continue;
|
||||
matched ??= [];
|
||||
matched.Add(inner);
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
private static List<Vector2[]> AssembleRings(LocalProjection projection, List<OverpassMember> members, string role)
|
||||
{
|
||||
var segments = new List<IReadOnlyList<Vector2>>();
|
||||
|
||||
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<Vector2[]>? 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<Vector2[]>? 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<Vector2[]>? 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<Vector2[]>? holes, in RectBounds clip)
|
||||
{
|
||||
if (holes is null || holes.Count == 0) return null;
|
||||
|
||||
List<int>? 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<OverpassNode> 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OsmWorldGenerator(
|
||||
OverpassClient client,
|
||||
OsmWorldBuilder builder,
|
||||
ILogger<OsmWorldGenerator> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions ParseOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly WorldPipeline _pipeline = WorldPipeline.CreateDefault();
|
||||
|
||||
public async Task<GameWorld> GenerateAsync(
|
||||
WorldGenerationRequest request,
|
||||
IProgress<string>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<OverpassResponse> ReadResponseAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var response = await JsonSerializer
|
||||
.DeserializeAsync<OverpassResponse>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Numerics;
|
||||
|
||||
namespace TheLivingWorld.Osm.Import;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class RingAssembler
|
||||
{
|
||||
/// <summary>Endpoints come from identical OSM nodes, so they match to well under a centimetre.</summary>
|
||||
private const float ToleranceMeters = 0.05f;
|
||||
|
||||
private const float ToleranceSquared = ToleranceMeters * ToleranceMeters;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles <paramref name="segments"/> into closed rings. Fragments that never close are dropped -
|
||||
/// broken multipolygons exist in the wild and a half-open ring cannot be filled.
|
||||
/// </summary>
|
||||
public static List<Vector2[]> Assemble(IEnumerable<IReadOnlyList<Vector2>> segments)
|
||||
{
|
||||
var pending = new List<List<Vector2>>();
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.Count >= 2) pending.Add([.. segment]);
|
||||
}
|
||||
|
||||
var rings = new List<Vector2[]>();
|
||||
|
||||
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<Vector2> ring) =>
|
||||
ring.Count >= 4 && Vector2.DistanceSquared(ring[0], ring[^1]) <= ToleranceSquared;
|
||||
|
||||
/// <summary>Removes and returns the fragment touching <paramref name="openEnd"/>, reversed if needed.</summary>
|
||||
private static bool TryTakeConnecting(List<List<Vector2>> pending, Vector2 openEnd, out List<Vector2> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Globalization;
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
using TheLivingWorld.Osm.Overpass;
|
||||
|
||||
namespace TheLivingWorld.Osm.Import;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Unknown</c> rather than being dropped, so a feature
|
||||
/// still shows up on the map even if the game does not yet understand it.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Recognises water bodies and watercourses. Returns null when the element is not water.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Recognises land cover polygons. Returns null when the element is not one.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the numeric prefix of a tag value. OpenStreetMap allows units and stray text, so
|
||||
/// <c>"12 m"</c> and <c>"3.5"</c> both need to work while <c>"tall"</c> must not.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace TheLivingWorld.Osm;
|
||||
|
||||
public sealed class OsmOptions
|
||||
{
|
||||
public const string SectionName = "Osm";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string[] Endpoints { get; set; } =
|
||||
[
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
"https://overpass.private.coffee/api/interpreter",
|
||||
];
|
||||
|
||||
/// <summary>Server-side query budget, passed to Overpass as <c>[timeout:...]</c>.</summary>
|
||||
public int QueryTimeoutSeconds { get; set; } = 180;
|
||||
|
||||
/// <summary>Client-side ceiling; must exceed <see cref="QueryTimeoutSeconds"/> to let the server reply first.</summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 240;
|
||||
|
||||
public int MaxAttemptsPerEndpoint { get; set; } = 2;
|
||||
|
||||
/// <summary>Overpass asks that clients identify themselves so abusive traffic can be traced.</summary>
|
||||
public string UserAgent { get; set; } = "TheLivingWorld/0.1 (map generator; contact: repository owner)";
|
||||
|
||||
/// <summary>Where raw Overpass responses are cached, relative to the content root unless rooted.</summary>
|
||||
public string CacheDirectory { get; set; } = "data/osm-cache";
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddOsmImport(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<OsmOptions>()
|
||||
.Bind(configuration.GetSection(OsmOptions.SectionName));
|
||||
|
||||
services.AddSingleton<OsmWorldBuilder>();
|
||||
services.AddSingleton<OsmWorldGenerator>();
|
||||
|
||||
return services.AddHttpClient<OverpassClient>(OverpassClient.HttpClientName, static (provider, client) =>
|
||||
{
|
||||
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OsmOptions>>().Value;
|
||||
client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OverpassClient(
|
||||
HttpClient http,
|
||||
IOptions<OsmOptions> options,
|
||||
ILogger<OverpassClient> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the path of the file holding the raw Overpass JSON for <paramref name="bounds"/>, downloading
|
||||
/// it first if it is not already cached.
|
||||
/// </summary>
|
||||
public async Task<string> FetchAsync(
|
||||
GeoBounds bounds,
|
||||
bool forceRefresh,
|
||||
IProgress<string>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Overpass error pages are verbose HTML; only the first few hundred characters are useful.</summary>
|
||||
private static async Task<string> 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)";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The cache key is the query itself, so any change to the box or the query invalidates it.</summary>
|
||||
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);
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace TheLivingWorld.Osm.Overpass;
|
||||
|
||||
/// <summary>Shape of an Overpass <c>[out:json]</c> response requested with <c>out geom;</c>.</summary>
|
||||
public sealed class OverpassResponse
|
||||
{
|
||||
[JsonPropertyName("elements")]
|
||||
public List<OverpassElement> Elements { get; set; } = [];
|
||||
|
||||
/// <summary>Set when Overpass aborts mid-query, typically on a timeout or memory limit.</summary>
|
||||
[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<string, string>? Tags { get; set; }
|
||||
|
||||
/// <summary>Present on ways when the query used <c>out geom</c>.</summary>
|
||||
[JsonPropertyName("geometry")]
|
||||
public List<OverpassNode>? Geometry { get; set; }
|
||||
|
||||
/// <summary>Present on relations; each member carries its own geometry under <c>out geom</c>.</summary>
|
||||
[JsonPropertyName("members")]
|
||||
public List<OverpassMember>? 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<OverpassNode>? Geometry { get; set; }
|
||||
}
|
||||
|
||||
public readonly struct OverpassNode
|
||||
{
|
||||
[JsonPropertyName("lat")]
|
||||
public double Lat { get; init; }
|
||||
|
||||
[JsonPropertyName("lon")]
|
||||
public double Lon { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Globalization;
|
||||
using TheLivingWorld.Core.Geo;
|
||||
|
||||
namespace TheLivingWorld.Osm.Overpass;
|
||||
|
||||
public static class OverpassQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Bumped whenever <see cref="Build"/> changes shape. It is mixed into the cache key so an edited query
|
||||
/// never silently reuses a response fetched under the old one.
|
||||
/// </summary>
|
||||
public const int Version = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the map renderer draws: building footprints, the road and rail network, water, and the
|
||||
/// land-cover polygons underneath them. <c>out geom</c> inlines coordinates into each way and relation
|
||||
/// member, so no second pass is needed to resolve node references.
|
||||
/// </summary>
|
||||
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;
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="TheLivingWorld.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<TBuilder>(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<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(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<TBuilder>(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<TBuilder>(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>The Living World</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
|
||||
<aside id="panel" class="panel">
|
||||
<header class="panel__header">
|
||||
<h1>The Living World</h1>
|
||||
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
|
||||
</header>
|
||||
|
||||
<form id="generate-form" class="form">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Latitude</span>
|
||||
<input id="field-lat" type="number" step="any" value="31.8966010" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Longitude</span>
|
||||
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Size <output id="field-size-value">10 km</output></span>
|
||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||
</label>
|
||||
|
||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||
</form>
|
||||
|
||||
<section class="worlds">
|
||||
<h2>Worlds</h2>
|
||||
<ul id="world-list" class="world-list"></ul>
|
||||
</section>
|
||||
|
||||
<footer id="status" class="status"></footer>
|
||||
</aside>
|
||||
|
||||
<div id="hud" class="hud"></div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+917
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
|
||||
|
||||
const BASE = '/api/worlds';
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<string> {
|
||||
try {
|
||||
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
|
||||
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<WorldSummary[]>(BASE),
|
||||
|
||||
createWorld: (body: CreateWorldRequest) =>
|
||||
request<WorldSummary>(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
getWorld: (id: string) => request<WorldSummary>(`${BASE}/${id}`),
|
||||
|
||||
getMap: (id: string) => request<WorldMap>(`${BASE}/${id}/map`),
|
||||
|
||||
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
|
||||
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
|
||||
|
||||
deleteWorld: async (id: string): Promise<void> => {
|
||||
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<WorldSummary> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<HTMLDivElement>('stage'),
|
||||
form: required<HTMLFormElement>('generate-form'),
|
||||
name: required<HTMLInputElement>('field-name'),
|
||||
latitude: required<HTMLInputElement>('field-lat'),
|
||||
longitude: required<HTMLInputElement>('field-lon'),
|
||||
size: required<HTMLInputElement>('field-size'),
|
||||
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||
generate: required<HTMLButtonElement>('generate-button'),
|
||||
worldList: required<HTMLUListElement>('world-list'),
|
||||
status: required<HTMLElement>('status'),
|
||||
hud: required<HTMLDivElement>('hud'),
|
||||
};
|
||||
|
||||
const view = new MapView();
|
||||
let activeWorldId: string | null = null;
|
||||
|
||||
function required<T extends HTMLElement>(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<WorldSummary[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, ChunkState>();
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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<DetailLevel, number> = { 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<number, BuildingFeature[]>();
|
||||
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<T, K>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<number, { x: number; y: number }>();
|
||||
private pinchDistance = 0;
|
||||
|
||||
onStatusChange: ((status: MapStatus) => void) | null = null;
|
||||
|
||||
async init(host: HTMLElement): Promise<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<AreaKind, number> = {
|
||||
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<WaterKind, number> = {
|
||||
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<RoadClass, number> = {
|
||||
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<RoadClass, number> = {
|
||||
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<RoadClass, number> = {
|
||||
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<RoadClass, 0 | 1 | 2> = {
|
||||
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<Record<BuildingKind, number>> = {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
/**
|
||||
* Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
|
||||
* `services__api__<scheme>__<index>`. 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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<ArgumentOutOfRangeException>(() => 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);
|
||||
}
|
||||
}
|
||||
@@ -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<IReadOnlyList<Vector2>> 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<IReadOnlyList<Vector2>> 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<IReadOnlyList<Vector2>> 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<IReadOnlyList<Vector2>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the whole import path - Overpass elements in, chunk payloads out - against hand-built geometry, so
|
||||
/// the tests do not need a network call to exercise it.
|
||||
/// </summary>
|
||||
public class ImportPipelineTests
|
||||
{
|
||||
private const double WorldSizeMeters = 2048;
|
||||
private static readonly GeoPoint Origin = new(31.8966010, -100.4858591);
|
||||
|
||||
[Fact]
|
||||
public void Imports_a_building_way_as_an_entity_with_a_footprint()
|
||||
{
|
||||
using var world = Build(WayWithGeometry(
|
||||
id: 42,
|
||||
tags: new() { ["building"] = "house", ["name"] = "Old Post Office" },
|
||||
metres: Square(100, 100, 20, closed: true)));
|
||||
|
||||
var chunk = Assert.Single(Export(world)).Chunk;
|
||||
var building = Assert.Single(chunk.Buildings);
|
||||
|
||||
Assert.Equal(42, building.Id);
|
||||
Assert.Equal(BuildingKind.House, building.Kind);
|
||||
Assert.Equal("Old Post Office", building.Name);
|
||||
|
||||
// Four corners, flattened to [x, y] pairs, with the repeated closing vertex dropped.
|
||||
Assert.Equal(8, building.Outline.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Places_features_in_the_chunk_that_contains_them()
|
||||
{
|
||||
using var world = Build(
|
||||
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(-900, -900, 20, closed: true)),
|
||||
WayWithGeometry(2, new() { ["building"] = "yes" }, Square(900, 900, 20, closed: true)));
|
||||
|
||||
var chunks = Export(world);
|
||||
|
||||
Assert.Equal(2, chunks.Count);
|
||||
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(0, 0));
|
||||
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Imports_a_highway_as_a_road_with_a_width()
|
||||
{
|
||||
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<string, string> { ["type"] = "multipolygon", ["landuse"] = "forest" },
|
||||
Members =
|
||||
[
|
||||
new OverpassMember { Type = "way", Ref = 1, Role = "outer", Geometry = outerWest },
|
||||
new OverpassMember { Type = "way", Ref = 2, Role = "outer", Geometry = outerEast },
|
||||
new OverpassMember { Type = "way", Ref = 3, Role = "inner", Geometry = clearing },
|
||||
],
|
||||
};
|
||||
|
||||
using var world = Build(relation);
|
||||
|
||||
var area = Assert.Single(Export(world)[0].Chunk.Areas);
|
||||
|
||||
Assert.Equal(AreaKind.Forest, area.Kind);
|
||||
Assert.Equal(4, area.Outline.Length / 2);
|
||||
Assert.NotNull(area.Holes);
|
||||
Assert.Single(area.Holes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splits_water_into_bodies_and_watercourses()
|
||||
{
|
||||
using var world = Build(
|
||||
WayWithGeometry(10, new() { ["natural"] = "water", ["water"] = "pond" }, Square(0, 0, 60, closed: true)),
|
||||
WayWithGeometry(11, new() { ["waterway"] = "stream" }, [new Vector2(-400, -400), new Vector2(-300, -350)]));
|
||||
|
||||
var water = Export(world).SelectMany(chunk => chunk.Chunk.Water).ToList();
|
||||
|
||||
var pond = Assert.Single(water, feature => feature.Kind == WaterKind.Pond);
|
||||
Assert.NotNull(pond.Outline);
|
||||
Assert.Null(pond.Path);
|
||||
|
||||
var stream = Assert.Single(water, feature => feature.Kind == WaterKind.Stream);
|
||||
Assert.NotNull(stream.Path);
|
||||
Assert.Null(stream.Outline);
|
||||
Assert.Equal(3f, stream.Width);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Counts_what_it_imported()
|
||||
{
|
||||
var stats = BuildWithStats(
|
||||
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(0, 0, 20, closed: true)),
|
||||
WayWithGeometry(2, new() { ["highway"] = "service" }, [new Vector2(0, 0), new Vector2(50, 0)]),
|
||||
WayWithGeometry(3, new() { ["landuse"] = "meadow" }, Square(200, 200, 100, closed: true)));
|
||||
|
||||
Assert.Equal(1, stats.Buildings);
|
||||
Assert.Equal(1, stats.Roads);
|
||||
Assert.Equal(1, stats.Areas);
|
||||
Assert.Equal(3, stats.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chunk_bounds_stretch_to_cover_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<OsmWorldBuilder>.Instance).Populate(world, elements);
|
||||
WorldPipeline.CreateDefault().Run(world);
|
||||
return world;
|
||||
}
|
||||
|
||||
private static WorldStats BuildWithStats(params OverpassElement[] elements)
|
||||
{
|
||||
using var world = NewWorld();
|
||||
return new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance).Populate(world, elements);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ExportedChunk> Export(GameWorld world) => new ChunkExporter().Export(world);
|
||||
|
||||
private static GameWorld NewWorld() => new(new WorldMetadata
|
||||
{
|
||||
Id = "test",
|
||||
Name = "Test",
|
||||
Origin = Origin,
|
||||
SizeMeters = WorldSizeMeters,
|
||||
Bounds = GeoBounds.FromCenter(Origin, WorldSizeMeters),
|
||||
ChunkSizeMeters = 512,
|
||||
ChunkCountX = 4,
|
||||
ChunkCountY = 4,
|
||||
GeneratedAt = DateTimeOffset.UnixEpoch,
|
||||
});
|
||||
|
||||
private static OverpassElement WayWithGeometry(long id, Dictionary<string, string> tags, Vector2[] metres) => new()
|
||||
{
|
||||
Type = "way",
|
||||
Id = id,
|
||||
Tags = tags,
|
||||
Geometry = Metres(metres),
|
||||
};
|
||||
|
||||
/// <summary>Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send.</summary>
|
||||
private static List<OverpassNode> Metres(Vector2[] points)
|
||||
{
|
||||
var projection = new LocalProjection(Origin);
|
||||
return [.. points.Select(point =>
|
||||
{
|
||||
var geo = projection.Unproject(point);
|
||||
return new OverpassNode { Lat = geo.Latitude, Lon = geo.Longitude };
|
||||
})];
|
||||
}
|
||||
|
||||
private static Vector2[] Square(float centerX, float centerY, float size, bool closed)
|
||||
{
|
||||
var half = size / 2;
|
||||
Vector2[] corners =
|
||||
[
|
||||
new(centerX - half, centerY - half),
|
||||
new(centerX + half, centerY - half),
|
||||
new(centerX + half, centerY + half),
|
||||
new(centerX - half, centerY + half),
|
||||
];
|
||||
|
||||
return closed ? [.. corners, corners[0]] : corners;
|
||||
}
|
||||
}
|
||||
@@ -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("<html><body>too busy</body></html>", System.Text.Encoding.UTF8, "text/html"),
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var failure = await Assert.ThrowsAsync<OverpassException>(() => 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("<p>gateway is down</p>"),
|
||||
});
|
||||
|
||||
var client = CreateClient(handler);
|
||||
|
||||
var failure = await Assert.ThrowsAsync<OverpassException>(() => 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<OverpassClient>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_cacheDirectory)) Directory.Delete(_cacheDirectory, recursive: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public int Requests { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests++;
|
||||
return Task.FromResult(respond(request, cancellationToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
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 _));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user