From 815d8a2d880b1467f139a343f80a61a08d2116b6 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 23 May 2026 10:24:34 +0200 Subject: [PATCH] step 6: add stable overlays and live update stress tests --- AGENTS.md | 19 ++- README.md | 9 +- examples/markers_live_thread.py | 66 ++++++++ examples/trajectory_live_thread.py | 76 +++++++++ src/dpg_map/draw_layers.py | 45 +++++ src/dpg_map/renderer.py | 258 +++++++++++++++++++++++++++-- tests/test_overlays_state.py | 39 +++++ tests/test_renderer.py | 89 +++++++++- 8 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 examples/markers_live_thread.py create mode 100644 examples/trajectory_live_thread.py diff --git a/AGENTS.md b/AGENTS.md index 00aa78e..878d0aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Current status -Step 5 complete. +Step 6 complete. ## Completed steps @@ -11,10 +11,11 @@ Step 2 - Thread-safe state, commands, overlays, and cache model. Step 3 - Widget shell, sizing system, and GUI-thread frame pump. Step 4 - Tile manager, persistent cache, and asynchronous loading. Step 5 - Interaction: pan, zoom, and view commands. +Step 6 - Overlay rendering and runtime update stress tests. ## Current step -Step 6 - Overlay rendering and runtime update stress tests. +Step 7 - Layers, provider switching, and clearing APIs. ## Design decisions @@ -81,7 +82,19 @@ None yet. - Ran `uv run ruff format --check .`. - Ran `uv run pyright`. - Ran a Dear PyGui context smoke check for `map_widget` child-window/drawlist/texture-registry/handler-registry creation. +- Implemented Dear PyGui draw-layer bookkeeping for background, tiles, overlays, and attribution. +- Rendered markers, polylines, and trajectories from GUI-thread overlay snapshots. +- Isolated overlay redraws so they clear only the overlay draw layer and do not clear tile draw commands or textures. +- Added live background-thread marker and trajectory stress examples. +- Added Step 6 tests for overlay draw-layer isolation, overlay-only dirty flags, threaded update coalescing, and view/drag-state isolation. +- Updated `README.md` with Step 6 overlay rendering behavior and examples. +- Ran `uv run ruff format .`. +- Ran `uv run pytest`. +- Ran `uv run pyright`. +- Ran `uv run ruff check .`. +- Ran `uv run ruff format --check .`. +- Ran a Dear PyGui context smoke check for `map_widget` with marker, polyline, and trajectory overlays. ## Next action -Implement Step 6. +Implement Step 7. diff --git a/README.md b/README.md index dbab9fd..e9f2b45 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `dpg-map` is a Dear PyGui map widget package under rebuild. -The Step 5 interaction layer is in place: +The Step 6 overlay rendering layer is in place: ```python import dpg_map as dpgm @@ -36,9 +36,8 @@ Implemented so far: - memory cache with visible-tile protection and GUI-thread texture deletion - measured-rectangle mouse interaction for left-drag panning and wheel zooming - programmatic view commands and screen/latitude-longitude conversion helpers - -Overlay drawing is not implemented yet. Step 6 will add overlay rendering and -live update stress examples. +- marker, polyline, and trajectory rendering on a draw layer separate from tiles +- background-thread overlay updates that coalesce without resetting center or zoom Examples: @@ -49,4 +48,6 @@ uv run python examples/sizing_child.py uv run python examples/sizing_table.py uv run python examples/hidden_tab.py uv run python examples/cache_stress.py +uv run python examples/markers_live_thread.py +uv run python examples/trajectory_live_thread.py ``` diff --git a/examples/markers_live_thread.py b/examples/markers_live_thread.py new file mode 100644 index 0000000..45e083e --- /dev/null +++ b/examples/markers_live_thread.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from math import cos, sin +from threading import Event, Thread +from time import sleep +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpgm.configure(user_agent="dpg-map markers_live_thread example") + stop = Event() + + dpg.create_context() + dpg.create_viewport(title="dpg-map live markers", width=1000, height=700) + + with ( + dpg.window(label="Live Markers", width=-1, height=-1), + dpgm.map_widget( + tag="live-markers-map", center=(47.9029, 1.9093), zoom=15, width=-1, height=-1 + ), + ): + for index in range(12): + dpgm.add_marker( + f"vehicle-{index}", + lat=47.9029, + lon=1.9093, + label=str(index + 1), + show_label=True, + color=(240, 92, 70, 255), + ) + + def update_markers() -> None: + tick = 0 + while not stop.is_set(): + for index in range(12): + angle = tick * 0.08 + index * 0.52 + radius = 0.0015 + (index % 4) * 0.0002 + dpgm.update_marker( + f"vehicle-{index}", + lat=47.9029 + sin(angle) * radius, + lon=1.9093 + cos(angle) * radius, + map_tag="live-markers-map", + ) + tick += 1 + sleep(1 / 30) + + worker = Thread(target=update_markers, name="dpg-map-live-markers", daemon=True) + worker.start() + try: + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + stop.set() + worker.join(timeout=1.0) + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/examples/trajectory_live_thread.py b/examples/trajectory_live_thread.py new file mode 100644 index 0000000..3870270 --- /dev/null +++ b/examples/trajectory_live_thread.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections import deque +from math import cos, sin +from threading import Event, Thread +from time import sleep +from typing import Any + +import dearpygui.dearpygui as _dpg + +import dpg_map as dpgm + +dpg: Any = _dpg + + +def main() -> None: + dpgm.configure(user_agent="dpg-map trajectory_live_thread example") + stop = Event() + points: deque[tuple[float, float]] = deque(maxlen=240) + + dpg.create_context() + dpg.create_viewport(title="dpg-map live trajectory", width=1000, height=700) + + with ( + dpg.window(label="Live Trajectory", width=-1, height=-1), + dpgm.map_widget( + tag="live-trajectory-map", + center=(47.9029, 1.9093), + zoom=15, + width=-1, + height=-1, + ), + ): + dpgm.add_trajectory( + "track", + points=[], + color=(250, 190, 80, 255), + thickness=3.0, + show_points=True, + point_stride=12, + ) + dpgm.add_marker( + "head", + lat=47.9029, + lon=1.9093, + color=(72, 205, 154, 255), + radius=6, + ) + + def update_trajectory() -> None: + tick = 0 + while not stop.is_set(): + angle = tick * 0.06 + lat = 47.9029 + sin(angle) * 0.0016 + sin(angle * 2.7) * 0.00025 + lon = 1.9093 + cos(angle) * 0.0016 + points.append((lat, lon)) + snapshot = tuple(points) + dpgm.update_trajectory("track", points=snapshot, map_tag="live-trajectory-map") + dpgm.update_marker("head", lat=lat, lon=lon, map_tag="live-trajectory-map") + tick += 1 + sleep(1 / 20) + + worker = Thread(target=update_trajectory, name="dpg-map-live-trajectory", daemon=True) + worker.start() + try: + dpg.setup_dearpygui() + dpg.show_viewport() + dpg.start_dearpygui() + finally: + stop.set() + worker.join(timeout=1.0) + dpg.destroy_context() + + +if __name__ == "__main__": + main() diff --git a/src/dpg_map/draw_layers.py b/src/dpg_map/draw_layers.py index b7e39fa..80d41bc 100644 --- a/src/dpg_map/draw_layers.py +++ b/src/dpg_map/draw_layers.py @@ -1 +1,46 @@ """Draw layer bookkeeping helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .types import Tag + + +@dataclass(frozen=True, slots=True) +class DrawLayerTags: + """Internal Dear PyGui draw layer tags for one map.""" + + background: str + tiles: str + overlays: str + attribution: str + + +def draw_layer_tags(map_tag: Tag) -> DrawLayerTags: + """Return stable internal draw layer tags for a map.""" + + return DrawLayerTags( + background=f"{map_tag}##layer-background", + tiles=f"{map_tag}##layer-tiles", + overlays=f"{map_tag}##layer-overlays", + attribution=f"{map_tag}##layer-attribution", + ) + + +def ensure_draw_layers(dpg: Any, *, drawlist_tag: Tag, map_tag: Tag) -> DrawLayerTags: + """Create draw layers if needed and return their tags.""" + + tags = draw_layer_tags(map_tag) + for layer_tag in (tags.background, tags.tiles, tags.overlays, tags.attribution): + if not dpg.does_item_exist(layer_tag): + dpg.add_draw_layer(parent=drawlist_tag, tag=layer_tag) + return tags + + +def clear_draw_layer(dpg: Any, layer_tag: Tag) -> None: + """Clear one draw layer without touching sibling layers.""" + + if dpg.does_item_exist(layer_tag): + dpg.delete_item(layer_tag, children_only=True) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index c16c815..9dd8c9b 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -3,13 +3,18 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import replace from typing import Any from .commands import CommandKind, MapCommand +from .draw_layers import DrawLayerTags, clear_draw_layer, ensure_draw_layers from .interaction import HitRect, calculate_hit_rect +from .overlays import MarkerOverlay, Overlay, PolylineOverlay, TrajectoryOverlay +from .projection import latlon_to_world from .sizing import SizeMeasurement, apply_size_measurement from .state import DirtyFlags, MapState from .tiles import Tile, VisibleTile +from .types import Color, LatLon class MapRenderer: @@ -18,10 +23,10 @@ class MapRenderer: def __init__(self, state: MapState, dpg: Any) -> None: self.state = state self._dpg = dpg - self._background_tag = f"{state.tag}##background" - self._attribution_tag = f"{state.tag}##attribution" + self._layers: DrawLayerTags | None = None self.last_drained_commands: tuple[MapCommand, ...] = () self.last_hit_rect: HitRect | None = None + self.last_overlay_count: int = 0 def schedule_next_frame(self) -> None: """Schedule this renderer to run on the next Dear PyGui frame.""" @@ -51,7 +56,8 @@ class MapRenderer: with self.state.lock: dirty = self.state.dirty - should_draw = bool(dirty & (DirtyFlags.FULL | DirtyFlags.SIZE | DirtyFlags.TILES)) + draw_tiles = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.TILES | DirtyFlags.PROVIDER)) + draw_overlays = bool(dirty & (DirtyFlags.SIZE | DirtyFlags.OVERLAYS)) visible = self.state.is_visible width = self.state.measured_width or self.state.last_nonzero_width height = self.state.measured_height or self.state.last_nonzero_height @@ -61,6 +67,12 @@ class MapRenderer: zoom = self.state.zoom generation = self.state.generation cache_dir = self.state.cache_dir + overlays = tuple( + _copy_overlay_for_render(overlay) for overlay in self.state.overlays.values() + ) + layers = { + name: (layer.show, layer.z_index) for name, layer in self.state.layers.items() + } self.state.dirty = DirtyFlags.NONE accepted_tiles = self.state.tile_manager.drain_results( @@ -84,7 +96,7 @@ class MapRenderer: margin=self._prefetch_margin(), ) - if visible and (should_draw or accepted_tiles): + if visible and (draw_tiles or accepted_tiles): self._draw_tile_layer( visible_tiles=visible_tiles, width=width, @@ -92,6 +104,16 @@ class MapRenderer: attribution=provider_attribution, tile_size=provider.tile_size, ) + if visible and draw_overlays: + self._draw_overlay_layer( + overlays=overlays, + layers=layers, + center=center, + zoom=zoom, + width=width, + height=height, + tile_size=provider.tile_size, + ) def _update_size_from_dpg(self) -> None: width, height = self._measure_child_content() @@ -128,12 +150,14 @@ class MapRenderer: ) -> None: width = max(1, int(width)) height = max(1, int(height)) - self._dpg.delete_item(self.state.drawlist_tag, children_only=True) + layers = self._ensure_draw_layers() + clear_draw_layer(self._dpg, layers.background) + clear_draw_layer(self._dpg, layers.tiles) + clear_draw_layer(self._dpg, layers.attribution) self._dpg.draw_rectangle( (0, 0), (width, height), - parent=self.state.drawlist_tag, - tag=self._background_tag, + parent=layers.background, color=(54, 68, 78, 255), fill=(29, 38, 45, 255), ) @@ -147,19 +171,193 @@ class MapRenderer: tile.texture_tag, (screen_x, screen_y), (screen_x + tile_size, screen_y + tile_size), - parent=self.state.drawlist_tag, + parent=layers.tiles, ) label = attribution or "Map tiles" text_y = max(28, height - 24) self._dpg.draw_text( (12, text_y), label, - parent=self.state.drawlist_tag, - tag=self._attribution_tag, + parent=layers.attribution, color=(172, 184, 192, 255), size=12, ) + def _draw_overlay_layer( + self, + *, + overlays: tuple[Overlay, ...], + layers: dict[str, tuple[bool, int]], + center: LatLon, + zoom: int, + width: int, + height: int, + tile_size: int, + ) -> None: + draw_layers = self._ensure_draw_layers() + clear_draw_layer(self._dpg, draw_layers.overlays) + if width <= 0 or height <= 0: + self.last_overlay_count = 0 + return + + center_x, center_y = latlon_to_world(center[0], center[1], zoom, tile_size) + visible_overlays = [ + overlay + for overlay in overlays + if overlay.show and layers.get(overlay.layer, (True, 0))[0] + ] + visible_overlays.sort(key=lambda overlay: layers.get(overlay.layer, (True, 0))[1]) + drawn = 0 + for overlay in visible_overlays: + if isinstance(overlay, MarkerOverlay): + self._draw_marker_overlay( + overlay, center_x, center_y, zoom, width, height, tile_size + ) + drawn += 1 + elif isinstance(overlay, PolylineOverlay): + self._draw_polyline_overlay( + overlay, + center_x, + center_y, + zoom, + width, + height, + tile_size, + draw_layers.overlays, + ) + drawn += 1 + elif isinstance(overlay, TrajectoryOverlay): + self._draw_trajectory_overlay( + overlay, + center_x, + center_y, + zoom, + width, + height, + tile_size, + draw_layers.overlays, + ) + drawn += 1 + self.last_overlay_count = drawn + + def _draw_marker_overlay( + self, + overlay: MarkerOverlay, + center_x: float, + center_y: float, + zoom: int, + width: int, + height: int, + tile_size: int, + ) -> None: + layers = self._ensure_draw_layers() + x, y = _latlon_to_screen( + overlay.lat, + overlay.lon, + center_x, + center_y, + zoom, + width, + height, + tile_size, + ) + radius = max(1.0, float(overlay.radius)) + self._dpg.draw_circle( + (x, y), + radius, + parent=layers.overlays, + color=(255, 255, 255, 230), + fill=_rgba(overlay.color), + thickness=1.5, + segments=20, + ) + if overlay.show_label and overlay.label: + self._dpg.draw_text( + (x + radius + 4.0, y - 7.0), + overlay.label, + parent=layers.overlays, + color=(245, 248, 250, 255), + size=12, + ) + + def _draw_polyline_overlay( + self, + overlay: PolylineOverlay, + center_x: float, + center_y: float, + zoom: int, + width: int, + height: int, + tile_size: int, + parent: str, + ) -> None: + points = _screen_points( + overlay.points, + center_x=center_x, + center_y=center_y, + zoom=zoom, + width=width, + height=height, + tile_size=tile_size, + ) + if len(points) < 2: + return + self._dpg.draw_polyline( + points, + parent=parent, + closed=overlay.closed, + color=_rgba(overlay.color), + thickness=max(1.0, float(overlay.thickness)), + ) + + def _draw_trajectory_overlay( + self, + overlay: TrajectoryOverlay, + center_x: float, + center_y: float, + zoom: int, + width: int, + height: int, + tile_size: int, + parent: str, + ) -> None: + points = _screen_points( + overlay.points, + center_x=center_x, + center_y=center_y, + zoom=zoom, + width=width, + height=height, + tile_size=tile_size, + ) + if len(points) >= 2: + self._dpg.draw_polyline( + points, + parent=parent, + color=_rgba(overlay.color), + thickness=max(1.0, float(overlay.thickness)), + ) + if overlay.show_points and points: + stride = max(1, int(overlay.point_stride)) + for point in points[::stride]: + self._dpg.draw_circle( + point, + 2.5, + parent=parent, + color=_rgba(overlay.color), + fill=_rgba(overlay.color), + segments=8, + ) + + def _ensure_draw_layers(self) -> DrawLayerTags: + if self._layers is None or not self._dpg.does_item_exist(self._layers.overlays): + self._layers = ensure_draw_layers( + self._dpg, + drawlist_tag=self.state.drawlist_tag, + map_tag=self.state.tag, + ) + return self._layers + def _ensure_texture(self, tile: Tile) -> None: if tile.texture_tag is not None: return @@ -231,3 +429,43 @@ def make_frame_pump(state: MapState, dpg: Any) -> Callable[[], None]: state.renderer = renderer renderer.schedule_next_frame() return renderer.render_frame + + +def _rgba(color: Color) -> tuple[int, int, int, int]: + if len(color) == 3: + return (int(color[0]), int(color[1]), int(color[2]), 255) + return (int(color[0]), int(color[1]), int(color[2]), int(color[3])) + + +def _latlon_to_screen( + lat: float, + lon: float, + center_x: float, + center_y: float, + zoom: int, + width: int, + height: int, + tile_size: int, +) -> tuple[float, float]: + world_x, world_y = latlon_to_world(lat, lon, zoom, tile_size) + return (world_x - center_x + width / 2.0, world_y - center_y + height / 2.0) + + +def _screen_points( + points: tuple[LatLon, ...], + *, + center_x: float, + center_y: float, + zoom: int, + width: int, + height: int, + tile_size: int, +) -> list[tuple[float, float]]: + return [ + _latlon_to_screen(lat, lon, center_x, center_y, zoom, width, height, tile_size) + for lat, lon in points + ] + + +def _copy_overlay_for_render(overlay: Overlay) -> Overlay: + return replace(overlay) diff --git a/tests/test_overlays_state.py b/tests/test_overlays_state.py index 6bbb961..b1fd854 100644 --- a/tests/test_overlays_state.py +++ b/tests/test_overlays_state.py @@ -1,8 +1,11 @@ from __future__ import annotations +from threading import Thread + import pytest import dpg_map as dpgm +from dpg_map.commands import CommandKind from dpg_map.exceptions import CoordinateError from dpg_map.overlays import TrajectoryOverlay from dpg_map.state import DirtyFlags, create_map_state, get_map_state @@ -61,3 +64,39 @@ def test_layer_state_tracks_visibility_and_overlay_membership() -> None: assert state.layers["fleet"].overlay_tags == set() assert "vehicle" not in state.overlays + + +def test_threaded_marker_updates_coalesce_without_touching_view_or_drag_state() -> None: + create_map_state(tag="threaded-marker", center=(47.0, 2.0), zoom=9) + dpgm.add_marker("vehicle", lat=47.0, lon=2.0, map_tag="threaded-marker") + state = get_map_state("threaded-marker") + state.command_queue.drain() + state.dirty = DirtyFlags.NONE + state.interaction.active_drag = True + state.interaction.last_mouse_position = (100.0, 100.0) + before_center = state.center + before_zoom = state.zoom + + def update_worker(offset: float) -> None: + for index in range(100): + dpgm.update_marker( + "vehicle", + lat=47.0 + offset, + lon=2.0 + index * 0.00001, + map_tag="threaded-marker", + ) + + threads = [Thread(target=update_worker, args=(worker * 0.0001,)) for worker in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + commands = state.command_queue.drain() + + assert state.center == before_center + assert state.zoom == before_zoom + assert state.interaction.active_drag is True + assert state.interaction.last_mouse_position == (100.0, 100.0) + assert state.dirty == DirtyFlags.OVERLAYS + assert [command.kind for command in commands] == [CommandKind.UPDATE_OVERLAY] diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 4a9aab2..805d50d 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,10 +1,50 @@ from __future__ import annotations +from typing import Any + +import dpg_map as dpgm from dpg_map.commands import CommandKind, MapCommand -from dpg_map.renderer import drain_renderer_commands +from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.state import DirtyFlags, create_map_state +class FakeDpg: + def __init__(self) -> None: + self.items: set[str | int] = set() + self.deleted: list[tuple[str | int, bool]] = [] + self.drawn: list[tuple[str, str | int]] = [] + + def does_item_exist(self, tag: str | int) -> bool: + return tag in self.items + + def add_draw_layer(self, *, parent: str | int, tag: str | int) -> None: + _ = parent + self.items.add(tag) + + def delete_item(self, tag: str | int, *, children_only: bool = False) -> None: + self.deleted.append((tag, children_only)) + + def draw_rectangle(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("rectangle", parent)) + + def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("image", parent)) + + def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("text", parent)) + + def draw_circle(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("circle", parent)) + + def draw_polyline(self, *args: Any, parent: str | int, **kwargs: Any) -> None: + _ = (args, kwargs) + self.drawn.append(("polyline", parent)) + + def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: state = create_map_state(tag="renderer-drain") state.dirty = DirtyFlags.NONE @@ -28,3 +68,50 @@ def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> No assert commands[2].payload == {"tag": "a", "v": 2} assert state.dirty & DirtyFlags.VIEW assert state.dirty & DirtyFlags.OVERLAYS + + +def test_overlay_draw_clears_only_overlay_layer() -> None: + state = create_map_state(tag="overlay-draw", center=(47.0, 2.0), zoom=8) + dpgm.add_marker( + "vehicle", + lat=47.0, + lon=2.0, + show_label=True, + label="Vehicle", + map_tag="overlay-draw", + ) + fake = FakeDpg() + fake.items.add(state.drawlist_tag) + renderer = MapRenderer(state, fake) + + renderer._draw_tile_layer( + visible_tiles=[], width=400, height=300, attribution="Tiles", tile_size=256 + ) + fake.deleted.clear() + with state.lock: + overlays = tuple(state.overlays.values()) + layers = {name: (layer.show, layer.z_index) for name, layer in state.layers.items()} + + renderer._draw_overlay_layer( + overlays=overlays, + layers=layers, + center=state.center, + zoom=state.zoom, + width=400, + height=300, + tile_size=256, + ) + + assert fake.deleted == [("overlay-draw##layer-overlays", True)] + assert ("circle", "overlay-draw##layer-overlays") in fake.drawn + assert ("text", "overlay-draw##layer-overlays") in fake.drawn + + +def test_overlay_update_drain_sets_only_overlay_dirty() -> None: + state = create_map_state(tag="overlay-dirty") + state.dirty = DirtyFlags.NONE + state.command_queue.put(MapCommand(CommandKind.UPDATE_OVERLAY, state.tag, {"tag": "a"})) + + drain_renderer_commands(state) + + assert state.dirty == DirtyFlags.OVERLAYS