Fixed tiling bug and multi map bug

This commit is contained in:
2026-06-08 13:35:09 +02:00
parent 50e38e18ee
commit a5e560937c
5 changed files with 217 additions and 15 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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))

View File

@@ -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