From a5e560937cb8ca07b41e3d2621ecde5d8174a36d Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Mon, 8 Jun 2026 13:35:09 +0200 Subject: [PATCH] Fixed tiling bug and multi map bug --- src/dpg_map/interaction.py | 26 +++++++++++- src/dpg_map/renderer.py | 77 ++++++++++++++++++++++++++++++------ src/dpg_map/widget.py | 10 +++++ tests/test_interaction.py | 38 ++++++++++++++++++ tests/test_renderer.py | 81 +++++++++++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 15 deletions(-) diff --git a/src/dpg_map/interaction.py b/src/dpg_map/interaction.py index 0c4fcd3..47b2fd4 100644 --- a/src/dpg_map/interaction.py +++ b/src/dpg_map/interaction.py @@ -4,13 +4,17 @@ from __future__ import annotations from dataclasses import dataclass from math import isfinite +from threading import RLock from typing import Any from .commands import CommandKind, MapCommand from .projection import latlon_to_world, screen_to_world, world_to_latlon from .sizing import effective_draw_size from .state import DirtyFlags, MapState, mark_dirty -from .types import LatLon, Point +from .types import LatLon, Point, Tag + +_drag_owner_lock = RLock() +_drag_owner_map: Tag | None = None @dataclass(frozen=True, slots=True) @@ -152,6 +156,12 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: state.interaction.active_drag = False state.interaction.last_mouse_position = None return + with _drag_owner_lock: + global _drag_owner_map + if _drag_owner_map is not None and _drag_owner_map != state.tag: + return + _drag_owner_map = state.tag + with state.lock: state.interaction.active_drag = True state.interaction.last_mouse_position = mouse_pos @@ -159,6 +169,9 @@ def handle_mouse_down(state: MapState, mouse_pos: tuple[float, float], hit_rect: def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: """Update center from a mouse drag event.""" + with _drag_owner_lock: + if _drag_owner_map != state.tag: + return with state.lock: if not state.interaction.active_drag: return @@ -172,6 +185,10 @@ def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None: def handle_mouse_release(state: MapState) -> None: """End any active drag.""" + with _drag_owner_lock: + global _drag_owner_map + if _drag_owner_map == state.tag: + _drag_owner_map = None with state.lock: state.interaction.active_drag = False state.interaction.last_mouse_position = None @@ -186,6 +203,9 @@ def handle_mouse_wheel( ) -> None: """Apply wheel zoom when the cursor is over the concrete map rectangle.""" + with state.lock: + if not state.is_visible: + return if not hit_rect.contains(mouse_pos[0], mouse_pos[1]): return zoom_state_at_screen_point( @@ -202,6 +222,7 @@ def update_drag_from_button_state( mouse_pos: tuple[float, float], hit_rect: HitRect, is_down: bool, + can_start: bool = True, ) -> None: """Poll left-button state and keep drag interaction moving.""" @@ -217,6 +238,9 @@ def update_drag_from_button_state( handle_mouse_drag(state, mouse_pos) return + if not can_start: + return + if hit_rect.contains(mouse_pos[0], mouse_pos[1]): handle_mouse_down(state, mouse_pos, hit_rect) diff --git a/src/dpg_map/renderer.py b/src/dpg_map/renderer.py index 8678b30..ccd53d8 100644 --- a/src/dpg_map/renderer.py +++ b/src/dpg_map/renderer.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import replace +from threading import RLock from typing import Any from .commands import CommandKind, MapCommand @@ -14,7 +15,12 @@ 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 +from .types import Color, LatLon, Tag + +_scheduler_lock = RLock() +_scheduled_renderers: dict[Tag, MapRenderer] = {} +_scheduler_dpg: Any | None = None +_scheduler_frame_scheduled = False class MapRenderer: @@ -29,23 +35,15 @@ class MapRenderer: self.last_overlay_count: int = 0 def schedule_next_frame(self) -> None: - """Schedule this renderer to run on the next Dear PyGui frame.""" + """Register this renderer with the shared Dear PyGui frame pump.""" - with self.state.lock: - if self.state.frame_scheduled: - return - self.state.frame_scheduled = True - frame = self._dpg.get_frame_count() + 1 - self._dpg.set_frame_callback(frame, self._frame_callback) + _schedule_renderer(self) def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: _ = (sender, app_data) - with self.state.lock: - self.state.frame_scheduled = False if not self._dpg.does_item_exist(self.state.drawlist_tag): return self.render_frame() - self.schedule_next_frame() def render_frame(self) -> None: """Drain pending commands, refresh size, process tiles, and redraw.""" @@ -146,6 +144,7 @@ class MapRenderer: mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])), hit_rect=self.last_hit_rect, is_down=is_down, + can_start=False, ) def _measure_child_content(self) -> tuple[int, int]: @@ -181,8 +180,8 @@ class MapRenderer: tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id) if tile is None or tile.texture_tag is None: continue - screen_x = visible_tile.screen_x - screen_y = visible_tile.screen_y + screen_x = _snap_tile_position(visible_tile.screen_x) + screen_y = _snap_tile_position(visible_tile.screen_y) self._dpg.draw_image( tile.texture_tag, (screen_x, screen_y), @@ -402,6 +401,54 @@ class MapRenderer: return get_config().prefetch_margin_tiles +def _schedule_renderer(renderer: MapRenderer) -> None: + """Schedule the shared frame pump for all registered map renderers.""" + + global _scheduler_dpg, _scheduler_frame_scheduled + + with _scheduler_lock: + _scheduled_renderers[renderer.state.tag] = renderer + _scheduler_dpg = renderer._dpg + if _scheduler_frame_scheduled: + return + _scheduler_frame_scheduled = True + dpg = renderer._dpg + + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, _shared_frame_callback) + + +def _shared_frame_callback(sender: Any | None = None, app_data: Any | None = None) -> None: + """Render every live map from one Dear PyGui frame callback.""" + + _ = (sender, app_data) + global _scheduler_frame_scheduled + + with _scheduler_lock: + renderers = tuple(_scheduled_renderers.values()) + _scheduler_frame_scheduled = False + + for renderer in renderers: + renderer._frame_callback() + + with _scheduler_lock: + active_renderers = [ + renderer + for renderer in _scheduled_renderers.values() + if renderer._dpg.does_item_exist(renderer.state.drawlist_tag) + ] + _scheduled_renderers.clear() + _scheduled_renderers.update((renderer.state.tag, renderer) for renderer in active_renderers) + if not active_renderers or _scheduler_frame_scheduled: + return + _scheduler_frame_scheduled = True + dpg = _scheduler_dpg + + if dpg is not None: + frame = dpg.get_frame_count() + 1 + dpg.set_frame_callback(frame, _shared_frame_callback) + + def drain_renderer_commands(state: MapState) -> list[MapCommand]: """Drain and apply GUI-thread command side effects.""" @@ -456,6 +503,10 @@ def _rgba(color: Color) -> tuple[int, int, int, int]: return (int(color[0]), int(color[1]), int(color[2]), int(color[3])) +def _snap_tile_position(value: float) -> int: + return int(round(value)) + + def _latlon_to_screen( lat: float, lon: float, diff --git a/src/dpg_map/widget.py b/src/dpg_map/widget.py index 3767dbf..a899cd2 100644 --- a/src/dpg_map/widget.py +++ b/src/dpg_map/widget.py @@ -81,8 +81,16 @@ def map_widget( draw_pos = tuple(float(value) for value in dpg.get_item_rect_min(state.drawlist_tag)) return calculate_hit_rect(state, (draw_pos[0], draw_pos[1])) + def _is_drawlist_hovered() -> bool: + try: + return bool(dpg.is_item_hovered(state.drawlist_tag)) + except Exception: + return False + def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None: _ = (sender, app_data, user_data) + if not _is_drawlist_hovered(): + return handle_mouse_down(state, _mouse_pos(), _hit_rect()) def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None: @@ -95,6 +103,8 @@ def map_widget( def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None: _ = (sender, user_data) + if not _is_drawlist_hovered(): + return handle_mouse_wheel( state, mouse_pos=_mouse_pos(), diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 4ec1ee8..41ea96f 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest import dpg_map as dpgm +import dpg_map.interaction as interaction_module from dpg_map.commands import CommandKind from dpg_map.interaction import ( calculate_hit_rect, @@ -74,6 +75,43 @@ def test_polled_drag_starts_and_moves_while_button_is_down() -> None: assert state.center[1] < 0.0 +def test_renderer_poll_does_not_start_drag_for_inactive_map() -> None: + state = create_map_state(tag="poll-no-start", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state, SizeMeasurement(width=400, height=300, visible=True)) + rect = calculate_hit_rect(state, (10.0, 20.0)) + + update_drag_from_button_state( + state, + mouse_pos=(20.0, 30.0), + hit_rect=rect, + is_down=True, + can_start=False, + ) + + assert state.interaction.active_drag is False + assert state.center == (0.0, 0.0) + + +def test_drag_owner_prevents_second_map_from_tracking_first_map() -> None: + with interaction_module._drag_owner_lock: + interaction_module._drag_owner_map = None + state_a = create_map_state(tag="drag-owner-a", center=(0.0, 0.0), zoom=3) + state_b = create_map_state(tag="drag-owner-b", center=(0.0, 0.0), zoom=3) + apply_size_measurement(state_a, SizeMeasurement(width=400, height=300, visible=True)) + apply_size_measurement(state_b, SizeMeasurement(width=400, height=300, visible=True)) + rect_a = calculate_hit_rect(state_a, (10.0, 20.0)) + rect_b = calculate_hit_rect(state_b, (10.0, 20.0)) + + handle_mouse_down(state_a, (20.0, 30.0), rect_a) + handle_mouse_down(state_b, (20.0, 30.0), rect_b) + handle_mouse_drag(state_a, (45.0, 30.0)) + handle_mouse_drag(state_b, (45.0, 30.0)) + + assert state_a.center[1] < 0.0 + assert state_b.center == (0.0, 0.0) + handle_mouse_release(state_a) + + def test_wheel_zoom_keeps_cursor_latlon_stable() -> None: state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) diff --git a/tests/test_renderer.py b/tests/test_renderer.py index c498f8a..60233cb 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -3,11 +3,12 @@ from __future__ import annotations from typing import Any import dpg_map as dpgm +import dpg_map.renderer as renderer_module from dpg_map.commands import CommandKind, MapCommand from dpg_map.providers import TileProvider from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.state import DirtyFlags, create_map_state -from dpg_map.tiles import TileID, TileResult, TileStatus +from dpg_map.tiles import TileID, TileResult, TileStatus, VisibleTile class FakeDpg: @@ -15,6 +16,7 @@ class FakeDpg: self.items: set[str | int] = set() self.deleted: list[tuple[str | int, bool]] = [] self.drawn: list[tuple[str, str | int]] = [] + self.images: list[tuple[Any, Any, str | int]] = [] def does_item_exist(self, tag: str | int) -> bool: return tag in self.items @@ -33,6 +35,7 @@ class FakeDpg: def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: _ = (args, kwargs) self.drawn.append(("image", parent)) + self.images.append((args[1], args[2], parent)) def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None: _ = (args, kwargs) @@ -47,6 +50,51 @@ class FakeDpg: self.drawn.append(("polyline", parent)) +class FrameCallbackDpg(FakeDpg): + def __init__(self) -> None: + super().__init__() + self.frame_count = 0 + self.callbacks: dict[int, Any] = {} + + def get_frame_count(self) -> int: + return self.frame_count + + def set_frame_callback(self, frame: int, callback: Any) -> None: + self.callbacks[frame] = callback + + +def test_shared_frame_pump_renders_multiple_maps_from_one_frame_callback() -> None: + with renderer_module._scheduler_lock: + renderer_module._scheduled_renderers.clear() + renderer_module._scheduler_frame_scheduled = False + renderer_module._scheduler_dpg = None + + state_a = create_map_state(tag="frame-pump-a") + state_b = create_map_state(tag="frame-pump-b") + fake = FrameCallbackDpg() + fake.items.update({state_a.drawlist_tag, state_b.drawlist_tag}) + renderer_a = MapRenderer(state_a, fake) + renderer_b = MapRenderer(state_b, fake) + rendered: list[str] = [] + renderer_a.render_frame = lambda: rendered.append("a") # type: ignore[method-assign] + renderer_b.render_frame = lambda: rendered.append("b") # type: ignore[method-assign] + + renderer_a.schedule_next_frame() + renderer_b.schedule_next_frame() + assert list(fake.callbacks) == [1] + + fake.frame_count = 1 + fake.callbacks[1]() + + assert rendered == ["a", "b"] + assert sorted(renderer_module._scheduled_renderers) == ["frame-pump-a", "frame-pump-b"] + + with renderer_module._scheduler_lock: + renderer_module._scheduled_renderers.clear() + renderer_module._scheduler_frame_scheduled = False + renderer_module._scheduler_dpg = None + + def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None: state = create_map_state(tag="renderer-drain") state.dirty = DirtyFlags.NONE @@ -109,6 +157,37 @@ def test_overlay_draw_clears_only_overlay_layer() -> None: assert ("text", "overlay-draw##layer-overlays") in fake.drawn +def test_tile_draw_snaps_fractional_positions_to_integer_pixels() -> None: + state = create_map_state(tag="tile-snap", center=(47.0, 2.0), zoom=8) + tile_id = TileID("osm", 8, 129, 89) + state.tile_manager._result_queue.put( + TileResult( + tile_id, + generation=state.generation, + status=TileStatus.READY, + width=1, + height=1, + pixels=(1.0, 1.0, 1.0, 1.0), + source="disk", + ) + ) + state.tile_manager.drain_results(generation=state.generation, provider_name="osm") + state.tile_manager.set_texture_tag(tile_id, "tile-texture") + fake = FakeDpg() + fake.items.add(state.drawlist_tag) + renderer = MapRenderer(state, fake) + + renderer._draw_tile_layer( + visible_tiles=[VisibleTile(tile_id, 10.4, 20.6)], + width=400, + height=300, + attribution="Tiles", + tile_size=256, + ) + + assert fake.images == [((10, 21), (266, 277), "tile-snap##layer-tiles")] + + def test_overlay_update_drain_sets_only_overlay_dirty() -> None: state = create_map_state(tag="overlay-dirty") state.dirty = DirtyFlags.NONE