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:
1 parent
57386fe879
commit
e53e35523e
6 files changed
+270
-17
No files matched your search
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user