diff --git a/README.md b/README.md index e69de29..53394f1 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,46 @@ +# Robot Simulator – Core 2D Rendering Primitives #19 + +## Project Overview + +This repository contains a 2D robot simulation environment with a Matplotlib‑based renderer. +As part of Ticket #19, the renderer has been extended with a set of core 2D drawing primitives that can be used independently of the robot model. + +These functions allow you to visualise obstacles, target points, trajectories, labels, and any other auxiliary information directly inside the simulation window. + +## Team & Role + +- Lead: Dennis Alexander Burdin +- Dev: Artem Shaturnyi +- Tester: Bashar Alahmad + +## Implemented Drawing Primitives + +All drawing methods are part of the `Renderer` class (`src/simulator/renderer.py`) and share a common interface: + +- Accept positions as lists or tuples `[x, y]` +- Support `kwargs` to pass any Matplotlib style option +- Automatically update the canvas (`draw_idle()`) +- Return the created artist for later modifications + +Method and Description: + +`draw_line(start, end, color='black', linewidth=2.0, alpha=1.0, **kwargs)` +Line from `start` to `end` + +`draw_circle(center, radius=1.0, color='lightblue', fill=True, alpha=1.0, linewidth=1.0, **kwargs)` +Circle with given center and radius. Returns the `Circle` patch. + +`draw_rectangle(pos, width, height, angle=0.0, color='gray', fill=True, alpha=1.0, **kwargs)` +Rectangle with center at `pos`. Width and height are full extents. Returns the `Rectangle` patch. + +`draw_polygon(vertices, color='green', fill=True, alpha=1.0, linewidth=1.0, **kwargs)` +Polygon defined by a list of vertices `[(x1,y1), (x2,y2), ...]`. Returns the `Polygon` patch. + +`draw_text(position, text, fontsize=10, color='black', alpha=1.0, **kwargs)` + Text label at `position`. Returns the `Text` object. + +## Testing +All primitives are covered by 33 unit tests (see tests/test_renderer.py). + +Run the tests with: +`pytest tests/test_renderer.py -v` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 370ab92..7707a72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "pre-commit>=4.6.0", "pytest>=9.0.3", "ruff>=0.15.12", + "matplotlib>=3.10.0", ] [tool.ruff.lint] diff --git a/src/simulator/renderer.py b/src/simulator/renderer.py index e7c4820..2ecbb92 100644 --- a/src/simulator/renderer.py +++ b/src/simulator/renderer.py @@ -1,7 +1,7 @@ import numpy as np import matplotlib.pyplot as plt -from matplotlib.patches import Circle, Rectangle +from matplotlib.patches import Circle, Rectangle, Polygon # Added Polygon from matplotlib.collections import LineCollection @@ -35,6 +35,72 @@ def __init__(self, x_limits=(-10.0, 10.0), y_limits=(-10.0, 10.0), max_colors=20 self.base_patch = None + # New functions for drawing primitives + + def clear(self): + """Clears the current drawing, resetting the axes for the next frame.""" + self.ax.cla() + # Reset limits and aspect ratio after clearing + self.ax.set_xlim(-self.view_size, self.view_size) + self.ax.set_ylim(-self.view_size, self.view_size) + self.ax.set_aspect("equal") + + # Redraw world frame axes + self.ax.axhline(0, color='black', lw=1, alpha=0.2) + self.ax.axvline(0, color='black', lw=1, alpha=0.2) + + def draw_line(self, start, end, color='black', linewidth=2.0, alpha=1.0, **kwargs): + """Draws a line between two points [x,y].""" + self.ax.plot([start[0], end[0]], [start[1], end[1]], + color=color, linewidth=linewidth, alpha=alpha, **kwargs) + self.fig.canvas.draw_idle() + + + def draw_circle(self, center, radius=1.0, color='lightblue', fill=True, + alpha=1.0, linewidth=1.0, **kwargs): + """Draws a circle with given center [x,y] and radius.""" + circle = Circle((center[0], center[1]), radius=radius, + color=color, fill=fill, alpha=alpha, + linewidth=linewidth, **kwargs) + self.ax.add_patch(circle) + self.fig.canvas.draw_idle() + return circle + + def draw_rectangle(self, pos, width, height, angle=0.0, color='gray', + fill=True, alpha=1.0, **kwargs): + """ + Draws a rectangle with center at `pos` (list/tuple [x,y]). + Width and height specify the dimensions. + """ + x = pos[0] - width / 2 + y = pos[1] - height / 2 + rect = Rectangle((x, y), width, height, angle=angle, + facecolor=color if fill else 'none', + edgecolor=color, fill=fill, alpha=alpha, **kwargs) + self.ax.add_patch(rect) + self.fig.canvas.draw_idle() + return rect + + def draw_polygon(self, vertices, color='green', + fill=True, alpha=1.0, linewidth=1.0, **kwargs): + """Draws a polygon from a list of vertices [(x1,y1), ...].""" + poly = Polygon(vertices, facecolor=color if fill else 'none', + edgecolor=color, fill=fill, alpha=alpha, + linewidth=linewidth, **kwargs) + self.ax.add_patch(poly) + self.fig.canvas.draw_idle() + return poly + + + def draw_text(self, position, text, fontsize=10, color='black', alpha=1.0, **kwargs): + """Draws text at position [x,y].""" + txt = self.ax.text(position[0], position[1], text, + fontsize=fontsize, color=color, alpha=alpha, **kwargs) + self.fig.canvas.draw_idle() + return txt + + # End of new functions for drawing primitives + def update(self, objects, dt=0.0001): if not objects: @@ -89,9 +155,7 @@ def draw_tree(obj, q): self.links_lines = LineCollection(links, colors=self.colors, linewidths=3) self.ax.add_collection(self.links_lines) - self.joints_circles = self.ax.scatter( - points[:, 0], points[:, 1], c="lightblue", s=40, zorder=10 - ) + self.joints_circles = self.ax.scatter(points[:, 0], points[:, 1], c="lightblue", s=40, zorder=10) self.ax.scatter(0.0, 0.0, c="blue", s=50, zorder=10) else: self.links_lines.set_segments(links) @@ -101,4 +165,4 @@ def draw_tree(obj, q): self.fig.canvas.flush_events() def close(self): - plt.close(self.fig) + plt.close(self.fig) \ No newline at end of file diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..deb9654 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,219 @@ +import pytest +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.patches import Circle, Rectangle, Polygon +import numpy as np + +from simulator.renderer import Renderer + + +@pytest.fixture(autouse=True) +def close_figures(): + yield + plt.close('all') + + +@pytest.fixture +def renderer(): + return Renderer() + + +# Tests for draw_line() +class TestDrawLine: + def test_adds_a_line_to_axes(self, renderer): + before = len(renderer.ax.lines) + renderer.draw_line([0, 0], [1, 1]) + assert len(renderer.ax.lines) == before + 1 + + def test_line_coordinates(self, renderer): + renderer.draw_line([0, 0], [3, 4]) + line = renderer.ax.lines[-1] + assert list(line.get_xdata()) == [0, 3] + assert list(line.get_ydata()) == [0, 4] + + def test_default_color_and_linewidth(self, renderer): + renderer.draw_line([0, 0], [1, 1]) + line = renderer.ax.lines[-1] + assert line.get_color() == 'black' + assert line.get_linewidth() == 2.0 + + def test_custom_color_and_linewidth(self, renderer): + renderer.draw_line([0, 0], [1, 1], color='red', linewidth=5) + line = renderer.ax.lines[-1] + assert line.get_color() == 'red' + assert line.get_linewidth() == 5 + + def test_negative_coordinates(self, renderer): + renderer.draw_line([-2, -3], [-1, -1]) + line = renderer.ax.lines[-1] + assert list(line.get_xdata()) == [-2, -1] + assert list(line.get_ydata()) == [-3, -1] + + def test_zero_length_line(self, renderer): + renderer.draw_line([5, 5], [5, 5]) + assert len(renderer.ax.lines) >= 1 + + +# Tests for draw_circle() +class TestDrawCircle: + def test_returns_circle_patch(self, renderer): + result = renderer.draw_circle([0, 0], radius=1.0) + assert isinstance(result, Circle) + + def test_circle_added_to_axes(self, renderer): + before = len(renderer.ax.patches) + renderer.draw_circle([1, 2], radius=1.0) + assert len(renderer.ax.patches) == before + 1 + + def test_center_and_radius(self, renderer): + circle = renderer.draw_circle([3, 4], radius=0.5) + assert tuple(circle.center) == (3, 4) + assert circle.radius == 0.5 + + def test_default_filled_true(self, renderer): + circle = renderer.draw_circle([0, 0], radius=1.0) + assert circle.get_fill() is True + + # fill=False should render the circle as outline only + def test_filled_false(self, renderer): + circle = renderer.draw_circle([0, 0], radius=1.0, fill=False) + assert circle.get_fill() is False + + def test_custom_color(self, renderer): + circle = renderer.draw_circle([0, 0], radius=1.0, color='red') + assert circle.get_facecolor() is not None + + def test_custom_radius(self, renderer): + circle = renderer.draw_circle([0, 0], radius=2.5) + assert circle.radius == 2.5 + + +# Tests for draw_rectangle() +class TestDrawRectangle: + def test_returns_rectangle_patch(self, renderer): + result = renderer.draw_rectangle([0, 0], 2, 1) + assert isinstance(result, Rectangle) + + def test_rectangle_added_to_axes(self, renderer): + before = len(renderer.ax.patches) + renderer.draw_rectangle([0, 0], 3, 2) + assert len(renderer.ax.patches) == before + 1 + + def test_position_and_size(self, renderer): + rect = renderer.draw_rectangle([2, 4], 4, 3) + assert rect.get_x() == 2 - 4 / 2 + assert rect.get_y() == 4 - 3 / 2 + assert rect.get_width() == 4 + assert rect.get_height() == 3 + + def test_default_filled(self, renderer): + rect = renderer.draw_rectangle([0, 0], 1, 1) + assert rect.get_fill() is True + + # fill=False should render the rectangle as outline only + def test_filled_false(self, renderer): + rect = renderer.draw_rectangle([0, 0], 1, 1, fill=False) + assert rect.get_fill() is False + + def test_custom_color(self, renderer): + rect = renderer.draw_rectangle([0, 0], 1, 1, color='blue') + assert rect.get_facecolor() is not None + + def test_zero_size_rectangle(self, renderer): + rect = renderer.draw_rectangle([0, 0], 0, 0) + assert rect.get_width() == 0 + assert rect.get_height() == 0 + + +# Tests for draw_polygon() +class TestDrawPolygon: + def test_returns_polygon_patch(self, renderer): + result = renderer.draw_polygon([(0, 0), (1, 0), (0.5, 1)]) + assert isinstance(result, Polygon) + + def test_polygon_added_to_axes(self, renderer): + before = len(renderer.ax.patches) + renderer.draw_polygon([(0, 0), (1, 0), (0.5, 1)]) + assert len(renderer.ax.patches) == before + 1 + + def test_default_filled(self, renderer): + poly = renderer.draw_polygon([(0, 0), (1, 0), (0.5, 1)]) + assert poly.get_fill() is True + + # fill=False should render the polygon as outline only + def test_filled_false(self, renderer): + poly = renderer.draw_polygon([(0, 0), (1, 0), (0.5, 1)], fill=False) + assert poly.get_fill() is False + + def test_custom_color(self, renderer): + poly = renderer.draw_polygon([(0, 0), (1, 0), (0.5, 1)], color='red') + assert poly.get_facecolor() is not None + + def test_quad_vertices(self, renderer): + poly = renderer.draw_polygon([(0, 0), (2, 0), (2, 2), (0, 2)]) + assert isinstance(poly, Polygon) + + def test_many_vertices(self, renderer): + angles = np.linspace(0, 2 * np.pi, 32, endpoint=False) + verts = [(np.cos(a), np.sin(a)) for a in angles] + poly = renderer.draw_polygon(verts) + assert isinstance(poly, Polygon) + + +# Tests for draw_text() +class TestDrawText: + def test_returns_text_object(self, renderer): + result = renderer.draw_text([0, 0], 'hello') + assert result is not None + + def test_text_added_to_axes(self, renderer): + before = len(renderer.ax.texts) + renderer.draw_text([1, 2], 'test') + assert len(renderer.ax.texts) == before + 1 + + def test_text_content_and_position(self, renderer): + renderer.draw_text([3, 4], 'robot') + txt = renderer.ax.texts[-1] + assert txt.get_text() == 'robot' + assert txt.get_position() == (3, 4) + + def test_default_fontsize(self, renderer): + renderer.draw_text([0, 0], 'x') + txt = renderer.ax.texts[-1] + assert txt.get_fontsize() == 10 + + def test_custom_fontsize(self, renderer): + renderer.draw_text([0, 0], 'x', fontsize=16) + txt = renderer.ax.texts[-1] + assert txt.get_fontsize() == 16 + + def test_empty_string(self, renderer): + renderer.draw_text([0, 0], '') + txt = renderer.ax.texts[-1] + assert txt.get_text() == '' + + def test_unicode_text(self, renderer): + renderer.draw_text([0, 0], 'Hello Robot') + txt = renderer.ax.texts[-1] + assert txt.get_text() == 'Hello Robot' + + +# Integration tests — verifies all primitives work together +class TestIntegration: + def test_multiple_shapes_all_added(self, renderer): + renderer.draw_line([0, 0], [1, 1]) + renderer.draw_circle([2, 2], radius=1.0) + renderer.draw_rectangle([3, 3], 1, 1) + renderer.draw_polygon([(5, 5), (6, 5), (5.5, 6)]) + renderer.draw_text([7, 7], 'ok') + + assert len(renderer.ax.lines) >= 1 + assert len(renderer.ax.patches) >= 3 + assert len(renderer.ax.texts) >= 1 + + def test_draw_robot_arm_segment(self, renderer): + renderer.draw_line([0, 0], [1, 0]) + joint = renderer.draw_circle([1, 0], radius=0.1) + assert isinstance(joint, Circle) + assert tuple(joint.center) == (1, 0)