Coverage for paintmaskanimator/brush_raster.py: 100%

38 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Shared non-antialiased brush geometry for painting and cursor previews.""" 

2import math 

3from functools import lru_cache 

4 

5import numpy as np 

6from PySide6.QtCore import QPointF, QRect 

7from PySide6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen 

8from PySide6.QtCore import Qt 

9 

10 

11def paint_segment(painter, start, end, color, width): 

12 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

13 # Subpixel cosmetic Qt pens lower alpha even with AA disabled. Pixel brushes 

14 # always cover at least one fully opaque pixel. 

15 painter.setPen(QPen(color, max(1.0, width), Qt.PenStyle.SolidLine, 

16 Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) 

17 if start == end: 

18 # Qt's degenerate floating-point line can discard a click entirely. 

19 painter.drawPoint(start) 

20 else: 

21 painter.drawLine(start, end) 

22 

23 

24def stamp_mask(center, width): 

25 margin = math.ceil(width / 2) + 3 

26 origin_x, origin_y = math.floor(center.x()) - margin, math.floor(center.y()) - margin 

27 size = margin * 2 + 1 

28 image = QImage(size, size, QImage.Format.Format_ARGB32_Premultiplied) 

29 image.fill(0) 

30 painter = QPainter(image) 

31 local = center - QPointF(origin_x, origin_y) 

32 paint_segment(painter, local, local, QColor("white"), width) 

33 painter.end() 

34 return image, QRect(origin_x, origin_y, size, size) 

35 

36 

37def stamp_outline(center, width): 

38 # Integer translation does not change raster coverage. Cache the exact 

39 # subpixel phase; rounding it would change the preview at fractional sizes. 

40 x, y = math.floor(center.x()), math.floor(center.y()) 

41 path = QPainterPath(_local_outline(center.x() - x, center.y() - y, width)) 

42 path.translate(x, y) 

43 return path 

44 

45 

46@lru_cache(maxsize=64) 

47def _local_outline(phase_x, phase_y, width): 

48 image, rect = stamp_mask(QPointF(phase_x, phase_y), width) 

49 alpha = np.frombuffer(image.constBits(), np.uint8).reshape(image.height(), image.bytesPerLine())[:, 3::4] 

50 path = QPainterPath() 

51 for y, row in enumerate(alpha): 

52 edges = np.flatnonzero(np.diff(np.r_[False, row != 0, False])) 

53 for left, right in zip(edges[::2], edges[1::2]): 

54 path.addRect(float(rect.x() + left), float(rect.y() + y), float(right - left), 1.0) 

55 return path.simplified() 

56