]> Repositories - hackapet/Adafruit_Blinka_Displayio.git/blob - vectorio/_vectorshape.py
Fixes for issue #158
[hackapet/Adafruit_Blinka_Displayio.git] / vectorio / _vectorshape.py
1 # SPDX-FileCopyrightText: 2020 Melissa LeBlanc-Williams for Adafruit Industries
2 #
3 # SPDX-License-Identifier: MIT
4
5 """
6 `vectorio._vectorshape`
7 ================================================================================
8
9 vectorio for Blinka
10
11 **Software and Dependencies:**
12
13 * Adafruit Blinka:
14   https://github.com/adafruit/Adafruit_Blinka/releases
15
16 * Author(s): Melissa LeBlanc-Williams
17
18 """
19
20 import struct
21 from typing import Union, Tuple
22 from circuitpython_typing import WriteableBuffer
23 from displayio._colorconverter import ColorConverter
24 from displayio._colorspace import Colorspace
25 from displayio._palette import Palette
26 from displayio._area import Area
27 from displayio._structs import null_transform, InputPixelStruct, OutputPixelStruct
28
29 __version__ = "0.0.0+auto.0"
30 __repo__ = "https://github.com/adafruit/Adafruit_Blinka_displayio.git"
31
32
33 class _VectorShape:
34     def __init__(
35         self,
36         pixel_shader: Union[ColorConverter, Palette],
37         x: int,
38         y: int,
39     ):
40         self._x = x
41         self._y = y
42         self._pixel_shader = pixel_shader
43         self._hidden = False
44         self._current_area_dirty = True
45         self._current_area = Area(0, 0, 0, 0)
46         self._ephemeral_dirty_area = Area(0, 0, 0, 0)
47         self._absolute_transform = null_transform
48         self._get_screen_area(self._current_area)
49
50     @property
51     def x(self) -> int:
52         """X position of the center point of the circle in the parent."""
53         return self._x
54
55     @x.setter
56     def x(self, value: int) -> None:
57         if self._x == value:
58             return
59         self._x = value
60         self._shape_set_dirty()
61
62     @property
63     def y(self) -> int:
64         """Y position of the center point of the circle in the parent."""
65         return self._y
66
67     @y.setter
68     def y(self, value: int) -> None:
69         if self._y == value:
70             return
71         self._y = value
72         self._shape_set_dirty()
73
74     @property
75     def hidden(self) -> bool:
76         """Hide the circle or not."""
77         return self._hidden
78
79     @hidden.setter
80     def hidden(self, value: bool) -> None:
81         self._hidden = value
82         self._shape_set_dirty()
83
84     @property
85     def location(self) -> Tuple[int, int]:
86         """(X,Y) position of the center point of the circle in the parent."""
87         return (self._x, self._y)
88
89     @location.setter
90     def location(self, value: Tuple[int, int]) -> None:
91         if len(value) != 2:
92             raise ValueError("location must be a list or tuple with exactly 2 integers")
93         x = value[0]
94         y = value[1]
95         dirty = False
96         if self._x != x:
97             self._x = x
98             dirty = True
99         if self._y != y:
100             self._y = y
101             dirty = True
102         if dirty:
103             self._shape_set_dirty()
104
105     @property
106     def pixel_shader(self) -> Union[ColorConverter, Palette]:
107         """The pixel shader of the circle."""
108         return self._pixel_shader
109
110     @pixel_shader.setter
111     def pixel_shader(self, value: Union[ColorConverter, Palette]) -> None:
112         self._pixel_shader = value
113
114     def _get_area(self, _out_area: Area) -> Area:
115         raise NotImplementedError("Subclass must implement _get_area")
116
117     def _get_pixel(self, _x: int, _y: int) -> int:
118         raise NotImplementedError("Subclass must implement _get_pixel")
119
120     def _shape_set_dirty(self) -> None:
121         current_area = Area()
122         self._get_screen_area(current_area)
123         moved = current_area != self._current_area
124         if moved:
125             # This will add _current_area (the old position) to dirty area 
126             self._current_area.union(
127                 self._ephemeral_dirty_area, self._ephemeral_dirty_area
128             )
129             # This will add the new position to the dirty area
130             current_area.union(
131                 self._ephemeral_dirty_area, self._ephemeral_dirty_area
132             )
133             # Dirty area tracks the shape's footprint between draws.  It's reset on refresh finish.
134             current_area.copy_into(self._current_area)
135         self._current_area_dirty = True
136
137     def _get_dirty_area(self, out_area: Area) -> Area:
138         out_area.x1 = out_area.x2
139         self._ephemeral_dirty_area.union(self._current_area, out_area)
140         return True  # For now just always redraw.
141
142     def _get_screen_area(self, out_area) -> Area:
143         self._get_area(out_area)
144         if self._absolute_transform.transpose_xy:
145             x = self._absolute_transform.x + self._absolute_transform.dx * self._y
146             y = self._absolute_transform.y + self._absolute_transform.dy * self._x
147             if self._absolute_transform.dx < 1:
148                 out_area.y1 = out_area.y1 * -1 + 1
149                 out_area.y2 = out_area.y2 * -1 + 1
150             if self._absolute_transform.dy < 1:
151                 out_area.x1 = out_area.x1 * -1 + 1
152                 out_area.x2 = out_area.x2 * -1 + 1
153             self._area_transpose(out_area)
154         else:
155             x = self._absolute_transform.x + self._absolute_transform.dx * self._x
156             y = self._absolute_transform.y + self._absolute_transform.dy * self._y
157             if self._absolute_transform.dx < 1:
158                 out_area.x1 = out_area.x1 * -1 + 1
159                 out_area.x2 = out_area.x2 * -1 + 1
160             if self._absolute_transform.dy < 1:
161                 out_area.y1 = out_area.y1 * -1 + 1
162                 out_area.y2 = out_area.y2 * -1 + 1
163         out_area.canon()
164         out_area.shift(x, y)
165
166     @staticmethod
167     def _area_transpose(to_transpose: Area) -> Area:
168         to_transpose.x1, to_transpose.y1 = to_transpose.y1, to_transpose.x1
169         to_transpose.x2, to_transpose.y2 = to_transpose.y2, to_transpose.x2
170
171     def _screen_to_shape_coordinates(self, x: int, y: int) -> Tuple[int, int]:
172         """Get the target pixel based on the shape's coordinate space"""
173         if self._absolute_transform.transpose_xy:
174             out_shape_x = (
175                 y - self._absolute_transform.y - self._absolute_transform.dy * self._x
176             )
177             out_shape_y = (
178                 x - self._absolute_transform.x - self._absolute_transform.dx * self._y
179             )
180
181             if self._absolute_transform.dx < 1:
182                 out_shape_x *= -1
183             if self._absolute_transform.dy < 1:
184                 out_shape_y *= -1
185         else:
186             out_shape_x = (
187                 x - self._absolute_transform.x - self._absolute_transform.dx * self._x
188             )
189             out_shape_y = (
190                 y - self._absolute_transform.y - self._absolute_transform.dy * self._y
191             )
192
193             if self._absolute_transform.dx < 1:
194                 out_shape_x *= -1
195             if self._absolute_transform.dy < 1:
196                 out_shape_y *= -1
197
198             # It's mirrored via dx. Maybe we need to add support for also separately mirroring?
199             # if self.absolute_transform.mirror_x:
200             #    pixel_to_get_x = (
201             #        (shape_area.x2 - shape_area.x1)
202             #        - (pixel_to_get_x - shape_area.x1)
203             #        + shape_area.x1
204             #        - 1
205             #    )
206             # if self.absolute_transform.mirror_y:
207             #    pixel_to_get_y = (
208             #        (shape_area.y2 - shape_area.y1)
209             #        - (pixel_to_get_y - shape_area.y1)
210             #        + +shape_area.y1
211             #        - 1
212             #    )
213
214         return out_shape_x, out_shape_y
215
216     def _shape_contains(self, x: int, y: int) -> bool:
217         shape_x, shape_y = self._screen_to_shape_coordinates(x, y)
218         return self._get_pixel(shape_x, shape_y) != 0
219
220     def _fill_area(
221         self,
222         colorspace: Colorspace,
223         area: Area,
224         mask: WriteableBuffer,
225         buffer: WriteableBuffer,
226     ) -> bool:
227         # pylint: disable=too-many-locals,too-many-branches,too-many-statements
228         if self._hidden:
229             return False
230
231         overlap = Area()
232         if not area.compute_overlap(self._current_area, overlap):
233             return False
234
235         full_coverage = area == overlap
236         pixels_per_byte = 8 // colorspace.depth
237         linestride_px = area.width()
238         line_dirty_offset_px = (overlap.y1 - area.y1) * linestride_px
239         column_dirty_offset_px = overlap.x1 - area.x1
240
241         input_pixel = InputPixelStruct()
242         output_pixel = OutputPixelStruct()
243
244         shape_area = Area()
245         self._get_area(shape_area)
246
247         mask_start_px = line_dirty_offset_px
248
249         for input_pixel.y in range(overlap.y1, overlap.y2):
250             mask_start_px += column_dirty_offset_px
251             for input_pixel.x in range(overlap.x1, overlap.x2):
252                 # Check the mask first to see if the pixel has already been set.
253                 pixel_index = mask_start_px + (input_pixel.x - overlap.x1)
254                 mask_doubleword = mask[pixel_index // 32]
255                 mask_bit = pixel_index % 32
256                 if (mask_doubleword & (1 << mask_bit)) != 0:
257                     continue
258                 output_pixel.pixel = 0
259
260                 # Cast input screen coordinates to shape coordinates to pick the pixel to draw
261                 pixel_to_get_x, pixel_to_get_y = self._screen_to_shape_coordinates(
262                     input_pixel.x, input_pixel.y
263                 )
264                 input_pixel.pixel = self._get_pixel(pixel_to_get_x, pixel_to_get_y)
265
266                 # vectorio shapes use 0 to mean "area is not covered."
267                 # We can skip all the rest of the work for this pixel
268                 # if it's not currently covered by the shape.
269                 if input_pixel.pixel == 0:
270                     full_coverage = False
271                 else:
272                     # Pixel is not transparent. Let's pull the pixel value index down
273                     # to 0-base for more error-resistant palettes.
274                     input_pixel.pixel -= 1
275                     output_pixel.opaque = True
276                     if self._pixel_shader is None:
277                         output_pixel.pixel = input_pixel.pixel
278                     elif isinstance(self._pixel_shader, Palette):
279                         self._pixel_shader._get_color(  # pylint: disable=protected-access
280                             colorspace, input_pixel, output_pixel
281                         )
282                     elif isinstance(self._pixel_shader, ColorConverter):
283                         self._pixel_shader._convert(  # pylint: disable=protected-access
284                             colorspace, input_pixel, output_pixel
285                         )
286
287                     if not output_pixel.opaque:
288                         full_coverage = False
289
290                     mask[pixel_index // 32] |= 1 << (pixel_index % 32)
291                     if colorspace.depth == 16:
292                         struct.pack_into(
293                             "H",
294                             buffer.cast("B"),
295                             pixel_index * 2,
296                             output_pixel.pixel,
297                         )
298                     elif colorspace.depth == 32:
299                         struct.pack_into(
300                             "I",
301                             buffer.cast("B"),
302                             pixel_index * 4,
303                             output_pixel.pixel,
304                         )
305                     elif colorspace.depth == 8:
306                         buffer.cast("B")[pixel_index] = output_pixel.pixel & 0xFF
307                     elif colorspace.depth < 8:
308                         # Reorder the offsets to pack multiple rows into
309                         # a byte (meaning they share a column).
310                         if not colorspace.pixels_in_byte_share_row:
311                             row = pixel_index // linestride_px
312                             col = pixel_index % linestride_px
313                             # Dividing by pixels_per_byte does truncated division
314                             # even if we multiply it back out
315                             pixel_index = (
316                                 col * pixels_per_byte
317                                 + (row // pixels_per_byte)
318                                 * pixels_per_byte
319                                 * linestride_px
320                                 + (row % pixels_per_byte)
321                             )
322                         shift = (pixel_index % pixels_per_byte) * colorspace.depth
323                         if colorspace.reverse_pixels_in_byte:
324                             # Reverse the shift by subtracting it from the leftmost shift
325                             shift = (pixels_per_byte - 1) * colorspace.depth - shift
326                         buffer.cast("B")[pixel_index // pixels_per_byte] |= (
327                             output_pixel.pixel << shift
328                         )
329             mask_start_px += linestride_px - column_dirty_offset_px
330
331         return full_coverage
332
333     def _finish_refresh(self) -> None:
334         if self._ephemeral_dirty_area.empty() and not self._current_area_dirty:
335             return
336         # Reset dirty area to nothing
337         self._ephemeral_dirty_area.x1 = self._ephemeral_dirty_area.x2
338         self._current_area_dirty = False
339
340         if isinstance(self._pixel_shader, (Palette, ColorConverter)):
341             self._pixel_shader._finish_refresh()  # pylint: disable=protected-access
342
343     def _get_refresh_areas(self, areas: list[Area]) -> None:
344         if self._current_area_dirty or (
345             isinstance(self._pixel_shader, (Palette, ColorConverter))
346             and self._pixel_shader._needs_refresh  # pylint: disable=protected-access
347         ):
348             if not self._ephemeral_dirty_area.empty():
349                 # Both are dirty, check if we should combine the areas or draw separately
350                 # Draws as few pixels as possible both when animations move short distances
351                 # and large distances. The display core implementation currently doesn't
352                 # combine areas to reduce redrawing of masked areas. If it does, this could
353                 # be simplified to just return the 2 possibly overlapping areas.
354                 area_swap = Area()
355                 self._ephemeral_dirty_area.compute_overlap(
356                     self._current_area, area_swap
357                 )
358                 overlap_size = area_swap.size()
359                 self._ephemeral_dirty_area.union(self._current_area, area_swap)
360                 union_size = area_swap.size()
361                 current_size = self._current_area.size()
362                 dirty_size = self._ephemeral_dirty_area.size()
363
364                 if union_size - dirty_size - current_size + overlap_size <= min(
365                     dirty_size, current_size
366                 ):
367                     # The excluded / non-overlapping area from the disjoint dirty and current
368                     # areas is smaller than the smallest area we need to draw. Redrawing the
369                     # overlapping area would cost more than just drawing the union disjoint
370                     # area once.
371                     area_swap.copy_into(self._ephemeral_dirty_area)
372                 else:
373                     # The excluded area between the 2 dirty areas is larger than the smallest
374                     # dirty area. It would be more costly to combine these areas than possibly
375                     # redraw some overlap.
376                     areas.append(self._current_area)
377                 areas.append(self._ephemeral_dirty_area)
378             else:
379                 areas.append(self._current_area)
380         elif not self._ephemeral_dirty_area.empty():
381             areas.append(self._ephemeral_dirty_area)
382
383     def _update_transform(self, group_transform) -> None:
384         self._absolute_transform = (
385             null_transform if group_transform is None else group_transform
386         )
387         self._shape_set_dirty()