diff --git a/deebot_client/map.py b/deebot_client/map.py index ba646dfa..4321f650 100644 --- a/deebot_client/map.py +++ b/deebot_client/map.py @@ -155,32 +155,6 @@ class TracePoint(Point): connected: bool -@dataclasses.dataclass -class AxisManipulation: - """Map manipulation.""" - - map_shift: float - svg_max: float - _transform: Callable[[float, float], float] | None = None - - def __post_init__(self) -> None: - self._svg_center = self.svg_max / 2 - - def transform(self, value: float) -> float: - """Transform value.""" - if self._transform is None: - return value - return self._transform(self._svg_center, value) - - -@dataclasses.dataclass -class MapManipulation: - """Map manipulation.""" - - x: AxisManipulation - y: AxisManipulation - - @dataclasses.dataclass class BackgroundImage: """Background image.""" @@ -189,6 +163,18 @@ class BackgroundImage: image: bytes +class ViewBoxFloat: + """ViewBox where all values are converted to float.""" + + def __init__(self, view_box: svg.ViewBoxSpec) -> None: + self.min_x = float(view_box.min_x) + self.min_y = float(view_box.min_y) + self.width = float(view_box.width) + self.height = float(view_box.height) + self.max_x = self.min_x + self.width + self.max_y = self.min_y + self.height + + # SVG definitions referred by map elements _SVG_DEFS = svg.Defs( elements=[ @@ -237,33 +223,27 @@ class BackgroundImage: ) -def _calc_value(value: float, axis_manipulation: AxisManipulation) -> float: - try: - if value is not None: - # SVG allows sub-pixel precision, so we use floating point coordinates for better placement. - new_value = ( - (float(value) / _PIXEL_WIDTH) + _OFFSET - axis_manipulation.map_shift - ) - new_value = axis_manipulation.transform(new_value) - # return value inside min and max - return round( - min(axis_manipulation.svg_max, max(0, new_value)), _ROUND_TO_DIGITS - ) - - except (ZeroDivisionError, ValueError): - pass - - return 0 - - def _calc_point( x: float, y: float, - map_manipulation: MapManipulation, ) -> Point: return Point( - _calc_value(x, map_manipulation.x), - _calc_value(y, map_manipulation.y), + 0 if x is None else round(x / _PIXEL_WIDTH, _ROUND_TO_DIGITS), + 0 if y is None else round(-y / _PIXEL_WIDTH, _ROUND_TO_DIGITS), + ) + + +def _calc_point_in_viewbox(x: float, y: float, view_box: ViewBoxFloat) -> Point: + point = _calc_point(x, y) + return Point( + min( + max(point.x, view_box.min_x), + view_box.max_x, + ), + min( + max(point.y, view_box.min_y), + view_box.max_y, + ), ) @@ -295,12 +275,11 @@ def _points_to_svg_path( def _get_svg_positions( - positions: list[Position], - map_manipulation: MapManipulation, + positions: list[Position], view_box: ViewBoxFloat ) -> list[svg.Element]: svg_positions: list[svg.Element] = [] for position in sorted(positions, key=lambda x: _POSITIONS_SVG[x.type].order): - pos = _calc_point(position.x, position.y, map_manipulation) + pos = _calc_point_in_viewbox(position.x, position.y, view_box) svg_positions.append( svg.Use(href=f"#{_POSITIONS_SVG[position.type].svg_id}", x=pos.x, y=pos.y) ) @@ -310,7 +289,6 @@ def _get_svg_positions( def _get_svg_subset( subset: MapSubsetEvent, - map_manipulation: MapManipulation, ) -> Path | svg.Polygon: _LOGGER.debug("Creating svg subset for %s", subset) @@ -319,7 +297,6 @@ def _get_svg_subset( _calc_point( subset_coordinates[i], subset_coordinates[i + 1], - map_manipulation, ) for i in range(0, len(subset_coordinates), 2) ] @@ -448,10 +425,7 @@ def _draw_map_pieces(self, image: Image.Image) -> None: if current_piece.in_use: image.paste(current_piece.image, (image_x, image_y)) - def _get_svg_traces_path( - self, - map_manipulation: MapManipulation, - ) -> Path | None: + def _get_svg_traces_path(self) -> Path | None: if len(self._map_data.trace_values) > 0: _LOGGER.debug("[get_svg_map] Draw Trace") return Path( @@ -461,11 +435,7 @@ def _get_svg_traces_path( stroke_linejoin="round", vector_effect="non-scaling-stroke", transform=[ - svg.Translate( - _OFFSET - map_manipulation.x.map_shift, - _OFFSET - map_manipulation.y.map_shift, - ), - svg.Scale(0.2, 0.2), + svg.Scale(0.2, -0.2), ], d=_points_to_svg_path(self._map_data.trace_values), ) @@ -569,29 +539,22 @@ def get_svg_map(self) -> str | None: # Build the SVG elements svg_map = svg.SVG() svg_map.elements = [_SVG_DEFS] - manipulation = MapManipulation( - AxisManipulation( - map_shift=background.bounding_box[0], - svg_max=background.bounding_box[2] - background.bounding_box[0], - ), - AxisManipulation( - map_shift=background.bounding_box[1], - svg_max=background.bounding_box[3] - background.bounding_box[1], - _transform=lambda c, v: 2 * c - v, - ), - ) # Set map viewBox based on background map bounding box. svg_map.viewBox = svg.ViewBoxSpec( - 0, - 0, - manipulation.x.svg_max, - manipulation.y.svg_max, + background.bounding_box[0] - _OFFSET, + _OFFSET - background.bounding_box[3], + (background.bounding_box[2] - background.bounding_box[0]), + (background.bounding_box[3] - background.bounding_box[1]), ) # Map background. svg_map.elements.append( svg.Image( + x=svg_map.viewBox.min_x, + y=svg_map.viewBox.min_y, + width=svg_map.viewBox.width, + height=svg_map.viewBox.height, style="image-rendering: pixelated", href=f"data:image/png;base64,{base64.b64encode(background.image).decode('ascii')}", ) @@ -599,26 +562,16 @@ def get_svg_map(self) -> str | None: # Additional subsets (VirtualWalls and NoMopZones) svg_map.elements.extend( - [ - _get_svg_subset(subset, manipulation) - for subset in self._map_data.map_subsets.values() - ] + [_get_svg_subset(subset) for subset in self._map_data.map_subsets.values()] ) # Traces (if any) - if svg_traces_path := self._get_svg_traces_path(manipulation): - svg_map.elements.append( - # Elements to vertically flip - svg.G( - transform_origin=r"50% 50%", - transform=[svg.Scale(1, -1)], - elements=[svg_traces_path], - ) - ) + if svg_traces_path := self._get_svg_traces_path(): + svg_map.elements.append(svg_traces_path) # Bot and Charge stations svg_map.elements.extend( - _get_svg_positions(self._map_data.positions, manipulation) + _get_svg_positions(self._map_data.positions, ViewBoxFloat(svg_map.viewBox)) ) self._last_image = str(svg_map) diff --git a/tests/test_map.py b/tests/test_map.py index e39e5ec2..8376aba2 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -2,7 +2,7 @@ import asyncio from typing import TYPE_CHECKING -from unittest.mock import ANY, AsyncMock, Mock, call +from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest from svg import ( @@ -15,8 +15,11 @@ MoveToRel, PathData, Polygon, + Scale, SmoothCubicBezierRel, + Use, VerticalLineToRel, + ViewBoxSpec, ) from deebot_client.events.map import ( @@ -32,14 +35,15 @@ PositionType, ) from deebot_client.map import ( - AxisManipulation, Map, MapData, - MapManipulation, Path, Point, TracePoint, + ViewBoxFloat, _calc_point, + _calc_point_in_viewbox, + _get_svg_positions, _get_svg_subset, _points_to_svg_path, ) @@ -53,54 +57,41 @@ from deebot_client.event_bus import EventBus _test_calc_point_data = [ - (10, 100, (100, 0, 200, 50), Point(100.0, 0.0)), - (10, 100, (0, 0, 1000, 1000), Point(400.2, 598.0)), - (None, 100, (0, 0, 1000, 1000), Point(0, 598.0)), + (5000, 0, Point(100.0, 0.0)), + (20010, -29900, Point(400.2, 598.0)), + (None, 29900, Point(0, -598.0)), ] -@pytest.mark.parametrize(("x", "y", "image_box", "expected"), _test_calc_point_data) +@pytest.mark.parametrize(("x", "y", "expected"), _test_calc_point_data) def test_calc_point( x: int, y: int, - image_box: tuple[int, int, int, int], expected: Point, ) -> None: - manipulation = MapManipulation( - AxisManipulation( - map_shift=image_box[0], - svg_max=image_box[2] - image_box[0], - ), - AxisManipulation( - map_shift=image_box[1], - svg_max=image_box[3] - image_box[1], - _transform=lambda c, v: 2 * c - v, - ), - ) - result = _calc_point(x, y, manipulation) + result = _calc_point(x, y) assert result == expected -@pytest.mark.parametrize(("error"), [ValueError(), ZeroDivisionError()]) -def test_calc_point_exceptions( - error: Exception, +_test_calc_point_in_viewbox_data = [ + (100, 100, ViewBoxSpec(-100, -100, 200, 150), Point(2.0, -2.0)), + (-64000, -64000, ViewBoxSpec(0, 0, 1000, 1000), Point(0.0, 1000.0)), + (64000, 64000, ViewBoxSpec(0, 0, 1000, 1000), Point(1000.0, 0.0)), + (None, 1000, ViewBoxSpec(-500, -500, 1000, 1000), Point(0.0, -20.0)), +] + + +@pytest.mark.parametrize( + ("x", "y", "view_box", "expected"), _test_calc_point_in_viewbox_data +) +def test_calc_point_in_viewbox( + x: int, + y: int, + view_box: ViewBoxSpec, + expected: Point, ) -> None: - def transform(_: float, __: float) -> float: - raise error - - manipulation = MapManipulation( - AxisManipulation( - map_shift=50, - svg_max=100, - _transform=transform, - ), - AxisManipulation( - map_shift=50, - svg_max=100, - ), - ) - result = _calc_point(100, 100, manipulation) - assert result == Point(0, 100) + result = _calc_point_in_viewbox(x, y, ViewBoxFloat(view_box)) + assert result == expected async def test_MapData(event_bus: EventBus) -> None: @@ -166,6 +157,34 @@ async def on_change() -> None: assert not map._unsubscribers +@patch( + "deebot_client.map.decompress_7z_base64_data", + Mock(return_value=b"\x10\x00\x00\x01\x00"), +) +async def test_Map_svg_traces_path( + execute_mock: AsyncMock, event_bus_mock: Mock +) -> None: + map = Map(execute_mock, event_bus_mock) + + path = map._get_svg_traces_path() + assert path is None + + map._update_trace_points("") + path = map._get_svg_traces_path() + + assert path == Path( + fill="none", + stroke="#fff", + stroke_width=1.5, + stroke_linejoin="round", + vector_effect="non-scaling-stroke", + transform=[ + Scale(0.2, -0.2), + ], + d=[MoveTo(x=16, y=256)], + ) + + def test_compact_path() -> None: """Test that the path is compacted correctly.""" path = Path( @@ -236,7 +255,7 @@ def test_points_to_svg_path( stroke_width=1.5, stroke_dasharray=[4], vector_effect="non-scaling-stroke", - d=[MoveTo(x=322.0, y=413.36), HorizontalLineToRel(dx=35.34)], + d=[MoveTo(x=-78.0, y=-13.36), HorizontalLineToRel(dx=35.34)], ), ), ( @@ -251,20 +270,52 @@ def test_points_to_svg_path( stroke_width=1.5, stroke_dasharray=[4], vector_effect="non-scaling-stroke", - points=[391.16, 458.2, 391.16, 419.64, 424.28, 419.64, 424.28, 458.2], + points=[-8.84, -58.2, -8.84, -19.64, 24.28, -19.64, 24.28, -58.2], ), ), ], ) def test_get_svg_subset(subset: MapSubsetEvent, expected: Path | Polygon) -> None: - manipulation = MapManipulation( - AxisManipulation( - map_shift=0, - svg_max=1000, - ), - AxisManipulation( - map_shift=0, - svg_max=1000, - ), - ) - assert _get_svg_subset(subset, manipulation) == expected + assert _get_svg_subset(subset) == expected + + +_test_get_svg_positions_data = [ + ( + [Position(PositionType.CHARGER, 5000, -55000, 0)], + ViewBoxSpec(-500, -500, 1000, 1000), + [Use(href="#c", x=100, y=500)], + ), + ( + [Position(PositionType.DEEBOT, 15000, 15000, 0)], + ViewBoxSpec(-500, -500, 1000, 1000), + [Use(href="#d", x=300, y=-300)], + ), + ( + [ + Position(PositionType.CHARGER, 25000, 55000, 0), + Position(PositionType.DEEBOT, -5000, -50000, 0), + ], + ViewBoxSpec(-500, -500, 1000, 1000), + [Use(href="#d", x=-100, y=500), Use(href="#c", x=500, y=-500)], + ), + ( + [ + Position(PositionType.DEEBOT, -10000, 10000, 0), + Position(PositionType.CHARGER, 50000, 5000, 0), + ], + ViewBoxSpec(-500, -500, 1000, 1000), + [Use(href="#d", x=-200, y=-200), Use(href="#c", x=500, y=-100)], + ), +] + + +@pytest.mark.parametrize( + ("positions", "view_box", "expected"), _test_get_svg_positions_data +) +def test_get_svg_positions( + positions: list[Position], + view_box: ViewBoxSpec, + expected: list[Use], +) -> None: + result = _get_svg_positions(positions, ViewBoxFloat(view_box)) + assert result == expected