Map: buildings as a texture, because a filled dot is an eraser

Draw the `building` layer at the top two zooms, stippled to every other dot.
On a braille canvas a dot is one bit, so a solid fill does not shade a region
— it lights every dot in every cell it covers and takes the street grid down
with it. Downtown becomes a wall of ⣿ with the roads gone. The stipple reads
as tone instead, and leaves room in every cell for a road to stay a legible
line through it. Outlines fail differently: at one bit a building's edge and a
road are the same mark.

The lattice is aligned to the canvas rather than to each polygon, so
neighbouring footprints share it and the texture doesn't shimmer at their
seams.

Below the gate a typical footprint is about two dots across — under half a
cell — and the layer collapses into a haze, so it stays off.

Each footprint is rejected on its own tile-local bounds before a single point
of it is projected. Without that, a downtown tile's 2,704 footprints cost the
frame 2-4x (87 -> 258 ms at z15, 82 -> 294 ms at z16, measured on the fixture);
with it, +7% at z15 and +14% at z16, and flat in zoom. Widening DRAWN_LAYERS
re-stamps the decoded sidecar, so this costs one re-decode per tile and no
re-download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-08 02:44:30 -04:00
1 parent fd312db8b9
commit 5268892780
3 files changed
+141 -8

No files matched your search

+52 -3
View File
@@ -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:
+27 -4
View File
@@ -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) --------------------------------------------
+62 -1
View File
@@ -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 ----------------------------------------------------