From e53e35523eb0acc80263b5fbb466f7bf4e3b4e03 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 9 Aug 2026 01:55:59 -0400 Subject: [PATCH] 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 --- meshterm/core/geo.py | 35 +++++++++ meshterm/ui/map_render.py | 15 +++- meshterm/ui/map_screen.py | 134 ++++++++++++++++++++++++++++++++-- meshterm/ui/tui/fastrender.py | 8 +- tests/test_map.py | 87 ++++++++++++++++++++++ tests/test_performance.py | 8 +- 6 files changed, 270 insertions(+), 17 deletions(-) diff --git a/meshterm/core/geo.py b/meshterm/core/geo.py index 8162800..c0bae1c 100644 --- a/meshterm/core/geo.py +++ b/meshterm/core/geo.py @@ -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]: diff --git a/meshterm/ui/map_render.py b/meshterm/ui/map_render.py index 1dc12ec..4909ad5 100644 --- a/meshterm/ui/map_render.py +++ b/meshterm/ui/map_render.py @@ -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: diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 3bf323d..6266009 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -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. diff --git a/meshterm/ui/tui/fastrender.py b/meshterm/ui/tui/fastrender.py index 68f25c1..c1eeecf 100644 --- a/meshterm/ui/tui/fastrender.py +++ b/meshterm/ui/tui/fastrender.py @@ -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): diff --git a/tests/test_map.py b/tests/test_map.py index 61cd2d4..4d889b5 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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" diff --git a/tests/test_performance.py b/tests/test_performance.py index 06a3aeb..60bd56b 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -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: