diff --git a/buildconfig/stubs/pygame/draw.pyi b/buildconfig/stubs/pygame/draw.pyi index b6d057f879..68ad3ace5b 100644 --- a/buildconfig/stubs/pygame/draw.pyi +++ b/buildconfig/stubs/pygame/draw.pyi @@ -82,6 +82,7 @@ def aaline( color: ColorLike, start_pos: Point, end_pos: Point, + width: int = 1, ) -> Rect: ... def aalines( surface: Surface, diff --git a/docs/reST/ref/code_examples/draw_module_example.png b/docs/reST/ref/code_examples/draw_module_example.png index f6fef800dd..6e3e6af13c 100644 Binary files a/docs/reST/ref/code_examples/draw_module_example.png and b/docs/reST/ref/code_examples/draw_module_example.png differ diff --git a/docs/reST/ref/code_examples/draw_module_example.py b/docs/reST/ref/code_examples/draw_module_example.py index ce13298477..1e155247b2 100644 --- a/docs/reST/ref/code_examples/draw_module_example.py +++ b/docs/reST/ref/code_examples/draw_module_example.py @@ -30,6 +30,10 @@ # 5 pixels wide. Uses (r, g, b) color - medium sea green. pygame.draw.line(screen, (60, 179, 113), [0, 0], [50, 30], 5) + # Draw on the screen a green antialiased line from (0, 25) to (50, 55) + # 5 pixels wide. Uses (r, g, b) color - medium sea green. + pygame.draw.aaline(screen, (60, 179, 113), [0, 25], [50, 55], 5) + # Draw on the screen a green line from (0, 50) to (50, 80) # Because it is an antialiased line, it is 1 pixel wide. # Uses (r, g, b) color - medium sea green. diff --git a/docs/reST/ref/draw.rst b/docs/reST/ref/draw.rst index acf95873cd..dfe4b669d3 100644 --- a/docs/reST/ref/draw.rst +++ b/docs/reST/ref/draw.rst @@ -456,72 +456,10 @@ object around the draw calls (see :func:`pygame.Surface.lock` and | :sl:`draw a straight antialiased line` | :sg:`aaline(surface, color, start_pos, end_pos) -> Rect` + :sg:`aaline(surface, color, start_pos, end_pos, width=1) -> Rect` - Draws a straight antialiased line on the given surface. - - The line has a thickness of one pixel and the endpoints have a height and - width of one pixel each. - - The way a line and its endpoints are drawn: - If both endpoints are equal, only a single pixel is drawn (after - rounding floats to nearest integer). - - Otherwise if the line is not steep (i.e. if the length along the x-axis - is greater than the height along the y-axis): - - For each endpoint: - - If ``x``, the endpoint's x-coordinate, is a whole number find - which pixels would be covered by it and draw them. - - Otherwise: - - Calculate the position of the nearest point with a whole number - for its x-coordinate, when extending the line past the - endpoint. - - Find which pixels would be covered and how much by that point. - - If the endpoint is the left one, multiply the coverage by (1 - - the decimal part of ``x``). - - Otherwise multiply the coverage by the decimal part of ``x``. - - Then draw those pixels. - - *e.g.:* - | The left endpoint of the line ``((1, 1.3), (5, 3))`` would - cover 70% of the pixel ``(1, 1)`` and 30% of the pixel - ``(1, 2)`` while the right one would cover 100% of the - pixel ``(5, 3)``. - | The left endpoint of the line ``((1.2, 1.4), (4.6, 3.1))`` - would cover 56% *(i.e. 0.8 * 70%)* of the pixel ``(1, 1)`` - and 24% *(i.e. 0.8 * 30%)* of the pixel ``(1, 2)`` while - the right one would cover 42% *(i.e. 0.6 * 70%)* of the - pixel ``(5, 3)`` and 18% *(i.e. 0.6 * 30%)* of the pixel - ``(5, 4)`` while the right - - Then for each point between the endpoints, along the line, whose - x-coordinate is a whole number: - - Find which pixels would be covered and how much by that point and - draw them. - - *e.g.:* - | The points along the line ``((1, 1), (4, 2.5))`` would be - ``(2, 1.5)`` and ``(3, 2)`` and would cover 50% of the pixel - ``(2, 1)``, 50% of the pixel ``(2, 2)`` and 100% of the pixel - ``(3, 2)``. - | The points along the line ``((1.2, 1.4), (4.6, 3.1))`` would - be ``(2, 1.8)`` (covering 20% of the pixel ``(2, 1)`` and 80% - of the pixel ``(2, 2)``), ``(3, 2.3)`` (covering 70% of the - pixel ``(3, 2)`` and 30% of the pixel ``(3, 3)``) and ``(4, - 2.8)`` (covering 20% of the pixel ``(2, 1)`` and 80% of the - pixel ``(2, 2)``) - - Otherwise do the same for steep lines as for non-steep lines except - along the y-axis instead of the x-axis (using ``y`` instead of ``x``, - top instead of left and bottom instead of right). + Draws a straight antialiased line on the given surface. There are no endcaps. + For thick lines the ends are squared off. .. note:: Regarding float values for coordinates, a point with coordinate @@ -543,6 +481,11 @@ object around the draw calls (see :func:`pygame.Surface.lock` and :param end_pos: end position of the line, (x, y) :type end_pos: tuple(int or float, int or float) or list(int or float, int or float) or Vector2(int or float, int or float) + :param int width: (optional) used for line thickness + + | if width >= 1, used for line thickness (default is 1) + | if width < 1, nothing will be drawn + | :returns: a rect bounding the changed pixels, if nothing is drawn the bounding rect's position will be the ``start_pos`` parameter value (float @@ -555,6 +498,7 @@ object around the draw calls (see :func:`pygame.Surface.lock` and .. versionchangedold:: 2.0.0 Added support for keyword arguments. .. versionchanged:: 2.4.0 Removed deprecated 'blend' argument .. versionchanged:: 2.5.0 ``blend`` argument readded for backcompat, but will always raise a deprecation exception when used + .. versionchanged:: 2.5.2 Added line width .. ## pygame.draw.aaline ## diff --git a/src_c/doc/draw_doc.h b/src_c/doc/draw_doc.h index b47ac37250..384936e90c 100644 --- a/src_c/doc/draw_doc.h +++ b/src_c/doc/draw_doc.h @@ -8,5 +8,5 @@ #define DOC_DRAW_ARC "arc(surface, color, rect, start_angle, stop_angle) -> Rect\narc(surface, color, rect, start_angle, stop_angle, width=1) -> Rect\ndraw an elliptical arc" #define DOC_DRAW_LINE "line(surface, color, start_pos, end_pos) -> Rect\nline(surface, color, start_pos, end_pos, width=1) -> Rect\ndraw a straight line" #define DOC_DRAW_LINES "lines(surface, color, closed, points) -> Rect\nlines(surface, color, closed, points, width=1) -> Rect\ndraw multiple contiguous straight line segments" -#define DOC_DRAW_AALINE "aaline(surface, color, start_pos, end_pos) -> Rect\ndraw a straight antialiased line" +#define DOC_DRAW_AALINE "aaline(surface, color, start_pos, end_pos) -> Rect\naaline(surface, color, start_pos, end_pos, width=1) -> Rect\ndraw a straight antialiased line" #define DOC_DRAW_AALINES "aalines(surface, color, closed, points) -> Rect\ndraw multiple contiguous straight antialiased line segments" diff --git a/src_c/draw.c b/src_c/draw.c index 115a6bfd26..bf29d2ac94 100644 --- a/src_c/draw.c +++ b/src_c/draw.c @@ -44,6 +44,10 @@ draw_line_width(SDL_Surface *surf, Uint32 color, int x1, int y1, int x2, static void draw_line(SDL_Surface *surf, int x1, int y1, int x2, int y2, Uint32 color, int *drawn_area); +void +line_width_corners(float from_x, float from_y, float to_x, float to_y, + int width, float *x1, float *y1, float *x2, float *y2, + float *x3, float *y3, float *x4, float *y4); static void draw_aaline(SDL_Surface *surf, Uint32 color, float startx, float starty, float endx, float endy, int *drawn_area, @@ -115,16 +119,17 @@ aaline(PyObject *self, PyObject *arg, PyObject *kwargs) PyObject *colorobj, *start, *end; SDL_Surface *surf = NULL; float startx, starty, endx, endy; + int width = 1; /* Default width. */ PyObject *blend = NULL; int drawn_area[4] = {INT_MAX, INT_MAX, INT_MIN, INT_MIN}; /* Used to store bounding box values */ Uint32 color; - static char *keywords[] = {"surface", "color", "start_pos", - "end_pos", "blend", NULL}; + static char *keywords[] = {"surface", "color", "start_pos", "end_pos", + "width", "blend", NULL}; - if (!PyArg_ParseTupleAndKeywords(arg, kwargs, "O!OOO|O", keywords, + if (!PyArg_ParseTupleAndKeywords(arg, kwargs, "O!OOO|iO", keywords, &pgSurface_Type, &surfobj, &colorobj, - &start, &end, &blend)) { + &start, &end, &width, &blend)) { return NULL; /* Exception already set. */ } @@ -157,11 +162,27 @@ aaline(PyObject *self, PyObject *arg, PyObject *kwargs) return RAISE(PyExc_TypeError, "invalid end_pos argument"); } + if (width < 1) { + return pgRect_New4((int)startx, (int)starty, 0, 0); + } + if (!pgSurface_Lock(surfobj)) { return RAISE(PyExc_RuntimeError, "error locking surface"); } - draw_aaline(surf, color, startx, starty, endx, endy, drawn_area, 0, 0, 0); + if (width > 1) { + float x1, y1, x2, y2, x3, y3, x4, y4; + line_width_corners(startx, starty, endx, endy, width, &x1, &y1, &x2, + &y2, &x3, &y3, &x4, &y4); + draw_line_width(surf, color, (int)startx, (int)starty, (int)endx, + (int)endy, width, drawn_area); + draw_aaline(surf, color, x1, y1, x2, y2, drawn_area, 0, 0, 0); + draw_aaline(surf, color, x3, y3, x4, y4, drawn_area, 0, 0, 0); + } + else { + draw_aaline(surf, color, startx, starty, endx, endy, drawn_area, 0, 0, + 0); + } if (!pgSurface_Unlock(surfobj)) { return RAISE(PyExc_RuntimeError, "error unlocking surface"); @@ -1836,6 +1857,39 @@ draw_line_width(SDL_Surface *surf, Uint32 color, int x1, int y1, int x2, } } +// Calculates 4 points, representing corners of draw_line_width() +// first two points assemble left line and second two - right line +void +line_width_corners(float from_x, float from_y, float to_x, float to_y, + int width, float *x1, float *y1, float *x2, float *y2, + float *x3, float *y3, float *x4, float *y4) +{ + float aa_width = (float)width / 2; + float extra_width = (1.0f - (width % 2)) / 2; + int steep = fabs(to_x - from_x) <= fabs(to_y - from_y); + + if (steep) { + *x1 = from_x + extra_width + aa_width; + *y1 = from_y; + *x2 = to_x + extra_width + aa_width; + *y2 = to_y; + *x3 = from_x + extra_width - aa_width; + *y3 = from_y; + *x4 = to_x + extra_width - aa_width; + *y4 = to_y; + } + else { + *x1 = from_x; + *y1 = from_y + extra_width + aa_width; + *x2 = to_x; + *y2 = to_y + extra_width + aa_width; + *x3 = from_x; + *y3 = from_y + extra_width - aa_width; + *x4 = to_x; + *y4 = to_y + extra_width - aa_width; + } +} + /* Algorithm modified from * https://rosettacode.org/wiki/Bitmap/Bresenham%27s_line_algorithm */ diff --git a/test/draw_test.py b/test/draw_test.py index eb61d29164..872e72ee58 100644 --- a/test/draw_test.py +++ b/test/draw_test.py @@ -2455,7 +2455,7 @@ def test_aaline__blend_warning(self): warnings.simplefilter("always") # Trigger DeprecationWarning. self.draw_aaline( - pygame.Surface((2, 2)), (0, 0, 0, 50), (0, 0), (2, 2), blend + pygame.Surface((2, 2)), (0, 0, 0, 50), (0, 0), (2, 2), 1, blend ) # Check if there is only one warning and is a DeprecationWarning. self.assertEqual(len(w), count + 1) @@ -2467,12 +2467,14 @@ def test_aaline__kwargs(self): color = pygame.Color("yellow") start_pos = (1, 1) end_pos = (2, 2) + width = 2 kwargs_list = [ { "surface": surface, "color": color, "start_pos": start_pos, "end_pos": end_pos, + "width": width, }, ] @@ -2485,6 +2487,7 @@ def test_aaline__kwargs_order_independent(self): """Ensures draw aaline's kwargs are not order dependent.""" bounds_rect = self.draw_aaline( start_pos=(1, 2), + width=2, end_pos=(2, 1), color=(10, 20, 30), surface=pygame.Surface((3, 2)), @@ -2516,6 +2519,7 @@ def test_aaline__kwargs_missing(self): "color": pygame.Color("red"), "start_pos": (2, 1), "end_pos": (2, 2), + "width": 2, } for name in ("end_pos", "start_pos", "color", "surface"): @@ -2554,30 +2558,42 @@ def test_aaline__kwarg_invalid_types(self): color = pygame.Color("green") start_pos = (1, 0) end_pos = (2, 0) + width = 1 kwargs_list = [ { "surface": pygame.Surface, # Invalid surface. "color": color, "start_pos": start_pos, "end_pos": end_pos, + "width": width, }, { "surface": surface, "color": 2.3, # Invalid color. "start_pos": start_pos, "end_pos": end_pos, + "width": width, }, { "surface": surface, "color": color, "start_pos": (0, 0, 0), # Invalid start_pos. "end_pos": end_pos, + "width": width, }, { "surface": surface, "color": color, "start_pos": start_pos, "end_pos": (0,), # Invalid end_pos. + "width": width, + }, + { + "surface": surface, + "color": color, + "start_pos": start_pos, + "end_pos": (0,), + "width": 1.2, # Invalid width. }, ] @@ -2591,12 +2607,14 @@ def test_aaline__kwarg_invalid_name(self): color = pygame.Color("cyan") start_pos = (1, 1) end_pos = (2, 0) + width = 1 kwargs_list = [ { "surface": surface, "color": color, "start_pos": start_pos, "end_pos": end_pos, + "width": width, "invalid": 1, }, { @@ -2618,14 +2636,16 @@ def test_aaline__args_and_kwargs(self): color = (255, 255, 0, 0) start_pos = (0, 1) end_pos = (1, 2) + width = 1 kwargs = { "surface": surface, "color": color, "start_pos": start_pos, "end_pos": end_pos, + "width": width, } - for name in ("surface", "color", "start_pos", "end_pos"): + for name in ("surface", "color", "start_pos", "end_pos", "width"): kwargs.pop(name) if "surface" == name: @@ -2645,6 +2665,30 @@ def test_aaline__args_and_kwargs(self): self.assertIsInstance(bounds_rect, pygame.Rect) + def test_aaline__valid_width_values(self): + """Ensures draw aaline accepts different width values.""" + line_color = pygame.Color("yellow") + surface_color = pygame.Color("white") + surface = pygame.Surface((3, 4)) + pos = (2, 1) + kwargs = { + "surface": surface, + "color": line_color, + "start_pos": pos, + "end_pos": (2, 2), + "width": None, + } + + for width in (-100, -10, -1, 0, 1, 10, 100): + surface.fill(surface_color) # Clear for each test. + kwargs["width"] = width + expected_color = line_color if width > 0 else surface_color + + bounds_rect = self.draw_aaline(**kwargs) + + self.assertEqual(surface.get_at(pos), expected_color) + self.assertIsInstance(bounds_rect, pygame.Rect) + def test_aaline__valid_start_pos_formats(self): """Ensures draw aaline accepts different start_pos formats.""" expected_color = pygame.Color("red") @@ -2655,6 +2699,7 @@ def test_aaline__valid_start_pos_formats(self): "color": expected_color, "start_pos": None, "end_pos": (2, 2), + "width": 2, } x, y = 2, 1 # start position positions = ((x, y), (x + 0.01, y), (x, y + 0.01), (x + 0.01, y + 0.01)) @@ -2683,6 +2728,7 @@ def test_aaline__valid_end_pos_formats(self): "color": expected_color, "start_pos": (2, 1), "end_pos": None, + "width": 2, } x, y = 2, 2 # end position positions = ((x, y), (x + 0.02, y), (x, y + 0.02), (x + 0.02, y + 0.02)) @@ -2708,6 +2754,7 @@ def test_aaline__invalid_start_pos_formats(self): "color": pygame.Color("red"), "start_pos": None, "end_pos": (2, 2), + "width": 2, } start_pos_fmts = ( @@ -2731,6 +2778,7 @@ def test_aaline__invalid_end_pos_formats(self): "color": pygame.Color("red"), "start_pos": (2, 2), "end_pos": None, + "width": 2, } end_pos_fmts = ( @@ -2758,6 +2806,7 @@ def test_aaline__valid_color_formats(self): "color": None, "start_pos": pos, "end_pos": (2, 1), + "width": 2, } greens = ( (0, 255, 0), @@ -2787,6 +2836,7 @@ def test_aaline__invalid_color_formats(self): "color": None, "start_pos": (1, 1), "end_pos": (2, 1), + "width": 2, } for expected_color in (2.3, self): @@ -2804,6 +2854,17 @@ def test_aaline__color(self): self.assertEqual(surface.get_at(pos), expected_color, f"pos={pos}") + def test_aaline__color_with_thickness(self): + """Ensures a thick aaline is drawn using the correct color.""" + from_x = 5 + to_x = 10 + y = 5 + for surface in self._create_surfaces(): + for expected_color in self.COLORS: + self.draw_aaline(surface, expected_color, (from_x, y), (to_x, y), 5) + for pos in ((x, y + i) for i in (-2, 0, 2) for x in (from_x, to_x)): + self.assertEqual(surface.get_at(pos), expected_color, f"pos={pos}") + def test_aaline__gaps(self): """Tests if the aaline drawn contains any gaps. @@ -2818,11 +2879,28 @@ def test_aaline__gaps(self): pos = (x, 0) self.assertEqual(surface.get_at(pos), expected_color, f"pos={pos}") + def test_line__gaps_with_thickness(self): + """Ensures a thick aaline is drawn without any gaps.""" + expected_color = (255, 255, 255) + thickness = 5 + for surface in self._create_surfaces(): + width = surface.get_width() - 1 + h = width // 5 + w = h * 5 + self.draw_aaline(surface, expected_color, (0, 5), (w, 5 + h), thickness) + + for x in range(w + 1): + for y in range(3, 8): + pos = (x, y + ((x + 2) // 5)) + self.assertEqual(surface.get_at(pos), expected_color, f"pos={pos}") + def test_aaline__bounding_rect(self): """Ensures draw aaline returns the correct bounding rect. - Test lines with endpoints on and off the surface. + Test lines with endpoints on and off the surface and a range of + width/thickness values. """ + line_color = pygame.Color("red") surf_color = pygame.Color("blue") width = height = 30 @@ -2840,16 +2918,32 @@ def test_aaline__bounding_rect(self): for pos in rect_corners_mids_and_center(surf_rect): helper_rect.center = pos - for start, end in self._rect_lines(helper_rect): - surface.fill(surf_color) # Clear for each test. + # Draw using different thicknesses. + for thickness in range(-1, 5): + for start, end in self._rect_lines(helper_rect): + surface.fill(surf_color) # Clear for each test. - bounding_rect = self.draw_aaline(surface, line_color, start, end) + bounding_rect = self.draw_aaline( + surface, line_color, start, end, thickness + ) - # Calculating the expected_rect after the line is - # drawn (it uses what is actually drawn). - expected_rect = create_bounding_rect(surface, surf_color, start) + if 0 < thickness: + # Calculating the expected_rect after the line is + # drawn (it uses what is actually drawn). + expected_rect = create_bounding_rect( + surface, surf_color, start + ) + else: + # Nothing drawn. + expected_rect = pygame.Rect(start, (0, 0)) - self.assertEqual(bounding_rect, expected_rect) + self.assertEqual( + bounding_rect, + expected_rect, + "start={}, end={}, size={}, thickness={}".format( + start, end, size, thickness + ), + ) def test_aaline__surface_clip(self): """Ensures draw aaline respects a surface's clip area.""" @@ -2863,37 +2957,52 @@ def test_aaline__surface_clip(self): clip_rect.center = surface.get_rect().center pos_rect = clip_rect.copy() # Manages the aaline's pos. - # Test centering the pos_rect along the clip rect's edge to allow for - # drawing the aaline over the clip_rect's bounds. - for center in rect_corners_mids_and_center(clip_rect): - pos_rect.center = center + for thickness in (1, 3): # Test different line widths. + # Test centering the pos_rect along the clip rect's edge to allow for + # drawing the aaline over the clip_rect's bounds. + for center in rect_corners_mids_and_center(clip_rect): + pos_rect.center = center - # Get the expected points by drawing the aaline without the - # clip area set. - surface.set_clip(None) - surface.fill(surface_color) - self.draw_aaline(surface, aaline_color, pos_rect.midtop, pos_rect.midbottom) + # Get the expected points by drawing the aaline without the + # clip area set. + surface.set_clip(None) + surface.fill(surface_color) + self.draw_aaline( + surface, + aaline_color, + pos_rect.midtop, + pos_rect.midbottom, + thickness, + ) - expected_pts = get_color_points(surface, surface_color, clip_rect, False) + expected_pts = get_color_points( + surface, surface_color, clip_rect, False + ) - # Clear the surface and set the clip area. Redraw the aaline - # and check that only the clip area is modified. - surface.fill(surface_color) - surface.set_clip(clip_rect) + # Clear the surface and set the clip area. Redraw the aaline + # and check that only the clip area is modified. + surface.fill(surface_color) + surface.set_clip(clip_rect) - self.draw_aaline(surface, aaline_color, pos_rect.midtop, pos_rect.midbottom) + self.draw_aaline( + surface, + aaline_color, + pos_rect.midtop, + pos_rect.midbottom, + thickness, + ) - surface.lock() # For possible speed up. + surface.lock() # For possible speed up. - # Check all the surface points to ensure the expected_pts - # are not surface_color. - for pt in ((x, y) for x in range(surfw) for y in range(surfh)): - if pt in expected_pts: - self.assertNotEqual(surface.get_at(pt), surface_color, pt) - else: - self.assertEqual(surface.get_at(pt), surface_color, pt) + # Check all the surface points to ensure the expected_pts + # are not surface_color. + for pt in ((x, y) for x in range(surfw) for y in range(surfh)): + if pt in expected_pts: + self.assertNotEqual(surface.get_at(pt), surface_color, pt) + else: + self.assertEqual(surface.get_at(pt), surface_color, pt) - surface.unlock() + surface.unlock() class DrawAALineTest(AALineMixin, DrawTestCase):