Enhance README with detailed testing instructions and clarify world generation process; implement chunk boundary handling in OsmWorldBuilder; integrate Vitest for testing in the web project; add theme toggle functionality and improve styling in the web interface.

This commit is contained in:
Leonid Pershin
2026-08-16 18:37:25 +03:00
parent 8460921bfa
commit 312d6bc58a
24 changed files with 2971 additions and 314 deletions
+55 -9
View File
@@ -46,12 +46,21 @@ dotnet run --project src/TheLivingWorld.Api
npm --prefix src/TheLivingWorld.Web run dev
```
Tests:
Tests — the backend under xUnit, the client under Vitest:
```bash
dotnet test
```
```bash
npm --prefix src/TheLivingWorld.Web test
```
The client tests cover the pure half of the renderer: geometry helpers, camera maths, layer ordering and the
palettes. Modules that hold PixiJS values are deliberately kept out of them, which is why `layers.ts` imports
`Container` as a type only and the container construction lives in `mapView.ts` — the ordering rules stay
testable without a browser.
## How a world is made
1. **Fetch.** `OverpassClient` posts one bounding-box query to Overpass and streams the response into
@@ -63,6 +72,9 @@ dotnet test
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.
Lines are then cut again at chunk boundaries — a road that spans the map becomes one entity per chunk it
crosses, which is what keeps a chunk's extent close to its own square. Neighbouring pieces overlap by a
metre and a half so the seam is covered rather than left as a hairline gap.
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.
@@ -106,13 +118,43 @@ 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.
`MapView` owns one scaled container holding the layer stack from `layers.ts`, plus a screen-space layer for
place names above it. `Camera` is the only place the Y flip lives; everything else thinks in map coordinates.
**Layers are global, not per chunk.** Every chunk paints into the same ordered set of containers rather than
into a container of its own. That is what makes junctions correct: with per-chunk containers the ordering
would only hold inside a chunk, so a side street loaded after a trunk road would paint straight over it
wherever the two meet. Roads are sorted into importance bands — tunnel, minor, local, secondary, major,
bridge — and within each band every casing goes down before any fill, so the fills merge into one continuous
surface. Land cover gets the same treatment in three bands: zoning blocks, natural cover, then parks and
pitches.
`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.
view, keeping the parsed data cached so panning back is instant. When a chunk is redrawn — a zoom step or a
theme switch — the new set fades in over the old one rather than replacing it outright.
Detail thins out as you zoom away: footpaths disappear first, then small buildings, and stroke widths gain a
floor so hairlines stay visible. At street level the map picks up the things that only read close up:
- buildings extrude, with walls drawn down from every footprint edge to a roof lifted by the building's height
- footways, paths, steps and cycleways switch to dashed lines so they never read as pale streets
- railways become a dark bed with light sleepers dashed over it
- one-way streets grow chevrons pointing the way traffic runs
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
are left alone, because a gridded town is full of genuine right angles
Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in
two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then
arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
label already placed, or any street name too long for the road it belongs to. Because a road is split across
chunks, the pieces are folded back together by OSM id so a street gets one label rather than one per chunk.
That pass runs on the same slow timer as chunk bookkeeping. `LabelLayer` then moves the chosen labels to
follow the camera every frame, which is nearly free — without that split they lag a fast pan by up to a tenth
of a second and snap back when the next placement lands.
Both palettes live in `theme.ts` and nothing else in the renderer names a colour. Switching theme changes the
render profile key, which is the same signal a zoom change uses, so every loaded chunk redraws through the
usual dissolve instead of a special case. The page chrome follows via a `data-theme` attribute.
## Configuration
@@ -130,6 +172,10 @@ coordinates and is not committed.
- 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.
- Lines are split at chunk boundaries, but polygons are not: a large forest or landuse block still belongs
whole to the chunk holding the centre of its extent and overhangs its neighbours. The exported chunk bounds
are widened to match and the client culls against those. Splitting polygons too would risk hairline seams
between the filled pieces.
- Building extrusion is a flat fake — walls swept in one fixed direction, no perspective and no roof shapes.
- Labels are placed along a straight line at the middle of a road, not curved along its path, so a name on a
sharply bending street sits at the average angle rather than following it.