Update README with project details and setup instructions; add data directories to .gitignore

This commit is contained in:
Leonid Pershin
2026-08-16 17:09:16 +03:00
parent 9d09d6e97f
commit 8460921bfa
73 changed files with 6978 additions and 1 deletions
+10
View File
@@ -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/
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
+134 -1
View File
@@ -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.
+12
View File
@@ -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>
+30
View File
@@ -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);
}
}
+69
View File
@@ -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"
}
}
}
+23
View File
@@ -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"
}
}
+13
View File
@@ -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; }
}
+32
View File
@@ -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);
}
}
}
+37
View File
@@ -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}");
}
+11
View File
@@ -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);
}
+45
View File
@@ -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;
}
}
+70
View File
@@ -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);
}
}
+31
View File
@@ -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>
+55
View File
@@ -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>
+917
View File
@@ -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
}
}
}
}
}
+20
View File
@@ -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"
}
}
+65
View File
@@ -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));
}
}
+132
View File
@@ -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[];
}
+204
View File
@@ -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();
+97
View File
@@ -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);
}
}
+243
View File
@@ -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 };
}
}
+169
View File
@@ -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;
}
+276
View File
@@ -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;
}
}
+20
View File
@@ -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"]
}
+40
View File
@@ -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,
},
};
});
+134
View File
@@ -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);
}
}
+152
View File
@@ -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>
+66
View File
@@ -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