Fixed tiling bug and multi map bug
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user