diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 18be2ed880..3275b2ef64 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -5,7 +5,7 @@ import math from enum import IntEnum import numpy from PyQt6.QtCore import Qt, QObject, pyqtEnum, QPointF -from PyQt6.QtGui import QImage, QPainter, QPen, QBrush, QPolygonF +from PyQt6.QtGui import QImage, QPainter, QPen, QBrush, QPolygonF, QPainterPath from typing import cast, Optional, Tuple, List from UM.Application import Application @@ -111,8 +111,16 @@ class PaintTool(Tool): Logger.error(f"Unknown brush shape '{self._brush_shape}', painting may not work.") return pen - def _createStrokeImage(self, polys: List[Polygon]) -> Tuple[QImage, Tuple[int, int]]: - return PaintTool._rasterizePolygons(polys, self._brush_pen, QBrush(self._brush_pen.color())) + def _createStrokePath(self, polygons: List[Polygon]) -> QPainterPath: + path = QPainterPath() + + for polygon in polygons: + path.moveTo(polygon[0][0], polygon[0][1]) + for point in polygon: + path.lineTo(point[0], point[1]) + path.closeSubpath() + + return path def getPaintType(self) -> str: return self._view.getPaintType() @@ -434,8 +442,8 @@ class PaintTool(Tool): brush_color = self._brush_color if self.getPaintType() != "extruder" else str(self._brush_extruder) uv_areas_cursor = self._getUvAreasForStroke(world_coords, world_coords) if len(uv_areas_cursor) > 0: - cursor_stroke_img, (start_x, start_y) = self._createStrokeImage(uv_areas_cursor) - self._view.setCursorStroke(cursor_stroke_img, start_x, start_y, brush_color) + cursor_path = self._createStrokePath(uv_areas_cursor) + self._view.setCursorStroke(cursor_path, brush_color) else: self._view.clearCursorStroke() @@ -443,8 +451,8 @@ class PaintTool(Tool): uv_areas = self._getUvAreasForStroke(self._last_world_coords, world_coords) if len(uv_areas) == 0: return False - stroke_img, (start_x, start_y) = self._createStrokeImage(uv_areas) - self._view.addStroke(stroke_img, start_x, start_y, brush_color, is_moved) + stroke_path = self._createStrokePath(uv_areas) + self._view.addStroke(stroke_path, brush_color, is_moved) except: Logger.logException("e", "Error when adding paint stroke") diff --git a/plugins/PaintTool/PaintUndoCommand.py b/plugins/PaintTool/PaintUndoCommand.py index 50bfb787b7..19e69eb147 100644 --- a/plugins/PaintTool/PaintUndoCommand.py +++ b/plugins/PaintTool/PaintUndoCommand.py @@ -2,21 +2,23 @@ # Cura is released under the terms of the LGPLv3 or higher. from typing import cast, Optional +import math -from PyQt6.QtCore import QRect, QPoint -from PyQt6.QtGui import QUndoCommand, QImage, QPainter +from PyQt6.QtCore import Qt, QRect, QPoint +from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush from UM.View.GL.Texture import Texture +from UM.Logger import Logger class PaintUndoCommand(QUndoCommand): """Provides the command that does the actual painting on objects with undo/redo mechanisms""" + PEN_OVERLAP_WIDTH = 2.5 + def __init__(self, texture: Texture, - stroke_mask: QImage, - x: int, - y: int, + stroke_path: QPainterPath, set_value: int, bit_range: tuple[int, int], mergeable: bool) -> None: @@ -24,9 +26,8 @@ class PaintUndoCommand(QUndoCommand): self._original_texture_image: Optional[QImage] = texture.getImage().copy() if not mergeable else None self._texture: Texture = texture - self._stroke_mask: QImage = stroke_mask - self._x: int = x - self._y: int = y + self._stroke_path: QPainterPath = stroke_path + self._calculateBoundingRect() self._set_value: int = set_value self._bit_range: tuple[int, int] = bit_range self._mergeable: bool = mergeable @@ -38,43 +39,39 @@ class PaintUndoCommand(QUndoCommand): def redo(self) -> None: actual_image = self._texture.getImage() + bounding_rect = self._stroke_path.boundingRect() + bounding_rect_rounded = QRect(QPoint(math.floor(bounding_rect.left()), math.floor(bounding_rect.top())), + QPoint(math.ceil(bounding_rect.right()), math.ceil(bounding_rect.bottom()))) + bit_range_start, bit_range_end = self._bit_range full_int32 = 0xffffffff clear_texture_bit_mask = full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> ( 32 - 1 - bit_range_end)) - image_rect = QRect(0, 0, self._stroke_mask.width(), self._stroke_mask.height()) - clear_bits_image = self._stroke_mask.copy() - clear_bits_image.invertPixels() - painter = QPainter(clear_bits_image) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten) - painter.fillRect(image_rect, clear_texture_bit_mask) - painter.end() - - set_value_image = self._stroke_mask.copy() - painter = QPainter(set_value_image) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply) - painter.fillRect(image_rect, self._set_value) - painter.end() - - stroked_image = actual_image.copy(self._x, self._y, self._stroke_mask.width(), self._stroke_mask.height()) + stroked_image = actual_image.copy(bounding_rect_rounded) painter = QPainter(stroked_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.translate(-bounding_rect.left(), -bounding_rect.top()) + + painter.setBrush(QBrush(clear_texture_bit_mask)) + painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) - painter.drawImage(0, 0, clear_bits_image) + painter.drawPath(self._stroke_path) + + painter.setBrush(QBrush(self._set_value)) + painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) - painter.drawImage(0, 0, set_value_image) + painter.drawPath(self._stroke_path) + painter.end() - self._texture.setSubImage(stroked_image, self._x, self._y) + self._texture.setSubImage(stroked_image, bounding_rect_rounded.left(), bounding_rect_rounded.top()) def undo(self) -> None: if self._original_texture_image is not None: - self._texture.setSubImage(self._original_texture_image.copy(self._x, - self._y, - self._stroke_mask.width(), - self._stroke_mask.height()), - self._x, - self._y) + self._texture.setSubImage(self._original_texture_image.copy(self._bounding_rect_rounded), + self._bounding_rect_rounded.left(), + self._bounding_rect_rounded.top()) def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintUndoCommand): @@ -84,21 +81,13 @@ class PaintUndoCommand(QUndoCommand): if not paint_undo_command._mergeable: return False - self_rect = QRect(QPoint(self._x, self._y), self._stroke_mask.size()) - command_rect = QRect(QPoint(paint_undo_command._x, paint_undo_command._y), paint_undo_command._stroke_mask.size()) - bounding_rect = self_rect.united(command_rect) - - merged_mask = QImage(bounding_rect.width(), bounding_rect.height(), self._stroke_mask.format()) - merged_mask.fill(0) - - painter = QPainter(merged_mask) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten) - painter.drawImage(self._x - bounding_rect.x(), self._y - bounding_rect.y(), self._stroke_mask) - painter.drawImage(paint_undo_command._x - bounding_rect.x(), paint_undo_command._y - bounding_rect.y(), paint_undo_command._stroke_mask) - painter.end() - - self._x = bounding_rect.x() - self._y = bounding_rect.y() - self._stroke_mask = merged_mask + self._stroke_path = self._stroke_path.united(paint_undo_command._stroke_path) + self._calculateBoundingRect() return True + + def _calculateBoundingRect(self): + self._bounding_rect = self._stroke_path.boundingRect() + self._bounding_rect_rounded = QRect( + QPoint(math.floor(self._bounding_rect.left()), math.floor(self._bounding_rect.top())), + QPoint(math.ceil(self._bounding_rect.right()), math.ceil(self._bounding_rect.bottom()))) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 0fa9cecba4..a767e9c726 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -2,9 +2,10 @@ # Cura is released under the terms of the LGPLv3 or higher. import os +import math -from PyQt6.QtCore import QRect, pyqtSignal -from PyQt6.QtGui import QImage, QUndoStack, QPainter, QColor +from PyQt6.QtCore import QRect, pyqtSignal, Qt, QPoint +from PyQt6.QtGui import QImage, QUndoStack, QPainter, QColor, QPainterPath, QBrush, QPen from typing import Optional, List, Tuple, Dict from cura.CuraApplication import CuraApplication @@ -37,7 +38,7 @@ class PaintView(CuraView): super().__init__(use_empty_menu_placeholder = True) self._paint_shader: Optional[ShaderProgram] = None self._current_paint_texture: Optional[Texture] = None - self._previous_paint_texture_stroke: Optional[QRect] = None + self._previous_paint_texture_rect: Optional[QRect] = None self._cursor_texture: Optional[Texture] = None self._current_bits_ranges: tuple[int, int] = (0, 0) self._current_paint_type = "" @@ -116,72 +117,63 @@ class PaintView(CuraView): shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader") self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename) - def setCursorStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str): + def setCursorStroke(self, cursor_path: QPainterPath, brush_color: str): if self._cursor_texture is None or self._cursor_texture.getImage() is None: return self.clearCursorStroke() - stroke_image = stroke_mask.copy() - alpha_mask = stroke_image.convertedTo(QImage.Format.Format_Mono) - stroke_image.setAlphaChannel(alpha_mask) + bounding_rect = cursor_path.boundingRect() + bounding_rect_rounded = QRect( + QPoint(math.floor(bounding_rect.left()), math.floor(bounding_rect.top())), + QPoint(math.ceil(bounding_rect.right()), math.ceil(bounding_rect.bottom()))) - painter = QPainter(stroke_image) + cursor_image = QImage(bounding_rect_rounded.width(), bounding_rect_rounded.height(), QImage.Format.Format_ARGB32) + cursor_image.fill(0) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceAtop) + painter = QPainter(cursor_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.translate(-bounding_rect.left(), -bounding_rect.top()) display_color = self._paint_modes[self._current_paint_type][brush_color].display_color paint_color = QColor(*[int(color_part * 255) for color_part in [display_color.r, display_color.g, display_color.b]]) paint_color.setAlpha(255) - painter.fillRect(0, 0, stroke_mask.width(), stroke_mask.height(), paint_color) + painter.setBrush(QBrush(paint_color)) + painter.setPen(QPen(Qt.PenStyle.NoPen)) + painter.drawPath(cursor_path) painter.end() - self._cursor_texture.setSubImage(stroke_image, start_x, start_y) + self._cursor_texture.setSubImage(cursor_image, bounding_rect_rounded.left(), bounding_rect_rounded.top()) - self._previous_paint_texture_stroke = QRect(start_x, start_y, stroke_mask.width(), stroke_mask.height()) + self._previous_paint_texture_rect = bounding_rect_rounded def clearCursorStroke(self) -> bool: - if (self._previous_paint_texture_stroke is None or + if (self._previous_paint_texture_rect is None or self._cursor_texture is None or self._cursor_texture.getImage() is None): return False - clear_image = QImage(self._previous_paint_texture_stroke.width(), - self._previous_paint_texture_stroke.height(), + clear_image = QImage(self._previous_paint_texture_rect.width(), + self._previous_paint_texture_rect.height(), QImage.Format.Format_ARGB32) clear_image.fill(0) self._cursor_texture.setSubImage(clear_image, - self._previous_paint_texture_stroke.x(), - self._previous_paint_texture_stroke.y()) - self._previous_paint_texture_stroke = None + self._previous_paint_texture_rect.left(), + self._previous_paint_texture_rect.top()) + self._previous_paint_texture_rect = None return True - def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str, merge_with_previous: bool) -> None: + def addStroke(self, stroke_path: QPainterPath, brush_color: str, merge_with_previous: bool) -> None: if self._current_paint_texture is None or self._current_paint_texture.getImage() is None: return self._prepareDataMapping() - current_image = self._current_paint_texture.getImage() - texture_rect = QRect(0, 0, current_image.width(), current_image.height()) - stroke_rect = QRect(start_x, start_y, stroke_mask.width(), stroke_mask.height()) - intersect_rect = texture_rect.intersected(stroke_rect) - if intersect_rect != stroke_rect: - # Stroke doesn't fully fit into the image, we have to crop it - stroke_mask = stroke_mask.copy(intersect_rect.x() - start_x, - intersect_rect.y() - start_y, - intersect_rect.width(), - intersect_rect.height()) - start_x = intersect_rect.x() - start_y = intersect_rect.y() - bit_range_start, bit_range_end = self._current_bits_ranges set_value = self._paint_modes[self._current_paint_type][brush_color].value << bit_range_start self._paint_undo_stack.push(PaintUndoCommand(self._current_paint_texture, - stroke_mask, - start_x, - start_y, + stroke_path, set_value, (bit_range_start, bit_range_end), merge_with_previous))