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:
1 parent
fd312db8b9
commit
5268892780
3 files changed
+141
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user