Map: draw the ground off the keystroke, and stop reprojecting the world

A pan cost 0.9-2.4 s. It was never the tiles - those already load in a
thread - it was render_map, which rasterizes the vector ground on the
paint path: 900-2342 ms for a view holding ~16k features.

Two changes, in that order.

The projection was doing the same trigonometry once per vertex. Every
feature_to_dot call recomputed Viewport.origin_world - a sin/log pair -
and a 2** for values constant across the whole frame, 107k times a frame.
The mapping is affine in tile-local coordinates, so tile_transform folds
it into three numbers and _draw_tile spells the arithmetic out inline: two
multiplies and two adds per vertex, no call at all. That alone is 2.4x
(z12 2342 -> 991 ms).

The rest is genuinely per-feature Python and will not shrink to keystroke
size, so the raster moved off the paint path. A paint now returns the best
picture available immediately and a background task draws the real one:
unchanged view serves the finished frame; a view that only gained a tile
keeps the old raster, which is still correctly aligned; a view that *moved*
draws the nodes alone on the new viewport, which is a few ms, so panning
tracks the keys and the streets catch up. The title says "drawing..." while
it does.

Exactly one raster runs at a time and it is always the newest requested -
a held arrow hands us a new viewport every repaint, and starting a second
of thread-bound work per keypress would queue frames that are already
stale on arrival and slow the keystrokes this exists to protect.

On the device: pan 1205 -> 89 ms, 1156 -> 130 ms, 2449 -> 91 ms; zoom
1161 -> 142 ms; a settled view 37 ms.

Also flips MESHTERM_FASTRENDER on by default (=0 still opts out), and asks
whether a loop is running *before* building a coroutine, so a static render
no longer leaves an un-awaited one behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-08-09 01:58:08 -04:00
1 parent 57386fe879
commit e53e35523e
6 files changed
+270 -17

No files matched your search

+35
View File
@@ -252,6 +252,41 @@ class Viewport:
out.append((tz, tx % n, ty)) # wrap x around the antimeridian
return out
def tile_transform(
self, tile_x: int, tile_y: int, tile_zoom: int, extent: int
) -> tuple[float, float, float]:
"""Return ``(base_x, base_y, step)`` mapping this tile's local coords to dots.
:meth:`feature_to_dot` is the readable form of the same projection, but it is a
*per-vertex* call, and a single map frame projects tens of thousands of vertices —
107k of them on a downtown view, each one redoing :attr:`origin_world` (a
``sin``/``log`` pair) and a ``2**`` for a value that is constant across the whole
frame. That was over half the cost of drawing the map.
The projection is affine in the tile's local coordinates, so all of that folds into
three numbers the caller can hoist out of its loop::
dot_x = base_x + lx * step
dot_y = base_y + ly * step
leaving two multiplies and two adds per vertex, inline, with no call at all. The
caller is expected to spell that arithmetic out in its own comprehension rather
than take a closure back — a function call per vertex is itself most of what is
left once the trigonometry is gone.
Args:
tile_x: The tile's x index at ``tile_zoom``.
tile_y: The tile's y index at ``tile_zoom``.
tile_zoom: The zoom the tile was fetched at.
extent: The tile's internal coordinate extent (e.g. 4096).
Returns:
The ``(base_x, base_y, step)`` coefficients described above.
"""
span = TILE_PX * (2.0 ** (self.zoom - tile_zoom))
ox, oy = self.origin_world
return tile_x * span - ox, tile_y * span - oy, span / extent
def feature_to_dot(
self, tile_x: int, tile_y: int, tile_zoom: int, extent: int, lx: float, ly: float
) -> tuple[float, float]:
+12 -3
View File
@@ -235,10 +235,19 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No
vp = frame.canvas
by_name = {layer.name: layer for layer in layers}
# The tile-local -> dot projection is affine, and every layer in a tile shares the
# same extent in practice, so its three coefficients are resolved once per extent and
# the per-vertex work is spelled out inline below (see Viewport.tile_transform): a
# downtown frame projects ~50k vertices, and at that count even the function call per
# vertex is worth removing.
transforms: dict[int, tuple[float, float, float]] = {}
def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]:
return [
frame.viewport.feature_to_dot(x, y, z, extent, lx, ly) for lx, ly in ring
]
t = transforms.get(extent)
if t is None:
t = transforms[extent] = frame.viewport.tile_transform(x, y, z, extent)
bx, by, step = t
return [(bx + lx * step, by + ly * step) for lx, ly in ring]
# Fills first (water, green space) so lines and labels sit on top.
for name in _FILL_LAYERS:
+127 -7
View File
@@ -59,6 +59,21 @@ _PAN_DIRS: dict[str, tuple[int, int]] = {
#: How far past the tile source's max zoom the display may go (lower tiles are magnified).
_OVERZOOM = 2
def _loop_running() -> bool:
"""Whether there is an event loop to hand background work to.
Asked *before* building a coroutine, not after: ``ensure_future`` without a loop
raises, but by then the coroutine exists and never gets awaited, which Python reports
as a resource warning on a path that is otherwise perfectly correct (a CLI export or a
test rendering a map with no loop at all).
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
#: How many screens' worth of decoded tiles to keep resident, as a multiple of what the
#: current view needs. A decoded tile costs ~0.9 MB on the PicoCalc (62 bytes a point,
#: measured) against a device that has ~100 MB in total, so a map panned far enough would
@@ -187,6 +202,14 @@ class MapScreen(Screen):
# Decoded tiles, least-recently-shown first — see :meth:`_trim_tiles`.
self._tiles: OrderedDict[tuple[int, int, int], Optional[list[Layer]]] = OrderedDict()
self._pending: set[tuple[int, int, int]] = set()
# The last finished ground frame and what it was drawn for — see :meth:`render_body`.
# Rasterizing a downtown view is ~0.5-1 s of pure Python (tens of thousands of
# vector features), far too slow to sit on a keystroke, so it happens off the paint
# path and the paint serves whatever is ready.
self._frame: Optional[list[str]] = None
self._frame_key: Optional[tuple] = None
self._drawing: Optional[tuple] = None # the key currently being rasterized
self._wanted: Optional[tuple[tuple, Viewport]] = None # the next one to draw
# --- rendering -----------------------------------------------------------
@@ -257,14 +280,107 @@ class MapScreen(Screen):
self._size = (dot_w, dot_h)
self._ensure_tiles(self._viewport)
self.title = self._title(self._viewport)
self._persist()
tiles = {t: self._tiles.get(t) for t in self._viewport.tiles(self._max_tile_zoom)}
lines = render_map(self._viewport, tiles, self._markers, find=self._filter)
lines = self._ground(self._viewport)
self.title = self._title(self._viewport)
if lines and self._query_echo():
lines[-1] = query_line(self._filter, width)
return lines
def _ground_key(self, vp: Viewport) -> tuple:
"""Everything the rasterized ground is a function of.
The tile *identities* go in the key rather than their contents: a tile's decoded
layers never change once loaded, so a tile arriving is the only way the picture can
gain detail, and that shows up here as a new key.
"""
loaded = tuple(t for t in vp.tiles(self._max_tile_zoom) if self._tiles.get(t))
return (vp, loaded, self._filter, len(self._markers))
def _ground(self, vp: Viewport) -> list[str]:
"""The map picture for ``vp`` — from the last raster if it still applies, else soon.
Rasterizing a view is 0.5-1 s of pure Python on the PicoCalc (a downtown frame
projects tens of thousands of vector features), so it cannot happen between a
keypress and the paint that answers it. Instead the paint always returns
immediately, with the best picture available *right now*, and a background task
draws the real one and asks for a repaint when it lands:
* **Nothing has changed** — the finished raster is exactly this view: serve it.
* **Only the tiles changed** (a fetch landed, the view did not move) — the previous
raster is still correctly aligned, just missing some streets. Keep showing it
rather than blanking a good picture to redraw the same ground.
* **The view moved** — the old raster is now in the wrong place, and showing it
would be a lie about where you are looking. Draw the nodes alone on the *new*
viewport, which costs a few milliseconds, so panning tracks the keys exactly and
the streets catch up.
"""
key = self._ground_key(vp)
if self._frame_key == key and self._frame is not None:
return list(self._frame)
self._schedule_ground(key, vp)
if self._frame is not None and self._frame_key is not None:
if self._frame_key[0] == vp and self._frame_key[2] == key[2]:
return list(self._frame) # same view, only tiles differ — still aligned
# The view moved (or nothing has ever been drawn): markers only, no tiles.
return render_map(vp, {}, self._markers, find=self._filter)
def _schedule_ground(self, key: tuple, vp: Viewport) -> None:
"""Note that ``key`` wants drawing, and start on it if nothing else is in flight.
Exactly **one** raster runs at a time, and it is always the newest one asked for.
A held arrow key hands us a new viewport on every repaint, and a render is most of
a second: starting one per keypress would pile up a queue of thread-bound work,
each frame of it already stale on arrival, and the contention would slow the very
keystrokes this is meant to keep quick. So a request that arrives mid-draw only
replaces the pending one, and the draw that finishes picks it up.
"""
if key == self._drawing or key == self._frame_key:
return
self._wanted = (key, vp)
if self._drawing is None:
self._start_ground()
def _start_ground(self) -> None:
"""Begin the pending raster, or draw it inline where there is no event loop."""
if self._wanted is None:
return
key, vp = self._wanted
self._wanted = None
tiles = {t: self._tiles.get(t) for t in vp.tiles(self._max_tile_zoom)}
if not _loop_running():
# A static render (the CLI's map export, a test): there is nothing to be
# responsive *to*, so draw it here and now rather than never.
self._drawing = None
self._frame = render_map(vp, tiles, self._markers, find=self._filter)
self._frame_key = key
return
self._drawing = key
asyncio.ensure_future(self._draw_ground(key, vp, tiles))
async def _draw_ground(self, key: tuple, vp: Viewport, tiles: dict) -> None:
"""Rasterize one view off the event loop, then repaint and take the next request.
The work is pure Python, so a thread does not truly run it in parallel — but the
interpreter still switches between threads every few milliseconds, which is the
whole point: keystrokes keep being serviced throughout instead of waiting for the
frame (measured worst-case delay ~50 ms, against the ~1 s of a blocking draw).
"""
try:
lines = await asyncio.to_thread(
render_map, vp, tiles, self._markers, find=self._filter
)
except Exception: # noqa: BLE001 - a frame we couldn't draw is one we draw again
lines = None
self._drawing = None
if lines is not None:
self._frame, self._frame_key = lines, key
self._needs_scrub = True
self._session.invalidate()
if self._wanted is not None:
self._start_ground()
def _initial_viewport(self, dot_w: int, dot_h: int) -> Viewport:
"""Restore the saved view (clamped to sane bounds) or frame the nodes' dense core.
@@ -320,6 +436,11 @@ class MapScreen(Screen):
f"{m_per_dot * vp.dot_w / 1000:.1f} km across"
if self._pending:
scale = f"{len(self._pending)} tiles…"
elif self._drawing is not None or self._wanted is not None:
# The ground for this view is still being rasterized off the paint path (see
# :meth:`_ground`), so what is on screen is the nodes alone, or the last view's
# streets. Say so, in the same slot the tile fetch reports from.
scale = "drawing…"
elif not self._source.available:
scale = "offline"
if self._filter:
@@ -339,14 +460,13 @@ class MapScreen(Screen):
self._trim_tiles(len(wanted))
if not self._source.available: # offline (resolved at open time) — nodes only
return
if not _loop_running(): # nothing to fetch onto — a static render draws what it has
return
for t in wanted:
if t in self._tiles or t in self._pending:
continue
self._pending.add(t)
try:
asyncio.ensure_future(self._load(t))
except RuntimeError: # pragma: no cover - no running loop (non-interactive)
self._pending.discard(t)
asyncio.ensure_future(self._load(t))
def _trim_tiles(self, in_view: int) -> None:
"""Release the least recently shown tiles once the view's budget is exceeded.
+5 -3
View File
@@ -43,10 +43,12 @@ from prompt_toolkit.renderer import Renderer
def enabled() -> bool:
"""Whether the direct row writer is active.
Off by default while it earns its keep on the device; set ``MESHTERM_FASTRENDER=1``
to turn it on, ``=0`` to force the stock renderer.
On by default — it is 2-3x on every navigation keystroke and its output is verified
byte-identical against the stock renderer by reading the console back. Set
``MESHTERM_FASTRENDER=0`` to fall back to prompt_toolkit's renderer, which is the
escape hatch if a terminal ever disagrees.
"""
return os.environ.get("MESHTERM_FASTRENDER", "") == "1"
return os.environ.get("MESHTERM_FASTRENDER", "1") != "0"
class FastRenderer(Renderer):
+87
View File
@@ -1506,3 +1506,90 @@ def test_render_map_find_matches_still_label_white() -> None:
markers = [MapMarker("TARGET", 45.5, -73.6, key="d4" * 32)]
lines = render_map(vp, {}, markers, find="targ")
assert _glyph_color(lines, "TARGET") == (255, 255, 255)
# --- the ground raster runs off the paint path ---------------------------------------
def _async_map(monkeypatch, drawn: list):
"""A map screen whose rasters are captured instead of run, with a live event loop."""
from meshterm.ui import map_screen as ms
from meshterm.ui.map_render import MapMarker
monkeypatch.setattr(ms, "_loop_running", lambda: True)
started: list[tuple] = []
monkeypatch.setattr(
ms.asyncio, "ensure_future", lambda coro: (coro.close(), started.append(coro))[0]
)
screen = ms.MapScreen(
_StubSession(80, 24), [MapMarker("A", 45.5, -73.6)], _StubSource(), 14
)
drawn.append(started)
return screen
def test_map_paints_without_waiting_for_the_ground(monkeypatch) -> None: # noqa: ANN001
"""A pan must answer the keystroke now — rasterizing takes ~1 s on the PicoCalc."""
from meshterm.ui import map_screen as ms
calls: list[tuple] = []
real = ms.render_map
monkeypatch.setattr(ms, "render_map", lambda vp, tiles, m, **k: calls.append(tiles) or real(vp, tiles, m, **k))
started: list = []
screen = _async_map(monkeypatch, started)
screen.render_body(80)
# Whatever ran on the paint path drew no tiles: the ground is somebody else's job.
assert calls, "the paint drew nothing at all"
assert all(tiles == {} for tiles in calls), calls
assert screen._drawing is not None, "no background raster was scheduled"
def test_map_keeps_one_raster_in_flight_while_panning(monkeypatch) -> None: # noqa: ANN001
"""A held arrow key must not queue a second of thread work per keypress."""
started: list = []
screen = _async_map(monkeypatch, started)
screen.render_body(80)
scheduled = len(started[0])
for _ in range(8):
screen.handle("right")
screen.render_body(80)
assert len(started[0]) == scheduled, "a second raster started before the first finished"
assert screen._wanted is not None, "the newest view was not remembered for next"
def test_map_holds_an_aligned_frame_while_a_tile_lands(monkeypatch) -> None: # noqa: ANN001
"""A tile arriving must not blank streets that are still in the right place.
The view has not moved, so the finished raster is still correctly aligned — it is only
missing the newcomer's detail. Redrawing from nothing would flash the ground away for
the second it takes to draw the same picture again.
"""
started: list = []
screen = _async_map(monkeypatch, started)
screen.render_body(80)
# Pretend the scheduled raster finished.
screen._frame = ["ground"] * 20
screen._frame_key = screen._ground_key(screen._viewport)
screen._drawing = None
assert screen.render_body(80) == ["ground"] * 20
in_view = screen._viewport.tiles(14)[0]
screen._tiles[in_view] = _loaded_tile() # a tile lands, view unmoved
assert screen.render_body(80) == ["ground"] * 20, "dropped an aligned frame"
assert screen._drawing is not None, "did not redraw for the new tile"
def test_map_drops_a_stale_frame_once_the_view_moves(monkeypatch) -> None: # noqa: ANN001
"""Streets drawn for somewhere else are a lie about where you are looking."""
started: list = []
screen = _async_map(monkeypatch, started)
screen.render_body(80)
screen._frame = ["ground"] * 20
screen._frame_key = screen._ground_key(screen._viewport)
screen._drawing = None
screen.handle("right")
assert screen.render_body(80) != ["ground"] * 20, "kept a misaligned frame"
+4 -4
View File
@@ -283,14 +283,14 @@ def _fast_renderer(frames: list[str]) -> tuple["FastRenderer", _CapturingOutput]
return renderer, out
def test_fastrender_is_off_unless_asked_for(monkeypatch) -> None: # noqa: ANN001
"""The bypass is opt-in: an unset environment leaves prompt_toolkit in charge."""
def test_fastrender_is_on_with_an_escape_hatch(monkeypatch) -> None: # noqa: ANN001
"""The row writer is the default paint; only an explicit 0 hands it back to pt."""
from meshterm.ui.tui import fastrender
monkeypatch.delenv("MESHTERM_FASTRENDER", raising=False)
assert not fastrender.enabled()
monkeypatch.setenv("MESHTERM_FASTRENDER", "1")
assert fastrender.enabled()
monkeypatch.setenv("MESHTERM_FASTRENDER", "0")
assert not fastrender.enabled()
def test_fastrender_rewrites_only_the_rows_that_changed() -> None: