Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`
5 changes: 4 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ def lint(session):
@nox.session
def tests(session):
session.run("uv", "sync", "--dev", external=True)
session.run("uv", "run", "pytest", external=True)
session.run("uv", "pip", "install", "-e", ".", external=True)
session.run("uv", "run", "pytest", external=True, env={"PYTHONPATH": "src"})


4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -35,3 +36,6 @@ line-length = 100
quote-style = "double"
indent-style = "space"
docstring-code-format = true

[tool.setuptools.packages.find]
where = ["src"]
74 changes: 69 additions & 5 deletions src/simulator/renderer.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
219 changes: 219 additions & 0 deletions tests/test_renderer.py
Original file line number Diff line number Diff line change
@@ -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)
Loading