diff --git a/README.md b/README.md index e360d28..aca533f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs b/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs index 520feb9..43ce18b 100644 --- a/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs +++ b/src/TheLivingWorld.Osm/Import/OsmWorldBuilder.cs @@ -16,6 +16,12 @@ public sealed class OsmWorldBuilder(ILogger logger) /// Rings smaller than this are noise at any zoom the client offers. private const float MinimumAreaSquareMeters = 1.0f; + /// + /// How far a split line reaches past its chunk. Neighbouring pieces overlap by this much so the seam at a + /// chunk boundary is covered rather than left as a hairline gap. + /// + private const float ChunkSeamOverlapMeters = 1.5f; + public WorldStats Populate(GameWorld world, IEnumerable elements) { var half = (float)(world.Metadata.SizeMeters / 2.0); @@ -206,12 +212,12 @@ public sealed class OsmWorldBuilder(ILogger logger) GameWorld world, OsmSource source, Road road, Vector2[] points, string? name, in RectBounds clip, Counters counters) { - foreach (var run in GeometryClipper.ClipPolyline(points, clip)) + foreach (var piece in SplitForChunks(world, points, clip)) { world.Ecs.Create( source, road, - new Polyline(world.Shapes.Add(run)), + new Polyline(world.Shapes.Add(piece)), new Bounds(), new InChunk(), new DisplayName(name)); @@ -224,12 +230,12 @@ public sealed class OsmWorldBuilder(ILogger logger) GameWorld world, OsmSource source, Water water, Vector2[] points, string? name, in RectBounds clip, Counters counters) { - foreach (var run in GeometryClipper.ClipPolyline(points, clip)) + foreach (var piece in SplitForChunks(world, points, clip)) { world.Ecs.Create( source, water, - new Polyline(world.Shapes.Add(run)), + new Polyline(world.Shapes.Add(piece)), new Bounds(), new InChunk(), new DisplayName(name)); @@ -238,6 +244,43 @@ public sealed class OsmWorldBuilder(ILogger logger) } } + /// + /// Clips a line to the world square and then to each chunk it passes through. Without this a road belongs + /// wholly to the chunk holding the centre of its extent, so one highway can stretch a chunk's bounds + /// across the entire map and force the client to load it from any viewpoint. Each piece ends up inside + /// the chunk it was cut for, so still places it by centre. + /// + private static List SplitForChunks(GameWorld world, Vector2[] points, in RectBounds clip) + { + var grid = world.Grid; + var pieces = new List(); + + foreach (var run in GeometryClipper.ClipPolyline(points, clip)) + { + var extent = RectBounds.FromPoints(run); + var first = grid.CoordOf(new Vector2(extent.MinX, extent.MinY)); + var last = grid.CoordOf(new Vector2(extent.MaxX, extent.MaxY)); + + if (first == last) + { + pieces.Add(run); + continue; + } + + for (var y = first.Y; y <= last.Y; y++) + { + for (var x = first.X; x <= last.X; x++) + { + var cell = grid.BoundsOf(new ChunkCoord(x, y)); + cell.Expand(ChunkSeamOverlapMeters); + pieces.AddRange(GeometryClipper.ClipPolyline(run, cell)); + } + } + } + + return pieces; + } + private static int[]? StoreHoles(GameWorld world, List? holes, in RectBounds clip) { if (holes is null || holes.Count == 0) return null; diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 708e85a..7ccbba2 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -11,8 +11,13 @@