X-Git-Url: https://git.ayoreis.com/hackapet/Adafruit_Blinka_Displayio.git/blobdiff_plain/61075a985e341a2f48344e71f5ccd15899dcc875..b598e8fe55aac339ba7d5a3c9b6eac56507bccb5:/vectorio/_vectorshape.py diff --git a/vectorio/_vectorshape.py b/vectorio/_vectorshape.py new file mode 100644 index 0000000..d3d1ddb --- /dev/null +++ b/vectorio/_vectorshape.py @@ -0,0 +1,382 @@ +# SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries +# +# SPDX-License-Identifier: MIT + +""" +`vectorio._vectorshape` +================================================================================ + +vectorio for Blinka + +**Software and Dependencies:** + +* Adafruit Blinka: + https://github.com/adafruit/Adafruit_Blinka/releases + +* Author(s): Melissa LeBlanc-Williams + +""" + +import struct +from typing import Union, Tuple +from circuitpython_typing import WriteableBuffer +from displayio._colorconverter import ColorConverter +from displayio._colorspace import Colorspace +from displayio._palette import Palette +from displayio._area import Area +from displayio._structs import null_transform, InputPixelStruct, OutputPixelStruct + +__version__ = "0.0.0+auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git" + + +class _VectorShape: + def __init__( + self, + pixel_shader: Union[ColorConverter, Palette], + x: int, + y: int, + ): + self._x = x + self._y = y + self._pixel_shader = pixel_shader + self._hidden = False + self._current_area_dirty = True + self._current_area = Area(0, 0, 0, 0) + self._ephemeral_dirty_area = Area(0, 0, 0, 0) + self._absolute_transform = null_transform + self._get_screen_area(self._current_area) + + @property + def x(self) -> int: + """X position of the center point of the circle in the parent.""" + return self._x + + @x.setter + def x(self, value: int) -> None: + if self._x == value: + return + self._x = value + self._shape_set_dirty() + + @property + def y(self) -> int: + """Y position of the center point of the circle in the parent.""" + return self._y + + @y.setter + def y(self, value: int) -> None: + if self._y == value: + return + self._y = value + self._shape_set_dirty() + + @property + def hidden(self) -> bool: + """Hide the circle or not.""" + return self._hidden + + @hidden.setter + def hidden(self, value: bool) -> None: + self._hidden = value + self._shape_set_dirty() + + @property + def location(self) -> Tuple[int, int]: + """(X,Y) position of the center point of the circle in the parent.""" + return (self._x, self._y) + + @location.setter + def location(self, value: Tuple[int, int]) -> None: + if len(value) != 2: + raise ValueError("location must be a list or tuple with exactly 2 integers") + x = value[0] + y = value[1] + dirty = False + if self._x != x: + self._x = x + dirty = True + if self._y != y: + self._y = y + dirty = True + if dirty: + self._shape_set_dirty() + + @property + def pixel_shader(self) -> Union[ColorConverter, Palette]: + """The pixel shader of the circle.""" + return self._pixel_shader + + @pixel_shader.setter + def pixel_shader(self, value: Union[ColorConverter, Palette]) -> None: + self._pixel_shader = value + + def _get_area(self, _out_area: Area) -> Area: + raise NotImplementedError("Subclass must implement _get_area") + + def _get_pixel(self, _x: int, _y: int) -> int: + raise NotImplementedError("Subclass must implement _get_pixel") + + def _shape_set_dirty(self) -> None: + current_area = Area() + self._get_screen_area(current_area) + moved = current_area != self._current_area + if moved: + self._current_area.union( + self._ephemeral_dirty_area, self._ephemeral_dirty_area + ) + # Dirty area tracks the shape's footprint between draws. It's reset on refresh finish. + current_area.copy_into(self._current_area) + self._current_area_dirty = True + + def _get_dirty_area(self, out_area: Area) -> Area: + out_area.x1 = out_area.x2 + self._ephemeral_dirty_area.union(self._current_area, out_area) + return True # For now just always redraw. + + def _get_screen_area(self, out_area) -> Area: + self._get_area(out_area) + if self._absolute_transform.transpose_xy: + x = self._absolute_transform.x + self._absolute_transform.dx * self._y + y = self._absolute_transform.y + self._absolute_transform.dy * self._x + if self._absolute_transform.dx < 1: + out_area.y1 = out_area.y1 * -1 + 1 + out_area.y2 = out_area.y2 * -1 + 1 + if self._absolute_transform.dy < 1: + out_area.x1 = out_area.x1 * -1 + 1 + out_area.x2 = out_area.x2 * -1 + 1 + self._area_transpose(out_area) + else: + x = self._absolute_transform.x + self._absolute_transform.dx * self._x + y = self._absolute_transform.y + self._absolute_transform.dy * self._y + if self._absolute_transform.dx < 1: + out_area.x1 = out_area.x1 * -1 + 1 + out_area.x2 = out_area.x2 * -1 + 1 + if self._absolute_transform.dy < 1: + out_area.y1 = out_area.y1 * -1 + 1 + out_area.y2 = out_area.y2 * -1 + 1 + out_area.canon() + out_area.shift(x, y) + + @staticmethod + def _area_transpose(to_transpose: Area) -> Area: + to_transpose.x1, to_transpose.y1 = to_transpose.y1, to_transpose.x1 + to_transpose.x2, to_transpose.y2 = to_transpose.y2, to_transpose.x2 + + def _screen_to_shape_coordinates(self, x: int, y: int) -> Tuple[int, int]: + """Get the target pixel based on the shape's coordinate space""" + if self._absolute_transform.transpose_xy: + out_shape_x = ( + y - self._absolute_transform.y - self._absolute_transform.dy * self._x + ) + out_shape_y = ( + x - self._absolute_transform.x - self._absolute_transform.dx * self._y + ) + + if self._absolute_transform.dx < 1: + out_shape_x *= -1 + if self._absolute_transform.dy < 1: + out_shape_y *= -1 + else: + out_shape_x = ( + x - self._absolute_transform.x - self._absolute_transform.dx * self._x + ) + out_shape_y = ( + y - self._absolute_transform.y - self._absolute_transform.dy * self._y + ) + + if self._absolute_transform.dx < 1: + out_shape_x *= -1 + if self._absolute_transform.dy < 1: + out_shape_y *= -1 + + # It's mirrored via dx. Maybe we need to add support for also separately mirroring? + # if self.absolute_transform.mirror_x: + # pixel_to_get_x = ( + # (shape_area.x2 - shape_area.x1) + # - (pixel_to_get_x - shape_area.x1) + # + shape_area.x1 + # - 1 + # ) + # if self.absolute_transform.mirror_y: + # pixel_to_get_y = ( + # (shape_area.y2 - shape_area.y1) + # - (pixel_to_get_y - shape_area.y1) + # + +shape_area.y1 + # - 1 + # ) + + return out_shape_x, out_shape_y + + def _shape_contains(self, x: int, y: int) -> bool: + shape_x, shape_y = self._screen_to_shape_coordinates(x, y) + return self._get_pixel(shape_x, shape_y) != 0 + + def _fill_area( + self, + colorspace: Colorspace, + area: Area, + mask: WriteableBuffer, + buffer: WriteableBuffer, + ) -> bool: + # pylint: disable=too-many-locals,too-many-branches,too-many-statements + if self._hidden: + return False + + overlap = Area() + if not area.compute_overlap(self._current_area, overlap): + return False + + full_coverage = area == overlap + pixels_per_byte = 8 // colorspace.depth + linestride_px = area.width() + line_dirty_offset_px = (overlap.y1 - area.y1) * linestride_px + column_dirty_offset_px = overlap.x1 - area.x1 + + input_pixel = InputPixelStruct() + output_pixel = OutputPixelStruct() + + shape_area = Area() + self._get_area(shape_area) + + mask_start_px = line_dirty_offset_px + + for input_pixel.y in range(overlap.y1, overlap.y2): + mask_start_px += column_dirty_offset_px + for input_pixel.x in range(overlap.x1, overlap.x2): + # Check the mask first to see if the pixel has already been set. + pixel_index = mask_start_px + (input_pixel.x - overlap.x1) + mask_doubleword = mask[pixel_index // 32] + mask_bit = pixel_index % 32 + if (mask_doubleword & (1 << mask_bit)) != 0: + continue + output_pixel.pixel = 0 + + # Cast input screen coordinates to shape coordinates to pick the pixel to draw + pixel_to_get_x, pixel_to_get_y = self._screen_to_shape_coordinates( + input_pixel.x, input_pixel.y + ) + input_pixel.pixel = self._get_pixel(pixel_to_get_x, pixel_to_get_y) + + # vectorio shapes use 0 to mean "area is not covered." + # We can skip all the rest of the work for this pixel + # if it's not currently covered by the shape. + if input_pixel.pixel == 0: + full_coverage = False + else: + # Pixel is not transparent. Let's pull the pixel value index down + # to 0-base for more error-resistant palettes. + input_pixel.pixel -= 1 + output_pixel.opaque = True + if self._pixel_shader is None: + output_pixel.pixel = input_pixel.pixel + elif isinstance(self._pixel_shader, Palette): + self._pixel_shader._get_color( # pylint: disable=protected-access + colorspace, input_pixel, output_pixel + ) + elif isinstance(self._pixel_shader, ColorConverter): + self._pixel_shader._convert( # pylint: disable=protected-access + colorspace, input_pixel, output_pixel + ) + + if not output_pixel.opaque: + full_coverage = False + + mask[pixel_index // 32] |= 1 << (pixel_index % 32) + if colorspace.depth == 16: + struct.pack_into( + "H", + buffer.cast("B"), + pixel_index * 2, + output_pixel.pixel, + ) + elif colorspace.depth == 32: + struct.pack_into( + "I", + buffer.cast("B"), + pixel_index * 4, + output_pixel.pixel, + ) + elif colorspace.depth == 8: + buffer.cast("B")[pixel_index] = output_pixel.pixel & 0xFF + elif colorspace.depth < 8: + # Reorder the offsets to pack multiple rows into + # a byte (meaning they share a column). + if not colorspace.pixels_in_byte_share_row: + row = pixel_index // linestride_px + col = pixel_index % linestride_px + # Dividing by pixels_per_byte does truncated division + # even if we multiply it back out + pixel_index = ( + col * pixels_per_byte + + (row // pixels_per_byte) + * pixels_per_byte + * linestride_px + + (row % pixels_per_byte) + ) + shift = (pixel_index % pixels_per_byte) * colorspace.depth + if colorspace.reverse_pixels_in_byte: + # Reverse the shift by subtracting it from the leftmost shift + shift = (pixels_per_byte - 1) * colorspace.depth - shift + buffer.cast("B")[pixel_index // pixels_per_byte] |= ( + output_pixel.pixel << shift + ) + mask_start_px += linestride_px - column_dirty_offset_px + + return full_coverage + + def _finish_refresh(self) -> None: + if self._ephemeral_dirty_area.empty() and not self._current_area_dirty: + return + # Reset dirty area to nothing + self._ephemeral_dirty_area.x1 = self._ephemeral_dirty_area.x2 + self._current_area_dirty = False + + if isinstance(self._pixel_shader, (Palette, ColorConverter)): + self._pixel_shader._finish_refresh() # pylint: disable=protected-access + + def _get_refresh_areas(self, areas: list[Area]) -> None: + if self._current_area_dirty or ( + isinstance(self._pixel_shader, (Palette, ColorConverter)) + and self._pixel_shader._needs_refresh # pylint: disable=protected-access + ): + if not self._ephemeral_dirty_area.empty(): + # Both are dirty, check if we should combine the areas or draw separately + # Draws as few pixels as possible both when animations move short distances + # and large distances. The display core implementation currently doesn't + # combine areas to reduce redrawing of masked areas. If it does, this could + # be simplified to just return the 2 possibly overlapping areas. + area_swap = Area() + self._ephemeral_dirty_area.compute_overlap( + self._current_area, area_swap + ) + overlap_size = area_swap.size() + self._ephemeral_dirty_area.union(self._current_area, area_swap) + union_size = area_swap.size() + current_size = self._current_area.size() + dirty_size = self._ephemeral_dirty_area.size() + + if union_size - dirty_size - current_size + overlap_size <= min( + dirty_size, current_size + ): + # The excluded / non-overlapping area from the disjoint dirty and current + # areas is smaller than the smallest area we need to draw. Redrawing the + # overlapping area would cost more than just drawing the union disjoint + # area once. + area_swap.copy_into(self._ephemeral_dirty_area) + else: + # The excluded area between the 2 dirty areas is larger than the smallest + # dirty area. It would be more costly to combine these areas than possibly + # redraw some overlap. + areas.append(self._current_area) + areas.append(self._ephemeral_dirty_area) + else: + areas.append(self._current_area) + elif not self._ephemeral_dirty_area.empty(): + areas.append(self._ephemeral_dirty_area) + + def _update_transform(self, group_transform) -> None: + self._absolute_transform = ( + null_transform if group_transform is None else group_transform + ) + self._shape_set_dirty()