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 dataclasses import dataclass
from math import isfinite from math import isfinite
from threading import RLock
from typing import Any from typing import Any
from .commands import CommandKind, MapCommand from .commands import CommandKind, MapCommand
from .projection import latlon_to_world, screen_to_world, world_to_latlon from .projection import latlon_to_world, screen_to_world, world_to_latlon
from .sizing import effective_draw_size from .sizing import effective_draw_size
from .state import DirtyFlags, MapState, mark_dirty 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) @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.active_drag = False
state.interaction.last_mouse_position = None state.interaction.last_mouse_position = None
return 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.active_drag = True
state.interaction.last_mouse_position = mouse_pos 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: def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
"""Update center from a mouse drag event.""" """Update center from a mouse drag event."""
with _drag_owner_lock:
if _drag_owner_map != state.tag:
return
with state.lock: with state.lock:
if not state.interaction.active_drag: if not state.interaction.active_drag:
return return
@@ -172,6 +185,10 @@ def handle_mouse_drag(state: MapState, mouse_pos: tuple[float, float]) -> None:
def handle_mouse_release(state: MapState) -> None: def handle_mouse_release(state: MapState) -> None:
"""End any active drag.""" """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: with state.lock:
state.interaction.active_drag = False state.interaction.active_drag = False
state.interaction.last_mouse_position = None state.interaction.last_mouse_position = None
@@ -186,6 +203,9 @@ def handle_mouse_wheel(
) -> None: ) -> None:
"""Apply wheel zoom when the cursor is over the concrete map rectangle.""" """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]): if not hit_rect.contains(mouse_pos[0], mouse_pos[1]):
return return
zoom_state_at_screen_point( zoom_state_at_screen_point(
@@ -202,6 +222,7 @@ def update_drag_from_button_state(
mouse_pos: tuple[float, float], mouse_pos: tuple[float, float],
hit_rect: HitRect, hit_rect: HitRect,
is_down: bool, is_down: bool,
can_start: bool = True,
) -> None: ) -> None:
"""Poll left-button state and keep drag interaction moving.""" """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) handle_mouse_drag(state, mouse_pos)
return return
if not can_start:
return
if hit_rect.contains(mouse_pos[0], mouse_pos[1]): if hit_rect.contains(mouse_pos[0], mouse_pos[1]):
handle_mouse_down(state, mouse_pos, hit_rect) handle_mouse_down(state, mouse_pos, hit_rect)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace from dataclasses import replace
from threading import RLock
from typing import Any from typing import Any
from .commands import CommandKind, MapCommand from .commands import CommandKind, MapCommand
@@ -14,7 +15,12 @@ from .projection import latlon_to_world
from .sizing import SizeMeasurement, apply_size_measurement from .sizing import SizeMeasurement, apply_size_measurement
from .state import DirtyFlags, MapState from .state import DirtyFlags, MapState
from .tiles import Tile, VisibleTile 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: class MapRenderer:
@@ -29,23 +35,15 @@ class MapRenderer:
self.last_overlay_count: int = 0 self.last_overlay_count: int = 0
def schedule_next_frame(self) -> None: 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: _schedule_renderer(self)
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)
def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None: def _frame_callback(self, sender: Any | None = None, app_data: Any | None = None) -> None:
_ = (sender, app_data) _ = (sender, app_data)
with self.state.lock:
self.state.frame_scheduled = False
if not self._dpg.does_item_exist(self.state.drawlist_tag): if not self._dpg.does_item_exist(self.state.drawlist_tag):
return return
self.render_frame() self.render_frame()
self.schedule_next_frame()
def render_frame(self) -> None: def render_frame(self) -> None:
"""Drain pending commands, refresh size, process tiles, and redraw.""" """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])), mouse_pos=(float(mouse_pos[0]), float(mouse_pos[1])),
hit_rect=self.last_hit_rect, hit_rect=self.last_hit_rect,
is_down=is_down, is_down=is_down,
can_start=False,
) )
def _measure_child_content(self) -> tuple[int, int]: 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) tile = self.state.tile_manager.get_ready_tile(visible_tile.tile_id)
if tile is None or tile.texture_tag is None: if tile is None or tile.texture_tag is None:
continue continue
screen_x = visible_tile.screen_x screen_x = _snap_tile_position(visible_tile.screen_x)
screen_y = visible_tile.screen_y screen_y = _snap_tile_position(visible_tile.screen_y)
self._dpg.draw_image( self._dpg.draw_image(
tile.texture_tag, tile.texture_tag,
(screen_x, screen_y), (screen_x, screen_y),
@@ -402,6 +401,54 @@ class MapRenderer:
return get_config().prefetch_margin_tiles 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]: def drain_renderer_commands(state: MapState) -> list[MapCommand]:
"""Drain and apply GUI-thread command side effects.""" """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])) 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( def _latlon_to_screen(
lat: float, lat: float,
lon: 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)) 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])) 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: def _on_mouse_down(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, app_data, user_data) _ = (sender, app_data, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_down(state, _mouse_pos(), _hit_rect()) handle_mouse_down(state, _mouse_pos(), _hit_rect())
def _on_mouse_drag(sender: Any, app_data: Any, user_data: Any) -> None: 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: def _on_mouse_wheel(sender: Any, app_data: Any, user_data: Any) -> None:
_ = (sender, user_data) _ = (sender, user_data)
if not _is_drawlist_hovered():
return
handle_mouse_wheel( handle_mouse_wheel(
state, state,
mouse_pos=_mouse_pos(), mouse_pos=_mouse_pos(),

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import pytest import pytest
import dpg_map as dpgm import dpg_map as dpgm
import dpg_map.interaction as interaction_module
from dpg_map.commands import CommandKind from dpg_map.commands import CommandKind
from dpg_map.interaction import ( from dpg_map.interaction import (
calculate_hit_rect, 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 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: def test_wheel_zoom_keeps_cursor_latlon_stable() -> None:
state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8) state = create_map_state(tag="wheel", center=(47.9029, 1.9093), zoom=8)
apply_size_measurement(state, SizeMeasurement(width=800, height=600, visible=True)) 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 from typing import Any
import dpg_map as dpgm import dpg_map as dpgm
import dpg_map.renderer as renderer_module
from dpg_map.commands import CommandKind, MapCommand from dpg_map.commands import CommandKind, MapCommand
from dpg_map.providers import TileProvider from dpg_map.providers import TileProvider
from dpg_map.renderer import MapRenderer, drain_renderer_commands from dpg_map.renderer import MapRenderer, drain_renderer_commands
from dpg_map.state import DirtyFlags, create_map_state 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: class FakeDpg:
@@ -15,6 +16,7 @@ class FakeDpg:
self.items: set[str | int] = set() self.items: set[str | int] = set()
self.deleted: list[tuple[str | int, bool]] = [] self.deleted: list[tuple[str | int, bool]] = []
self.drawn: list[tuple[str, str | int]] = [] self.drawn: list[tuple[str, str | int]] = []
self.images: list[tuple[Any, Any, str | int]] = []
def does_item_exist(self, tag: str | int) -> bool: def does_item_exist(self, tag: str | int) -> bool:
return tag in self.items return tag in self.items
@@ -33,6 +35,7 @@ class FakeDpg:
def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None: def draw_image(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs) _ = (args, kwargs)
self.drawn.append(("image", parent)) 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: def draw_text(self, *args: Any, parent: str | int, **kwargs: Any) -> None:
_ = (args, kwargs) _ = (args, kwargs)
@@ -47,6 +50,51 @@ class FakeDpg:
self.drawn.append(("polyline", parent)) 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: def test_renderer_command_drain_preserves_structural_order_and_coalesces() -> None:
state = create_map_state(tag="renderer-drain") state = create_map_state(tag="renderer-drain")
state.dirty = DirtyFlags.NONE 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 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: def test_overlay_update_drain_sets_only_overlay_dirty() -> None:
state = create_map_state(tag="overlay-dirty") state = create_map_state(tag="overlay-dirty")
state.dirty = DirtyFlags.NONE state.dirty = DirtyFlags.NONE