diff --git a/meshterm/ui/map_render.py b/meshterm/ui/map_render.py index e13fd1b..1dc12ec 100644 --- a/meshterm/ui/map_render.py +++ b/meshterm/ui/map_render.py @@ -123,9 +123,20 @@ _STREET_LABEL = ("#9aa0aa", False, 7) #: Polygon layers drawn as fills, in painting order (later wins the shared cell). _FILL_LAYERS = ("water", "landcover", "landuse", "park") +#: Buildings: colour, cell priority, stipple density, and the zoom they appear at. +#: They sit above the ground fills and below every road, and they are *stippled* rather +#: than solid because a braille dot is one bit: a solid fill does not shade a region, it +#: fills every cell it touches and takes the street grid down with it. Below the gate a +#: typical footprint is about two dots across — under half a cell — so the whole layer +#: collapses to a haze that costs the streets their legibility and says nothing the +#: street pattern didn't already say. +_BUILDING_FILL = ("#4a4f5a", 8) +_BUILDING_STIPPLE = 2 +_BUILDING_MIN_ZOOM = 15 + #: Every basemap layer :func:`_draw_tile` looks at — and so the only geometry the map -#: has any use for. A planet tile also ships ``building``, ``housenumber``, ``poi`` and -#: ``mountain_peak``, which together are a large share of its features and none of its +#: has any use for. A planet tile also ships ``housenumber``, ``poi``, ``mountain_peak`` +#: and the aero layers, which together are a large share of its features and none of its #: pixels: decoding them cost ~40% of every tile until this set was handed to #: :func:`~meshterm.core.mvt.decode_tile` (measured on the Lyra — a 153 KB tile went #: 1236 ms → 281 ms). Passed to the tile source at construction @@ -133,7 +144,8 @@ _FILL_LAYERS = ("water", "landcover", "landuse", "park") #: whole tiles, so widening this set costs a re-decode, never a re-download. DRAWN_LAYERS: frozenset[str] = frozenset( _FILL_LAYERS - + ("waterway", "transportation", "boundary", "transportation_name", "place", "water_name") + + ("building", "waterway", "transportation", "boundary", "transportation_name", + "place", "water_name") ) @@ -246,6 +258,43 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No [project(r, layer.extent) for r in feat.rings], mark_rgb(color), prio ) + # Buildings, as a stippled texture under the streets. + building = by_name.get("building") + if building is not None and frame.viewport.zoom >= _BUILDING_MIN_ZOOM: + rgb = mark_rgb(_BUILDING_FILL[0]) + # One tile-local unit is a fixed number of dots, so the visible slab of the tile + # is a rectangle in tile coordinates: work out its bounds once and reject each + # footprint on its own bounds before projecting a single point. A downtown tile + # holds thousands of footprints and a zoomed-in view shows a few dozen of them. + ext = building.extent + x0, y0 = frame.viewport.feature_to_dot(x, y, z, ext, 0, 0) + x1, y1 = frame.viewport.feature_to_dot(x, y, z, ext, ext, ext) + if x1 != x0 and y1 != y0: + lo_x, hi_x = sorted((-x0 * ext / (x1 - x0), (vp.dot_w - x0) * ext / (x1 - x0))) + lo_y, hi_y = sorted((-y0 * ext / (y1 - y0), (vp.dot_h - y0) * ext / (y1 - y0))) + else: # a degenerate projection can't be culled against; draw it all. + lo_x = lo_y = -math.inf + hi_x = hi_y = math.inf + for feat in building.features: + if feat.geom_type != GEOM_POLYGON: + continue + keep = [] + for ring in feat.rings: + if len(ring) < 4: + continue + rxs = [p[0] for p in ring] + rys = [p[1] for p in ring] + if max(rxs) < lo_x or min(rxs) > hi_x or max(rys) < lo_y or min(rys) > hi_y: + continue + keep.append(ring) + if keep: + vp.fill_polygon( + [project(r, ext) for r in keep], + rgb, + _BUILDING_FILL[1], + stipple=_BUILDING_STIPPLE, + ) + # Waterways (rivers/streams) as lines. waterway = by_name.get("waterway") if waterway is not None: diff --git a/meshterm/ui/mapcanvas.py b/meshterm/ui/mapcanvas.py index 45873a0..040244a 100644 --- a/meshterm/ui/mapcanvas.py +++ b/meshterm/ui/mapcanvas.py @@ -143,9 +143,28 @@ class MapCanvas: ya += sy def fill_polygon( - self, rings: list[list[tuple[float, float]]], color: RGB, priority: int + self, + rings: list[list[tuple[float, float]]], + color: RGB, + priority: int, + *, + stipple: int = 1, ) -> None: - """Even-odd scanline fill of a polygon (with holes) in dot space.""" + """Even-odd scanline fill of a polygon (with holes) in dot space. + + Args: + rings: The polygon's rings in dot coordinates — one outer ring, then any + holes; a multipolygon may pass all its parts at once, since even-odd + gives the same answer for footprints that don't overlap. + color: Fill colour. + priority: Cell-colour priority; a higher one drawn later wins the cell. + stipple: Draw every *n*-th dot on both axes instead of every one, so the + fill reads as a texture rather than a solid. A braille dot is one bit, + so a solid fill doesn't shade a region — it *erases* what shares those + cells. ``2`` lights a quarter of the dots, enough to read as tone while + leaving room for a road to stay a legible line through it. ``1``, the + default, is the ordinary solid fill. + """ edges: list[tuple[float, float, float, float]] = [] ys: list[float] = [] for ring in rings: @@ -157,7 +176,9 @@ class MapCanvas: return y_start = max(0, int(min(ys))) y_end = min(self.dot_h - 1, int(max(ys))) - for y in range(y_start, y_end + 1): + if stipple > 1: + y_start += -y_start % stipple + for y in range(y_start, y_end + 1, stipple): yc = y + 0.5 xs: list[float] = [] for x0, y0, x1, y1 in edges: @@ -167,7 +188,9 @@ class MapCanvas: for i in range(0, len(xs) - 1, 2): x_from = max(0, int(round(xs[i]))) x_to = min(self.dot_w - 1, int(round(xs[i + 1]))) - for x in range(x_from, x_to + 1): + if stipple > 1: + x_from += -x_from % stipple + for x in range(x_from, x_to + 1, stipple): self.plot(x, y, color, priority) # -- overlay (markers + labels) -------------------------------------------- diff --git a/tests/test_map.py b/tests/test_map.py index ac1893c..f98e37b 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -105,11 +105,72 @@ def test_decode_tile_filter_skips_the_layers_the_map_never_draws() -> None: from meshterm.ui.map_render import DRAWN_LAYERS narrow = {layer.name: layer for layer in decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)} - for skipped in ("building", "housenumber", "poi"): + for skipped in ("housenumber", "poi", "mountain_peak"): assert skipped in narrow, f"{skipped} should still be named" assert not narrow[skipped].features, f"{skipped} should not have been decoded" +# -- buildings ---------------------------------------------------------------- + + +def _building_dots(zoom: int) -> int: + """Lit cells in a render of the fixture's buildings alone, at ``zoom``.""" + from meshterm.ui.map_render import render_map + + layers = decode_tile(_FIXTURE.read_bytes(), layers={"building"}) + vp = Viewport(45.4995, -73.5690, zoom, 53 * 2, 26 * 4) + out = _plain(render_map(vp, {(14, 4843, 5861): layers}, [])) + return sum(1 for ch in out if ch not in " \n") + + +def test_buildings_wait_for_the_zoom_that_can_hold_them() -> None: + """Below the gate a footprint is about two dots across, so the layer is a haze.""" + from meshterm.ui.map_render import _BUILDING_MIN_ZOOM + + assert _building_dots(_BUILDING_MIN_ZOOM) > 100 + assert _building_dots(_BUILDING_MIN_ZOOM - 1) == 0 + + +def test_building_fill_is_stippled_so_the_streets_survive_it() -> None: + """A braille dot is one bit: a *solid* fill would erase the street grid it covers. + + The stipple is what keeps a building a shade rather than an eraser — every cell it + touches keeps free dots for a road to be drawn through. + """ + from meshterm.ui.map_render import _BUILDING_MIN_ZOOM, render_map + + layers = decode_tile(_FIXTURE.read_bytes(), layers={"building"}) + vp = Viewport(45.4995, -73.5690, _BUILDING_MIN_ZOOM, 53 * 2, 26 * 4) + out = _plain(render_map(vp, {(14, 4843, 5861): layers}, [])) + + assert "⣿" not in out, "a fully lit cell means the fill left a road nowhere to go" + + +def test_offscreen_buildings_do_not_change_the_frame() -> None: + """The bbox reject is an optimisation, so it must be invisible in the output. + + A downtown tile carries thousands of footprints and a zoomed-in view holds a few + dozen; each one is rejected on its own tile-local bounds before a single point of it + is projected. Adding a footprint the view cannot reach must render byte-identically. + """ + from meshterm.core.mvt import Feature + from meshterm.ui.map_render import _BUILDING_MIN_ZOOM, render_map + + vp = Viewport(45.4995, -73.5690, _BUILDING_MIN_ZOOM, 53 * 2, 26 * 4) + cx, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861) + here = [(cx - 40, cy - 40), (cx + 40, cy - 40), (cx + 40, cy + 40), + (cx - 40, cy + 40), (cx - 40, cy - 40)] + far = [(10, 10), (90, 10), (90, 90), (10, 90), (10, 10)] + + def frame(rings): + layer = Layer(name="building", extent=4096, + features=[Feature(geom_type=GEOM_POLYGON, rings=rings, tags={})]) + return render_map(vp, {(14, 4843, 5861): [layer]}, []) + + assert _plain(frame([here])).strip(), "the in-view footprint should draw something" + assert frame([here]) == frame([here, far]) + + # -- projection / viewport ----------------------------------------------------