Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
+10
@@ -414,3 +414,13 @@ FodyWeavers.xsd
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
|
||||||
|
# ---> The Living World
|
||||||
|
# Generated worlds and cached Overpass responses; both are reproducible from coordinates.
|
||||||
|
data/
|
||||||
|
src/TheLivingWorld.Api/data/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,2 +1,135 @@
|
|||||||
# the-living-world
|
# The Living World
|
||||||
|
|
||||||
|
A web game whose map is a real place. The backend pulls a square of OpenStreetMap data once, turns it into an
|
||||||
|
ECS world, and serves it to a PixiJS client that renders it as a vector map.
|
||||||
|
|
||||||
|
This is the first iteration: world generation and rendering only, no gameplay yet.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Piece | Choice |
|
||||||
|
| --- | --- |
|
||||||
|
| Backend | .NET 10, ASP.NET Core minimal APIs |
|
||||||
|
| World model | [Arch](https://github.com/genaray/Arch) ECS |
|
||||||
|
| Map data | OpenStreetMap via the Overpass API |
|
||||||
|
| Frontend | PixiJS 8 + TypeScript + Vite |
|
||||||
|
| Orchestration | .NET Aspire 13 |
|
||||||
|
| Storage | Plain files under `data/` |
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
On Windows, double-click `run.cmd` or run it from a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
Anywhere else, or if you prefer the CLI directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/TheLivingWorld.AppHost
|
||||||
|
```
|
||||||
|
|
||||||
|
Aspire starts the API, runs `npm install` for the client, launches the Vite dev server, and prints a dashboard
|
||||||
|
URL. Open the `web` endpoint from the dashboard, enter coordinates, and press **Generate world**.
|
||||||
|
|
||||||
|
The default coordinates are Robert Lee, Texas (`31.8966010, -100.4858591`) — a small town that generates in a
|
||||||
|
few seconds.
|
||||||
|
|
||||||
|
To run the two halves separately instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/TheLivingWorld.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --prefix src/TheLivingWorld.Web run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
## How a world is made
|
||||||
|
|
||||||
|
1. **Fetch.** `OverpassClient` posts one bounding-box query to Overpass and streams the response into
|
||||||
|
`data/osm-cache/<hash>.json`. The hash covers the query text, so the same box is never downloaded twice and
|
||||||
|
editing the query invalidates the cache. Public mirrors are tried in order, with retries.
|
||||||
|
2. **Project.** `LocalProjection` flattens WGS84 onto a metric plane centred on the requested point: X east,
|
||||||
|
Y north, both in metres. Over a 20 km square the error stays under a metre, and distances are directly
|
||||||
|
usable as game units — which Web Mercator would not give.
|
||||||
|
3. **Import.** `OsmWorldBuilder` reads each element's tags, decides what it is, and creates one ECS entity per
|
||||||
|
feature. Multipolygon relations are stitched into rings by `RingAssembler`; everything is clipped to the
|
||||||
|
world square by `GeometryClipper`, so a highway crossing town does not drag geometry 40 km off the map.
|
||||||
|
4. **Systems.** `ComputeBoundsSystem` fills each entity's extent, `AssignChunksSystem` buckets it into the
|
||||||
|
chunk grid.
|
||||||
|
5. **Export.** `ChunkExporter` walks the ECS world and writes one JSON file per chunk, plus an index.
|
||||||
|
|
||||||
|
### The ECS shape
|
||||||
|
|
||||||
|
Geometry does not live in components. `ShapeStore` holds the vertex arrays and components carry an integer
|
||||||
|
handle, which keeps component data blittable and archetype chunks dense.
|
||||||
|
|
||||||
|
| Component | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `OsmSource` | Which OSM element this came from |
|
||||||
|
| `Outline` / `Holes` | Closed ring and the rings cut out of it |
|
||||||
|
| `Polyline` | Open centreline, for roads and streams |
|
||||||
|
| `Bounds` / `InChunk` | Cached extent and spatial bucket, filled in by systems |
|
||||||
|
| `Building` | Kind, height, levels |
|
||||||
|
| `Road` | Class, width, lanes, bridge/tunnel/oneway flags |
|
||||||
|
| `AreaFeature` / `Water` | Land cover and water classification |
|
||||||
|
| `DisplayName` | The `name` tag |
|
||||||
|
|
||||||
|
There are no simulation systems yet — the pipeline is the two passes above. Gameplay systems slot in beside
|
||||||
|
them without reworking the data model.
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/worlds` | Start generating a world. Returns immediately with `status: "pending"` |
|
||||||
|
| `GET /api/worlds` | List worlds, with live status for anything still generating |
|
||||||
|
| `GET /api/worlds/{id}` | Status of one world |
|
||||||
|
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
|
||||||
|
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
|
||||||
|
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
|
||||||
|
|
||||||
|
Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client
|
||||||
|
polls for status. Only one generation runs at a time, to stay a good citizen on the shared Overpass mirrors.
|
||||||
|
|
||||||
|
Geometry travels as flat `[x0, y0, x1, y1, …]` arrays of world metres, which is exactly what PixiJS
|
||||||
|
`Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written
|
||||||
|
in wire format and streamed straight from disk.
|
||||||
|
|
||||||
|
## The client
|
||||||
|
|
||||||
|
`MapView` owns one scaled container holding five layers — land cover, water, road casings, road fills,
|
||||||
|
buildings — so a building in one chunk never ends up under a park from the next. `Camera` is the only place
|
||||||
|
the Y flip lives; everything else thinks in map coordinates.
|
||||||
|
|
||||||
|
`ChunkManager` fetches chunks as the camera reaches them and drops their graphics once they are well out of
|
||||||
|
view, keeping the parsed data cached so panning back is instant. Detail thins out as you zoom away: footpaths
|
||||||
|
disappear first, then small buildings, and stroke widths gain a floor so hairlines stay visible.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`src/TheLivingWorld.Api/appsettings.json`:
|
||||||
|
|
||||||
|
- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`)
|
||||||
|
- `Osm:Endpoints` — Overpass mirrors, tried in order
|
||||||
|
- `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`)
|
||||||
|
- `Osm:QueryTimeoutSeconds` / `Osm:RequestTimeoutSeconds` — server-side and client-side budgets
|
||||||
|
|
||||||
|
Relative paths resolve against the API's content root. Everything under `data/` is reproducible from
|
||||||
|
coordinates and is not committed.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The Overpass response is parsed in one pass rather than streamed. Fine for the small towns this targets; a
|
||||||
|
dense 20 km city would want a streaming reader.
|
||||||
|
- Chunk assignment buckets a feature by the centre of its extent and never splits geometry, so a long road
|
||||||
|
overhangs its chunk. The exported chunk bounds are widened to match and the client culls against those.
|
||||||
|
- Buildings are flat footprints shaded by height. No 3D, no roofs.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/TheLivingWorld.Api/TheLivingWorld.Api.csproj" />
|
||||||
|
<Project Path="src/TheLivingWorld.AppHost/TheLivingWorld.AppHost.csproj" />
|
||||||
|
<Project Path="src/TheLivingWorld.Core/TheLivingWorld.Core.csproj" />
|
||||||
|
<Project Path="src/TheLivingWorld.Osm/TheLivingWorld.Osm.csproj" />
|
||||||
|
<Project Path="src/TheLivingWorld.ServiceDefaults/TheLivingWorld.ServiceDefaults.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/TheLivingWorld.Tests/TheLivingWorld.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@echo off
|
||||||
|
rem Starts the whole stack through Aspire: the API, then npm install and the Vite dev server.
|
||||||
|
rem The dashboard URL (with its login token) is printed a few seconds after launch.
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
where dotnet >nul 2>&1 || (
|
||||||
|
echo The .NET SDK was not found on PATH. Install .NET 10 from https://dotnet.microsoft.com/download
|
||||||
|
goto :failed
|
||||||
|
)
|
||||||
|
|
||||||
|
where node >nul 2>&1 || (
|
||||||
|
echo Node.js was not found on PATH. The web client needs Node 20 or newer.
|
||||||
|
goto :failed
|
||||||
|
)
|
||||||
|
|
||||||
|
dotnet run --project src\TheLivingWorld.AppHost %*
|
||||||
|
if errorlevel 1 goto :failed
|
||||||
|
|
||||||
|
endlocal
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:failed
|
||||||
|
echo.
|
||||||
|
echo Startup failed. See the output above.
|
||||||
|
rem Keep the window open when this was launched from Explorer rather than a console.
|
||||||
|
if /i "%~1"=="" pause
|
||||||
|
endlocal
|
||||||
|
exit /b 1
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using TheLivingWorld.Api.Generation;
|
||||||
|
using TheLivingWorld.Api.Storage;
|
||||||
|
using TheLivingWorld.Core.Contracts;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class WorldEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var worlds = app.MapGroup("/api/worlds").WithTags("worlds");
|
||||||
|
|
||||||
|
worlds.MapGet("/", ListWorlds);
|
||||||
|
worlds.MapPost("/", CreateWorld);
|
||||||
|
worlds.MapGet("/{id}", GetWorld);
|
||||||
|
worlds.MapGet("/{id}/map", GetMap);
|
||||||
|
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
|
||||||
|
worlds.MapDelete("/{id}", DeleteWorld);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListWorlds(
|
||||||
|
WorldStore store,
|
||||||
|
WorldGenerationService generation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stored = await store.ListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// A world being generated right now has a fresher status in memory than on disk.
|
||||||
|
var merged = stored
|
||||||
|
.Select(summary => generation.GetInFlight(summary.Id) ?? summary)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateWorld(
|
||||||
|
CreateWorldRequest request,
|
||||||
|
WorldGenerationService generation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var summary = await generation.StartAsync(request, cancellationToken);
|
||||||
|
return Results.Created($"/api/worlds/{summary.Id}", summary);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["request"] = [ex.Message],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetWorld(
|
||||||
|
string id,
|
||||||
|
WorldStore store,
|
||||||
|
WorldGenerationService generation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||||
|
|
||||||
|
if (generation.GetInFlight(id) is { } live) return Results.Ok(live);
|
||||||
|
|
||||||
|
var summary = await store.GetSummaryAsync(id, cancellationToken);
|
||||||
|
return summary is null ? Results.NotFound() : Results.Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetMap(string id, WorldStore store, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||||
|
|
||||||
|
var world = await store.GetWorldAsync(id, cancellationToken);
|
||||||
|
return world is null ? Results.NotFound() : Results.Ok(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult GetChunk(string id, int x, int y, WorldStore store, HttpContext context)
|
||||||
|
{
|
||||||
|
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||||
|
|
||||||
|
var stream = store.OpenChunk(id, x, y);
|
||||||
|
if (stream is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// World ids are unique per generation, so a chunk's contents can never change under a client.
|
||||||
|
context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
|
return Results.Stream(stream, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult DeleteWorld(string id, WorldStore store)
|
||||||
|
{
|
||||||
|
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||||
|
return store.Delete(id) ? Results.NoContent() : Results.NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using TheLivingWorld.Api.Storage;
|
||||||
|
using TheLivingWorld.Core.Contracts;
|
||||||
|
using TheLivingWorld.Core.Export;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
using TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Api.Generation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns world generation. Generation takes tens of seconds - most of it waiting on Overpass - so the request
|
||||||
|
/// returns immediately with a pending world and the work continues in the background; the client polls the
|
||||||
|
/// world's status until it turns ready.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorldGenerationService(
|
||||||
|
OsmWorldGenerator generator,
|
||||||
|
WorldStore store,
|
||||||
|
ChunkExporter exporter,
|
||||||
|
IHostApplicationLifetime lifetime,
|
||||||
|
ILogger<WorldGenerationService> logger) : IDisposable
|
||||||
|
{
|
||||||
|
public const double MinSizeKm = 0.5;
|
||||||
|
public const double MaxSizeKm = 50.0;
|
||||||
|
|
||||||
|
/// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
|
||||||
|
|
||||||
|
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var origin = new GeoPoint(request.Latitude, request.Longitude);
|
||||||
|
if (!origin.IsValid)
|
||||||
|
throw new ArgumentException("Latitude must be within +/-90 and longitude within +/-180.");
|
||||||
|
|
||||||
|
if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm)
|
||||||
|
throw new ArgumentException($"Size must be between {MinSizeKm} and {MaxSizeKm} km.");
|
||||||
|
|
||||||
|
var name = string.IsNullOrWhiteSpace(request.Name)
|
||||||
|
? $"{origin.Latitude:F4}, {origin.Longitude:F4}"
|
||||||
|
: request.Name.Trim();
|
||||||
|
|
||||||
|
var id = CreateId(name);
|
||||||
|
var summary = new WorldSummaryDto
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Latitude = origin.Latitude,
|
||||||
|
Longitude = origin.Longitude,
|
||||||
|
SizeMeters = request.SizeKm * 1000.0,
|
||||||
|
Status = WorldStatus.Pending,
|
||||||
|
Stage = "Queued",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
_inFlight[id] = summary;
|
||||||
|
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Detached on purpose: the caller gets the pending world back straight away.
|
||||||
|
_ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
|
||||||
|
public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
|
||||||
|
|
||||||
|
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
|
||||||
|
{
|
||||||
|
// Generation should stop when the host does, not drag shutdown out for minutes.
|
||||||
|
var cancellationToken = lifetime.ApplicationStopping;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new CallbackProgress(stage => Publish(summary with
|
||||||
|
{
|
||||||
|
Status = WorldStatus.Generating,
|
||||||
|
Stage = stage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
Publish(summary with { Status = WorldStatus.Generating, Stage = "Starting" });
|
||||||
|
|
||||||
|
using var world = await generator.GenerateAsync(
|
||||||
|
new WorldGenerationRequest
|
||||||
|
{
|
||||||
|
Id = summary.Id,
|
||||||
|
Name = summary.Name,
|
||||||
|
Origin = new GeoPoint(summary.Latitude, summary.Longitude),
|
||||||
|
SizeMeters = summary.SizeMeters,
|
||||||
|
ForceRefresh = forceRefresh,
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress.Report("Slicing into chunks");
|
||||||
|
var chunks = exporter.Export(world);
|
||||||
|
|
||||||
|
progress.Report($"Writing {chunks.Count:N0} chunks");
|
||||||
|
await store.SaveWorldAsync(ToDto(world, chunks), chunks, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var stats = world.Metadata.Stats;
|
||||||
|
var ready = summary with
|
||||||
|
{
|
||||||
|
Status = WorldStatus.Ready,
|
||||||
|
Stage = null,
|
||||||
|
Stats = new WorldStatsDto
|
||||||
|
{
|
||||||
|
Buildings = stats.Buildings,
|
||||||
|
Roads = stats.Roads,
|
||||||
|
Areas = stats.Areas,
|
||||||
|
Water = stats.Water,
|
||||||
|
Vertices = stats.Vertices,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
Publish(ready);
|
||||||
|
_inFlight.TryRemove(summary.Id, out _);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Generation of world {Id} failed", summary.Id);
|
||||||
|
|
||||||
|
var failed = summary with
|
||||||
|
{
|
||||||
|
Status = WorldStatus.Failed,
|
||||||
|
Stage = null,
|
||||||
|
Error = ex.Message,
|
||||||
|
};
|
||||||
|
|
||||||
|
Publish(failed);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await store.SaveSummaryAsync(failed, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception saveFailure)
|
||||||
|
{
|
||||||
|
logger.LogError(saveFailure, "Could not record the failure of world {Id}", summary.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Publish(WorldSummaryDto summary) => _inFlight[summary.Id] = summary;
|
||||||
|
|
||||||
|
private static WorldDto ToDto(GameWorld world, IReadOnlyList<ExportedChunk> chunks)
|
||||||
|
{
|
||||||
|
var metadata = world.Metadata;
|
||||||
|
var stats = metadata.Stats;
|
||||||
|
|
||||||
|
return new WorldDto
|
||||||
|
{
|
||||||
|
Id = metadata.Id,
|
||||||
|
Name = metadata.Name,
|
||||||
|
Latitude = metadata.Origin.Latitude,
|
||||||
|
Longitude = metadata.Origin.Longitude,
|
||||||
|
SizeMeters = metadata.SizeMeters,
|
||||||
|
Bounds = new GeoBoundsDto(
|
||||||
|
metadata.Bounds.South, metadata.Bounds.West, metadata.Bounds.North, metadata.Bounds.East),
|
||||||
|
ChunkSizeMeters = metadata.ChunkSizeMeters,
|
||||||
|
ChunkCountX = metadata.ChunkCountX,
|
||||||
|
ChunkCountY = metadata.ChunkCountY,
|
||||||
|
GeneratedAt = metadata.GeneratedAt,
|
||||||
|
Stats = new WorldStatsDto
|
||||||
|
{
|
||||||
|
Buildings = stats.Buildings,
|
||||||
|
Roads = stats.Roads,
|
||||||
|
Areas = stats.Areas,
|
||||||
|
Water = stats.Water,
|
||||||
|
Vertices = stats.Vertices,
|
||||||
|
},
|
||||||
|
Chunks = [.. chunks.Select(static chunk => chunk.Index)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a readable, filesystem-safe id: a slug of the name plus enough entropy to stay unique.</summary>
|
||||||
|
private static string CreateId(string name)
|
||||||
|
{
|
||||||
|
var slug = new StringBuilder(32);
|
||||||
|
var previousWasDash = false;
|
||||||
|
|
||||||
|
foreach (var rune in name.Normalize(NormalizationForm.FormD))
|
||||||
|
{
|
||||||
|
if (slug.Length >= 24) break;
|
||||||
|
if (CharUnicodeInfo.GetUnicodeCategory(rune) == UnicodeCategory.NonSpacingMark) continue;
|
||||||
|
|
||||||
|
var lower = char.ToLowerInvariant(rune);
|
||||||
|
if (char.IsAsciiLetterLower(lower) || char.IsAsciiDigit(lower))
|
||||||
|
{
|
||||||
|
slug.Append(lower);
|
||||||
|
previousWasDash = false;
|
||||||
|
}
|
||||||
|
else if (!previousWasDash && slug.Length > 0)
|
||||||
|
{
|
||||||
|
slug.Append('-');
|
||||||
|
previousWasDash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = slug.ToString().Trim('-');
|
||||||
|
if (prefix.Length == 0) prefix = "world";
|
||||||
|
|
||||||
|
return $"{prefix}-{Guid.NewGuid().ToString("n")[..8]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _gate.Dispose();
|
||||||
|
|
||||||
|
private sealed class CallbackProgress(Action<string> callback) : IProgress<string>
|
||||||
|
{
|
||||||
|
public void Report(string value) => callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
|
using TheLivingWorld.Api.Endpoints;
|
||||||
|
using TheLivingWorld.Api.Generation;
|
||||||
|
using TheLivingWorld.Api.Storage;
|
||||||
|
using TheLivingWorld.Core.Contracts;
|
||||||
|
using TheLivingWorld.Core.Export;
|
||||||
|
using TheLivingWorld.Osm;
|
||||||
|
|
||||||
|
const string WebClientCorsPolicy = "web-client";
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options => MapJson.Apply(options.SerializerOptions));
|
||||||
|
|
||||||
|
builder.Services.Configure<WorldStorageOptions>(
|
||||||
|
builder.Configuration.GetSection(WorldStorageOptions.SectionName));
|
||||||
|
|
||||||
|
// Relative paths in configuration are meant to sit next to the app, not next to whatever the working
|
||||||
|
// directory happens to be when it is launched.
|
||||||
|
builder.Services.PostConfigure<WorldStorageOptions>(options =>
|
||||||
|
options.RootDirectory = RootPath(options.RootDirectory));
|
||||||
|
|
||||||
|
#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is still marked experimental.
|
||||||
|
builder.Services.AddOsmImport(builder.Configuration)
|
||||||
|
// Overpass answers in minutes, not seconds; the resilience pipeline that AddServiceDefaults installs on
|
||||||
|
// every client would abandon the request long before that. OverpassClient retries across mirrors itself.
|
||||||
|
.RemoveAllResilienceHandlers();
|
||||||
|
#pragma warning restore EXTEXP0001
|
||||||
|
|
||||||
|
builder.Services.PostConfigure<OsmOptions>(options =>
|
||||||
|
options.CacheDirectory = RootPath(options.CacheDirectory));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<WorldStore>();
|
||||||
|
builder.Services.AddSingleton<ChunkExporter>();
|
||||||
|
builder.Services.AddSingleton<WorldGenerationService>();
|
||||||
|
|
||||||
|
// Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude.
|
||||||
|
builder.Services.AddResponseCompression(options =>
|
||||||
|
{
|
||||||
|
options.EnableForHttps = true;
|
||||||
|
options.Providers.Add<BrotliCompressionProvider>();
|
||||||
|
options.Providers.Add<GzipCompressionProvider>();
|
||||||
|
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||||
|
});
|
||||||
|
builder.Services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
|
||||||
|
builder.Services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
|
||||||
|
|
||||||
|
// The Vite dev server proxies /api during `aspire run`, but the client can also be pointed at the API
|
||||||
|
// directly, which crosses an origin.
|
||||||
|
builder.Services.AddCors(options => options.AddPolicy(WebClientCorsPolicy, policy => policy
|
||||||
|
.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseResponseCompression();
|
||||||
|
app.UseCors(WebClientCorsPolicy);
|
||||||
|
|
||||||
|
app.MapDefaultEndpoints();
|
||||||
|
app.MapWorldEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
string RootPath(string path) =>
|
||||||
|
Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, path));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5195",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7162;http://localhost:5195",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace TheLivingWorld.Api.Storage;
|
||||||
|
|
||||||
|
public sealed class WorldStorageOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "WorldStorage";
|
||||||
|
|
||||||
|
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
|
||||||
|
public string RootDirectory { get; set; } = "data/worlds";
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TheLivingWorld.Core.Contracts;
|
||||||
|
using TheLivingWorld.Core.Export;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Api.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists generated worlds as plain files: one folder per world holding its state, its index, and one file
|
||||||
|
/// per chunk. Chunk files are written in the exact wire format so serving them is a byte copy.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<WorldStore> logger)
|
||||||
|
{
|
||||||
|
private const string StateFileName = "state.json";
|
||||||
|
private const string IndexFileName = "world.json";
|
||||||
|
private const string ChunksDirectoryName = "chunks";
|
||||||
|
|
||||||
|
private readonly string _root = options.Value.RootDirectory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that
|
||||||
|
/// reaches the filesystem goes through here.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidId(string? id) =>
|
||||||
|
!string.IsNullOrEmpty(id) &&
|
||||||
|
id.Length <= 64 &&
|
||||||
|
id.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-');
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorldSummaryDto>> ListAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_root)) return [];
|
||||||
|
|
||||||
|
var summaries = new List<WorldSummaryDto>();
|
||||||
|
foreach (var directory in Directory.EnumerateDirectories(_root))
|
||||||
|
{
|
||||||
|
var id = Path.GetFileName(directory);
|
||||||
|
if (!IsValidId(id)) continue;
|
||||||
|
|
||||||
|
if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is { } summary)
|
||||||
|
summaries.Add(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries.Sort(static (a, b) => b.CreatedAt.CompareTo(a.CreatedAt));
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(WorldDirectory(id), StateFileName);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
return await JsonSerializer
|
||||||
|
.DeserializeAsync<WorldSummaryDto>(stream, MapJson.Options, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or IOException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Could not read state for world {Id}", id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveSummaryAsync(WorldSummaryDto summary, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var directory = WorldDirectory(summary.Id);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
await WriteJsonAsync(Path.Combine(directory, StateFileName), summary, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorldDto?> GetWorldAsync(string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(WorldDirectory(id), IndexFileName);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
return await JsonSerializer
|
||||||
|
.DeserializeAsync<WorldDto>(stream, MapJson.Options, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens a chunk file for streaming straight to the response. Null when the chunk is empty.</summary>
|
||||||
|
public Stream? OpenChunk(string id, int x, int y)
|
||||||
|
{
|
||||||
|
if (x < 0 || y < 0) return null;
|
||||||
|
|
||||||
|
var path = Path.Combine(WorldDirectory(id), ChunksDirectoryName, $"{x}_{y}.json");
|
||||||
|
return File.Exists(path) ? File.OpenRead(path) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveWorldAsync(
|
||||||
|
WorldDto world,
|
||||||
|
IReadOnlyList<ExportedChunk> chunks,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var directory = WorldDirectory(world.Id);
|
||||||
|
var chunkDirectory = Path.Combine(directory, ChunksDirectoryName);
|
||||||
|
|
||||||
|
// A regenerated world must not keep chunks that no longer exist.
|
||||||
|
if (Directory.Exists(chunkDirectory)) Directory.Delete(chunkDirectory, recursive: true);
|
||||||
|
Directory.CreateDirectory(chunkDirectory);
|
||||||
|
|
||||||
|
foreach (var chunk in chunks)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(chunkDirectory, $"{chunk.Coord.X}_{chunk.Coord.Y}.json");
|
||||||
|
await WriteJsonAsync(path, chunk.Chunk, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteJsonAsync(Path.Combine(directory, IndexFileName), world, cancellationToken).ConfigureAwait(false);
|
||||||
|
logger.LogInformation("Saved world {Id} with {Chunks:N0} chunks to {Directory}", world.Id, chunks.Count, directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Delete(string id)
|
||||||
|
{
|
||||||
|
var directory = WorldDirectory(id);
|
||||||
|
if (!Directory.Exists(directory)) return false;
|
||||||
|
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string WorldDirectory(string id)
|
||||||
|
{
|
||||||
|
if (!IsValidId(id)) throw new ArgumentException($"'{id}' is not a valid world id.", nameof(id));
|
||||||
|
return Path.Combine(_root, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteJsonAsync<T>(string path, T value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Write beside the target and swap, so a reader never sees a half-written file.
|
||||||
|
var temporary = path + ".tmp";
|
||||||
|
await using (var stream = File.Create(temporary))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(temporary, path, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
|
||||||
|
<ProjectReference Include="..\TheLivingWorld.ServiceDefaults\TheLivingWorld.ServiceDefaults.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"WorldStorage": {
|
||||||
|
"RootDirectory": "data/worlds"
|
||||||
|
},
|
||||||
|
"Osm": {
|
||||||
|
"Endpoints": [
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
"https://overpass.private.coffee/api/interpreter"
|
||||||
|
],
|
||||||
|
"QueryTimeoutSeconds": 180,
|
||||||
|
"RequestTimeoutSeconds": 240,
|
||||||
|
"MaxAttemptsPerEndpoint": 2,
|
||||||
|
"CacheDirectory": "data/osm-cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var api = builder.AddProject<Projects.TheLivingWorld_Api>("api")
|
||||||
|
.WithHttpHealthCheck("/health");
|
||||||
|
|
||||||
|
// The Vite dev server proxies /api to the address Aspire injects, so the browser only ever talks to one origin.
|
||||||
|
builder.AddViteApp("web", "../TheLivingWorld.Web")
|
||||||
|
.WithNpm()
|
||||||
|
.WithReference(api)
|
||||||
|
.WaitFor(api)
|
||||||
|
.WithExternalHttpEndpoints();
|
||||||
|
|
||||||
|
builder.Build().Run();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:17232;http://localhost:15180",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21024",
|
||||||
|
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23156",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22053"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:15180",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"DOTNET_ENVIRONMENT": "Development",
|
||||||
|
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19142",
|
||||||
|
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18115",
|
||||||
|
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20218"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\TheLivingWorld.Api\TheLivingWorld.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UserSecretsId>2ffb5eff-6e16-47c4-a3f8-37b68224c539</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Aspire.Hosting.Dcp": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"appHost": {
|
||||||
|
"path": "TheLivingWorld.AppHost.csproj"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The wire format the client renders. Geometry travels as flat <c>[x0, y0, x1, y1, ...]</c> arrays of world
|
||||||
|
/// metres — that is exactly what PixiJS <c>Graphics.poly()</c> takes, so the client never has to reshape it.
|
||||||
|
/// X grows east, Y grows north; the client flips Y once at the container level.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ChunkDto
|
||||||
|
{
|
||||||
|
public required int X { get; init; }
|
||||||
|
|
||||||
|
public required int Y { get; init; }
|
||||||
|
|
||||||
|
/// <summary>[minX, minY, maxX, maxY] widened to cover geometry overhanging the chunk's own square.</summary>
|
||||||
|
public required float[] Bounds { get; init; }
|
||||||
|
|
||||||
|
public BuildingDto[] Buildings { get; init; } = [];
|
||||||
|
|
||||||
|
public RoadDto[] Roads { get; init; } = [];
|
||||||
|
|
||||||
|
public AreaDto[] Areas { get; init; } = [];
|
||||||
|
|
||||||
|
public WaterDto[] Water { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BuildingDto
|
||||||
|
{
|
||||||
|
public required long Id { get; init; }
|
||||||
|
|
||||||
|
public required BuildingKind Kind { get; init; }
|
||||||
|
|
||||||
|
public required float Height { get; init; }
|
||||||
|
|
||||||
|
public required float[] Outline { get; init; }
|
||||||
|
|
||||||
|
public float[][]? Holes { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RoadDto
|
||||||
|
{
|
||||||
|
public required long Id { get; init; }
|
||||||
|
|
||||||
|
public required RoadClass Class { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Carriageway width in metres; the client strokes the centreline at this width.</summary>
|
||||||
|
public required float Width { get; init; }
|
||||||
|
|
||||||
|
public required float[] Path { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Bit field matching <see cref="RoadFlags"/>.</summary>
|
||||||
|
public byte Flags { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AreaDto
|
||||||
|
{
|
||||||
|
public required long Id { get; init; }
|
||||||
|
|
||||||
|
public required AreaKind Kind { get; init; }
|
||||||
|
|
||||||
|
public required float[] Outline { get; init; }
|
||||||
|
|
||||||
|
public float[][]? Holes { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Water is either an area (a lake) or a line (a stream), never both.</summary>
|
||||||
|
public sealed record WaterDto
|
||||||
|
{
|
||||||
|
public required long Id { get; init; }
|
||||||
|
|
||||||
|
public required WaterKind Kind { get; init; }
|
||||||
|
|
||||||
|
public float[]? Outline { get; init; }
|
||||||
|
|
||||||
|
public float[][]? Holes { get; init; }
|
||||||
|
|
||||||
|
public float[]? Path { get; init; }
|
||||||
|
|
||||||
|
public float Width { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single serializer configuration for everything on the wire. Chunk files are written to disk with these
|
||||||
|
/// options and later streamed straight to the client without re-serialization, so the store and the HTTP
|
||||||
|
/// endpoints must agree on them exactly.
|
||||||
|
/// </summary>
|
||||||
|
public static class MapJson
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Applies the same configuration to an options instance owned by someone else, such as ASP.NET Core's.</summary>
|
||||||
|
public static void Apply(JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
options.PropertyNameCaseInsensitive = true;
|
||||||
|
options.NumberHandling = Options.NumberHandling;
|
||||||
|
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
|
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
namespace TheLivingWorld.Core.Contracts;
|
||||||
|
|
||||||
|
public enum WorldStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Generating,
|
||||||
|
Ready,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Request body for <c>POST /api/worlds</c>.</summary>
|
||||||
|
public sealed record CreateWorldRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
public required double Latitude { get; init; }
|
||||||
|
|
||||||
|
public required double Longitude { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Side length of the square to generate, in kilometres.</summary>
|
||||||
|
public required double SizeKm { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Re-download from Overpass even if a cached response for this box exists.</summary>
|
||||||
|
public bool ForceRefresh { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A world as it appears in listings and while generation is still running.</summary>
|
||||||
|
public sealed record WorldSummaryDto
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required double Latitude { get; init; }
|
||||||
|
|
||||||
|
public required double Longitude { get; init; }
|
||||||
|
|
||||||
|
public required double SizeMeters { get; init; }
|
||||||
|
|
||||||
|
public required WorldStatus Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable progress line while <see cref="Status"/> is <see cref="WorldStatus.Generating"/>.</summary>
|
||||||
|
public string? Stage { get; init; }
|
||||||
|
|
||||||
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
public WorldStatsDto? Stats { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Everything the client needs to set up its camera and decide which chunks to fetch.</summary>
|
||||||
|
public sealed record WorldDto
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required double Latitude { get; init; }
|
||||||
|
|
||||||
|
public required double Longitude { get; init; }
|
||||||
|
|
||||||
|
public required double SizeMeters { get; init; }
|
||||||
|
|
||||||
|
public required GeoBoundsDto Bounds { get; init; }
|
||||||
|
|
||||||
|
public required float ChunkSizeMeters { get; init; }
|
||||||
|
|
||||||
|
public required int ChunkCountX { get; init; }
|
||||||
|
|
||||||
|
public required int ChunkCountY { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
|
||||||
|
public required WorldStatsDto Stats { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Only chunks that actually hold features; empty ones are omitted.</summary>
|
||||||
|
public required ChunkIndexEntryDto[] Chunks { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record GeoBoundsDto(double South, double West, double North, double East);
|
||||||
|
|
||||||
|
public sealed record ChunkIndexEntryDto
|
||||||
|
{
|
||||||
|
public required int X { get; init; }
|
||||||
|
|
||||||
|
public required int Y { get; init; }
|
||||||
|
|
||||||
|
/// <summary>[minX, minY, maxX, maxY], widened to cover overhanging geometry.</summary>
|
||||||
|
public required float[] Bounds { get; init; }
|
||||||
|
|
||||||
|
public required int Features { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WorldStatsDto
|
||||||
|
{
|
||||||
|
public required int Buildings { get; init; }
|
||||||
|
|
||||||
|
public required int Roads { get; init; }
|
||||||
|
|
||||||
|
public required int Areas { get; init; }
|
||||||
|
|
||||||
|
public required int Water { get; init; }
|
||||||
|
|
||||||
|
public required int Vertices { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Ecs;
|
||||||
|
|
||||||
|
/// <summary>Where this entity came from in OpenStreetMap. Kept so features stay traceable back to the source data.</summary>
|
||||||
|
public record struct OsmSource(long Id, OsmElementKind Kind);
|
||||||
|
|
||||||
|
/// <summary>A closed ring: the entity's area footprint. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
|
||||||
|
public record struct Outline(int ShapeId);
|
||||||
|
|
||||||
|
/// <summary>Inner rings cut out of <see cref="Outline"/>. <c>ShapeIds</c> is null for the common no-hole case.</summary>
|
||||||
|
public record struct Holes(int[]? ShapeIds);
|
||||||
|
|
||||||
|
/// <summary>An open polyline: the entity's centreline. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
|
||||||
|
public record struct Polyline(int ShapeId);
|
||||||
|
|
||||||
|
/// <summary>Cached world-space extent, filled in by <see cref="Systems.ComputeBoundsSystem"/>.</summary>
|
||||||
|
public record struct Bounds(RectBounds Value);
|
||||||
|
|
||||||
|
/// <summary>Spatial bucket, filled in by <see cref="Systems.AssignChunksSystem"/>.</summary>
|
||||||
|
public record struct InChunk(int X, int Y);
|
||||||
|
|
||||||
|
public record struct Building(BuildingKind Kind, float HeightMeters, byte Levels);
|
||||||
|
|
||||||
|
public record struct Road(RoadClass Class, float WidthMeters, byte Lanes, RoadFlags Flags);
|
||||||
|
|
||||||
|
public record struct AreaFeature(AreaKind Kind);
|
||||||
|
|
||||||
|
public record struct Water(WaterKind Kind, float WidthMeters);
|
||||||
|
|
||||||
|
/// <summary>The <c>name</c> tag, or null. Always present so every feature archetype stays uniform.</summary>
|
||||||
|
public record struct DisplayName(string? Value);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
namespace TheLivingWorld.Core.Ecs;
|
||||||
|
|
||||||
|
public enum OsmElementKind : byte
|
||||||
|
{
|
||||||
|
Node = 0,
|
||||||
|
Way = 1,
|
||||||
|
Relation = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BuildingKind : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
House,
|
||||||
|
Residential,
|
||||||
|
Apartments,
|
||||||
|
Commercial,
|
||||||
|
Retail,
|
||||||
|
Industrial,
|
||||||
|
Civic,
|
||||||
|
School,
|
||||||
|
Church,
|
||||||
|
Garage,
|
||||||
|
Shed,
|
||||||
|
Farm,
|
||||||
|
Ruins,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RoadClass : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Motorway,
|
||||||
|
Trunk,
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Tertiary,
|
||||||
|
Unclassified,
|
||||||
|
Residential,
|
||||||
|
LivingStreet,
|
||||||
|
Service,
|
||||||
|
Track,
|
||||||
|
Pedestrian,
|
||||||
|
Footway,
|
||||||
|
Cycleway,
|
||||||
|
Steps,
|
||||||
|
Path,
|
||||||
|
Railway,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AreaKind : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Forest,
|
||||||
|
Grass,
|
||||||
|
Meadow,
|
||||||
|
Farmland,
|
||||||
|
Orchard,
|
||||||
|
Scrub,
|
||||||
|
Heath,
|
||||||
|
Sand,
|
||||||
|
BareRock,
|
||||||
|
Wetland,
|
||||||
|
Park,
|
||||||
|
Garden,
|
||||||
|
Pitch,
|
||||||
|
Cemetery,
|
||||||
|
ResidentialZone,
|
||||||
|
IndustrialZone,
|
||||||
|
CommercialZone,
|
||||||
|
RetailZone,
|
||||||
|
Quarry,
|
||||||
|
Parking,
|
||||||
|
School,
|
||||||
|
Beach,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum WaterKind : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Water,
|
||||||
|
Lake,
|
||||||
|
Pond,
|
||||||
|
Reservoir,
|
||||||
|
Riverbank,
|
||||||
|
River,
|
||||||
|
Stream,
|
||||||
|
Canal,
|
||||||
|
Ditch,
|
||||||
|
Drain,
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum RoadFlags : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Bridge = 1 << 0,
|
||||||
|
Tunnel = 1 << 1,
|
||||||
|
Oneway = 1 << 2,
|
||||||
|
Unpaved = 1 << 3,
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Arch.Core;
|
||||||
|
using TheLivingWorld.Core.Contracts;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Export;
|
||||||
|
|
||||||
|
public sealed record ExportedChunk(ChunkCoord Coord, ChunkDto Chunk, ChunkIndexEntryDto Index);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the ECS world into the per-chunk payloads the client fetches. Runs once per generation, right after
|
||||||
|
/// <see cref="Systems.AssignChunksSystem"/> has bucketed every feature.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChunkExporter
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription Buildings =
|
||||||
|
new QueryDescription().WithAll<OsmSource, Building, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||||
|
|
||||||
|
private static readonly QueryDescription Roads =
|
||||||
|
new QueryDescription().WithAll<OsmSource, Road, Polyline, Bounds, InChunk, DisplayName>();
|
||||||
|
|
||||||
|
private static readonly QueryDescription Areas =
|
||||||
|
new QueryDescription().WithAll<OsmSource, AreaFeature, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||||
|
|
||||||
|
private static readonly QueryDescription WaterAreas =
|
||||||
|
new QueryDescription().WithAll<OsmSource, Water, Outline, Holes, Bounds, InChunk, DisplayName>();
|
||||||
|
|
||||||
|
private static readonly QueryDescription WaterLines =
|
||||||
|
new QueryDescription().WithAll<OsmSource, Water, Polyline, Bounds, InChunk, DisplayName>();
|
||||||
|
|
||||||
|
public IReadOnlyList<ExportedChunk> Export(GameWorld world)
|
||||||
|
{
|
||||||
|
var shapes = world.Shapes;
|
||||||
|
var grid = world.Grid;
|
||||||
|
var buckets = new Dictionary<ChunkCoord, Bucket>();
|
||||||
|
|
||||||
|
Bucket BucketFor(int x, int y)
|
||||||
|
{
|
||||||
|
var coord = new ChunkCoord(x, y);
|
||||||
|
if (!buckets.TryGetValue(coord, out var bucket))
|
||||||
|
{
|
||||||
|
bucket = new Bucket(grid.BoundsOf(coord));
|
||||||
|
buckets[coord] = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.Ecs.Query(in Buildings, (
|
||||||
|
ref OsmSource source, ref Building building, ref Outline outline, ref Holes holes,
|
||||||
|
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
|
||||||
|
{
|
||||||
|
var bucket = BucketFor(chunk.X, chunk.Y);
|
||||||
|
bucket.Extend(bounds.Value);
|
||||||
|
bucket.Buildings.Add(new BuildingDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Kind = building.Kind,
|
||||||
|
Height = building.HeightMeters,
|
||||||
|
Outline = Flatten(shapes.Get(outline.ShapeId)),
|
||||||
|
Holes = FlattenHoles(shapes, holes),
|
||||||
|
Name = name.Value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Ecs.Query(in Roads, (
|
||||||
|
ref OsmSource source, ref Road road, ref Polyline line,
|
||||||
|
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
|
||||||
|
{
|
||||||
|
var bucket = BucketFor(chunk.X, chunk.Y);
|
||||||
|
bucket.Extend(bounds.Value);
|
||||||
|
bucket.Roads.Add(new RoadDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Class = road.Class,
|
||||||
|
Width = road.WidthMeters,
|
||||||
|
Path = Flatten(shapes.Get(line.ShapeId)),
|
||||||
|
Flags = (byte)road.Flags,
|
||||||
|
Name = name.Value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Ecs.Query(in Areas, (
|
||||||
|
ref OsmSource source, ref AreaFeature area, ref Outline outline, ref Holes holes,
|
||||||
|
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
|
||||||
|
{
|
||||||
|
var bucket = BucketFor(chunk.X, chunk.Y);
|
||||||
|
bucket.Extend(bounds.Value);
|
||||||
|
bucket.Areas.Add(new AreaDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Kind = area.Kind,
|
||||||
|
Outline = Flatten(shapes.Get(outline.ShapeId)),
|
||||||
|
Holes = FlattenHoles(shapes, holes),
|
||||||
|
Name = name.Value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Ecs.Query(in WaterAreas, (
|
||||||
|
ref OsmSource source, ref Water water, ref Outline outline, ref Holes holes,
|
||||||
|
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
|
||||||
|
{
|
||||||
|
var bucket = BucketFor(chunk.X, chunk.Y);
|
||||||
|
bucket.Extend(bounds.Value);
|
||||||
|
bucket.Water.Add(new WaterDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Kind = water.Kind,
|
||||||
|
Outline = Flatten(shapes.Get(outline.ShapeId)),
|
||||||
|
Holes = FlattenHoles(shapes, holes),
|
||||||
|
Name = name.Value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Ecs.Query(in WaterLines, (
|
||||||
|
ref OsmSource source, ref Water water, ref Polyline line,
|
||||||
|
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
|
||||||
|
{
|
||||||
|
var bucket = BucketFor(chunk.X, chunk.Y);
|
||||||
|
bucket.Extend(bounds.Value);
|
||||||
|
bucket.Water.Add(new WaterDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Kind = water.Kind,
|
||||||
|
Path = Flatten(shapes.Get(line.ShapeId)),
|
||||||
|
Width = water.WidthMeters,
|
||||||
|
Name = name.Value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = new List<ExportedChunk>(buckets.Count);
|
||||||
|
foreach (var (coord, bucket) in buckets)
|
||||||
|
result.Add(bucket.Build(coord));
|
||||||
|
|
||||||
|
result.Sort(static (a, b) => a.Coord.Y != b.Coord.Y ? a.Coord.Y - b.Coord.Y : a.Coord.X - b.Coord.X);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Packs points into <c>[x0, y0, x1, y1, ...]</c>, rounded to the centimetre to keep JSON small.</summary>
|
||||||
|
private static float[] Flatten(ReadOnlySpan<Vector2> points)
|
||||||
|
{
|
||||||
|
var flat = new float[points.Length * 2];
|
||||||
|
for (var i = 0; i < points.Length; i++)
|
||||||
|
{
|
||||||
|
flat[i * 2] = MathF.Round(points[i].X, 2);
|
||||||
|
flat[i * 2 + 1] = MathF.Round(points[i].Y, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[][]? FlattenHoles(ShapeStore shapes, in Holes holes)
|
||||||
|
{
|
||||||
|
if (holes.ShapeIds is not { Length: > 0 } ids) return null;
|
||||||
|
|
||||||
|
var flat = new float[ids.Length][];
|
||||||
|
for (var i = 0; i < ids.Length; i++)
|
||||||
|
flat[i] = Flatten(shapes.Get(ids[i]));
|
||||||
|
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Bucket(RectBounds square)
|
||||||
|
{
|
||||||
|
private RectBounds _bounds = square;
|
||||||
|
|
||||||
|
public List<BuildingDto> Buildings { get; } = [];
|
||||||
|
|
||||||
|
public List<RoadDto> Roads { get; } = [];
|
||||||
|
|
||||||
|
public List<AreaDto> Areas { get; } = [];
|
||||||
|
|
||||||
|
public List<WaterDto> Water { get; } = [];
|
||||||
|
|
||||||
|
public void Extend(in RectBounds featureBounds) => _bounds.Encapsulate(featureBounds);
|
||||||
|
|
||||||
|
public ExportedChunk Build(ChunkCoord coord)
|
||||||
|
{
|
||||||
|
var bounds = _bounds.ToArray();
|
||||||
|
var chunk = new ChunkDto
|
||||||
|
{
|
||||||
|
X = coord.X,
|
||||||
|
Y = coord.Y,
|
||||||
|
Bounds = bounds,
|
||||||
|
Buildings = [.. Buildings],
|
||||||
|
Roads = [.. Roads],
|
||||||
|
Areas = [.. Areas],
|
||||||
|
Water = [.. Water],
|
||||||
|
};
|
||||||
|
|
||||||
|
var index = new ChunkIndexEntryDto
|
||||||
|
{
|
||||||
|
X = coord.X,
|
||||||
|
Y = coord.Y,
|
||||||
|
Bounds = bounds,
|
||||||
|
Features = Buildings.Count + Roads.Count + Areas.Count + Water.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ExportedChunk(coord, chunk, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
/// <summary>An axis-aligned WGS84 bounding box, in degrees.</summary>
|
||||||
|
public readonly record struct GeoBounds(double South, double West, double North, double East)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the square that encloses <paramref name="sizeMeters"/> of ground on a side, centred on
|
||||||
|
/// <paramref name="center"/>. The longitude span is widened by 1/cos(lat) so the box stays square in metres.
|
||||||
|
/// </summary>
|
||||||
|
public static GeoBounds FromCenter(GeoPoint center, double sizeMeters)
|
||||||
|
{
|
||||||
|
var half = sizeMeters / 2.0;
|
||||||
|
var latDelta = half / LocalProjection.MetersPerDegreeLatitude;
|
||||||
|
var cosLat = Math.Cos(center.Latitude * Math.PI / 180.0);
|
||||||
|
|
||||||
|
// Guard the poles: cos(lat) collapses to zero there and the longitude span would explode.
|
||||||
|
var lonDelta = half / (LocalProjection.MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6));
|
||||||
|
|
||||||
|
return new GeoBounds(
|
||||||
|
South: center.Latitude - latDelta,
|
||||||
|
West: center.Longitude - lonDelta,
|
||||||
|
North: center.Latitude + latDelta,
|
||||||
|
East: center.Longitude + lonDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GeoPoint Center => new((South + North) / 2.0, (West + East) / 2.0);
|
||||||
|
|
||||||
|
public bool Contains(GeoPoint p) =>
|
||||||
|
p.Latitude >= South && p.Latitude <= North && p.Longitude >= West && p.Longitude <= East;
|
||||||
|
|
||||||
|
/// <summary>Overpass expects bounding boxes as <c>(south,west,north,east)</c>.</summary>
|
||||||
|
public string ToOverpassBbox() => string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{South:F7},{West:F7},{North:F7},{East:F7}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
/// <summary>A WGS84 coordinate. Latitude and longitude are in degrees.</summary>
|
||||||
|
public readonly record struct GeoPoint(double Latitude, double Longitude)
|
||||||
|
{
|
||||||
|
public bool IsValid =>
|
||||||
|
double.IsFinite(Latitude) && double.IsFinite(Longitude) &&
|
||||||
|
Latitude is >= -90 and <= 90 && Longitude is >= -180 and <= 180;
|
||||||
|
|
||||||
|
public override string ToString() => $"{Latitude:F7}, {Longitude:F7}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims imported geometry to the generated square. OpenStreetMap returns whole ways whenever they touch the
|
||||||
|
/// query box, so a highway crossing town can reach tens of kilometres past the map edge; clipping keeps the
|
||||||
|
/// world's extents honest and stops one stray way from inflating a chunk's bounds.
|
||||||
|
/// </summary>
|
||||||
|
public static class GeometryClipper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sutherland-Hodgman clip of a closed ring against the rectangle. Returns null when nothing survives.
|
||||||
|
/// The ring is expected without a repeated closing vertex, and comes back the same way.
|
||||||
|
/// </summary>
|
||||||
|
public static Vector2[]? ClipPolygon(ReadOnlySpan<Vector2> ring, in RectBounds rect)
|
||||||
|
{
|
||||||
|
if (ring.Length < 3) return null;
|
||||||
|
|
||||||
|
var current = new List<Vector2>(ring.Length + 8);
|
||||||
|
current.AddRange(ring);
|
||||||
|
|
||||||
|
var next = new List<Vector2>(ring.Length + 8);
|
||||||
|
|
||||||
|
for (var edge = 0; edge < 4; edge++)
|
||||||
|
{
|
||||||
|
next.Clear();
|
||||||
|
if (current.Count == 0) return null;
|
||||||
|
|
||||||
|
for (var i = 0; i < current.Count; i++)
|
||||||
|
{
|
||||||
|
var a = current[i];
|
||||||
|
var b = current[(i + 1) % current.Count];
|
||||||
|
|
||||||
|
var aInside = Inside(a, edge, rect);
|
||||||
|
var bInside = Inside(b, edge, rect);
|
||||||
|
|
||||||
|
if (aInside)
|
||||||
|
{
|
||||||
|
next.Add(a);
|
||||||
|
if (!bInside) next.Add(IntersectEdge(a, b, edge, rect));
|
||||||
|
}
|
||||||
|
else if (bInside)
|
||||||
|
{
|
||||||
|
next.Add(IntersectEdge(a, b, edge, rect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(current, next) = (next, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.Count >= 3 ? current.ToArray() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liang-Barsky clip of an open polyline. A line that leaves and re-enters the rectangle comes back as
|
||||||
|
/// several pieces, so the result is a list of runs rather than a single path.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Vector2[]> ClipPolyline(ReadOnlySpan<Vector2> line, in RectBounds rect)
|
||||||
|
{
|
||||||
|
var runs = new List<Vector2[]>();
|
||||||
|
if (line.Length < 2) return runs;
|
||||||
|
|
||||||
|
var run = new List<Vector2>();
|
||||||
|
|
||||||
|
for (var i = 0; i < line.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (!ClipSegment(line[i], line[i + 1], rect, out var start, out var end))
|
||||||
|
{
|
||||||
|
// The segment misses the box entirely, which breaks the current run.
|
||||||
|
FlushRun(run, runs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.Count > 0 && !NearlyEqual(run[^1], start))
|
||||||
|
FlushRun(run, runs);
|
||||||
|
|
||||||
|
if (run.Count == 0) run.Add(start);
|
||||||
|
run.Add(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushRun(run, runs);
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FlushRun(List<Vector2> run, List<Vector2[]> runs)
|
||||||
|
{
|
||||||
|
if (run.Count >= 2) runs.Add(run.ToArray());
|
||||||
|
run.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NearlyEqual(Vector2 a, Vector2 b) => Vector2.DistanceSquared(a, b) < 1e-4f;
|
||||||
|
|
||||||
|
private static bool ClipSegment(Vector2 a, Vector2 b, in RectBounds rect, out Vector2 start, out Vector2 end)
|
||||||
|
{
|
||||||
|
var dx = b.X - a.X;
|
||||||
|
var dy = b.Y - a.Y;
|
||||||
|
var t0 = 0f;
|
||||||
|
var t1 = 1f;
|
||||||
|
|
||||||
|
Span<float> p = [-dx, dx, -dy, dy];
|
||||||
|
Span<float> q = [a.X - rect.MinX, rect.MaxX - a.X, a.Y - rect.MinY, rect.MaxY - a.Y];
|
||||||
|
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
if (p[i] == 0f)
|
||||||
|
{
|
||||||
|
// Parallel to this boundary: outside it means the whole segment is rejected.
|
||||||
|
if (q[i] < 0f)
|
||||||
|
{
|
||||||
|
start = default;
|
||||||
|
end = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var t = q[i] / p[i];
|
||||||
|
if (p[i] < 0f)
|
||||||
|
{
|
||||||
|
if (t > t1) { start = default; end = default; return false; }
|
||||||
|
if (t > t0) t0 = t;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (t < t0) { start = default; end = default; return false; }
|
||||||
|
if (t < t1) t1 = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = new Vector2(a.X + t0 * dx, a.Y + t0 * dy);
|
||||||
|
end = new Vector2(a.X + t1 * dx, a.Y + t1 * dy);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Inside(Vector2 p, int edge, in RectBounds rect) => edge switch
|
||||||
|
{
|
||||||
|
0 => p.X >= rect.MinX,
|
||||||
|
1 => p.X <= rect.MaxX,
|
||||||
|
2 => p.Y >= rect.MinY,
|
||||||
|
_ => p.Y <= rect.MaxY,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Vector2 IntersectEdge(Vector2 a, Vector2 b, int edge, in RectBounds rect)
|
||||||
|
{
|
||||||
|
var dx = b.X - a.X;
|
||||||
|
var dy = b.Y - a.Y;
|
||||||
|
|
||||||
|
switch (edge)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
{
|
||||||
|
var x = edge == 0 ? rect.MinX : rect.MaxX;
|
||||||
|
var t = dx == 0f ? 0f : (x - a.X) / dx;
|
||||||
|
return new Vector2(x, a.Y + t * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
var y = edge == 2 ? rect.MinY : rect.MaxY;
|
||||||
|
var t = dy == 0f ? 0f : (y - a.Y) / dy;
|
||||||
|
return new Vector2(a.X + t * dx, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flattens WGS84 coordinates onto a local metric plane centred on an origin: X grows east, Y grows north,
|
||||||
|
/// both in metres. This is an equirectangular projection with the scale factor frozen at the origin latitude,
|
||||||
|
/// which stays sub-metre accurate across the 5-20 km worlds this game generates and — unlike Web Mercator —
|
||||||
|
/// keeps distances usable directly as game units.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalProjection
|
||||||
|
{
|
||||||
|
public const double EarthRadiusMeters = 6378137.0;
|
||||||
|
public const double MetersPerDegreeLatitude = EarthRadiusMeters * Math.PI / 180.0;
|
||||||
|
|
||||||
|
private readonly double _metersPerDegreeLongitude;
|
||||||
|
|
||||||
|
public LocalProjection(GeoPoint origin)
|
||||||
|
{
|
||||||
|
if (!origin.IsValid)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(origin), origin, "Origin is not a valid WGS84 coordinate.");
|
||||||
|
|
||||||
|
Origin = origin;
|
||||||
|
var cosLat = Math.Cos(origin.Latitude * Math.PI / 180.0);
|
||||||
|
_metersPerDegreeLongitude = MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GeoPoint Origin { get; }
|
||||||
|
|
||||||
|
public Vector2 Project(GeoPoint point) => Project(point.Latitude, point.Longitude);
|
||||||
|
|
||||||
|
public Vector2 Project(double latitude, double longitude) => new(
|
||||||
|
(float)((longitude - Origin.Longitude) * _metersPerDegreeLongitude),
|
||||||
|
(float)((latitude - Origin.Latitude) * MetersPerDegreeLatitude));
|
||||||
|
|
||||||
|
public GeoPoint Unproject(Vector2 local) => new(
|
||||||
|
Origin.Latitude + local.Y / MetersPerDegreeLatitude,
|
||||||
|
Origin.Longitude + local.X / _metersPerDegreeLongitude);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
public static class Polygons
|
||||||
|
{
|
||||||
|
/// <summary>Twice the signed area of a ring: positive when the ring winds counter-clockwise.</summary>
|
||||||
|
public static float SignedDoubleArea(ReadOnlySpan<Vector2> ring)
|
||||||
|
{
|
||||||
|
if (ring.Length < 3) return 0f;
|
||||||
|
|
||||||
|
var sum = 0f;
|
||||||
|
for (var i = 0; i < ring.Length; i++)
|
||||||
|
{
|
||||||
|
var a = ring[i];
|
||||||
|
var b = ring[(i + 1) % ring.Length];
|
||||||
|
sum += a.X * b.Y - b.X * a.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float Area(ReadOnlySpan<Vector2> ring) => MathF.Abs(SignedDoubleArea(ring)) * 0.5f;
|
||||||
|
|
||||||
|
/// <summary>Standard even-odd ray cast. Points exactly on the boundary may land either way.</summary>
|
||||||
|
public static bool Contains(ReadOnlySpan<Vector2> ring, Vector2 point)
|
||||||
|
{
|
||||||
|
if (ring.Length < 3) return false;
|
||||||
|
|
||||||
|
var inside = false;
|
||||||
|
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
|
||||||
|
{
|
||||||
|
var a = ring[i];
|
||||||
|
var b = ring[j];
|
||||||
|
|
||||||
|
if (a.Y > point.Y != b.Y > point.Y &&
|
||||||
|
point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||||
|
{
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
/// <summary>An axis-aligned bounding box in world metres.</summary>
|
||||||
|
public struct RectBounds
|
||||||
|
{
|
||||||
|
public float MinX;
|
||||||
|
public float MinY;
|
||||||
|
public float MaxX;
|
||||||
|
public float MaxY;
|
||||||
|
|
||||||
|
public RectBounds(float minX, float minY, float maxX, float maxY)
|
||||||
|
{
|
||||||
|
MinX = minX;
|
||||||
|
MinY = minY;
|
||||||
|
MaxX = maxX;
|
||||||
|
MaxY = maxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An inverted box that swallows the first point it encapsulates.</summary>
|
||||||
|
public static RectBounds Empty => new(float.MaxValue, float.MaxValue, float.MinValue, float.MinValue);
|
||||||
|
|
||||||
|
public readonly bool IsEmpty => MinX > MaxX || MinY > MaxY;
|
||||||
|
|
||||||
|
public readonly float Width => MaxX - MinX;
|
||||||
|
|
||||||
|
public readonly float Height => MaxY - MinY;
|
||||||
|
|
||||||
|
public readonly Vector2 Center => new((MinX + MaxX) * 0.5f, (MinY + MaxY) * 0.5f);
|
||||||
|
|
||||||
|
public void Encapsulate(Vector2 point)
|
||||||
|
{
|
||||||
|
if (point.X < MinX) MinX = point.X;
|
||||||
|
if (point.Y < MinY) MinY = point.Y;
|
||||||
|
if (point.X > MaxX) MaxX = point.X;
|
||||||
|
if (point.Y > MaxY) MaxY = point.Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Encapsulate(in RectBounds other)
|
||||||
|
{
|
||||||
|
if (other.IsEmpty) return;
|
||||||
|
if (other.MinX < MinX) MinX = other.MinX;
|
||||||
|
if (other.MinY < MinY) MinY = other.MinY;
|
||||||
|
if (other.MaxX > MaxX) MaxX = other.MaxX;
|
||||||
|
if (other.MaxY > MaxY) MaxY = other.MaxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Expand(float margin)
|
||||||
|
{
|
||||||
|
MinX -= margin;
|
||||||
|
MinY -= margin;
|
||||||
|
MaxX += margin;
|
||||||
|
MaxY += margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly bool Intersects(in RectBounds other) =>
|
||||||
|
MinX <= other.MaxX && MaxX >= other.MinX &&
|
||||||
|
MinY <= other.MaxY && MaxY >= other.MinY;
|
||||||
|
|
||||||
|
public static RectBounds FromPoints(ReadOnlySpan<Vector2> points)
|
||||||
|
{
|
||||||
|
var bounds = Empty;
|
||||||
|
foreach (var point in points)
|
||||||
|
bounds.Encapsulate(point);
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly float[] ToArray() => [MinX, MinY, MaxX, MaxY];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Systems;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buckets each feature into the chunk containing the centre of its extent. Features are never split, so a
|
||||||
|
/// long road stays whole and simply overhangs its chunk; the exported chunk bounds are widened to match, and
|
||||||
|
/// the client uses those widened bounds to decide what to fetch.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssignChunksSystem : IWorldSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription Placeable = new QueryDescription().WithAll<Bounds, InChunk>();
|
||||||
|
|
||||||
|
public string Name => nameof(AssignChunksSystem);
|
||||||
|
|
||||||
|
public void Execute(GameWorld world)
|
||||||
|
{
|
||||||
|
var grid = world.Grid;
|
||||||
|
|
||||||
|
world.Ecs.Query(in Placeable, (ref Bounds bounds, ref InChunk chunk) =>
|
||||||
|
{
|
||||||
|
if (bounds.Value.IsEmpty) return;
|
||||||
|
var coord = grid.CoordOf(bounds.Value.Center);
|
||||||
|
chunk.X = coord.X;
|
||||||
|
chunk.Y = coord.Y;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Systems;
|
||||||
|
|
||||||
|
/// <summary>Fills every feature's cached world-space extent from the geometry it points at.</summary>
|
||||||
|
public sealed class ComputeBoundsSystem : IWorldSystem
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription AreaFeatures = new QueryDescription().WithAll<Outline, Bounds>();
|
||||||
|
private static readonly QueryDescription LineFeatures = new QueryDescription().WithAll<Polyline, Bounds>();
|
||||||
|
|
||||||
|
public string Name => nameof(ComputeBoundsSystem);
|
||||||
|
|
||||||
|
public void Execute(GameWorld world)
|
||||||
|
{
|
||||||
|
var shapes = world.Shapes;
|
||||||
|
|
||||||
|
// Holes are contained by their outline, so the outline alone determines the extent.
|
||||||
|
world.Ecs.Query(in AreaFeatures, (ref Outline outline, ref Bounds bounds) =>
|
||||||
|
{
|
||||||
|
bounds.Value = RectBounds.FromPoints(shapes.Get(outline.ShapeId));
|
||||||
|
});
|
||||||
|
|
||||||
|
world.Ecs.Query(in LineFeatures, (ref Polyline line, ref Bounds bounds) =>
|
||||||
|
{
|
||||||
|
bounds.Value = RectBounds.FromPoints(shapes.Get(line.ShapeId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Systems;
|
||||||
|
|
||||||
|
/// <summary>A pass over the ECS world. Systems are ordered and run once per generation for now.</summary>
|
||||||
|
public interface IWorldSystem
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
void Execute(GameWorld world);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs a fixed sequence of systems over a world.</summary>
|
||||||
|
public sealed class WorldPipeline(params IWorldSystem[] systems)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<IWorldSystem> Systems { get; } = systems;
|
||||||
|
|
||||||
|
public void Run(GameWorld world)
|
||||||
|
{
|
||||||
|
foreach (var system in Systems)
|
||||||
|
system.Execute(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The passes every freshly built world needs before it can be sliced into chunks.</summary>
|
||||||
|
public static WorldPipeline CreateDefault() => new(
|
||||||
|
new ComputeBoundsSystem(),
|
||||||
|
new AssignChunksSystem());
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Arch" Version="2.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
public readonly record struct ChunkCoord(int X, int Y);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A uniform square grid over the world square, used to slice the map into separately fetchable pieces.
|
||||||
|
/// The world is centred on the projection origin, so it spans [-half, +half] on both axes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChunkGrid
|
||||||
|
{
|
||||||
|
public ChunkGrid(double worldSizeMeters, float chunkSizeMeters)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(worldSizeMeters);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkSizeMeters);
|
||||||
|
|
||||||
|
ChunkSizeMeters = chunkSizeMeters;
|
||||||
|
MinX = (float)(-worldSizeMeters / 2.0);
|
||||||
|
MinY = (float)(-worldSizeMeters / 2.0);
|
||||||
|
CountX = (int)Math.Ceiling(worldSizeMeters / chunkSizeMeters);
|
||||||
|
CountY = CountX;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float ChunkSizeMeters { get; }
|
||||||
|
|
||||||
|
public float MinX { get; }
|
||||||
|
|
||||||
|
public float MinY { get; }
|
||||||
|
|
||||||
|
public int CountX { get; }
|
||||||
|
|
||||||
|
public int CountY { get; }
|
||||||
|
|
||||||
|
/// <summary>Locates the chunk containing <paramref name="point"/>, clamped to the grid.</summary>
|
||||||
|
public ChunkCoord CoordOf(Vector2 point)
|
||||||
|
{
|
||||||
|
var x = (int)Math.Floor((point.X - MinX) / ChunkSizeMeters);
|
||||||
|
var y = (int)Math.Floor((point.Y - MinY) / ChunkSizeMeters);
|
||||||
|
return new ChunkCoord(Math.Clamp(x, 0, CountX - 1), Math.Clamp(y, 0, CountY - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public RectBounds BoundsOf(ChunkCoord coord) => new(
|
||||||
|
MinX + coord.X * ChunkSizeMeters,
|
||||||
|
MinY + coord.Y * ChunkSizeMeters,
|
||||||
|
MinX + (coord.X + 1) * ChunkSizeMeters,
|
||||||
|
MinY + (coord.Y + 1) * ChunkSizeMeters);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A generated world: the Arch ECS world holding every map feature as an entity, the shape store its
|
||||||
|
/// components point into, and the projection/grid needed to interpret their coordinates.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameWorld : IDisposable
|
||||||
|
{
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public GameWorld(WorldMetadata metadata)
|
||||||
|
{
|
||||||
|
Metadata = metadata;
|
||||||
|
Projection = new LocalProjection(metadata.Origin);
|
||||||
|
Grid = new ChunkGrid(metadata.SizeMeters, metadata.ChunkSizeMeters);
|
||||||
|
Shapes = new ShapeStore();
|
||||||
|
Ecs = World.Create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public World Ecs { get; }
|
||||||
|
|
||||||
|
public ShapeStore Shapes { get; }
|
||||||
|
|
||||||
|
public LocalProjection Projection { get; }
|
||||||
|
|
||||||
|
public ChunkGrid Grid { get; }
|
||||||
|
|
||||||
|
public WorldMetadata Metadata { get; set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
World.Destroy(Ecs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Side table holding every polyline and ring in the world. ECS components carry a plain integer handle into
|
||||||
|
/// this store instead of an array reference, which keeps component data blittable and keeps the archetype
|
||||||
|
/// chunks dense.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShapeStore
|
||||||
|
{
|
||||||
|
private readonly List<Vector2[]> _shapes = [];
|
||||||
|
|
||||||
|
public int Count => _shapes.Count;
|
||||||
|
|
||||||
|
/// <summary>Takes ownership of <paramref name="points"/> and returns its handle.</summary>
|
||||||
|
public int Add(Vector2[] points)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(points);
|
||||||
|
_shapes.Add(points);
|
||||||
|
return _shapes.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReadOnlySpan<Vector2> Get(int shapeId) => _shapes[shapeId];
|
||||||
|
|
||||||
|
public int VertexCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var total = 0;
|
||||||
|
foreach (var shape in _shapes)
|
||||||
|
total += shape.Length;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
/// <summary>Everything needed to describe a generated world without loading its geometry.</summary>
|
||||||
|
public sealed record WorldMetadata
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The point the player asked for. It is the projection origin, so it sits at world (0, 0).</summary>
|
||||||
|
public required GeoPoint Origin { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Side length of the generated square, in metres.</summary>
|
||||||
|
public required double SizeMeters { get; init; }
|
||||||
|
|
||||||
|
public required GeoBounds Bounds { get; init; }
|
||||||
|
|
||||||
|
public required float ChunkSizeMeters { get; init; }
|
||||||
|
|
||||||
|
public required int ChunkCountX { get; init; }
|
||||||
|
|
||||||
|
public required int ChunkCountY { get; init; }
|
||||||
|
|
||||||
|
public required DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
|
||||||
|
public WorldStats Stats { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record WorldStats
|
||||||
|
{
|
||||||
|
public int Buildings { get; init; }
|
||||||
|
|
||||||
|
public int Roads { get; init; }
|
||||||
|
|
||||||
|
public int Areas { get; init; }
|
||||||
|
|
||||||
|
public int Water { get; init; }
|
||||||
|
|
||||||
|
public int Vertices { get; init; }
|
||||||
|
|
||||||
|
public int Total => Buildings + Roads + Areas + Water;
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns raw Overpass elements into ECS entities. Every feature becomes one entity whose components describe
|
||||||
|
/// what it is; the geometry itself lives in the world's <see cref="ShapeStore"/> and is referenced by handle.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Rings smaller than this are noise at any zoom the client offers.</summary>
|
||||||
|
private const float MinimumAreaSquareMeters = 1.0f;
|
||||||
|
|
||||||
|
public WorldStats Populate(GameWorld world, IEnumerable<OverpassElement> elements)
|
||||||
|
{
|
||||||
|
var half = (float)(world.Metadata.SizeMeters / 2.0);
|
||||||
|
var clip = new RectBounds(-half, -half, half, half);
|
||||||
|
var counters = new Counters();
|
||||||
|
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (element.IsWay) AddWay(world, element, clip, counters);
|
||||||
|
else if (element.IsRelation) AddRelation(world, element, clip, counters);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// One malformed element must not abort a whole world.
|
||||||
|
logger.LogWarning(ex, "Skipped {Type}/{Id} while importing", element.Type, element.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorldStats
|
||||||
|
{
|
||||||
|
Buildings = counters.Buildings,
|
||||||
|
Roads = counters.Roads,
|
||||||
|
Areas = counters.Areas,
|
||||||
|
Water = counters.Water,
|
||||||
|
Vertices = world.Shapes.VertexCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddWay(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
if (element.Geometry is not { Count: >= 2 } geometry) return;
|
||||||
|
|
||||||
|
var points = Project(world.Projection, geometry);
|
||||||
|
var closed = IsClosed(points);
|
||||||
|
var ring = closed ? TrimClosingVertex(points) : null;
|
||||||
|
var name = TagInterpreter.ReadName(element);
|
||||||
|
var source = new OsmSource(element.Id, OsmElementKind.Way);
|
||||||
|
|
||||||
|
if (element.HasTag("building") || element.HasTag("building:part"))
|
||||||
|
{
|
||||||
|
if (ring is null) return;
|
||||||
|
AddBuilding(world, source, TagInterpreter.ReadBuilding(element), ring, holes: null, name, clip, counters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TagInterpreter.ReadWater(element) is { } water)
|
||||||
|
{
|
||||||
|
if (ring is not null) AddWaterArea(world, source, water, ring, holes: null, name, clip, counters);
|
||||||
|
else AddWaterLine(world, source, water, points, name, clip, counters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TagInterpreter.IsRoad(element))
|
||||||
|
{
|
||||||
|
AddRoad(world, source, TagInterpreter.ReadRoad(element), points, name, clip, counters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TagInterpreter.ReadAreaKind(element) is { } areaKind && ring is not null)
|
||||||
|
AddArea(world, source, areaKind, ring, holes: null, name, clip, counters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRelation(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
if (element.Members is not { Count: > 0 } members) return;
|
||||||
|
|
||||||
|
var outerRings = AssembleRings(world.Projection, members, "outer");
|
||||||
|
if (outerRings.Count == 0) return;
|
||||||
|
|
||||||
|
var innerRings = AssembleRings(world.Projection, members, "inner");
|
||||||
|
var name = TagInterpreter.ReadName(element);
|
||||||
|
var source = new OsmSource(element.Id, OsmElementKind.Relation);
|
||||||
|
|
||||||
|
var isBuilding = element.HasTag("building");
|
||||||
|
var water = TagInterpreter.ReadWater(element);
|
||||||
|
var areaKind = TagInterpreter.ReadAreaKind(element);
|
||||||
|
|
||||||
|
if (!isBuilding && water is null && areaKind is null) return;
|
||||||
|
|
||||||
|
var building = isBuilding ? TagInterpreter.ReadBuilding(element) : default;
|
||||||
|
|
||||||
|
foreach (var outer in outerRings)
|
||||||
|
{
|
||||||
|
var holes = MatchHoles(outer, innerRings, outerRings.Count);
|
||||||
|
|
||||||
|
if (isBuilding) AddBuilding(world, source, building, outer, holes, name, clip, counters);
|
||||||
|
else if (water is { } w) AddWaterArea(world, source, w, outer, holes, name, clip, counters);
|
||||||
|
else if (areaKind is { } kind) AddArea(world, source, kind, outer, holes, name, clip, counters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assigns inner rings to the outer ring that encloses them. With a single outer ring the containment
|
||||||
|
/// test is skipped - every inner ring must belong to it.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Vector2[]>? MatchHoles(Vector2[] outer, List<Vector2[]> innerRings, int outerCount)
|
||||||
|
{
|
||||||
|
if (innerRings.Count == 0) return null;
|
||||||
|
if (outerCount == 1) return innerRings;
|
||||||
|
|
||||||
|
List<Vector2[]>? matched = null;
|
||||||
|
foreach (var inner in innerRings)
|
||||||
|
{
|
||||||
|
if (!Polygons.Contains(outer, inner[0])) continue;
|
||||||
|
matched ??= [];
|
||||||
|
matched.Add(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector2[]> AssembleRings(LocalProjection projection, List<OverpassMember> members, string role)
|
||||||
|
{
|
||||||
|
var segments = new List<IReadOnlyList<Vector2>>();
|
||||||
|
|
||||||
|
foreach (var member in members)
|
||||||
|
{
|
||||||
|
if (member.Type != "way") continue;
|
||||||
|
|
||||||
|
// An empty role means "outer" by the multipolygon spec.
|
||||||
|
var memberRole = string.IsNullOrEmpty(member.Role) ? "outer" : member.Role;
|
||||||
|
if (memberRole != role) continue;
|
||||||
|
|
||||||
|
if (member.Geometry is { Count: >= 2 } geometry)
|
||||||
|
segments.Add(Project(projection, geometry));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.Count == 0 ? [] : RingAssembler.Assemble(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddBuilding(
|
||||||
|
GameWorld world, OsmSource source, Building building, Vector2[] ring,
|
||||||
|
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
if (ClipRing(ring, clip) is not { } clipped) return;
|
||||||
|
|
||||||
|
world.Ecs.Create(
|
||||||
|
source,
|
||||||
|
building,
|
||||||
|
new Outline(world.Shapes.Add(clipped)),
|
||||||
|
new Holes(StoreHoles(world, holes, clip)),
|
||||||
|
new Bounds(),
|
||||||
|
new InChunk(),
|
||||||
|
new DisplayName(name));
|
||||||
|
|
||||||
|
counters.Buildings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddArea(
|
||||||
|
GameWorld world, OsmSource source, AreaKind kind, Vector2[] ring,
|
||||||
|
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
if (ClipRing(ring, clip) is not { } clipped) return;
|
||||||
|
|
||||||
|
world.Ecs.Create(
|
||||||
|
source,
|
||||||
|
new AreaFeature(kind),
|
||||||
|
new Outline(world.Shapes.Add(clipped)),
|
||||||
|
new Holes(StoreHoles(world, holes, clip)),
|
||||||
|
new Bounds(),
|
||||||
|
new InChunk(),
|
||||||
|
new DisplayName(name));
|
||||||
|
|
||||||
|
counters.Areas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddWaterArea(
|
||||||
|
GameWorld world, OsmSource source, Water water, Vector2[] ring,
|
||||||
|
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
if (ClipRing(ring, clip) is not { } clipped) return;
|
||||||
|
|
||||||
|
world.Ecs.Create(
|
||||||
|
source,
|
||||||
|
water,
|
||||||
|
new Outline(world.Shapes.Add(clipped)),
|
||||||
|
new Holes(StoreHoles(world, holes, clip)),
|
||||||
|
new Bounds(),
|
||||||
|
new InChunk(),
|
||||||
|
new DisplayName(name));
|
||||||
|
|
||||||
|
counters.Water++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRoad(
|
||||||
|
GameWorld world, OsmSource source, Road road, Vector2[] points,
|
||||||
|
string? name, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
|
||||||
|
{
|
||||||
|
world.Ecs.Create(
|
||||||
|
source,
|
||||||
|
road,
|
||||||
|
new Polyline(world.Shapes.Add(run)),
|
||||||
|
new Bounds(),
|
||||||
|
new InChunk(),
|
||||||
|
new DisplayName(name));
|
||||||
|
|
||||||
|
counters.Roads++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddWaterLine(
|
||||||
|
GameWorld world, OsmSource source, Water water, Vector2[] points,
|
||||||
|
string? name, in RectBounds clip, Counters counters)
|
||||||
|
{
|
||||||
|
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
|
||||||
|
{
|
||||||
|
world.Ecs.Create(
|
||||||
|
source,
|
||||||
|
water,
|
||||||
|
new Polyline(world.Shapes.Add(run)),
|
||||||
|
new Bounds(),
|
||||||
|
new InChunk(),
|
||||||
|
new DisplayName(name));
|
||||||
|
|
||||||
|
counters.Water++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[]? StoreHoles(GameWorld world, List<Vector2[]>? holes, in RectBounds clip)
|
||||||
|
{
|
||||||
|
if (holes is null || holes.Count == 0) return null;
|
||||||
|
|
||||||
|
List<int>? ids = null;
|
||||||
|
foreach (var hole in holes)
|
||||||
|
{
|
||||||
|
if (ClipRing(hole, clip) is not { } clipped) continue;
|
||||||
|
ids ??= [];
|
||||||
|
ids.Add(world.Shapes.Add(clipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids?.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2[]? ClipRing(Vector2[] ring, in RectBounds clip)
|
||||||
|
{
|
||||||
|
var clipped = GeometryClipper.ClipPolygon(ring, clip);
|
||||||
|
return clipped is not null && Polygons.Area(clipped) >= MinimumAreaSquareMeters ? clipped : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2[] Project(LocalProjection projection, List<OverpassNode> geometry)
|
||||||
|
{
|
||||||
|
var points = new Vector2[geometry.Count];
|
||||||
|
for (var i = 0; i < geometry.Count; i++)
|
||||||
|
points[i] = projection.Project(geometry[i].Lat, geometry[i].Lon);
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsClosed(Vector2[] points) =>
|
||||||
|
points.Length >= 4 && Vector2.DistanceSquared(points[0], points[^1]) < 0.0025f;
|
||||||
|
|
||||||
|
private static Vector2[] TrimClosingVertex(Vector2[] points) => points[..^1];
|
||||||
|
|
||||||
|
private sealed class Counters
|
||||||
|
{
|
||||||
|
public int Buildings;
|
||||||
|
public int Roads;
|
||||||
|
public int Areas;
|
||||||
|
public int Water;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Systems;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
public sealed record WorldGenerationRequest
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
public required GeoPoint Origin { get; init; }
|
||||||
|
|
||||||
|
public required double SizeMeters { get; init; }
|
||||||
|
|
||||||
|
public float ChunkSizeMeters { get; init; } = 512f;
|
||||||
|
|
||||||
|
public bool ForceRefresh { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one-shot world generation pass: pull the box from Overpass, import it into an ECS world, then run the
|
||||||
|
/// systems that give every feature its extent and chunk.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OsmWorldGenerator(
|
||||||
|
OverpassClient client,
|
||||||
|
OsmWorldBuilder builder,
|
||||||
|
ILogger<OsmWorldGenerator> logger)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions ParseOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly WorldPipeline _pipeline = WorldPipeline.CreateDefault();
|
||||||
|
|
||||||
|
public async Task<GameWorld> GenerateAsync(
|
||||||
|
WorldGenerationRequest request,
|
||||||
|
IProgress<string>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var bounds = GeoBounds.FromCenter(request.Origin, request.SizeMeters);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Generating world {Id} at {Origin} spanning {Size:N0} m",
|
||||||
|
request.Id, request.Origin, request.SizeMeters);
|
||||||
|
|
||||||
|
var cachePath = await client
|
||||||
|
.FetchAsync(bounds, request.ForceRefresh, progress, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress?.Report("Parsing OpenStreetMap response");
|
||||||
|
var response = await ReadResponseAsync(cachePath, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Overpass reports an aborted query as a remark inside an otherwise valid 200 response. Caching that
|
||||||
|
// would poison every later run for this box, so the file goes and the caller sees the reason.
|
||||||
|
if (response.Remark is { Length: > 0 } remark && response.Elements.Count == 0)
|
||||||
|
{
|
||||||
|
TryDelete(cachePath);
|
||||||
|
throw new OverpassException($"Overpass could not complete the query: {remark}");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Parsed {Count:N0} OSM elements", response.Elements.Count);
|
||||||
|
|
||||||
|
var world = new GameWorld(new WorldMetadata
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
Name = request.Name,
|
||||||
|
Origin = request.Origin,
|
||||||
|
SizeMeters = request.SizeMeters,
|
||||||
|
Bounds = bounds,
|
||||||
|
ChunkSizeMeters = request.ChunkSizeMeters,
|
||||||
|
ChunkCountX = 0,
|
||||||
|
ChunkCountY = 0,
|
||||||
|
GeneratedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report($"Importing {response.Elements.Count:N0} elements");
|
||||||
|
var stats = builder.Populate(world, response.Elements);
|
||||||
|
|
||||||
|
progress?.Report("Computing bounds and chunks");
|
||||||
|
_pipeline.Run(world);
|
||||||
|
|
||||||
|
world.Metadata = world.Metadata with
|
||||||
|
{
|
||||||
|
ChunkCountX = world.Grid.CountX,
|
||||||
|
ChunkCountY = world.Grid.CountY,
|
||||||
|
Stats = stats,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"World {Id} built: {Buildings:N0} buildings, {Roads:N0} roads, {Areas:N0} areas, {Water:N0} water",
|
||||||
|
request.Id, stats.Buildings, stats.Roads, stats.Areas, stats.Water);
|
||||||
|
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
world.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the cached response in one pass. Small-town boxes are a few megabytes of JSON; streaming the
|
||||||
|
/// element array would be the next step if this ever has to swallow a dense metropolis.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<OverpassResponse> ReadResponseAsync(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(path);
|
||||||
|
var response = await JsonSerializer
|
||||||
|
.DeserializeAsync<OverpassResponse>(stream, ParseOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response ?? throw new OverpassException($"Cached Overpass response '{path}' is empty or malformed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Could not remove the unusable cached response {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stitches the member ways of an OpenStreetMap multipolygon into closed rings. Members arrive as arbitrary
|
||||||
|
/// fragments in arbitrary order and direction, so rings are grown by repeatedly attaching whichever fragment
|
||||||
|
/// shares an endpoint with the open end.
|
||||||
|
/// </summary>
|
||||||
|
public static class RingAssembler
|
||||||
|
{
|
||||||
|
/// <summary>Endpoints come from identical OSM nodes, so they match to well under a centimetre.</summary>
|
||||||
|
private const float ToleranceMeters = 0.05f;
|
||||||
|
|
||||||
|
private const float ToleranceSquared = ToleranceMeters * ToleranceMeters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles <paramref name="segments"/> into closed rings. Fragments that never close are dropped -
|
||||||
|
/// broken multipolygons exist in the wild and a half-open ring cannot be filled.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Vector2[]> Assemble(IEnumerable<IReadOnlyList<Vector2>> segments)
|
||||||
|
{
|
||||||
|
var pending = new List<List<Vector2>>();
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
if (segment.Count >= 2) pending.Add([.. segment]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rings = new List<Vector2[]>();
|
||||||
|
|
||||||
|
while (pending.Count > 0)
|
||||||
|
{
|
||||||
|
var ring = pending[^1];
|
||||||
|
pending.RemoveAt(pending.Count - 1);
|
||||||
|
|
||||||
|
while (!IsClosed(ring) && TryTakeConnecting(pending, ring[^1], out var next))
|
||||||
|
{
|
||||||
|
// The first vertex of `next` duplicates the open end, so skip it when appending.
|
||||||
|
for (var i = 1; i < next.Count; i++)
|
||||||
|
ring.Add(next[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsClosed(ring)) continue;
|
||||||
|
|
||||||
|
ring.RemoveAt(ring.Count - 1);
|
||||||
|
if (ring.Count >= 3) rings.Add(ring.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
return rings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsClosed(List<Vector2> ring) =>
|
||||||
|
ring.Count >= 4 && Vector2.DistanceSquared(ring[0], ring[^1]) <= ToleranceSquared;
|
||||||
|
|
||||||
|
/// <summary>Removes and returns the fragment touching <paramref name="openEnd"/>, reversed if needed.</summary>
|
||||||
|
private static bool TryTakeConnecting(List<List<Vector2>> pending, Vector2 openEnd, out List<Vector2> segment)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < pending.Count; i++)
|
||||||
|
{
|
||||||
|
var candidate = pending[i];
|
||||||
|
|
||||||
|
if (Vector2.DistanceSquared(candidate[0], openEnd) <= ToleranceSquared)
|
||||||
|
{
|
||||||
|
pending.RemoveAt(i);
|
||||||
|
segment = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Vector2.DistanceSquared(candidate[^1], openEnd) <= ToleranceSquared)
|
||||||
|
{
|
||||||
|
pending.RemoveAt(i);
|
||||||
|
candidate.Reverse();
|
||||||
|
segment = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segment = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collapses OpenStreetMap's open-ended tagging into the small closed set of kinds the game renders and will
|
||||||
|
/// later simulate. Anything unrecognised falls back to <c>Unknown</c> rather than being dropped, so a feature
|
||||||
|
/// still shows up on the map even if the game does not yet understand it.
|
||||||
|
/// </summary>
|
||||||
|
public static class TagInterpreter
|
||||||
|
{
|
||||||
|
private const float MetersPerLevel = 3.0f;
|
||||||
|
|
||||||
|
private static readonly string[] UnpavedSurfaces =
|
||||||
|
["unpaved", "gravel", "dirt", "ground", "grass", "sand", "compacted", "fine_gravel", "earth", "mud"];
|
||||||
|
|
||||||
|
public static Building ReadBuilding(OverpassElement element)
|
||||||
|
{
|
||||||
|
var kind = ReadBuildingKind(element.Tag("building") ?? element.Tag("building:part"));
|
||||||
|
var levels = ReadLevels(element);
|
||||||
|
var height = ReadHeight(element, kind, levels);
|
||||||
|
return new Building(kind, height, levels);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BuildingKind ReadBuildingKind(string? value) => value switch
|
||||||
|
{
|
||||||
|
"house" or "detached" or "semidetached_house" or "bungalow" or "cabin" or "static_caravan" => BuildingKind.House,
|
||||||
|
"residential" or "terrace" or "dormitory" => BuildingKind.Residential,
|
||||||
|
"apartments" or "flats" => BuildingKind.Apartments,
|
||||||
|
"commercial" or "office" or "hotel" or "kiosk" => BuildingKind.Commercial,
|
||||||
|
"retail" or "supermarket" or "shop" or "mall" => BuildingKind.Retail,
|
||||||
|
"industrial" or "warehouse" or "factory" or "manufacture" or "silo" or "storage_tank" => BuildingKind.Industrial,
|
||||||
|
"civic" or "public" or "government" or "hospital" or "fire_station" or "train_station" => BuildingKind.Civic,
|
||||||
|
"school" or "university" or "college" or "kindergarten" => BuildingKind.School,
|
||||||
|
"church" or "chapel" or "cathedral" or "mosque" or "temple" or "synagogue" or "religious" => BuildingKind.Church,
|
||||||
|
"garage" or "garages" or "carport" => BuildingKind.Garage,
|
||||||
|
"shed" or "hut" or "roof" or "container" or "service" => BuildingKind.Shed,
|
||||||
|
"farm" or "barn" or "farm_auxiliary" or "stable" or "greenhouse" or "cowshed" => BuildingKind.Farm,
|
||||||
|
"ruins" or "collapsed" => BuildingKind.Ruins,
|
||||||
|
_ => BuildingKind.Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static byte ReadLevels(OverpassElement element)
|
||||||
|
{
|
||||||
|
var raw = element.Tag("building:levels") ?? element.Tag("levels");
|
||||||
|
if (TryParseLeadingNumber(raw, out var levels) && levels is > 0 and < 200)
|
||||||
|
return (byte)Math.Round(levels);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ReadHeight(OverpassElement element, BuildingKind kind, byte levels)
|
||||||
|
{
|
||||||
|
if (TryParseLeadingNumber(element.Tag("height"), out var height) && height is > 0 and < 1000)
|
||||||
|
return (float)height;
|
||||||
|
|
||||||
|
if (levels > 0) return levels * MetersPerLevel;
|
||||||
|
|
||||||
|
// A sensible silhouette beats no height at all; the client shades buildings by it.
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
BuildingKind.Shed or BuildingKind.Garage => 3f,
|
||||||
|
BuildingKind.House => 5f,
|
||||||
|
BuildingKind.Farm => 7f,
|
||||||
|
BuildingKind.Apartments => 15f,
|
||||||
|
BuildingKind.Industrial => 9f,
|
||||||
|
BuildingKind.Church => 12f,
|
||||||
|
_ => 6f,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRoad(OverpassElement element) =>
|
||||||
|
element.HasTag("highway") || element.HasTag("railway");
|
||||||
|
|
||||||
|
public static Road ReadRoad(OverpassElement element)
|
||||||
|
{
|
||||||
|
if (element.Tag("highway") is not { } highway)
|
||||||
|
return new Road(RoadClass.Railway, 3f, 0, ReadRoadFlags(element));
|
||||||
|
|
||||||
|
var roadClass = ReadRoadClass(highway);
|
||||||
|
var lanes = ReadLanes(element);
|
||||||
|
var width = ReadRoadWidth(element, roadClass, lanes);
|
||||||
|
return new Road(roadClass, width, lanes, ReadRoadFlags(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RoadClass ReadRoadClass(string highway)
|
||||||
|
{
|
||||||
|
// motorway_link and friends carry the same traffic role as their parent class.
|
||||||
|
var trimmed = highway.EndsWith("_link", StringComparison.Ordinal) ? highway[..^5] : highway;
|
||||||
|
|
||||||
|
return trimmed switch
|
||||||
|
{
|
||||||
|
"motorway" => RoadClass.Motorway,
|
||||||
|
"trunk" => RoadClass.Trunk,
|
||||||
|
"primary" => RoadClass.Primary,
|
||||||
|
"secondary" => RoadClass.Secondary,
|
||||||
|
"tertiary" => RoadClass.Tertiary,
|
||||||
|
"unclassified" or "road" => RoadClass.Unclassified,
|
||||||
|
"residential" => RoadClass.Residential,
|
||||||
|
"living_street" => RoadClass.LivingStreet,
|
||||||
|
"service" => RoadClass.Service,
|
||||||
|
"track" => RoadClass.Track,
|
||||||
|
"pedestrian" => RoadClass.Pedestrian,
|
||||||
|
"footway" or "sidewalk" => RoadClass.Footway,
|
||||||
|
"cycleway" => RoadClass.Cycleway,
|
||||||
|
"steps" => RoadClass.Steps,
|
||||||
|
"path" or "bridleway" => RoadClass.Path,
|
||||||
|
_ => RoadClass.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte ReadLanes(OverpassElement element) =>
|
||||||
|
TryParseLeadingNumber(element.Tag("lanes"), out var lanes) && lanes is > 0 and <= 16
|
||||||
|
? (byte)Math.Round(lanes)
|
||||||
|
: (byte)0;
|
||||||
|
|
||||||
|
private static float ReadRoadWidth(OverpassElement element, RoadClass roadClass, byte lanes)
|
||||||
|
{
|
||||||
|
if (TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 100)
|
||||||
|
return (float)width;
|
||||||
|
|
||||||
|
if (lanes > 0) return lanes * 3.4f;
|
||||||
|
|
||||||
|
return roadClass switch
|
||||||
|
{
|
||||||
|
RoadClass.Motorway => 14f,
|
||||||
|
RoadClass.Trunk => 12f,
|
||||||
|
RoadClass.Primary => 10f,
|
||||||
|
RoadClass.Secondary => 9f,
|
||||||
|
RoadClass.Tertiary => 8f,
|
||||||
|
RoadClass.Residential or RoadClass.Unclassified => 6.5f,
|
||||||
|
RoadClass.LivingStreet => 5.5f,
|
||||||
|
RoadClass.Pedestrian => 5f,
|
||||||
|
RoadClass.Service => 4f,
|
||||||
|
RoadClass.Track => 3f,
|
||||||
|
RoadClass.Cycleway => 2f,
|
||||||
|
RoadClass.Footway or RoadClass.Path or RoadClass.Steps => 1.5f,
|
||||||
|
RoadClass.Railway => 3f,
|
||||||
|
_ => 5f,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RoadFlags ReadRoadFlags(OverpassElement element)
|
||||||
|
{
|
||||||
|
var flags = RoadFlags.None;
|
||||||
|
|
||||||
|
if (element.Tag("bridge") is { } bridge && bridge != "no") flags |= RoadFlags.Bridge;
|
||||||
|
if (element.Tag("tunnel") is { } tunnel && tunnel != "no") flags |= RoadFlags.Tunnel;
|
||||||
|
if (element.Tag("oneway") is "yes" or "1" or "-1") flags |= RoadFlags.Oneway;
|
||||||
|
|
||||||
|
if (element.Tag("surface") is { } surface && Array.IndexOf(UnpavedSurfaces, surface) >= 0)
|
||||||
|
flags |= RoadFlags.Unpaved;
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recognises water bodies and watercourses. Returns null when the element is not water.</summary>
|
||||||
|
public static Water? ReadWater(OverpassElement element)
|
||||||
|
{
|
||||||
|
if (element.Tag("natural") == "water")
|
||||||
|
return new Water(ReadWaterBodyKind(element.Tag("water")), 0f);
|
||||||
|
|
||||||
|
if (element.Tag("landuse") is "reservoir")
|
||||||
|
return new Water(WaterKind.Reservoir, 0f);
|
||||||
|
|
||||||
|
if (element.Tag("landuse") is "basin")
|
||||||
|
return new Water(WaterKind.Water, 0f);
|
||||||
|
|
||||||
|
if (element.Tag("waterway") is not { } waterway) return null;
|
||||||
|
|
||||||
|
return waterway switch
|
||||||
|
{
|
||||||
|
"riverbank" => new Water(WaterKind.Riverbank, 0f),
|
||||||
|
"river" => new Water(WaterKind.River, ReadWaterwayWidth(element, 12f)),
|
||||||
|
"stream" => new Water(WaterKind.Stream, ReadWaterwayWidth(element, 3f)),
|
||||||
|
"canal" => new Water(WaterKind.Canal, ReadWaterwayWidth(element, 8f)),
|
||||||
|
"ditch" => new Water(WaterKind.Ditch, ReadWaterwayWidth(element, 1.5f)),
|
||||||
|
"drain" => new Water(WaterKind.Drain, ReadWaterwayWidth(element, 1.5f)),
|
||||||
|
// dam, weir, lock_gate and the like are structures, not water.
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WaterKind ReadWaterBodyKind(string? water) => water switch
|
||||||
|
{
|
||||||
|
"lake" => WaterKind.Lake,
|
||||||
|
"pond" => WaterKind.Pond,
|
||||||
|
"reservoir" or "basin" => WaterKind.Reservoir,
|
||||||
|
"river" => WaterKind.Riverbank,
|
||||||
|
_ => WaterKind.Water,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static float ReadWaterwayWidth(OverpassElement element, float fallback) =>
|
||||||
|
TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 500
|
||||||
|
? (float)width
|
||||||
|
: fallback;
|
||||||
|
|
||||||
|
/// <summary>Recognises land cover polygons. Returns null when the element is not one.</summary>
|
||||||
|
public static AreaKind? ReadAreaKind(OverpassElement element)
|
||||||
|
{
|
||||||
|
if (element.Tag("landuse") is { } landuse)
|
||||||
|
{
|
||||||
|
var kind = landuse switch
|
||||||
|
{
|
||||||
|
"forest" => AreaKind.Forest,
|
||||||
|
"grass" or "village_green" or "greenfield" => AreaKind.Grass,
|
||||||
|
"meadow" => AreaKind.Meadow,
|
||||||
|
"farmland" or "farmyard" or "allotments" => AreaKind.Farmland,
|
||||||
|
"orchard" or "vineyard" or "plant_nursery" => AreaKind.Orchard,
|
||||||
|
"residential" => AreaKind.ResidentialZone,
|
||||||
|
"industrial" or "railway" or "landfill" => AreaKind.IndustrialZone,
|
||||||
|
"commercial" => AreaKind.CommercialZone,
|
||||||
|
"retail" => AreaKind.RetailZone,
|
||||||
|
"quarry" => AreaKind.Quarry,
|
||||||
|
"cemetery" => AreaKind.Cemetery,
|
||||||
|
"recreation_ground" => AreaKind.Park,
|
||||||
|
_ => (AreaKind?)null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind is not null) return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.Tag("natural") is { } natural)
|
||||||
|
{
|
||||||
|
var kind = natural switch
|
||||||
|
{
|
||||||
|
"wood" or "tree_row" => AreaKind.Forest,
|
||||||
|
"scrub" => AreaKind.Scrub,
|
||||||
|
"heath" => AreaKind.Heath,
|
||||||
|
"grassland" => AreaKind.Grass,
|
||||||
|
"sand" or "dune" => AreaKind.Sand,
|
||||||
|
"bare_rock" or "rock" or "scree" or "cliff" => AreaKind.BareRock,
|
||||||
|
"wetland" or "marsh" => AreaKind.Wetland,
|
||||||
|
"beach" => AreaKind.Beach,
|
||||||
|
_ => (AreaKind?)null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind is not null) return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.Tag("leisure") is { } leisure)
|
||||||
|
{
|
||||||
|
var kind = leisure switch
|
||||||
|
{
|
||||||
|
"park" or "nature_reserve" or "common" => AreaKind.Park,
|
||||||
|
"garden" => AreaKind.Garden,
|
||||||
|
"pitch" or "playground" or "track" or "sports_centre" or "stadium" => AreaKind.Pitch,
|
||||||
|
"golf_course" => AreaKind.Grass,
|
||||||
|
_ => (AreaKind?)null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind is not null) return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element.Tag("amenity") switch
|
||||||
|
{
|
||||||
|
"parking" => AreaKind.Parking,
|
||||||
|
"school" or "college" or "university" or "kindergarten" => AreaKind.School,
|
||||||
|
"grave_yard" => AreaKind.Cemetery,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? ReadName(OverpassElement element) =>
|
||||||
|
element.Tag("name") is { Length: > 0 } name ? name : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the numeric prefix of a tag value. OpenStreetMap allows units and stray text, so
|
||||||
|
/// <c>"12 m"</c> and <c>"3.5"</c> both need to work while <c>"tall"</c> must not.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool TryParseLeadingNumber(string? value, out double number)
|
||||||
|
{
|
||||||
|
number = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||||
|
|
||||||
|
var span = value.AsSpan().TrimStart();
|
||||||
|
var length = 0;
|
||||||
|
while (length < span.Length && (char.IsAsciiDigit(span[length]) || span[length] is '.' or '-' or '+'))
|
||||||
|
length++;
|
||||||
|
|
||||||
|
return length > 0 &&
|
||||||
|
double.TryParse(span[..length], NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace TheLivingWorld.Osm;
|
||||||
|
|
||||||
|
public sealed class OsmOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Osm";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overpass mirrors, tried in order. The public instances are shared infrastructure and rate limited, so
|
||||||
|
/// responses are cached on disk and a world is only ever downloaded once.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Endpoints { get; set; } =
|
||||||
|
[
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
"https://overpass.kumi.systems/api/interpreter",
|
||||||
|
"https://overpass.private.coffee/api/interpreter",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Server-side query budget, passed to Overpass as <c>[timeout:...]</c>.</summary>
|
||||||
|
public int QueryTimeoutSeconds { get; set; } = 180;
|
||||||
|
|
||||||
|
/// <summary>Client-side ceiling; must exceed <see cref="QueryTimeoutSeconds"/> to let the server reply first.</summary>
|
||||||
|
public int RequestTimeoutSeconds { get; set; } = 240;
|
||||||
|
|
||||||
|
public int MaxAttemptsPerEndpoint { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>Overpass asks that clients identify themselves so abusive traffic can be traced.</summary>
|
||||||
|
public string UserAgent { get; set; } = "TheLivingWorld/0.1 (map generator; contact: repository owner)";
|
||||||
|
|
||||||
|
/// <summary>Where raw Overpass responses are cached, relative to the content root unless rooted.</summary>
|
||||||
|
public string CacheDirectory { get; set; } = "data/osm-cache";
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TheLivingWorld.Osm.Import;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm;
|
||||||
|
|
||||||
|
public static class OsmServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the OpenStreetMap import pipeline. The returned builder is the Overpass HTTP client, which
|
||||||
|
/// callers usually need to adjust: Overpass queries run for minutes and do not fit default HTTP policies.
|
||||||
|
/// </summary>
|
||||||
|
public static IHttpClientBuilder AddOsmImport(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<OsmOptions>()
|
||||||
|
.Bind(configuration.GetSection(OsmOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<OsmWorldBuilder>();
|
||||||
|
services.AddSingleton<OsmWorldGenerator>();
|
||||||
|
|
||||||
|
return services.AddHttpClient<OverpassClient>(OverpassClient.HttpClientName, static (provider, client) =>
|
||||||
|
{
|
||||||
|
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OsmOptions>>().Value;
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads a bounding box of OpenStreetMap data through Overpass and keeps the raw response on disk.
|
||||||
|
/// A world is generated once, so the cache means re-running generation for the same box costs nothing and
|
||||||
|
/// the public Overpass mirrors are not hit twice for the same data.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OverpassClient(
|
||||||
|
HttpClient http,
|
||||||
|
IOptions<OsmOptions> options,
|
||||||
|
ILogger<OverpassClient> logger)
|
||||||
|
{
|
||||||
|
public const string HttpClientName = "overpass";
|
||||||
|
|
||||||
|
private static readonly HttpStatusCode[] RetryableStatuses =
|
||||||
|
[
|
||||||
|
HttpStatusCode.TooManyRequests,
|
||||||
|
HttpStatusCode.BadGateway,
|
||||||
|
HttpStatusCode.ServiceUnavailable,
|
||||||
|
HttpStatusCode.GatewayTimeout,
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly OsmOptions _options = options.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the path of the file holding the raw Overpass JSON for <paramref name="bounds"/>, downloading
|
||||||
|
/// it first if it is not already cached.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> FetchAsync(
|
||||||
|
GeoBounds bounds,
|
||||||
|
bool forceRefresh,
|
||||||
|
IProgress<string>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = OverpassQuery.Build(bounds, _options.QueryTimeoutSeconds);
|
||||||
|
var cachePath = ResolveCachePath(query);
|
||||||
|
|
||||||
|
if (!forceRefresh && File.Exists(cachePath))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Using cached Overpass response {Path}", cachePath);
|
||||||
|
progress?.Report("Using cached OpenStreetMap data");
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
|
||||||
|
|
||||||
|
// Download to a temporary file first: a half-written response must never end up in the cache.
|
||||||
|
var tempPath = cachePath + ".partial";
|
||||||
|
Exception? lastFailure = null;
|
||||||
|
|
||||||
|
foreach (var endpoint in _options.Endpoints)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; attempt <= _options.MaxAttemptsPerEndpoint; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
progress?.Report($"Querying OpenStreetMap ({new Uri(endpoint).Host}, attempt {attempt})");
|
||||||
|
await DownloadAsync(endpoint, query, tempPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
File.Move(tempPath, cachePath, overwrite: true);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Downloaded {Bytes:N0} bytes of OSM data from {Endpoint}",
|
||||||
|
new FileInfo(cachePath).Length,
|
||||||
|
endpoint);
|
||||||
|
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
TryDeleteTemp(tempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastFailure = ex;
|
||||||
|
TryDeleteTemp(tempPath);
|
||||||
|
logger.LogWarning(ex, "Overpass request to {Endpoint} failed (attempt {Attempt})", endpoint, attempt);
|
||||||
|
|
||||||
|
if (attempt < _options.MaxAttemptsPerEndpoint)
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3 * attempt), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message surfaces in the UI, so it carries the last reason rather than pointing at a log.
|
||||||
|
throw new OverpassException(
|
||||||
|
$"Every Overpass endpoint failed. Last error: {lastFailure?.Message ?? "unknown"}",
|
||||||
|
lastFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadAsync(string endpoint, string query, string destination, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// HttpClient.Timeout stops applying once the headers are in, and a busy Overpass instance will answer
|
||||||
|
// 200 immediately and then sit on the body indefinitely. This deadline covers the whole download.
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
deadline.CancelAfter(TimeSpan.FromSeconds(_options.RequestTimeoutSeconds));
|
||||||
|
var token = deadline.Token;
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||||
|
{
|
||||||
|
Content = new StringContent(query, Encoding.UTF8, "text/plain"),
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await http
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var message = $"Overpass returned {(int)response.StatusCode} {response.StatusCode}: " +
|
||||||
|
await ReadSnippetAsync(response, token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
throw Array.IndexOf(RetryableStatuses, response.StatusCode) >= 0
|
||||||
|
? new OverpassTransientException(message)
|
||||||
|
: new OverpassException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An overloaded instance reports "the server is probably too busy" as an HTML page under a 200.
|
||||||
|
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||||
|
if (mediaType is not null && !mediaType.Contains("json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new OverpassTransientException(
|
||||||
|
$"Overpass returned {mediaType} instead of JSON: " +
|
||||||
|
await ReadSnippetAsync(response, token).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||||
|
await using var target = File.Create(destination);
|
||||||
|
await source.CopyToAsync(target, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Overpass error pages are verbose HTML; only the first few hundred characters are useful.</summary>
|
||||||
|
private static async Task<string> ReadSnippetAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var text = System.Text.RegularExpressions.Regex.Replace(body, "<[^>]*>", " ");
|
||||||
|
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
||||||
|
return text.Length > 300 ? text[..300] : text;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
return "(response body unavailable)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cache key is the query itself, so any change to the box or the query invalidates it.</summary>
|
||||||
|
private string ResolveCachePath(string query)
|
||||||
|
{
|
||||||
|
var payload = $"v{OverpassQuery.Version}\n{query}";
|
||||||
|
var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
|
||||||
|
return Path.Combine(_options.CacheDirectory, $"{hash}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteTemp(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// A leftover .partial file is harmless; it is overwritten on the next attempt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OverpassException : Exception
|
||||||
|
{
|
||||||
|
public OverpassException(string message) : base(message) { }
|
||||||
|
|
||||||
|
public OverpassException(string message, Exception? inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OverpassTransientException(string message) : OverpassException(message);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
/// <summary>Shape of an Overpass <c>[out:json]</c> response requested with <c>out geom;</c>.</summary>
|
||||||
|
public sealed class OverpassResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("elements")]
|
||||||
|
public List<OverpassElement> Elements { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>Set when Overpass aborts mid-query, typically on a timeout or memory limit.</summary>
|
||||||
|
[JsonPropertyName("remark")]
|
||||||
|
public string? Remark { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OverpassElement
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public Dictionary<string, string>? Tags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Present on ways when the query used <c>out geom</c>.</summary>
|
||||||
|
[JsonPropertyName("geometry")]
|
||||||
|
public List<OverpassNode>? Geometry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Present on relations; each member carries its own geometry under <c>out geom</c>.</summary>
|
||||||
|
[JsonPropertyName("members")]
|
||||||
|
public List<OverpassMember>? Members { get; set; }
|
||||||
|
|
||||||
|
public bool IsWay => Type == "way";
|
||||||
|
|
||||||
|
public bool IsRelation => Type == "relation";
|
||||||
|
|
||||||
|
public string? Tag(string key) => Tags is not null && Tags.TryGetValue(key, out var value) ? value : null;
|
||||||
|
|
||||||
|
public bool HasTag(string key) => Tags is not null && Tags.ContainsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OverpassMember
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("ref")]
|
||||||
|
public long Ref { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("role")]
|
||||||
|
public string? Role { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("geometry")]
|
||||||
|
public List<OverpassNode>? Geometry { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct OverpassNode
|
||||||
|
{
|
||||||
|
[JsonPropertyName("lat")]
|
||||||
|
public double Lat { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lon")]
|
||||||
|
public double Lon { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
public static class OverpassQuery
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bumped whenever <see cref="Build"/> changes shape. It is mixed into the cache key so an edited query
|
||||||
|
/// never silently reuses a response fetched under the old one.
|
||||||
|
/// </summary>
|
||||||
|
public const int Version = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the map renderer draws: building footprints, the road and rail network, water, and the
|
||||||
|
/// land-cover polygons underneath them. <c>out geom</c> inlines coordinates into each way and relation
|
||||||
|
/// member, so no second pass is needed to resolve node references.
|
||||||
|
/// </summary>
|
||||||
|
public static string Build(GeoBounds bounds, int timeoutSeconds)
|
||||||
|
{
|
||||||
|
var bbox = bounds.ToOverpassBbox();
|
||||||
|
var timeout = timeoutSeconds.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return $"""
|
||||||
|
[out:json][timeout:{timeout}];
|
||||||
|
(
|
||||||
|
way["building"]({bbox});
|
||||||
|
relation["building"]["type"="multipolygon"]({bbox});
|
||||||
|
way["building:part"]({bbox});
|
||||||
|
way["highway"]({bbox});
|
||||||
|
way["railway"~"^(rail|light_rail|narrow_gauge|tram|subway|disused|abandoned)$"]({bbox});
|
||||||
|
way["waterway"]({bbox});
|
||||||
|
way["natural"]({bbox});
|
||||||
|
relation["natural"]["type"="multipolygon"]({bbox});
|
||||||
|
way["landuse"]({bbox});
|
||||||
|
relation["landuse"]["type"="multipolygon"]({bbox});
|
||||||
|
way["leisure"]({bbox});
|
||||||
|
relation["leisure"]["type"="multipolygon"]({bbox});
|
||||||
|
way["amenity"~"^(parking|school|grave_yard)$"]({bbox});
|
||||||
|
);
|
||||||
|
out geom;
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="TheLivingWorld.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.ServiceDiscovery;
|
||||||
|
using OpenTelemetry;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
|
|
||||||
|
namespace Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||||
|
// This project should be referenced by each service project in your solution.
|
||||||
|
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
private const string HealthEndpointPath = "/health";
|
||||||
|
private const string AlivenessEndpointPath = "/alive";
|
||||||
|
|
||||||
|
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.ConfigureOpenTelemetry();
|
||||||
|
|
||||||
|
builder.AddDefaultHealthChecks();
|
||||||
|
|
||||||
|
builder.Services.AddServiceDiscovery();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||||
|
{
|
||||||
|
// Turn on resilience by default
|
||||||
|
http.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
// Turn on service discovery by default
|
||||||
|
http.AddServiceDiscovery();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||||
|
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||||
|
// {
|
||||||
|
// options.AllowedSchemes = ["https"];
|
||||||
|
// });
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Logging.AddOpenTelemetry(logging =>
|
||||||
|
{
|
||||||
|
logging.IncludeFormattedMessage = true;
|
||||||
|
logging.IncludeScopes = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddOpenTelemetry()
|
||||||
|
.WithMetrics(metrics =>
|
||||||
|
{
|
||||||
|
metrics.AddAspNetCoreInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation()
|
||||||
|
.AddRuntimeInstrumentation();
|
||||||
|
})
|
||||||
|
.WithTracing(tracing =>
|
||||||
|
{
|
||||||
|
tracing.AddSource(builder.Environment.ApplicationName)
|
||||||
|
.AddAspNetCoreInstrumentation(tracing =>
|
||||||
|
// Exclude health check requests from tracing
|
||||||
|
tracing.Filter = context =>
|
||||||
|
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||||
|
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||||
|
)
|
||||||
|
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||||
|
//.AddGrpcClientInstrumentation()
|
||||||
|
.AddHttpClientInstrumentation();
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.AddOpenTelemetryExporters();
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||||
|
|
||||||
|
if (useOtlpExporter)
|
||||||
|
{
|
||||||
|
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||||
|
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||||
|
//{
|
||||||
|
// builder.Services.AddOpenTelemetry()
|
||||||
|
// .UseAzureMonitor();
|
||||||
|
//}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||||
|
{
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
// Add a default liveness check to ensure app is responsive
|
||||||
|
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||||
|
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||||
|
app.MapHealthChecks(HealthEndpointPath);
|
||||||
|
|
||||||
|
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||||
|
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("live")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<title>The Living World</title>
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage"></div>
|
||||||
|
|
||||||
|
<aside id="panel" class="panel">
|
||||||
|
<header class="panel__header">
|
||||||
|
<h1>The Living World</h1>
|
||||||
|
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="generate-form" class="form">
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Latitude</span>
|
||||||
|
<input id="field-lat" type="number" step="any" value="31.8966010" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Longitude</span>
|
||||||
|
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Size <output id="field-size-value">10 km</output></span>
|
||||||
|
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="worlds">
|
||||||
|
<h2>Worlds</h2>
|
||||||
|
<ul id="world-list" class="world-list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer id="status" class="status"></footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div id="hud" class="hud"></div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+917
@@ -0,0 +1,917 @@
|
|||||||
|
{
|
||||||
|
"name": "the-living-world-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "the-living-world-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"pixi.js": "^8.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxc-project/types": {
|
||||||
|
"version": "0.144.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||||
|
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pixi/colord": {
|
||||||
|
"version": "2.9.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
|
||||||
|
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/earcut": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||||
|
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@webgpu/types": {
|
||||||
|
"version": "0.1.71",
|
||||||
|
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||||
|
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
|
||||||
|
"integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gifuct-js": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-binary-schema-parser": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ismobilejs": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/js-binary-schema-parser": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-x64": "1.33.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.33.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.33.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse-svg-path": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pixi.js": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"examples",
|
||||||
|
"playground"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@pixi/colord": "^2.9.6",
|
||||||
|
"@types/earcut": "^3.0.0",
|
||||||
|
"@webgpu/types": "^0.1.69",
|
||||||
|
"@xmldom/xmldom": "^0.8.13",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"gifuct-js": "^2.1.2",
|
||||||
|
"ismobilejs": "^1.1.1",
|
||||||
|
"parse-svg-path": "^0.2.0",
|
||||||
|
"tiny-lru": "^11.4.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/pixijs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.17",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rolldown": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oxc-project/types": "=0.144.0",
|
||||||
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rolldown": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rolldown/binding-android-arm64": "1.2.4",
|
||||||
|
"@rolldown/binding-darwin-arm64": "1.2.4",
|
||||||
|
"@rolldown/binding-darwin-x64": "1.2.4",
|
||||||
|
"@rolldown/binding-freebsd-x64": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-arm64-musl": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-x64-gnu": "1.2.4",
|
||||||
|
"@rolldown/binding-linux-x64-musl": "1.2.4",
|
||||||
|
"@rolldown/binding-openharmony-arm64": "1.2.4",
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tiny-lru": {
|
||||||
|
"version": "11.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
|
||||||
|
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vite": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lightningcss": "^1.33.0",
|
||||||
|
"picomatch": "^4.0.5",
|
||||||
|
"postcss": "^8.5.25",
|
||||||
|
"rolldown": "~1.2.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
|
"@vitejs/devtools": "^0.4.0",
|
||||||
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"sass-embedded": "^1.70.0",
|
||||||
|
"stylus": ">=0.54.8",
|
||||||
|
"sugarss": "^5.0.0",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitejs/devtools": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "the-living-world-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pixi.js": "^8.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
|
||||||
|
|
||||||
|
const BASE = '/api/worlds';
|
||||||
|
|
||||||
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await describeFailure(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeFailure(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
|
||||||
|
const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
|
||||||
|
if (details) return details;
|
||||||
|
if (problem.title) return problem.title;
|
||||||
|
} catch {
|
||||||
|
// Not a problem-details body; fall through to the status line.
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listWorlds: () => request<WorldSummary[]>(BASE),
|
||||||
|
|
||||||
|
createWorld: (body: CreateWorldRequest) =>
|
||||||
|
request<WorldSummary>(BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getWorld: (id: string) => request<WorldSummary>(`${BASE}/${id}`),
|
||||||
|
|
||||||
|
getMap: (id: string) => request<WorldMap>(`${BASE}/${id}/map`),
|
||||||
|
|
||||||
|
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
|
||||||
|
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
|
||||||
|
|
||||||
|
deleteWorld: async (id: string): Promise<void> => {
|
||||||
|
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error(await describeFailure(response));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Polls a world until generation finishes, reporting each stage change as it happens. */
|
||||||
|
export async function waitForWorld(
|
||||||
|
id: string,
|
||||||
|
onProgress: (summary: WorldSummary) => void,
|
||||||
|
intervalMs = 1500,
|
||||||
|
): Promise<WorldSummary> {
|
||||||
|
for (;;) {
|
||||||
|
const summary = await api.getWorld(id);
|
||||||
|
onProgress(summary);
|
||||||
|
|
||||||
|
if (summary.status === 'ready' || summary.status === 'failed') return summary;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/** Mirrors the DTOs in TheLivingWorld.Core.Contracts. Keep both sides in step. */
|
||||||
|
|
||||||
|
export type WorldStatus = 'pending' | 'generating' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
export interface WorldStats {
|
||||||
|
buildings: number;
|
||||||
|
roads: number;
|
||||||
|
areas: number;
|
||||||
|
water: number;
|
||||||
|
vertices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
sizeMeters: number;
|
||||||
|
status: WorldStatus;
|
||||||
|
stage?: string;
|
||||||
|
error?: string;
|
||||||
|
createdAt: string;
|
||||||
|
stats?: WorldStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWorldRequest {
|
||||||
|
name?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
sizeKm: number;
|
||||||
|
forceRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minX, minY, maxX, maxY] in world metres. */
|
||||||
|
export type BoundsArray = [number, number, number, number];
|
||||||
|
|
||||||
|
export interface ChunkIndexEntry {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
bounds: BoundsArray;
|
||||||
|
features: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldMap {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
sizeMeters: number;
|
||||||
|
bounds: { south: number; west: number; north: number; east: number };
|
||||||
|
chunkSizeMeters: number;
|
||||||
|
chunkCountX: number;
|
||||||
|
chunkCountY: number;
|
||||||
|
generatedAt: string;
|
||||||
|
stats: WorldStats;
|
||||||
|
chunks: ChunkIndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildingKind =
|
||||||
|
| 'unknown' | 'house' | 'residential' | 'apartments' | 'commercial' | 'retail' | 'industrial'
|
||||||
|
| 'civic' | 'school' | 'church' | 'garage' | 'shed' | 'farm' | 'ruins';
|
||||||
|
|
||||||
|
export type RoadClass =
|
||||||
|
| 'unknown' | 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'unclassified'
|
||||||
|
| 'residential' | 'livingStreet' | 'service' | 'track' | 'pedestrian' | 'footway' | 'cycleway'
|
||||||
|
| 'steps' | 'path' | 'railway';
|
||||||
|
|
||||||
|
export type AreaKind =
|
||||||
|
| 'unknown' | 'forest' | 'grass' | 'meadow' | 'farmland' | 'orchard' | 'scrub' | 'heath' | 'sand'
|
||||||
|
| 'bareRock' | 'wetland' | 'park' | 'garden' | 'pitch' | 'cemetery' | 'residentialZone'
|
||||||
|
| 'industrialZone' | 'commercialZone' | 'retailZone' | 'quarry' | 'parking' | 'school' | 'beach';
|
||||||
|
|
||||||
|
export type WaterKind =
|
||||||
|
| 'unknown' | 'water' | 'lake' | 'pond' | 'reservoir' | 'riverbank' | 'river' | 'stream'
|
||||||
|
| 'canal' | 'ditch' | 'drain';
|
||||||
|
|
||||||
|
/** Bit values matching RoadFlags on the server. */
|
||||||
|
export const RoadFlags = {
|
||||||
|
bridge: 1,
|
||||||
|
tunnel: 2,
|
||||||
|
oneway: 4,
|
||||||
|
unpaved: 8,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Geometry travels as flat [x0, y0, x1, y1, ...] arrays, ready for Graphics.poly(). */
|
||||||
|
export type FlatPoints = number[];
|
||||||
|
|
||||||
|
export interface BuildingFeature {
|
||||||
|
id: number;
|
||||||
|
kind: BuildingKind;
|
||||||
|
height: number;
|
||||||
|
outline: FlatPoints;
|
||||||
|
holes?: FlatPoints[];
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadFeature {
|
||||||
|
id: number;
|
||||||
|
class: RoadClass;
|
||||||
|
width: number;
|
||||||
|
path: FlatPoints;
|
||||||
|
flags?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AreaFeature {
|
||||||
|
id: number;
|
||||||
|
kind: AreaKind;
|
||||||
|
outline: FlatPoints;
|
||||||
|
holes?: FlatPoints[];
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterFeature {
|
||||||
|
id: number;
|
||||||
|
kind: WaterKind;
|
||||||
|
outline?: FlatPoints;
|
||||||
|
holes?: FlatPoints[];
|
||||||
|
path?: FlatPoints;
|
||||||
|
width?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapChunk {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
bounds: BoundsArray;
|
||||||
|
buildings: BuildingFeature[];
|
||||||
|
roads: RoadFeature[];
|
||||||
|
areas: AreaFeature[];
|
||||||
|
water: WaterFeature[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import './styles.css';
|
||||||
|
import { api, waitForWorld } from './api/client';
|
||||||
|
import type { WorldSummary } from './api/types';
|
||||||
|
import { MapView, type MapStatus } from './map/mapView';
|
||||||
|
|
||||||
|
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||||
|
|
||||||
|
const elements = {
|
||||||
|
stage: required<HTMLDivElement>('stage'),
|
||||||
|
form: required<HTMLFormElement>('generate-form'),
|
||||||
|
name: required<HTMLInputElement>('field-name'),
|
||||||
|
latitude: required<HTMLInputElement>('field-lat'),
|
||||||
|
longitude: required<HTMLInputElement>('field-lon'),
|
||||||
|
size: required<HTMLInputElement>('field-size'),
|
||||||
|
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||||
|
generate: required<HTMLButtonElement>('generate-button'),
|
||||||
|
worldList: required<HTMLUListElement>('world-list'),
|
||||||
|
status: required<HTMLElement>('status'),
|
||||||
|
hud: required<HTMLDivElement>('hud'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = new MapView();
|
||||||
|
let activeWorldId: string | null = null;
|
||||||
|
|
||||||
|
function required<T extends HTMLElement>(id: string): T {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error(`Missing element #${id}`);
|
||||||
|
return element as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): void {
|
||||||
|
elements.status.textContent = message;
|
||||||
|
elements.status.dataset.tone = tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHud(status: MapStatus): void {
|
||||||
|
const scale = status.metersPerPixel >= 10
|
||||||
|
? `${Math.round(status.metersPerPixel)} m/px`
|
||||||
|
: `${status.metersPerPixel.toFixed(1)} m/px`;
|
||||||
|
|
||||||
|
const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`;
|
||||||
|
const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`;
|
||||||
|
|
||||||
|
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||||
|
const worlds = await api.listWorlds();
|
||||||
|
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
|
||||||
|
return worlds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'world';
|
||||||
|
item.dataset.status = world.status;
|
||||||
|
if (world.id === activeWorldId) item.dataset.active = 'true';
|
||||||
|
|
||||||
|
const open = document.createElement('button');
|
||||||
|
open.type = 'button';
|
||||||
|
open.className = 'world__open';
|
||||||
|
open.disabled = world.status !== 'ready';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'world__name';
|
||||||
|
title.textContent = world.name;
|
||||||
|
|
||||||
|
const detail = document.createElement('span');
|
||||||
|
detail.className = 'world__detail';
|
||||||
|
detail.textContent = describeWorld(world);
|
||||||
|
|
||||||
|
open.append(title, detail);
|
||||||
|
open.addEventListener('click', () => {
|
||||||
|
void openWorld(world.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.className = 'world__delete';
|
||||||
|
remove.title = 'Delete world';
|
||||||
|
remove.textContent = '×';
|
||||||
|
remove.addEventListener('click', () => {
|
||||||
|
void deleteWorld(world);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.append(open, remove);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeWorld(world: WorldSummary): string {
|
||||||
|
if (world.status === 'failed') return world.error ?? 'Generation failed';
|
||||||
|
if (world.status !== 'ready') return world.stage ?? world.status;
|
||||||
|
|
||||||
|
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
|
||||||
|
if (!world.stats) return size;
|
||||||
|
|
||||||
|
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorld(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
setStatus('Loading map…', 'busy');
|
||||||
|
const map = await api.getMap(id);
|
||||||
|
|
||||||
|
activeWorldId = id;
|
||||||
|
localStorage.setItem(LAST_WORLD_KEY, id);
|
||||||
|
|
||||||
|
view.showWorld(map);
|
||||||
|
setStatus(`${map.name} — ${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`);
|
||||||
|
await refreshWorldList();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Could not open world: ${message(error)}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteWorld(world: WorldSummary): Promise<void> {
|
||||||
|
if (!confirm(`Delete "${world.name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteWorld(world.id);
|
||||||
|
|
||||||
|
if (activeWorldId === world.id) {
|
||||||
|
activeWorldId = null;
|
||||||
|
localStorage.removeItem(LAST_WORLD_KEY);
|
||||||
|
view.clear();
|
||||||
|
elements.hud.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshWorldList();
|
||||||
|
setStatus(`Deleted ${world.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Could not delete world: ${message(error)}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const latitude = Number(elements.latitude.value);
|
||||||
|
const longitude = Number(elements.longitude.value);
|
||||||
|
const sizeKm = Number(elements.size.value);
|
||||||
|
|
||||||
|
elements.generate.disabled = true;
|
||||||
|
setStatus('Requesting world…', 'busy');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await api.createWorld({
|
||||||
|
name: elements.name.value.trim() || undefined,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
sizeKm,
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshWorldList();
|
||||||
|
|
||||||
|
const finished = await waitForWorld(created.id, (summary) => {
|
||||||
|
setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy');
|
||||||
|
void refreshWorldList();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finished.status === 'failed') {
|
||||||
|
setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openWorld(finished.id);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Generation failed: ${message(error)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
elements.generate.disabled = false;
|
||||||
|
void refreshWorldList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
elements.size.addEventListener('input', () => {
|
||||||
|
elements.sizeValue.textContent = `${elements.size.value} km`;
|
||||||
|
});
|
||||||
|
elements.form.addEventListener('submit', (event) => {
|
||||||
|
void generate(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
view.onStatusChange = renderHud;
|
||||||
|
await view.init(elements.stage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const worlds = await refreshWorldList();
|
||||||
|
const remembered = localStorage.getItem(LAST_WORLD_KEY);
|
||||||
|
const target =
|
||||||
|
worlds.find((world) => world.id === remembered && world.status === 'ready') ??
|
||||||
|
worlds.find((world) => world.status === 'ready');
|
||||||
|
|
||||||
|
if (target) await openWorld(target.id);
|
||||||
|
else setStatus('No worlds yet — generate one to get started.');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export interface Viewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldRect {
|
||||||
|
minX: number;
|
||||||
|
minY: number;
|
||||||
|
maxX: number;
|
||||||
|
maxY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps world metres to screen pixels. World Y grows north, screen Y grows down, so the vertical scale is
|
||||||
|
* negative — the one place the flip is expressed, and every other module can think in map coordinates.
|
||||||
|
*/
|
||||||
|
export class Camera {
|
||||||
|
/** Camera centre, in world metres. */
|
||||||
|
x = 0;
|
||||||
|
y = 0;
|
||||||
|
|
||||||
|
/** Screen pixels per world metre. */
|
||||||
|
zoom = 0.1;
|
||||||
|
|
||||||
|
minZoom = 0.005;
|
||||||
|
maxZoom = 6;
|
||||||
|
|
||||||
|
/** Frames the whole world square with a little breathing room and pins the zoom-out limit to it. */
|
||||||
|
fit(worldSizeMeters: number, viewport: Viewport): void {
|
||||||
|
const usable = Math.min(viewport.width, viewport.height);
|
||||||
|
this.zoom = usable > 0 ? (usable / worldSizeMeters) * 0.92 : 0.1;
|
||||||
|
this.minZoom = this.zoom * 0.35;
|
||||||
|
this.maxZoom = 6;
|
||||||
|
this.x = 0;
|
||||||
|
this.y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
translateBy(screenDx: number, screenDy: number): void {
|
||||||
|
this.x -= screenDx / this.zoom;
|
||||||
|
this.y += screenDy / this.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zooms about a screen anchor so the world point under the cursor stays put. */
|
||||||
|
zoomAt(factor: number, screenX: number, screenY: number, viewport: Viewport): void {
|
||||||
|
const before = this.screenToWorld(screenX, screenY, viewport);
|
||||||
|
this.zoom = clamp(this.zoom * factor, this.minZoom, this.maxZoom);
|
||||||
|
const after = this.screenToWorld(screenX, screenY, viewport);
|
||||||
|
|
||||||
|
this.x += before.x - after.x;
|
||||||
|
this.y += before.y - after.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the centre inside the world square so the map cannot be flung off screen. */
|
||||||
|
clampToWorld(worldSizeMeters: number): void {
|
||||||
|
const half = worldSizeMeters / 2;
|
||||||
|
this.x = clamp(this.x, -half, half);
|
||||||
|
this.y = clamp(this.y, -half, half);
|
||||||
|
}
|
||||||
|
|
||||||
|
screenToWorld(screenX: number, screenY: number, viewport: Viewport): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: this.x + (screenX - viewport.width / 2) / this.zoom,
|
||||||
|
y: this.y - (screenY - viewport.height / 2) / this.zoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The world rectangle currently on screen, optionally grown by a fraction of its size. */
|
||||||
|
visibleRect(viewport: Viewport, padding = 0): WorldRect {
|
||||||
|
const halfWidth = viewport.width / 2 / this.zoom;
|
||||||
|
const halfHeight = viewport.height / 2 / this.zoom;
|
||||||
|
const padX = halfWidth * padding;
|
||||||
|
const padY = halfHeight * padding;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: this.x - halfWidth - padX,
|
||||||
|
minY: this.y - halfHeight - padY,
|
||||||
|
maxX: this.x + halfWidth + padX,
|
||||||
|
maxY: this.y + halfHeight + padY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Container offset that places the camera centre at the middle of the viewport. */
|
||||||
|
containerPosition(viewport: Viewport): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: viewport.width / 2 - this.x * this.zoom,
|
||||||
|
y: viewport.height / 2 + this.y * this.zoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectsIntersect(a: WorldRect, b: WorldRect): boolean {
|
||||||
|
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return value < min ? min : value > max ? max : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { Container } from 'pixi.js';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { ChunkIndexEntry, MapChunk } from '../api/types';
|
||||||
|
import { rectsIntersect, type WorldRect } from './camera';
|
||||||
|
import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer';
|
||||||
|
|
||||||
|
export interface MapLayers {
|
||||||
|
areas: Container;
|
||||||
|
water: Container;
|
||||||
|
roadCasings: Container;
|
||||||
|
roads: Container;
|
||||||
|
buildings: Container;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChunkState {
|
||||||
|
entry: ChunkIndexEntry;
|
||||||
|
rect: WorldRect;
|
||||||
|
data?: MapChunk;
|
||||||
|
graphics?: ChunkGraphics;
|
||||||
|
/** Profile the current graphics were built for; a mismatch means they need redrawing. */
|
||||||
|
drawnWith?: string;
|
||||||
|
request?: AbortController;
|
||||||
|
failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far past the viewport chunks are fetched, as a fraction of the viewport size. */
|
||||||
|
const PREFETCH_PADDING = 0.35;
|
||||||
|
|
||||||
|
/** Graphics survive a little further out than that, so small pans do not thrash the renderer. */
|
||||||
|
const RETAIN_PADDING = 1.0;
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_REQUESTS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the visible slice of a world on screen: fetches chunks as the camera reaches them, draws them into
|
||||||
|
* the shared layers, and drops their graphics once they are well out of view. Chunk data itself stays cached,
|
||||||
|
* so panning back is instant.
|
||||||
|
*/
|
||||||
|
export class ChunkManager {
|
||||||
|
private readonly states = new Map<string, ChunkState>();
|
||||||
|
private worldId: string | null = null;
|
||||||
|
private inFlight = 0;
|
||||||
|
private pending: ChunkState[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly layers: MapLayers) {}
|
||||||
|
|
||||||
|
get loadedCount(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const state of this.states.values()) if (state.data) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalCount(): number {
|
||||||
|
return this.states.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isBusy(): boolean {
|
||||||
|
return this.inFlight > 0 || this.pending.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorld(worldId: string, index: ChunkIndexEntry[]): void {
|
||||||
|
this.clear();
|
||||||
|
this.worldId = worldId;
|
||||||
|
|
||||||
|
for (const entry of index) {
|
||||||
|
const [minX, minY, maxX, maxY] = entry.bounds;
|
||||||
|
this.states.set(key(entry.x, entry.y), {
|
||||||
|
entry,
|
||||||
|
rect: { minX, minY, maxX, maxY },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
for (const state of this.states.values()) {
|
||||||
|
state.request?.abort();
|
||||||
|
if (state.graphics) {
|
||||||
|
detach(state.graphics);
|
||||||
|
destroyChunkGraphics(state.graphics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.states.clear();
|
||||||
|
this.pending = [];
|
||||||
|
this.inFlight = 0;
|
||||||
|
this.worldId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconciles what is on screen with what should be, given the camera's current view and zoom. */
|
||||||
|
update(visible: WorldRect, profile: RenderProfile): void {
|
||||||
|
if (!this.worldId) return;
|
||||||
|
|
||||||
|
const prefetch = grow(visible, PREFETCH_PADDING);
|
||||||
|
const retain = grow(visible, RETAIN_PADDING);
|
||||||
|
|
||||||
|
this.pending = [];
|
||||||
|
|
||||||
|
for (const state of this.states.values()) {
|
||||||
|
const wanted = rectsIntersect(state.rect, prefetch);
|
||||||
|
|
||||||
|
if (wanted && !state.data && !state.request && !state.failed) {
|
||||||
|
this.pending.push(state);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.data) continue;
|
||||||
|
|
||||||
|
if (wanted) {
|
||||||
|
if (!state.graphics || state.drawnWith !== profile.key) this.draw(state, profile);
|
||||||
|
} else if (state.graphics && !rectsIntersect(state.rect, retain)) {
|
||||||
|
detach(state.graphics);
|
||||||
|
destroyChunkGraphics(state.graphics);
|
||||||
|
state.graphics = undefined;
|
||||||
|
state.drawnWith = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest chunks first: the middle of the screen is what the player is looking at.
|
||||||
|
const centerX = (visible.minX + visible.maxX) / 2;
|
||||||
|
const centerY = (visible.minY + visible.maxY) / 2;
|
||||||
|
this.pending.sort((a, b) => distanceSquared(a.rect, centerX, centerY) - distanceSquared(b.rect, centerX, centerY));
|
||||||
|
|
||||||
|
this.pump(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump(profile: RenderProfile): void {
|
||||||
|
while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) {
|
||||||
|
const state = this.pending.shift()!;
|
||||||
|
void this.fetch(state, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(state: ChunkState, profile: RenderProfile): Promise<void> {
|
||||||
|
const worldId = this.worldId;
|
||||||
|
if (!worldId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
state.request = controller;
|
||||||
|
this.inFlight++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunk = await api.getChunk(worldId, state.entry.x, state.entry.y, controller.signal);
|
||||||
|
|
||||||
|
// The world may have been swapped while this request was in the air.
|
||||||
|
if (this.worldId !== worldId) return;
|
||||||
|
|
||||||
|
state.data = chunk;
|
||||||
|
this.draw(state, profile);
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
state.failed = true;
|
||||||
|
console.error(`Failed to load chunk ${state.entry.x},${state.entry.y}`, error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.inFlight--;
|
||||||
|
state.request = undefined;
|
||||||
|
this.pump(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(state: ChunkState, profile: RenderProfile): void {
|
||||||
|
if (!state.data) return;
|
||||||
|
|
||||||
|
if (state.graphics) {
|
||||||
|
detach(state.graphics);
|
||||||
|
destroyChunkGraphics(state.graphics);
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphics = renderChunk(state.data, profile);
|
||||||
|
state.graphics = graphics;
|
||||||
|
state.drawnWith = profile.key;
|
||||||
|
|
||||||
|
this.layers.areas.addChild(graphics.areas);
|
||||||
|
this.layers.water.addChild(graphics.water);
|
||||||
|
this.layers.roadCasings.addChild(graphics.roadCasings);
|
||||||
|
this.layers.roads.addChild(graphics.roads);
|
||||||
|
this.layers.buildings.addChild(graphics.buildings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach(graphics: ChunkGraphics): void {
|
||||||
|
for (const layer of Object.values(graphics)) {
|
||||||
|
layer.removeFromParent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(x: number, y: number): string {
|
||||||
|
return `${x},${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grow(rect: WorldRect, fraction: number): WorldRect {
|
||||||
|
const padX = (rect.maxX - rect.minX) * fraction * 0.5;
|
||||||
|
const padY = (rect.maxY - rect.minY) * fraction * 0.5;
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: rect.minX - padX,
|
||||||
|
minY: rect.minY - padY,
|
||||||
|
maxX: rect.maxX + padX,
|
||||||
|
maxY: rect.maxY + padY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceSquared(rect: WorldRect, x: number, y: number): number {
|
||||||
|
const dx = (rect.minX + rect.maxX) / 2 - x;
|
||||||
|
const dy = (rect.minY + rect.maxY) / 2 - y;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { Graphics } from 'pixi.js';
|
||||||
|
import type { AreaFeature, BuildingFeature, FlatPoints, MapChunk, RoadFeature, WaterFeature } from '../api/types';
|
||||||
|
import {
|
||||||
|
AREA_COLORS,
|
||||||
|
BUILDING_EDGE_COLOR,
|
||||||
|
ROAD_CASING_COLORS,
|
||||||
|
ROAD_COLORS,
|
||||||
|
ROAD_MIN_DETAIL,
|
||||||
|
ROAD_RANK,
|
||||||
|
WATER_COLOR,
|
||||||
|
WATER_EDGE_COLOR,
|
||||||
|
WATER_LINE_COLORS,
|
||||||
|
buildingColor,
|
||||||
|
} from './style';
|
||||||
|
|
||||||
|
/** 0 = whole town in view, 1 = neighbourhood, 2 = street level. */
|
||||||
|
export type DetailLevel = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export interface RenderProfile {
|
||||||
|
/** Changes exactly when the geometry needs redrawing, and not otherwise. */
|
||||||
|
key: string;
|
||||||
|
detail: DetailLevel;
|
||||||
|
/** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */
|
||||||
|
minStrokeMeters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strokes thinner than this fade into the background. */
|
||||||
|
const MIN_STROKE_PIXELS = 1.3;
|
||||||
|
|
||||||
|
const MIN_BUILDING_AREA: Record<DetailLevel, number> = { 0: 150, 1: 30, 2: 0 };
|
||||||
|
|
||||||
|
export function profileForZoom(zoom: number): RenderProfile {
|
||||||
|
const detail: DetailLevel = zoom >= 0.35 ? 2 : zoom >= 0.12 ? 1 : 0;
|
||||||
|
|
||||||
|
// Quantising to powers of two means a slow zoom redraws a handful of times rather than every frame.
|
||||||
|
const step = Math.floor(Math.log2(zoom));
|
||||||
|
const minStrokeMeters = MIN_STROKE_PIXELS / 2 ** step;
|
||||||
|
|
||||||
|
return { key: `${detail}@${step}`, detail, minStrokeMeters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkGraphics {
|
||||||
|
areas: Graphics;
|
||||||
|
water: Graphics;
|
||||||
|
roadCasings: Graphics;
|
||||||
|
roads: Graphics;
|
||||||
|
buildings: Graphics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics {
|
||||||
|
return {
|
||||||
|
areas: renderAreas(chunk.areas),
|
||||||
|
water: renderWater(chunk.water, profile),
|
||||||
|
roadCasings: renderRoads(chunk.roads, profile, true),
|
||||||
|
roads: renderRoads(chunk.roads, profile, false),
|
||||||
|
buildings: renderBuildings(chunk.buildings, profile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyChunkGraphics(graphics: ChunkGraphics): void {
|
||||||
|
for (const layer of Object.values(graphics)) {
|
||||||
|
layer.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ring {
|
||||||
|
outline: FlatPoints;
|
||||||
|
holes?: FlatPoints[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeStyle {
|
||||||
|
width: number;
|
||||||
|
color: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAreas(areas: AreaFeature[]): Graphics {
|
||||||
|
const g = new Graphics();
|
||||||
|
|
||||||
|
// Big landuse blocks first, so the parks and pitches sitting inside them stay visible.
|
||||||
|
const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline));
|
||||||
|
|
||||||
|
forEachRun(
|
||||||
|
sorted,
|
||||||
|
(area) => AREA_COLORS[area.kind] ?? AREA_COLORS.unknown,
|
||||||
|
(run, color) => fillRings(g, run, color),
|
||||||
|
);
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWater(water: WaterFeature[], profile: RenderProfile): Graphics {
|
||||||
|
const g = new Graphics();
|
||||||
|
|
||||||
|
const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline);
|
||||||
|
fillRings(g, bodies, WATER_COLOR, { width: profile.minStrokeMeters, color: WATER_EDGE_COLOR });
|
||||||
|
|
||||||
|
for (const stream of water) {
|
||||||
|
if (!stream.path) continue;
|
||||||
|
|
||||||
|
g.poly(stream.path, false).stroke({
|
||||||
|
width: Math.max(stream.width ?? 2, profile.minStrokeMeters),
|
||||||
|
color: WATER_LINE_COLORS[stream.kind] ?? WATER_COLOR,
|
||||||
|
cap: 'round',
|
||||||
|
join: 'round',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics {
|
||||||
|
const g = new Graphics();
|
||||||
|
|
||||||
|
const visible = roads
|
||||||
|
.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail)
|
||||||
|
.sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width);
|
||||||
|
|
||||||
|
// The casing is a slightly wider stroke drawn underneath; it is what gives roads their outline.
|
||||||
|
const casingMargin = Math.max(1.4, profile.minStrokeMeters * 0.7);
|
||||||
|
|
||||||
|
// Sorted by class then width, so a run shares both and can be stroked in a single call.
|
||||||
|
forEachRun(
|
||||||
|
visible,
|
||||||
|
(road) => `${road.class}:${road.width}`,
|
||||||
|
(run) => {
|
||||||
|
const first = run[0]!;
|
||||||
|
for (const road of run) g.poly(road.path, false);
|
||||||
|
|
||||||
|
const width = Math.max(first.width, profile.minStrokeMeters);
|
||||||
|
g.stroke({
|
||||||
|
width: casing ? width + casingMargin : width,
|
||||||
|
color: casing ? ROAD_CASING_COLORS[first.class] : ROAD_COLORS[first.class],
|
||||||
|
cap: 'round',
|
||||||
|
join: 'round',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics {
|
||||||
|
const g = new Graphics();
|
||||||
|
const minArea = MIN_BUILDING_AREA[profile.detail];
|
||||||
|
const edge: EdgeStyle | undefined =
|
||||||
|
profile.detail === 2 ? { width: profile.minStrokeMeters * 0.8, color: BUILDING_EDGE_COLOR } : undefined;
|
||||||
|
|
||||||
|
// Footprints never overlap, so they can be regrouped by colour without disturbing the picture.
|
||||||
|
const byColor = new Map<number, BuildingFeature[]>();
|
||||||
|
for (const building of buildings) {
|
||||||
|
if (minArea > 0 && boundingArea(building.outline) < minArea) continue;
|
||||||
|
|
||||||
|
const color = buildingColor(building.kind, building.height);
|
||||||
|
const group = byColor.get(color);
|
||||||
|
if (group) group.push(building);
|
||||||
|
else byColor.set(color, [building]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [color, group] of byColor) {
|
||||||
|
fillRings(g, group, color, edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills a batch of rings. Rings without holes share one draw call; a ring with holes needs its own, because
|
||||||
|
* each cut applies to the shape that precedes it.
|
||||||
|
*/
|
||||||
|
function fillRings(g: Graphics, rings: Ring[], color: number, edge?: EdgeStyle): void {
|
||||||
|
let solidCount = 0;
|
||||||
|
|
||||||
|
for (const ring of rings) {
|
||||||
|
if (ring.holes?.length) continue;
|
||||||
|
g.poly(ring.outline);
|
||||||
|
solidCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solidCount > 0) {
|
||||||
|
g.fill({ color });
|
||||||
|
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ring of rings) {
|
||||||
|
if (!ring.holes?.length) continue;
|
||||||
|
|
||||||
|
g.poly(ring.outline);
|
||||||
|
for (const hole of ring.holes) {
|
||||||
|
g.poly(hole);
|
||||||
|
g.cut();
|
||||||
|
}
|
||||||
|
|
||||||
|
g.fill({ color });
|
||||||
|
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */
|
||||||
|
function boundingArea(points: FlatPoints): number {
|
||||||
|
if (points.length < 6) return 0;
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i]!;
|
||||||
|
const y = points[i + 1]!;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (maxX - minX) * (maxY - minY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks a sorted list, handing over each maximal run of items that share a key. */
|
||||||
|
function forEachRun<T, K>(items: T[], keyOf: (item: T) => K, draw: (run: T[], key: K) => void): void {
|
||||||
|
let index = 0;
|
||||||
|
while (index < items.length) {
|
||||||
|
const key = keyOf(items[index]!);
|
||||||
|
const start = index;
|
||||||
|
while (index < items.length && keyOf(items[index]!) === key) index++;
|
||||||
|
draw(items.slice(start, index), key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { Application, Container, Graphics } from 'pixi.js';
|
||||||
|
import type { WorldMap } from '../api/types';
|
||||||
|
import { Camera, type Viewport } from './camera';
|
||||||
|
import { ChunkManager, type MapLayers } from './chunkManager';
|
||||||
|
import { profileForZoom, type RenderProfile } from './chunkRenderer';
|
||||||
|
import { LAND_COLOR } from './style';
|
||||||
|
|
||||||
|
const OUTSIDE_COLOR = 0xd5d9d2;
|
||||||
|
const BORDER_COLOR = 0xb4bab2;
|
||||||
|
|
||||||
|
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
|
||||||
|
const CHUNK_UPDATE_INTERVAL_MS = 90;
|
||||||
|
|
||||||
|
export interface MapStatus {
|
||||||
|
zoom: number;
|
||||||
|
metersPerPixel: number;
|
||||||
|
center: { x: number; y: number };
|
||||||
|
loadedChunks: number;
|
||||||
|
totalChunks: number;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PixiJS side of the map: one scaled container holding the layer stack, a camera driving it, and the
|
||||||
|
* pointer handling that lets the player move around.
|
||||||
|
*/
|
||||||
|
export class MapView {
|
||||||
|
private readonly app = new Application();
|
||||||
|
private readonly root = new Container();
|
||||||
|
private readonly background = new Graphics();
|
||||||
|
private readonly border = new Graphics();
|
||||||
|
private readonly camera = new Camera();
|
||||||
|
|
||||||
|
private readonly layers: MapLayers = {
|
||||||
|
areas: new Container(),
|
||||||
|
water: new Container(),
|
||||||
|
roadCasings: new Container(),
|
||||||
|
roads: new Container(),
|
||||||
|
buildings: new Container(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly chunks = new ChunkManager(this.layers);
|
||||||
|
|
||||||
|
private host: HTMLElement | null = null;
|
||||||
|
private worldSizeMeters = 0;
|
||||||
|
private profile: RenderProfile = profileForZoom(0.1);
|
||||||
|
private cameraDirty = true;
|
||||||
|
private lastChunkUpdate = 0;
|
||||||
|
private readonly activePointers = new Map<number, { x: number; y: number }>();
|
||||||
|
private pinchDistance = 0;
|
||||||
|
|
||||||
|
onStatusChange: ((status: MapStatus) => void) | null = null;
|
||||||
|
|
||||||
|
async init(host: HTMLElement): Promise<void> {
|
||||||
|
this.host = host;
|
||||||
|
|
||||||
|
await this.app.init({
|
||||||
|
background: OUTSIDE_COLOR,
|
||||||
|
antialias: true,
|
||||||
|
resizeTo: host,
|
||||||
|
resolution: window.devicePixelRatio || 1,
|
||||||
|
autoDensity: true,
|
||||||
|
preference: 'webgl',
|
||||||
|
});
|
||||||
|
|
||||||
|
host.appendChild(this.app.canvas);
|
||||||
|
|
||||||
|
// Painter's order: land cover, then water, then the road casings their fills sit on, then buildings.
|
||||||
|
this.root.addChild(
|
||||||
|
this.background,
|
||||||
|
this.layers.areas,
|
||||||
|
this.layers.water,
|
||||||
|
this.layers.roadCasings,
|
||||||
|
this.layers.roads,
|
||||||
|
this.layers.buildings,
|
||||||
|
this.border,
|
||||||
|
);
|
||||||
|
this.app.stage.addChild(this.root);
|
||||||
|
|
||||||
|
this.attachInput(this.app.canvas);
|
||||||
|
|
||||||
|
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
this.cameraDirty = true;
|
||||||
|
}).observe(host);
|
||||||
|
|
||||||
|
this.app.ticker.add(() => this.tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
showWorld(map: WorldMap): void {
|
||||||
|
this.chunks.clear();
|
||||||
|
this.worldSizeMeters = map.sizeMeters;
|
||||||
|
|
||||||
|
const half = map.sizeMeters / 2;
|
||||||
|
this.background.clear().rect(-half, -half, map.sizeMeters, map.sizeMeters).fill({ color: LAND_COLOR });
|
||||||
|
|
||||||
|
this.camera.fit(map.sizeMeters, this.viewport);
|
||||||
|
this.chunks.setWorld(map.id, map.chunks);
|
||||||
|
this.cameraDirty = true;
|
||||||
|
this.lastChunkUpdate = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.chunks.clear();
|
||||||
|
this.background.clear();
|
||||||
|
this.border.clear();
|
||||||
|
this.worldSizeMeters = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.chunks.clear();
|
||||||
|
this.app.destroy(true, { children: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private get viewport(): Viewport {
|
||||||
|
return {
|
||||||
|
width: this.host?.clientWidth || this.app.screen.width,
|
||||||
|
height: this.host?.clientHeight || this.app.screen.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
if (this.worldSizeMeters === 0) return;
|
||||||
|
|
||||||
|
const viewport = this.viewport;
|
||||||
|
|
||||||
|
if (this.cameraDirty) {
|
||||||
|
this.camera.clampToWorld(this.worldSizeMeters);
|
||||||
|
|
||||||
|
const position = this.camera.containerPosition(viewport);
|
||||||
|
this.root.position.set(position.x, position.y);
|
||||||
|
this.root.scale.set(this.camera.zoom, -this.camera.zoom);
|
||||||
|
|
||||||
|
this.profile = profileForZoom(this.camera.zoom);
|
||||||
|
this.drawBorder();
|
||||||
|
this.cameraDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) {
|
||||||
|
this.lastChunkUpdate = now;
|
||||||
|
this.chunks.update(this.camera.visibleRect(viewport), this.profile);
|
||||||
|
this.publishStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBorder(): void {
|
||||||
|
const half = this.worldSizeMeters / 2;
|
||||||
|
this.border
|
||||||
|
.clear()
|
||||||
|
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters)
|
||||||
|
.stroke({ width: 2 / this.camera.zoom, color: BORDER_COLOR, alignment: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishStatus(): void {
|
||||||
|
this.onStatusChange?.({
|
||||||
|
zoom: this.camera.zoom,
|
||||||
|
metersPerPixel: 1 / this.camera.zoom,
|
||||||
|
center: { x: this.camera.x, y: this.camera.y },
|
||||||
|
loadedChunks: this.chunks.loadedCount,
|
||||||
|
totalChunks: this.chunks.totalCount,
|
||||||
|
loading: this.chunks.isBusy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachInput(canvas: HTMLCanvasElement): void {
|
||||||
|
canvas.style.touchAction = 'none';
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (event) => {
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
this.pinchDistance = this.currentPinchDistance();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', (event) => {
|
||||||
|
const previous = this.activePointers.get(event.pointerId);
|
||||||
|
if (!previous) return;
|
||||||
|
|
||||||
|
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
|
||||||
|
if (this.activePointers.size >= 2) {
|
||||||
|
this.handlePinch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.camera.translateBy(event.clientX - previous.x, event.clientY - previous.y);
|
||||||
|
this.cameraDirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = (event: PointerEvent) => {
|
||||||
|
this.activePointers.delete(event.pointerId);
|
||||||
|
this.pinchDistance = this.currentPinchDistance();
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerup', release);
|
||||||
|
canvas.addEventListener('pointercancel', release);
|
||||||
|
canvas.addEventListener('pointerleave', release);
|
||||||
|
|
||||||
|
canvas.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
// A gentle exponential keeps trackpads and mouse wheels feeling alike.
|
||||||
|
this.camera.zoomAt(
|
||||||
|
Math.exp(-event.deltaY * 0.0015),
|
||||||
|
event.clientX - rect.left,
|
||||||
|
event.clientY - rect.top,
|
||||||
|
this.viewport,
|
||||||
|
);
|
||||||
|
this.cameraDirty = true;
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePinch(): void {
|
||||||
|
const distance = this.currentPinchDistance();
|
||||||
|
if (this.pinchDistance === 0 || distance === 0) {
|
||||||
|
this.pinchDistance = distance;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const midpoint = this.pointerMidpoint();
|
||||||
|
const rect = this.app.canvas.getBoundingClientRect();
|
||||||
|
this.camera.zoomAt(distance / this.pinchDistance, midpoint.x - rect.left, midpoint.y - rect.top, this.viewport);
|
||||||
|
|
||||||
|
this.pinchDistance = distance;
|
||||||
|
this.cameraDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPinchDistance(): number {
|
||||||
|
if (this.activePointers.size < 2) return 0;
|
||||||
|
|
||||||
|
const [first, second] = [...this.activePointers.values()];
|
||||||
|
return Math.hypot(second!.x - first!.x, second!.y - first!.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pointerMidpoint(): { x: number; y: number } {
|
||||||
|
const [first, second] = [...this.activePointers.values()];
|
||||||
|
return { x: (first!.x + second!.x) / 2, y: (first!.y + second!.y) / 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quiet daytime cartography palette: muted land cover, white roads, warm building fills. Everything the
|
||||||
|
* renderer draws gets its colour from here so the map reads as one coherent style.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const LAND_COLOR = 0xeceee7;
|
||||||
|
|
||||||
|
export const AREA_COLORS: Record<AreaKind, number> = {
|
||||||
|
unknown: 0xe6e6e0,
|
||||||
|
forest: 0xc6ddb8,
|
||||||
|
grass: 0xd8e9c6,
|
||||||
|
meadow: 0xdfeecd,
|
||||||
|
farmland: 0xece7c8,
|
||||||
|
orchard: 0xd9e7bd,
|
||||||
|
scrub: 0xd9e6c9,
|
||||||
|
heath: 0xdfe4cb,
|
||||||
|
sand: 0xf0e9cf,
|
||||||
|
bareRock: 0xdedcd5,
|
||||||
|
wetland: 0xd2e2d7,
|
||||||
|
park: 0xd2eac0,
|
||||||
|
garden: 0xd9edc6,
|
||||||
|
pitch: 0xc9e6b4,
|
||||||
|
cemetery: 0xd6e0cd,
|
||||||
|
residentialZone: 0xe6e3de,
|
||||||
|
industrialZone: 0xe3dee2,
|
||||||
|
commercialZone: 0xeae0dd,
|
||||||
|
retailZone: 0xeddfd7,
|
||||||
|
quarry: 0xdfdad2,
|
||||||
|
parking: 0xe7e4dd,
|
||||||
|
school: 0xeae3d7,
|
||||||
|
beach: 0xf4ead1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WATER_COLOR = 0x9fc9e0;
|
||||||
|
export const WATER_EDGE_COLOR = 0x84b4cf;
|
||||||
|
|
||||||
|
export const WATER_LINE_COLORS: Record<WaterKind, number> = {
|
||||||
|
unknown: WATER_COLOR,
|
||||||
|
water: WATER_COLOR,
|
||||||
|
lake: WATER_COLOR,
|
||||||
|
pond: WATER_COLOR,
|
||||||
|
reservoir: WATER_COLOR,
|
||||||
|
riverbank: WATER_COLOR,
|
||||||
|
river: WATER_COLOR,
|
||||||
|
stream: 0xa9d0e4,
|
||||||
|
canal: WATER_COLOR,
|
||||||
|
ditch: 0xb5d7e8,
|
||||||
|
drain: 0xb5d7e8,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROAD_COLORS: Record<RoadClass, number> = {
|
||||||
|
unknown: 0xf7f6f4,
|
||||||
|
motorway: 0xf3ae68,
|
||||||
|
trunk: 0xf7c489,
|
||||||
|
primary: 0xfad89b,
|
||||||
|
secondary: 0xfae9b8,
|
||||||
|
tertiary: 0xfdfaf0,
|
||||||
|
unclassified: 0xffffff,
|
||||||
|
residential: 0xffffff,
|
||||||
|
livingStreet: 0xf7f7f5,
|
||||||
|
service: 0xfbfbfa,
|
||||||
|
track: 0xe6d9bd,
|
||||||
|
pedestrian: 0xf1eee9,
|
||||||
|
footway: 0xecc9b3,
|
||||||
|
cycleway: 0xd2dcef,
|
||||||
|
steps: 0xe4b39a,
|
||||||
|
path: 0xe8cbb6,
|
||||||
|
railway: 0xb2aca6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROAD_CASING_COLORS: Record<RoadClass, number> = {
|
||||||
|
unknown: 0xd8d4cd,
|
||||||
|
motorway: 0xd48f45,
|
||||||
|
trunk: 0xd9a163,
|
||||||
|
primary: 0xdcb872,
|
||||||
|
secondary: 0xdfcb8c,
|
||||||
|
tertiary: 0xd2cec6,
|
||||||
|
unclassified: 0xd2cec6,
|
||||||
|
residential: 0xd2cec6,
|
||||||
|
livingStreet: 0xd2cec6,
|
||||||
|
service: 0xdad6cf,
|
||||||
|
track: 0xc7b795,
|
||||||
|
pedestrian: 0xd6d1c9,
|
||||||
|
footway: 0xd0a488,
|
||||||
|
cycleway: 0xafbcd6,
|
||||||
|
steps: 0xcb9077,
|
||||||
|
path: 0xcfab8f,
|
||||||
|
railway: 0x8d8781,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Draw order within the road layer: bigger roads sit on top of the network they feed. */
|
||||||
|
export const ROAD_RANK: Record<RoadClass, number> = {
|
||||||
|
path: 0,
|
||||||
|
steps: 0,
|
||||||
|
footway: 1,
|
||||||
|
cycleway: 1,
|
||||||
|
track: 2,
|
||||||
|
service: 3,
|
||||||
|
pedestrian: 3,
|
||||||
|
railway: 4,
|
||||||
|
livingStreet: 5,
|
||||||
|
residential: 6,
|
||||||
|
unclassified: 6,
|
||||||
|
unknown: 6,
|
||||||
|
tertiary: 7,
|
||||||
|
secondary: 8,
|
||||||
|
primary: 9,
|
||||||
|
trunk: 10,
|
||||||
|
motorway: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Minor paths vanish first as the camera pulls back. */
|
||||||
|
export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
|
||||||
|
path: 2,
|
||||||
|
steps: 2,
|
||||||
|
footway: 2,
|
||||||
|
cycleway: 2,
|
||||||
|
track: 1,
|
||||||
|
service: 1,
|
||||||
|
pedestrian: 1,
|
||||||
|
railway: 0,
|
||||||
|
livingStreet: 0,
|
||||||
|
residential: 0,
|
||||||
|
unclassified: 0,
|
||||||
|
unknown: 1,
|
||||||
|
tertiary: 0,
|
||||||
|
secondary: 0,
|
||||||
|
primary: 0,
|
||||||
|
trunk: 0,
|
||||||
|
motorway: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BUILDING_BASE = 0xdfd7cc;
|
||||||
|
const BUILDING_TALL = 0xc4b8a8;
|
||||||
|
|
||||||
|
export const BUILDING_EDGE_COLOR = 0xbdb2a3;
|
||||||
|
|
||||||
|
const BUILDING_TINTS: Partial<Record<BuildingKind, number>> = {
|
||||||
|
church: 0xd9cfc4,
|
||||||
|
school: 0xdcd6c4,
|
||||||
|
civic: 0xd8d2c6,
|
||||||
|
industrial: 0xd7d1cd,
|
||||||
|
retail: 0xe2d5cc,
|
||||||
|
commercial: 0xe0d6cd,
|
||||||
|
shed: 0xe3ddd4,
|
||||||
|
garage: 0xe3ddd4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
|
||||||
|
* Anything above ~30 m is already at the darkest end of the ramp.
|
||||||
|
*/
|
||||||
|
export function buildingColor(kind: BuildingKind, height: number): number {
|
||||||
|
const base = BUILDING_TINTS[kind] ?? BUILDING_BASE;
|
||||||
|
return mix(base, BUILDING_TALL, clamp01((height - 3) / 27));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mix(from: number, to: number, t: number): number {
|
||||||
|
const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t);
|
||||||
|
const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t);
|
||||||
|
const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t);
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
:root {
|
||||||
|
--panel-bg: rgba(252, 252, 250, 0.94);
|
||||||
|
--panel-border: #d9d6cf;
|
||||||
|
--text: #2b2a27;
|
||||||
|
--text-muted: #75726b;
|
||||||
|
--accent: #3d6b4a;
|
||||||
|
--accent-hover: #325a3d;
|
||||||
|
--error: #a63d33;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text);
|
||||||
|
background: #d5d9d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage canvas {
|
||||||
|
display: block;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
width: 320px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='number'] {
|
||||||
|
padding: 7px 9px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='range'] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field output {
|
||||||
|
float: right;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worlds h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world[data-active='true'] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 2px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.world[data-status='failed'] .world__detail {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__open {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__open:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__open:hover:not(:disabled) {
|
||||||
|
background: #f3f5f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__delete {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--panel-border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world__delete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
background: #faf1f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
min-height: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-tone='error'] {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status[data-tone='busy']::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 7px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.panel {
|
||||||
|
top: auto;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: auto;
|
||||||
|
max-height: 55vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
|
||||||
|
* `services__api__<scheme>__<index>`. Outside Aspire the fallback lets a plain `npm run dev` still work.
|
||||||
|
*/
|
||||||
|
function resolveApiUrl(): string {
|
||||||
|
const fromAspire = Object.keys(process.env)
|
||||||
|
.filter((key) => key.startsWith('services__api__'))
|
||||||
|
// Ascending order puts `http` ahead of `https`; plain HTTP is the simpler hop for a local proxy.
|
||||||
|
.sort()
|
||||||
|
.map((key) => process.env[key])
|
||||||
|
.find((value): value is string => !!value);
|
||||||
|
|
||||||
|
// Matches the `http` launch profile of TheLivingWorld.Api.
|
||||||
|
return fromAspire ?? process.env.API_URL ?? 'http://localhost:5195';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(() => {
|
||||||
|
const apiUrl = resolveApiUrl();
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
// Aspire issues a development certificate the proxy has no reason to distrust.
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: 'es2022',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Tests;
|
||||||
|
|
||||||
|
public class LocalProjectionTests
|
||||||
|
{
|
||||||
|
// Robert Lee, Texas - the town the first worlds were generated around.
|
||||||
|
private static readonly GeoPoint Origin = new(31.8966010, -100.4858591);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Origin_projects_to_world_zero()
|
||||||
|
{
|
||||||
|
var projection = new LocalProjection(Origin);
|
||||||
|
|
||||||
|
var projected = projection.Project(Origin);
|
||||||
|
|
||||||
|
Assert.Equal(0f, projected.X, 3);
|
||||||
|
Assert.Equal(0f, projected.Y, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void One_kilometre_north_lands_one_kilometre_up()
|
||||||
|
{
|
||||||
|
var projection = new LocalProjection(Origin);
|
||||||
|
var north = new GeoPoint(Origin.Latitude + 1000.0 / LocalProjection.MetersPerDegreeLatitude, Origin.Longitude);
|
||||||
|
|
||||||
|
var projected = projection.Project(north);
|
||||||
|
|
||||||
|
Assert.Equal(0f, projected.X, 2);
|
||||||
|
Assert.Equal(1000f, projected.Y, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unproject_reverses_project()
|
||||||
|
{
|
||||||
|
var projection = new LocalProjection(Origin);
|
||||||
|
var point = new Vector2(-4321.5f, 6789.25f);
|
||||||
|
|
||||||
|
var roundTripped = projection.Project(projection.Unproject(point));
|
||||||
|
|
||||||
|
Assert.Equal(point.X, roundTripped.X, 1);
|
||||||
|
Assert.Equal(point.Y, roundTripped.Y, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_coordinates_outside_the_globe()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new LocalProjection(new GeoPoint(91, 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GeoBoundsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(31.8966010, -100.4858591, 10_000)]
|
||||||
|
[InlineData(60.0, 30.3, 20_000)]
|
||||||
|
[InlineData(-33.87, 151.21, 5_000)]
|
||||||
|
public void FromCenter_produces_a_square_of_the_requested_size(double lat, double lon, double sizeMeters)
|
||||||
|
{
|
||||||
|
var center = new GeoPoint(lat, lon);
|
||||||
|
var bounds = GeoBounds.FromCenter(center, sizeMeters);
|
||||||
|
var projection = new LocalProjection(center);
|
||||||
|
|
||||||
|
var southWest = projection.Project(bounds.South, bounds.West);
|
||||||
|
var northEast = projection.Project(bounds.North, bounds.East);
|
||||||
|
|
||||||
|
// Half a metre of slack over a 20 km span is the float rounding in the projection.
|
||||||
|
Assert.Equal(sizeMeters, northEast.X - southWest.X, 0.5);
|
||||||
|
Assert.Equal(sizeMeters, northEast.Y - southWest.Y, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Overpass_bbox_is_south_west_north_east()
|
||||||
|
{
|
||||||
|
var bounds = new GeoBounds(South: 1, West: 2, North: 3, East: 4);
|
||||||
|
|
||||||
|
Assert.Equal("1.0000000,2.0000000,3.0000000,4.0000000", bounds.ToOverpassBbox());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChunkGridTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Covers_the_whole_world_square()
|
||||||
|
{
|
||||||
|
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||||
|
|
||||||
|
Assert.Equal(4, grid.CountX);
|
||||||
|
Assert.Equal(4, grid.CountY);
|
||||||
|
Assert.Equal(-1024f, grid.MinX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rounds_the_count_up_when_the_size_does_not_divide_evenly()
|
||||||
|
{
|
||||||
|
var grid = new ChunkGrid(worldSizeMeters: 10_000, chunkSizeMeters: 512);
|
||||||
|
|
||||||
|
Assert.Equal(20, grid.CountX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Maps_points_to_the_chunk_containing_them()
|
||||||
|
{
|
||||||
|
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||||
|
|
||||||
|
Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-1024, -1024)));
|
||||||
|
Assert.Equal(new ChunkCoord(2, 2), grid.CoordOf(new Vector2(1, 1)));
|
||||||
|
Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(1023, 1023)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clamps_points_that_fall_outside_the_world()
|
||||||
|
{
|
||||||
|
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||||
|
|
||||||
|
Assert.Equal(new ChunkCoord(0, 0), grid.CoordOf(new Vector2(-99_999, -99_999)));
|
||||||
|
Assert.Equal(new ChunkCoord(3, 3), grid.CoordOf(new Vector2(99_999, 99_999)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Chunk_bounds_line_up_with_the_grid()
|
||||||
|
{
|
||||||
|
var grid = new ChunkGrid(worldSizeMeters: 2048, chunkSizeMeters: 512);
|
||||||
|
|
||||||
|
var bounds = grid.BoundsOf(new ChunkCoord(1, 2));
|
||||||
|
|
||||||
|
Assert.Equal(-512f, bounds.MinX);
|
||||||
|
Assert.Equal(0f, bounds.MinY);
|
||||||
|
Assert.Equal(0f, bounds.MaxX);
|
||||||
|
Assert.Equal(512f, bounds.MaxY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Osm.Import;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Tests;
|
||||||
|
|
||||||
|
public class GeometryClipperTests
|
||||||
|
{
|
||||||
|
private static readonly RectBounds Unit = new(0, 0, 100, 100);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaves_a_polygon_that_is_already_inside_alone()
|
||||||
|
{
|
||||||
|
Vector2[] square = [new(10, 10), new(90, 10), new(90, 90), new(10, 90)];
|
||||||
|
|
||||||
|
var clipped = GeometryClipper.ClipPolygon(square, Unit);
|
||||||
|
|
||||||
|
Assert.NotNull(clipped);
|
||||||
|
Assert.Equal(4, clipped.Length);
|
||||||
|
Assert.Equal(Polygons.Area(square), Polygons.Area(clipped), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trims_a_polygon_that_hangs_over_the_edge()
|
||||||
|
{
|
||||||
|
Vector2[] overhanging = [new(50, 50), new(200, 50), new(200, 90), new(50, 90)];
|
||||||
|
|
||||||
|
var clipped = GeometryClipper.ClipPolygon(overhanging, Unit);
|
||||||
|
|
||||||
|
Assert.NotNull(clipped);
|
||||||
|
Assert.All(clipped, point => Assert.InRange(point.X, 0, 100));
|
||||||
|
Assert.Equal(50f * 40f, Polygons.Area(clipped), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drops_a_polygon_that_misses_the_box_entirely()
|
||||||
|
{
|
||||||
|
Vector2[] elsewhere = [new(500, 500), new(600, 500), new(600, 600)];
|
||||||
|
|
||||||
|
Assert.Null(GeometryClipper.ClipPolygon(elsewhere, Unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Keeps_the_part_of_a_line_that_crosses_the_box()
|
||||||
|
{
|
||||||
|
Vector2[] line = [new(-50, 50), new(150, 50)];
|
||||||
|
|
||||||
|
var runs = GeometryClipper.ClipPolyline(line, Unit);
|
||||||
|
|
||||||
|
var run = Assert.Single(runs);
|
||||||
|
Assert.Equal(new Vector2(0, 50), run[0]);
|
||||||
|
Assert.Equal(new Vector2(100, 50), run[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Splits_a_line_that_leaves_and_comes_back()
|
||||||
|
{
|
||||||
|
Vector2[] line = [new(10, 50), new(150, 50), new(150, 20), new(10, 20)];
|
||||||
|
|
||||||
|
var runs = GeometryClipper.ClipPolyline(line, Unit);
|
||||||
|
|
||||||
|
Assert.Equal(2, runs.Count);
|
||||||
|
Assert.All(runs, run => Assert.All(run, point => Assert.InRange(point.X, 0, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drops_a_line_that_never_touches_the_box()
|
||||||
|
{
|
||||||
|
Vector2[] line = [new(-50, -50), new(-10, -20)];
|
||||||
|
|
||||||
|
Assert.Empty(GeometryClipper.ClipPolyline(line, Unit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PolygonsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Computes_the_area_of_a_square()
|
||||||
|
{
|
||||||
|
Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)];
|
||||||
|
|
||||||
|
Assert.Equal(100f, Polygons.Area(square));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_containment()
|
||||||
|
{
|
||||||
|
Vector2[] square = [new(0, 0), new(10, 0), new(10, 10), new(0, 10)];
|
||||||
|
|
||||||
|
Assert.True(Polygons.Contains(square, new Vector2(5, 5)));
|
||||||
|
Assert.False(Polygons.Contains(square, new Vector2(15, 5)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RingAssemblerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Joins_fragments_into_one_ring_whatever_their_order_or_direction()
|
||||||
|
{
|
||||||
|
// A 10x10 square split into three fragments; the middle one runs backwards.
|
||||||
|
List<IReadOnlyList<Vector2>> fragments =
|
||||||
|
[
|
||||||
|
[new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||||
|
[new Vector2(0, 0), new Vector2(10, 0)],
|
||||||
|
[new Vector2(10, 10), new Vector2(10, 0)],
|
||||||
|
];
|
||||||
|
|
||||||
|
var rings = RingAssembler.Assemble(fragments);
|
||||||
|
|
||||||
|
var ring = Assert.Single(rings);
|
||||||
|
Assert.Equal(4, ring.Length);
|
||||||
|
Assert.Equal(100f, Polygons.Area(ring));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Accepts_a_way_that_is_already_closed()
|
||||||
|
{
|
||||||
|
List<IReadOnlyList<Vector2>> fragments =
|
||||||
|
[
|
||||||
|
[new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||||
|
];
|
||||||
|
|
||||||
|
var ring = Assert.Single(RingAssembler.Assemble(fragments));
|
||||||
|
|
||||||
|
// The repeated closing vertex is dropped: a ring is stored open.
|
||||||
|
Assert.Equal(4, ring.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drops_fragments_that_never_close()
|
||||||
|
{
|
||||||
|
List<IReadOnlyList<Vector2>> fragments =
|
||||||
|
[
|
||||||
|
[new Vector2(0, 0), new Vector2(10, 0)],
|
||||||
|
[new Vector2(10, 0), new Vector2(10, 10)],
|
||||||
|
];
|
||||||
|
|
||||||
|
Assert.Empty(RingAssembler.Assemble(fragments));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separates_two_independent_rings()
|
||||||
|
{
|
||||||
|
List<IReadOnlyList<Vector2>> fragments =
|
||||||
|
[
|
||||||
|
[new Vector2(0, 0), new Vector2(10, 0), new Vector2(10, 10), new Vector2(0, 10), new Vector2(0, 0)],
|
||||||
|
[new Vector2(50, 50), new Vector2(60, 50), new Vector2(60, 60), new Vector2(50, 50)],
|
||||||
|
];
|
||||||
|
|
||||||
|
Assert.Equal(2, RingAssembler.Assemble(fragments).Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Core.Export;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Core.Systems;
|
||||||
|
using TheLivingWorld.Core.Worlds;
|
||||||
|
using TheLivingWorld.Osm.Import;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the whole import path - Overpass elements in, chunk payloads out - against hand-built geometry, so
|
||||||
|
/// the tests do not need a network call to exercise it.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportPipelineTests
|
||||||
|
{
|
||||||
|
private const double WorldSizeMeters = 2048;
|
||||||
|
private static readonly GeoPoint Origin = new(31.8966010, -100.4858591);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Imports_a_building_way_as_an_entity_with_a_footprint()
|
||||||
|
{
|
||||||
|
using var world = Build(WayWithGeometry(
|
||||||
|
id: 42,
|
||||||
|
tags: new() { ["building"] = "house", ["name"] = "Old Post Office" },
|
||||||
|
metres: Square(100, 100, 20, closed: true)));
|
||||||
|
|
||||||
|
var chunk = Assert.Single(Export(world)).Chunk;
|
||||||
|
var building = Assert.Single(chunk.Buildings);
|
||||||
|
|
||||||
|
Assert.Equal(42, building.Id);
|
||||||
|
Assert.Equal(BuildingKind.House, building.Kind);
|
||||||
|
Assert.Equal("Old Post Office", building.Name);
|
||||||
|
|
||||||
|
// Four corners, flattened to [x, y] pairs, with the repeated closing vertex dropped.
|
||||||
|
Assert.Equal(8, building.Outline.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Places_features_in_the_chunk_that_contains_them()
|
||||||
|
{
|
||||||
|
using var world = Build(
|
||||||
|
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(-900, -900, 20, closed: true)),
|
||||||
|
WayWithGeometry(2, new() { ["building"] = "yes" }, Square(900, 900, 20, closed: true)));
|
||||||
|
|
||||||
|
var chunks = Export(world);
|
||||||
|
|
||||||
|
Assert.Equal(2, chunks.Count);
|
||||||
|
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(0, 0));
|
||||||
|
Assert.Contains(chunks, c => c.Coord == new ChunkCoord(3, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Imports_a_highway_as_a_road_with_a_width()
|
||||||
|
{
|
||||||
|
using var world = Build(WayWithGeometry(
|
||||||
|
id: 7,
|
||||||
|
tags: new() { ["highway"] = "residential", ["name"] = "Main Street" },
|
||||||
|
metres: [new Vector2(-200, 0), new Vector2(200, 0)]));
|
||||||
|
|
||||||
|
var road = Assert.Single(Export(world)[0].Chunk.Roads);
|
||||||
|
|
||||||
|
Assert.Equal(RoadClass.Residential, road.Class);
|
||||||
|
Assert.Equal("Main Street", road.Name);
|
||||||
|
Assert.Equal(6.5f, road.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clips_geometry_that_reaches_beyond_the_world_square()
|
||||||
|
{
|
||||||
|
// A highway running far past the eastern edge of the generated square.
|
||||||
|
using var world = Build(WayWithGeometry(
|
||||||
|
id: 8,
|
||||||
|
tags: new() { ["highway"] = "primary" },
|
||||||
|
metres: [new Vector2(0, 0), new Vector2(50_000, 0)]));
|
||||||
|
|
||||||
|
var road = Assert.Single(Export(world)[0].Chunk.Roads);
|
||||||
|
var maxX = road.Path.Where((_, index) => index % 2 == 0).Max();
|
||||||
|
|
||||||
|
Assert.True(maxX <= WorldSizeMeters / 2 + 0.5, $"Road reached {maxX} m, past the world edge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Assembles_a_multipolygon_relation_and_keeps_its_holes()
|
||||||
|
{
|
||||||
|
// A forest with a clearing cut out of it, split into two member ways as OSM usually stores them.
|
||||||
|
var outerWest = Metres([new(-300, -300), new(-300, 300), new(300, 300)]);
|
||||||
|
var outerEast = Metres([new(300, 300), new(300, -300), new(-300, -300)]);
|
||||||
|
var clearing = Metres([new(-50, -50), new(50, -50), new(50, 50), new(-50, 50), new(-50, -50)]);
|
||||||
|
|
||||||
|
var relation = new OverpassElement
|
||||||
|
{
|
||||||
|
Type = "relation",
|
||||||
|
Id = 99,
|
||||||
|
Tags = new Dictionary<string, string> { ["type"] = "multipolygon", ["landuse"] = "forest" },
|
||||||
|
Members =
|
||||||
|
[
|
||||||
|
new OverpassMember { Type = "way", Ref = 1, Role = "outer", Geometry = outerWest },
|
||||||
|
new OverpassMember { Type = "way", Ref = 2, Role = "outer", Geometry = outerEast },
|
||||||
|
new OverpassMember { Type = "way", Ref = 3, Role = "inner", Geometry = clearing },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
using var world = Build(relation);
|
||||||
|
|
||||||
|
var area = Assert.Single(Export(world)[0].Chunk.Areas);
|
||||||
|
|
||||||
|
Assert.Equal(AreaKind.Forest, area.Kind);
|
||||||
|
Assert.Equal(4, area.Outline.Length / 2);
|
||||||
|
Assert.NotNull(area.Holes);
|
||||||
|
Assert.Single(area.Holes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Splits_water_into_bodies_and_watercourses()
|
||||||
|
{
|
||||||
|
using var world = Build(
|
||||||
|
WayWithGeometry(10, new() { ["natural"] = "water", ["water"] = "pond" }, Square(0, 0, 60, closed: true)),
|
||||||
|
WayWithGeometry(11, new() { ["waterway"] = "stream" }, [new Vector2(-400, -400), new Vector2(-300, -350)]));
|
||||||
|
|
||||||
|
var water = Export(world).SelectMany(chunk => chunk.Chunk.Water).ToList();
|
||||||
|
|
||||||
|
var pond = Assert.Single(water, feature => feature.Kind == WaterKind.Pond);
|
||||||
|
Assert.NotNull(pond.Outline);
|
||||||
|
Assert.Null(pond.Path);
|
||||||
|
|
||||||
|
var stream = Assert.Single(water, feature => feature.Kind == WaterKind.Stream);
|
||||||
|
Assert.NotNull(stream.Path);
|
||||||
|
Assert.Null(stream.Outline);
|
||||||
|
Assert.Equal(3f, stream.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Counts_what_it_imported()
|
||||||
|
{
|
||||||
|
var stats = BuildWithStats(
|
||||||
|
WayWithGeometry(1, new() { ["building"] = "yes" }, Square(0, 0, 20, closed: true)),
|
||||||
|
WayWithGeometry(2, new() { ["highway"] = "service" }, [new Vector2(0, 0), new Vector2(50, 0)]),
|
||||||
|
WayWithGeometry(3, new() { ["landuse"] = "meadow" }, Square(200, 200, 100, closed: true)));
|
||||||
|
|
||||||
|
Assert.Equal(1, stats.Buildings);
|
||||||
|
Assert.Equal(1, stats.Roads);
|
||||||
|
Assert.Equal(1, stats.Areas);
|
||||||
|
Assert.Equal(3, stats.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Chunk_bounds_stretch_to_cover_geometry_that_overhangs()
|
||||||
|
{
|
||||||
|
// A road whose centre sits in one chunk but which reaches well into its neighbours.
|
||||||
|
using var world = Build(WayWithGeometry(
|
||||||
|
id: 12,
|
||||||
|
tags: new() { ["highway"] = "primary" },
|
||||||
|
metres: [new Vector2(-900, 0), new Vector2(900, 0)]));
|
||||||
|
|
||||||
|
var exported = Assert.Single(Export(world));
|
||||||
|
var square = world.Grid.BoundsOf(exported.Coord);
|
||||||
|
|
||||||
|
Assert.True(exported.Index.Bounds[0] < square.MinX);
|
||||||
|
Assert.True(exported.Index.Bounds[2] > square.MaxX);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Skips_elements_it_does_not_understand()
|
||||||
|
{
|
||||||
|
var stats = BuildWithStats(
|
||||||
|
WayWithGeometry(1, new() { ["barrier"] = "fence" }, [new Vector2(0, 0), new Vector2(10, 0)]),
|
||||||
|
WayWithGeometry(2, new() { ["waterway"] = "dam" }, [new Vector2(0, 0), new Vector2(10, 0)]));
|
||||||
|
|
||||||
|
Assert.Equal(0, stats.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameWorld Build(params OverpassElement[] elements)
|
||||||
|
{
|
||||||
|
var world = NewWorld();
|
||||||
|
new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance).Populate(world, elements);
|
||||||
|
WorldPipeline.CreateDefault().Run(world);
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldStats BuildWithStats(params OverpassElement[] elements)
|
||||||
|
{
|
||||||
|
using var world = NewWorld();
|
||||||
|
return new OsmWorldBuilder(NullLogger<OsmWorldBuilder>.Instance).Populate(world, elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ExportedChunk> Export(GameWorld world) => new ChunkExporter().Export(world);
|
||||||
|
|
||||||
|
private static GameWorld NewWorld() => new(new WorldMetadata
|
||||||
|
{
|
||||||
|
Id = "test",
|
||||||
|
Name = "Test",
|
||||||
|
Origin = Origin,
|
||||||
|
SizeMeters = WorldSizeMeters,
|
||||||
|
Bounds = GeoBounds.FromCenter(Origin, WorldSizeMeters),
|
||||||
|
ChunkSizeMeters = 512,
|
||||||
|
ChunkCountX = 4,
|
||||||
|
ChunkCountY = 4,
|
||||||
|
GeneratedAt = DateTimeOffset.UnixEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static OverpassElement WayWithGeometry(long id, Dictionary<string, string> tags, Vector2[] metres) => new()
|
||||||
|
{
|
||||||
|
Type = "way",
|
||||||
|
Id = id,
|
||||||
|
Tags = tags,
|
||||||
|
Geometry = Metres(metres),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Turns world-metre offsets from the origin back into the lat/lon pairs Overpass would send.</summary>
|
||||||
|
private static List<OverpassNode> Metres(Vector2[] points)
|
||||||
|
{
|
||||||
|
var projection = new LocalProjection(Origin);
|
||||||
|
return [.. points.Select(point =>
|
||||||
|
{
|
||||||
|
var geo = projection.Unproject(point);
|
||||||
|
return new OverpassNode { Lat = geo.Latitude, Lon = geo.Longitude };
|
||||||
|
})];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector2[] Square(float centerX, float centerY, float size, bool closed)
|
||||||
|
{
|
||||||
|
var half = size / 2;
|
||||||
|
Vector2[] corners =
|
||||||
|
[
|
||||||
|
new(centerX - half, centerY - half),
|
||||||
|
new(centerX + half, centerY - half),
|
||||||
|
new(centerX + half, centerY + half),
|
||||||
|
new(centerX - half, centerY + half),
|
||||||
|
];
|
||||||
|
|
||||||
|
return closed ? [.. corners, corners[0]] : corners;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TheLivingWorld.Core.Geo;
|
||||||
|
using TheLivingWorld.Osm;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Tests;
|
||||||
|
|
||||||
|
public class OverpassClientTests : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly GeoBounds Bounds = GeoBounds.FromCenter(new GeoPoint(31.8966010, -100.4858591), 1000);
|
||||||
|
|
||||||
|
private readonly string _cacheDirectory =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"tlw-osm-cache-{Guid.NewGuid():n}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Caches_the_response_and_serves_it_again_without_a_second_request()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler((_, _) => Json("""{"elements":[]}"""));
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var first = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||||
|
var second = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.Equal(1, handler.Requests);
|
||||||
|
Assert.Equal("""{"elements":[]}""", await File.ReadAllTextAsync(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refetches_when_asked_to_refresh()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler((_, _) => Json("""{"elements":[]}"""));
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
await client.FetchAsync(Bounds, forceRefresh: false);
|
||||||
|
await client.FetchAsync(Bounds, forceRefresh: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Falls_back_to_the_next_mirror_after_a_retryable_failure()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler((request, _) => request.RequestUri!.Host == "first.example"
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("busy") }
|
||||||
|
: Json("""{"elements":[]}"""));
|
||||||
|
|
||||||
|
var client = CreateClient(handler, "https://first.example/api", "https://second.example/api");
|
||||||
|
|
||||||
|
var path = await client.FetchAsync(Bounds, forceRefresh: false);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.Equal(2, handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_an_html_error_page_served_under_a_200()
|
||||||
|
{
|
||||||
|
// An overloaded Overpass instance answers "the server is probably too busy" as HTML with status 200.
|
||||||
|
var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("<html><body>too busy</body></html>", System.Text.Encoding.UTF8, "text/html"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var failure = await Assert.ThrowsAsync<OverpassException>(() => client.FetchAsync(Bounds, forceRefresh: false));
|
||||||
|
|
||||||
|
Assert.Contains("text/html", failure.Message);
|
||||||
|
Assert.False(Directory.EnumerateFiles(_cacheDirectory, "*.json").Any());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reports_the_last_error_when_every_mirror_fails()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler((_, _) => new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||||
|
{
|
||||||
|
Content = new StringContent("<p>gateway is down</p>"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = CreateClient(handler);
|
||||||
|
|
||||||
|
var failure = await Assert.ThrowsAsync<OverpassException>(() => client.FetchAsync(Bounds, forceRefresh: false));
|
||||||
|
|
||||||
|
Assert.Contains("gateway is down", failure.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private OverpassClient CreateClient(StubHandler handler, params string[] endpoints)
|
||||||
|
{
|
||||||
|
var options = new OsmOptions
|
||||||
|
{
|
||||||
|
CacheDirectory = _cacheDirectory,
|
||||||
|
MaxAttemptsPerEndpoint = 1,
|
||||||
|
RequestTimeoutSeconds = 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (endpoints.Length > 0) options.Endpoints = endpoints;
|
||||||
|
else options.Endpoints = ["https://only.example/api"];
|
||||||
|
|
||||||
|
return new OverpassClient(
|
||||||
|
new HttpClient(handler),
|
||||||
|
Options.Create(options),
|
||||||
|
NullLogger<OverpassClient>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_cacheDirectory)) Directory.Delete(_cacheDirectory, recursive: true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
public int Requests { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests++;
|
||||||
|
return Task.FromResult(respond(request, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using TheLivingWorld.Core.Ecs;
|
||||||
|
using TheLivingWorld.Osm.Import;
|
||||||
|
using TheLivingWorld.Osm.Overpass;
|
||||||
|
|
||||||
|
namespace TheLivingWorld.Tests;
|
||||||
|
|
||||||
|
public class TagInterpreterTests
|
||||||
|
{
|
||||||
|
private static OverpassElement Way(params string[] tags)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>();
|
||||||
|
for (var i = 0; i < tags.Length; i += 2)
|
||||||
|
map[tags[i]] = tags[i + 1];
|
||||||
|
|
||||||
|
return new OverpassElement { Type = "way", Id = 1, Tags = map };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_an_explicit_height_including_its_unit()
|
||||||
|
{
|
||||||
|
var building = TagInterpreter.ReadBuilding(Way("building", "yes", "height", "12.5 m"));
|
||||||
|
|
||||||
|
Assert.Equal(12.5f, building.HeightMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Derives_height_from_levels_when_none_is_tagged()
|
||||||
|
{
|
||||||
|
var building = TagInterpreter.ReadBuilding(Way("building", "apartments", "building:levels", "4"));
|
||||||
|
|
||||||
|
Assert.Equal(4, building.Levels);
|
||||||
|
Assert.Equal(12f, building.HeightMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Falls_back_to_a_height_that_suits_the_kind()
|
||||||
|
{
|
||||||
|
var shed = TagInterpreter.ReadBuilding(Way("building", "shed"));
|
||||||
|
var house = TagInterpreter.ReadBuilding(Way("building", "house"));
|
||||||
|
|
||||||
|
Assert.Equal(BuildingKind.Shed, shed.Kind);
|
||||||
|
Assert.True(shed.HeightMeters < house.HeightMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ignores_a_height_that_is_not_a_number()
|
||||||
|
{
|
||||||
|
var building = TagInterpreter.ReadBuilding(Way("building", "house", "height", "tall"));
|
||||||
|
|
||||||
|
Assert.Equal(5f, building.HeightMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("motorway", RoadClass.Motorway)]
|
||||||
|
[InlineData("motorway_link", RoadClass.Motorway)]
|
||||||
|
[InlineData("residential", RoadClass.Residential)]
|
||||||
|
[InlineData("living_street", RoadClass.LivingStreet)]
|
||||||
|
[InlineData("nonsense", RoadClass.Unknown)]
|
||||||
|
public void Maps_highway_values_to_road_classes(string highway, RoadClass expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TagInterpreter.ReadRoad(Way("highway", highway)).Class);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prefers_a_tagged_width_over_lane_count()
|
||||||
|
{
|
||||||
|
var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "2", "width", "9"));
|
||||||
|
|
||||||
|
Assert.Equal(9f, road.WidthMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Derives_width_from_lanes_when_no_width_is_tagged()
|
||||||
|
{
|
||||||
|
var road = TagInterpreter.ReadRoad(Way("highway", "residential", "lanes", "4"));
|
||||||
|
|
||||||
|
Assert.Equal(4, road.Lanes);
|
||||||
|
Assert.Equal(13.6f, road.WidthMeters, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_road_flags()
|
||||||
|
{
|
||||||
|
var road = TagInterpreter.ReadRoad(Way("highway", "track", "bridge", "yes", "surface", "gravel"));
|
||||||
|
|
||||||
|
Assert.True(road.Flags.HasFlag(RoadFlags.Bridge));
|
||||||
|
Assert.True(road.Flags.HasFlag(RoadFlags.Unpaved));
|
||||||
|
Assert.False(road.Flags.HasFlag(RoadFlags.Tunnel));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Treats_railways_without_a_highway_tag_as_rail()
|
||||||
|
{
|
||||||
|
Assert.True(TagInterpreter.IsRoad(Way("railway", "rail")));
|
||||||
|
Assert.Equal(RoadClass.Railway, TagInterpreter.ReadRoad(Way("railway", "rail")).Class);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("river", WaterKind.River, 12f)]
|
||||||
|
[InlineData("stream", WaterKind.Stream, 3f)]
|
||||||
|
public void Reads_watercourses_with_a_default_width(string waterway, WaterKind kind, float width)
|
||||||
|
{
|
||||||
|
var water = TagInterpreter.ReadWater(Way("waterway", waterway));
|
||||||
|
|
||||||
|
Assert.NotNull(water);
|
||||||
|
Assert.Equal(kind, water.Value.Kind);
|
||||||
|
Assert.Equal(width, water.Value.WidthMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reads_a_lake_as_a_water_body()
|
||||||
|
{
|
||||||
|
var water = TagInterpreter.ReadWater(Way("natural", "water", "water", "lake"));
|
||||||
|
|
||||||
|
Assert.Equal(WaterKind.Lake, water!.Value.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Does_not_treat_a_dam_as_water()
|
||||||
|
{
|
||||||
|
Assert.Null(TagInterpreter.ReadWater(Way("waterway", "dam")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("landuse", "forest", AreaKind.Forest)]
|
||||||
|
[InlineData("natural", "wood", AreaKind.Forest)]
|
||||||
|
[InlineData("leisure", "park", AreaKind.Park)]
|
||||||
|
[InlineData("amenity", "parking", AreaKind.Parking)]
|
||||||
|
public void Maps_land_cover_tags_to_area_kinds(string key, string value, AreaKind expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TagInterpreter.ReadAreaKind(Way(key, value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Returns_no_area_kind_for_an_unrelated_element()
|
||||||
|
{
|
||||||
|
Assert.Null(TagInterpreter.ReadAreaKind(Way("highway", "residential")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("12", 12.0)]
|
||||||
|
[InlineData("12 m", 12.0)]
|
||||||
|
[InlineData("3.5", 3.5)]
|
||||||
|
[InlineData("-2", -2.0)]
|
||||||
|
public void Parses_numeric_prefixes(string raw, double expected)
|
||||||
|
{
|
||||||
|
Assert.True(TagInterpreter.TryParseLeadingNumber(raw, out var value));
|
||||||
|
Assert.Equal(expected, value, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("tall")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void Rejects_values_without_a_number(string? raw)
|
||||||
|
{
|
||||||
|
Assert.False(TagInterpreter.TryParseLeadingNumber(raw, out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Api", "src\TheLivingWorld.Api\TheLivingWorld.Api.csproj", "{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.AppHost", "src\TheLivingWorld.AppHost\TheLivingWorld.AppHost.csproj", "{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Core", "src\TheLivingWorld.Core\TheLivingWorld.Core.csproj", "{0C8158F7-A44A-2243-729F-7287A7A00520}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Osm", "src\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj", "{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.ServiceDefaults", "src\TheLivingWorld.ServiceDefaults\TheLivingWorld.ServiceDefaults.csproj", "{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TheLivingWorld.Tests", "tests\TheLivingWorld.Tests\TheLivingWorld.Tests.csproj", "{9E0B47BE-A768-D9DB-AD74-01E7397587CC}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{85E45975-AB22-8AF2-BC2D-7A352F60EFDD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{0C8158F7-A44A-2243-729F-7287A7A00520}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{0C8158F7-A44A-2243-729F-7287A7A00520}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{0C8158F7-A44A-2243-729F-7287A7A00520}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{0C8158F7-A44A-2243-729F-7287A7A00520}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9E0B47BE-A768-D9DB-AD74-01E7397587CC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{F54BAF64-1440-F7D3-0F0B-BF1DB3E9C7D9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{85E45975-AB22-8AF2-BC2D-7A352F60EFDD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{0C8158F7-A44A-2243-729F-7287A7A00520} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{AC728E4B-71A3-F367-5920-D6B8E8BD0DF5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{A1EBC8EC-B0D9-D7A8-7A89-392DF16B06D6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{9E0B47BE-A768-D9DB-AD74-01E7397587CC} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {2485AB6C-35FD-4D36-B8DD-1167BDC206BD}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
Reference in New Issue
Block a user