From 6cf1f2df2a5cf4bcc116f52ceca87d34114761ed Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Fri, 19 Sep 2025 16:01:41 +0200 Subject: [PATCH 1/5] Optimize painting display performance CURA-12731 --- plugins/PaintTool/PaintTool.py | 22 ++++--- plugins/PaintTool/PaintUndoCommand.py | 85 ++++++++++++--------------- plugins/PaintTool/PaintView.py | 62 +++++++++---------- 3 files changed, 79 insertions(+), 90 deletions(-) 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)) From 4c92ce664413d8034c9ea89ec9f6fd1210e187dc Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Fri, 19 Sep 2025 16:16:47 +0200 Subject: [PATCH 2/5] Fix crash after loading a second model for painting CURA-12731 --- plugins/PaintTool/PaintView.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index a767e9c726..c2eb81935c 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -237,7 +237,7 @@ class PaintView(CuraView): for node in Selection.getAllSelectedObjects(): paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) paint_texture = node.callDecoration("getPaintTexture") - if paint_texture != self._current_paint_texture: + if paint_texture != self._current_paint_texture and paint_texture is not None: self._current_paint_texture = paint_texture self._paint_shader.setTexture(0, self._current_paint_texture) From a2db7b3004f99a3a3f207cffe8bee3c0c73e6b8e Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 22 Sep 2025 11:40:44 +0200 Subject: [PATCH 3/5] Fix clear painting with optimized painting mechanisms CURA-12731 --- plugins/PaintTool/PaintClearCommand.py | 44 ++++++++++++++++++++++++++ plugins/PaintTool/PaintCommand.py | 22 +++++++++++++ plugins/PaintTool/PaintTool.py | 6 +--- plugins/PaintTool/PaintUndoCommand.py | 18 +++-------- plugins/PaintTool/PaintView.py | 8 +++++ 5 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 plugins/PaintTool/PaintClearCommand.py create mode 100644 plugins/PaintTool/PaintCommand.py diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py new file mode 100644 index 0000000000..ff69930027 --- /dev/null +++ b/plugins/PaintTool/PaintClearCommand.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from typing import Optional + +from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush + +from UM.View.GL.Texture import Texture + +from .PaintCommand import PaintCommand + + +class PaintClearCommand(PaintCommand): + """Provides the command that clear all the painting for the current mode""" + + def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None: + super().__init__(texture, bit_range) + + self._original_texture_image: Optional[QImage] = texture.getImage().copy() + + self._cleared_texture = self._original_texture_image.copy() + painter = QPainter(self._cleared_texture) + + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) + painter.fillRect(self._cleared_texture.rect(), QBrush(self._getClearTextureBitMask())) + + painter.end() + + def id(self) -> int: + return 1 + + def redo(self) -> None: + self._texture.setSubImage(self._cleared_texture, 0, 0) + + def undo(self) -> None: + self._texture.setSubImage(self._original_texture_image, 0, 0) + + def mergeWith(self, command: QUndoCommand) -> bool: + if not isinstance(command, PaintClearCommand): + return False + + # There is actually nothing more to do here, both clear commands already have the same original texture + return True diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py new file mode 100644 index 0000000000..8c78a13697 --- /dev/null +++ b/plugins/PaintTool/PaintCommand.py @@ -0,0 +1,22 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt6.QtGui import QUndoCommand + +from UM.View.GL.Texture import Texture + + +class PaintCommand(QUndoCommand): + """Provides a command that somehow modifies the actual painting on objects with undo/redo mechanisms""" + + def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None: + super().__init__() + + self._texture: Texture = texture + self._bit_range: tuple[int, int] = bit_range + + def _getClearTextureBitMask(self): + bit_range_start, bit_range_end = self._bit_range + full_int32 = 0xffffffff + return full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> + (32 - 1 - bit_range_end)) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 3275b2ef64..582fa35bd1 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -192,11 +192,7 @@ class PaintTool(Tool): self._updateScene() def clear(self) -> None: - width, height = self._view.getUvTexDimensions() - clear_image = QImage(width, height, QImage.Format.Format_RGB32) - clear_image.fill(Qt.GlobalColor.white) - self._view.addStroke(clear_image, 0, 0, "none" if self.getPaintType() != "extruder" else "0", False) - + self._view.clearPaint() self._updateScene() @staticmethod diff --git a/plugins/PaintTool/PaintUndoCommand.py b/plugins/PaintTool/PaintUndoCommand.py index 19e69eb147..321403a6de 100644 --- a/plugins/PaintTool/PaintUndoCommand.py +++ b/plugins/PaintTool/PaintUndoCommand.py @@ -4,14 +4,14 @@ from typing import cast, Optional import math -from PyQt6.QtCore import Qt, QRect, QPoint +from PyQt6.QtCore import QRect, QPoint from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush from UM.View.GL.Texture import Texture -from UM.Logger import Logger +from .PaintCommand import PaintCommand -class PaintUndoCommand(QUndoCommand): +class PaintUndoCommand(PaintCommand): """Provides the command that does the actual painting on objects with undo/redo mechanisms""" PEN_OVERLAP_WIDTH = 2.5 @@ -22,18 +22,15 @@ class PaintUndoCommand(QUndoCommand): set_value: int, bit_range: tuple[int, int], mergeable: bool) -> None: - super().__init__() + super().__init__(texture, bit_range) self._original_texture_image: Optional[QImage] = texture.getImage().copy() if not mergeable else None - self._texture: Texture = texture 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 def id(self) -> int: - # Since the undo stack will contain only commands of this type, we can use a fixed ID return 0 def redo(self) -> None: @@ -43,17 +40,12 @@ class PaintUndoCommand(QUndoCommand): 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)) - 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.setBrush(QBrush(self._getClearTextureBitMask())) painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) painter.drawPath(self._stroke_path) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index c2eb81935c..78c1fd8bd4 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -22,6 +22,7 @@ from UM.i18n import i18nCatalog from UM.Math.Color import Color from .PaintUndoCommand import PaintUndoCommand +from .PaintClearCommand import PaintClearCommand catalog = i18nCatalog("cura") @@ -178,6 +179,13 @@ class PaintView(CuraView): (bit_range_start, bit_range_end), merge_with_previous)) + def clearPaint(self): + if self._current_paint_texture is None or self._current_paint_texture.getImage() is None: + return + + self._prepareDataMapping() + self._paint_undo_stack.push(PaintClearCommand(self._current_paint_texture, self._current_bits_ranges)) + def undoStroke(self) -> None: self._paint_undo_stack.undo() From 2eaa4fb82051ece24b86dccc9d452fb9e80bdd15 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 22 Sep 2025 14:34:39 +0200 Subject: [PATCH 4/5] Make sure paint commands only change their feature CURA-12731 --- plugins/PaintTool/PaintClearCommand.py | 23 +++---- plugins/PaintTool/PaintCommand.py | 62 ++++++++++++++++-- plugins/PaintTool/PaintStrokeCommand.py | 70 ++++++++++++++++++++ plugins/PaintTool/PaintUndoCommand.py | 85 ------------------------- plugins/PaintTool/PaintView.py | 44 ++++++++----- 5 files changed, 162 insertions(+), 122 deletions(-) create mode 100644 plugins/PaintTool/PaintStrokeCommand.py delete mode 100644 plugins/PaintTool/PaintUndoCommand.py diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index ff69930027..7ee8993bd3 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -11,30 +11,19 @@ from .PaintCommand import PaintCommand class PaintClearCommand(PaintCommand): - """Provides the command that clear all the painting for the current mode""" + """Provides the command that clears all the painting for the current mode""" def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None: super().__init__(texture, bit_range) - self._original_texture_image: Optional[QImage] = texture.getImage().copy() - - self._cleared_texture = self._original_texture_image.copy() - painter = QPainter(self._cleared_texture) - - painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) - painter.fillRect(self._cleared_texture.rect(), QBrush(self._getClearTextureBitMask())) - - painter.end() - def id(self) -> int: return 1 def redo(self) -> None: - self._texture.setSubImage(self._cleared_texture, 0, 0) + cleared_image, painter = self._makeClearedTexture() + painter.end() - def undo(self) -> None: - self._texture.setSubImage(self._original_texture_image, 0, 0) + self._texture.setSubImage(cleared_image, 0, 0) def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintClearCommand): @@ -42,3 +31,7 @@ class PaintClearCommand(PaintCommand): # There is actually nothing more to do here, both clear commands already have the same original texture return True + + def _clearTextureBits(self, painter: QPainter): + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination) + painter.fillRect(self._texture.getImage().rect(), QBrush(self._getBitRangeMask())) \ No newline at end of file diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index 8c78a13697..38f1dbc6e4 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -1,7 +1,10 @@ # Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. -from PyQt6.QtGui import QUndoCommand +from typing import Tuple, Optional + +from PyQt6.QtCore import QRect +from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush from UM.View.GL.Texture import Texture @@ -9,14 +12,61 @@ from UM.View.GL.Texture import Texture class PaintCommand(QUndoCommand): """Provides a command that somehow modifies the actual painting on objects with undo/redo mechanisms""" - def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None: + FULL_INT32 = 0xffffffff + + def __init__(self, texture: Texture, bit_range: tuple[int, int], make_original_image = True) -> None: super().__init__() self._texture: Texture = texture self._bit_range: tuple[int, int] = bit_range + self._original_texture_image = None + self._bounding_rect = texture.getImage().rect() - def _getClearTextureBitMask(self): + if make_original_image: + self._original_texture_image, painter = ( + self._preparePainting(specific_source_image=self._texture.getImage().copy(), + specific_bounding_rect=self._texture.getImage().rect())) + + # Keep only the bits contained in the bit range, so that we won't modify anything else in the image + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) + painter.fillRect(self._original_texture_image.rect(), QBrush(self._getBitRangeMask())) + painter.end() + + def undo(self) -> None: + if self._original_texture_image is None: + return + + cleared_image, painter = self._makeClearedTexture() + + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) + painter.drawImage(0, 0, self._original_texture_image) + + painter.end() + + self._texture.setSubImage(cleared_image, self._bounding_rect.left(), self._bounding_rect.top()) + + def _makeClearedTexture(self) -> Tuple[QImage, QPainter]: + dest_image, painter = self._preparePainting() + self._clearTextureBits(painter) + return dest_image, painter + + def _clearTextureBits(self, painter: QPainter): + raise NotImplementedError() + + def _getBitRangeMask(self) -> int: bit_range_start, bit_range_end = self._bit_range - full_int32 = 0xffffffff - return full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> - (32 - 1 - bit_range_end)) + return (((PaintCommand.FULL_INT32 << (32 - 1 - (bit_range_end - bit_range_start))) & PaintCommand.FULL_INT32) >> + (32 - 1 - bit_range_end)) + + def _preparePainting(self, + specific_source_image: Optional[QImage] = None, + specific_bounding_rect: Optional[QRect] = None) -> Tuple[QImage, QPainter]: + source_image = specific_source_image if specific_source_image is not None else self._texture.getImage() + bounding_rect = specific_bounding_rect if specific_bounding_rect is not None else self._bounding_rect + + dest_image = source_image.copy(bounding_rect) + painter = QPainter(dest_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.translate(-bounding_rect.left(), -bounding_rect.top()) + + return dest_image, painter diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py new file mode 100644 index 0000000000..969d5d8a81 --- /dev/null +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -0,0 +1,70 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from typing import cast, Optional +import math + +from PyQt6.QtCore import QRect, QRectF, QPoint +from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush + +from UM.View.GL.Texture import Texture + +from .PaintCommand import PaintCommand + +class PaintStrokeCommand(PaintCommand): + """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_path: QPainterPath, + set_value: int, + bit_range: tuple[int, int], + mergeable: bool) -> None: + super().__init__(texture, bit_range, make_original_image = not mergeable) + + self._stroke_path: QPainterPath = stroke_path + self._calculateBoundingRect() + self._set_value: int = set_value + self._mergeable: bool = mergeable + + def id(self) -> int: + return 0 + + def redo(self) -> None: + stroked_image, painter = self._makeClearedTexture() + + painter.setBrush(QBrush(self._set_value)) + painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) + painter.drawPath(self._stroke_path) + + painter.end() + + self._texture.setSubImage(stroked_image, self._bounding_rect.left(), self._bounding_rect.top()) + + def mergeWith(self, command: QUndoCommand) -> bool: + if not isinstance(command, PaintStrokeCommand): + return False + paint_undo_command = cast(PaintStrokeCommand, command) + + if not paint_undo_command._mergeable: + return False + + self._stroke_path = self._stroke_path.united(paint_undo_command._stroke_path) + self._calculateBoundingRect() + + return True + + def _clearTextureBits(self, painter: QPainter): + painter.setBrush(QBrush(self._getBitRangeMask())) + painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination) + painter.drawPath(self._stroke_path) + + def _calculateBoundingRect(self): + bounding_rect: QRectF = self._stroke_path.boundingRect() + self._bounding_rect = QRect( + QPoint(math.floor(bounding_rect.left()), math.floor(bounding_rect.top())), + QPoint(math.ceil(bounding_rect.right()), math.ceil(bounding_rect.bottom()))) \ No newline at end of file diff --git a/plugins/PaintTool/PaintUndoCommand.py b/plugins/PaintTool/PaintUndoCommand.py deleted file mode 100644 index 321403a6de..0000000000 --- a/plugins/PaintTool/PaintUndoCommand.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) 2025 UltiMaker -# 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, QPainterPath, QPen, QBrush - -from UM.View.GL.Texture import Texture - -from .PaintCommand import PaintCommand - -class PaintUndoCommand(PaintCommand): - """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_path: QPainterPath, - set_value: int, - bit_range: tuple[int, int], - mergeable: bool) -> None: - super().__init__(texture, bit_range) - - self._original_texture_image: Optional[QImage] = texture.getImage().copy() if not mergeable else None - self._stroke_path: QPainterPath = stroke_path - self._calculateBoundingRect() - self._set_value: int = set_value - self._mergeable: bool = mergeable - - def id(self) -> int: - return 0 - - 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()))) - - 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(self._getClearTextureBitMask())) - painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) - painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) - 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.drawPath(self._stroke_path) - - painter.end() - - 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._bounding_rect_rounded), - self._bounding_rect_rounded.left(), - self._bounding_rect_rounded.top()) - - def mergeWith(self, command: QUndoCommand) -> bool: - if not isinstance(command, PaintUndoCommand): - return False - paint_undo_command = cast(PaintUndoCommand, command) - - if not paint_undo_command._mergeable: - return False - - 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 78c1fd8bd4..e8e56336e4 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -21,7 +21,7 @@ from UM.View.GL.OpenGL import OpenGL from UM.i18n import i18nCatalog from UM.Math.Color import Color -from .PaintUndoCommand import PaintUndoCommand +from .PaintStrokeCommand import PaintStrokeCommand from .PaintClearCommand import PaintClearCommand catalog = i18nCatalog("cura") @@ -44,11 +44,7 @@ class PaintView(CuraView): self._current_bits_ranges: tuple[int, int] = (0, 0) self._current_paint_type = "" self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {} - - self._paint_undo_stack: QUndoStack = QUndoStack() - self._paint_undo_stack.setUndoLimit(32) # Set a quite low amount since every command copies the full texture - self._paint_undo_stack.canUndoChanged.connect(self.canUndoChanged) - self._paint_undo_stack.canRedoChanged.connect(self.canRedoChanged) + self._paint_undo_stacks: Dict[str, QUndoStack] = {} application = CuraApplication.getInstance() application.engineCreatedSignal.connect(self._makePaintModes) @@ -60,10 +56,12 @@ class PaintView(CuraView): canRedoChanged = pyqtSignal(bool) def canUndo(self): - return self._paint_undo_stack.canUndo() + return (self._current_paint_type in self._paint_undo_stacks and + self._paint_undo_stacks[self._current_paint_type].canUndo()) def canRedo(self): - return self._paint_undo_stack.canRedo() + return (self._current_paint_type in self._paint_undo_stacks and + self._paint_undo_stacks[self._current_paint_type].canRedo()) def _makePaintModes(self): application = CuraApplication.getInstance() @@ -169,28 +167,34 @@ class PaintView(CuraView): return self._prepareDataMapping() + self._prepareUndoRedoStack() 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_path, - set_value, - (bit_range_start, bit_range_end), - merge_with_previous)) + self._paint_undo_stacks[self._current_paint_type].push( + PaintStrokeCommand(self._current_paint_texture, + stroke_path, + set_value, + (bit_range_start, bit_range_end), + merge_with_previous)) def clearPaint(self): if self._current_paint_texture is None or self._current_paint_texture.getImage() is None: return self._prepareDataMapping() - self._paint_undo_stack.push(PaintClearCommand(self._current_paint_texture, self._current_bits_ranges)) + self._prepareUndoRedoStack() + self._paint_undo_stacks[self._current_paint_type].push( + PaintClearCommand(self._current_paint_texture, self._current_bits_ranges)) def undoStroke(self) -> None: - self._paint_undo_stack.undo() + if self._current_paint_type in self._paint_undo_stacks: + self._paint_undo_stacks[self._current_paint_type].undo() def redoStroke(self) -> None: - self._paint_undo_stack.redo() + if self._current_paint_type in self._paint_undo_stacks: + self._paint_undo_stacks[self._current_paint_type].redo() def getUvTexDimensions(self) -> Tuple[int, int]: if self._current_paint_texture is not None: @@ -204,6 +208,14 @@ class PaintView(CuraView): self._current_paint_type = paint_type self._prepareDataMapping() + def _prepareUndoRedoStack(self): + if self._current_paint_type not in self._paint_undo_stacks: + stack: QUndoStack = QUndoStack() + stack.setUndoLimit(32) # Set a quite low amount since some commands copy the full texture + stack.canUndoChanged.connect(self.canUndoChanged) + stack.canRedoChanged.connect(self.canRedoChanged) + self._paint_undo_stacks[self._current_paint_type] = stack + def _prepareDataMapping(self): node = Selection.getAllSelectedObjects()[0] if node is None: From b3ea6742513ecfe15d4cb09578efaaede9c637f7 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 22 Sep 2025 15:54:32 +0200 Subject: [PATCH 5/5] Use per-object undo-redo stacks for painting CURA-12731 --- plugins/PaintTool/PaintTool.py | 4 +- plugins/PaintTool/PaintView.py | 81 ++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 582fa35bd1..07d619fde2 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -471,7 +471,9 @@ class PaintTool(Tool): def _onSelectionChanged(self): super()._onSelectionChanged() - self.setActiveView("PaintTool" if len(Selection.getAllSelectedObjects()) == 1 else None) + single_selection = len(Selection.getAllSelectedObjects()) == 1 + self.setActiveView("PaintTool" if single_selection else None) + self._view.setCurrentPaintedObject(Selection.getSelectedObject(0) if single_selection else None) self._updateState() def _updateState(self): diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index e8e56336e4..4a98fe8217 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -8,6 +8,7 @@ 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 UM.Scene.SceneNode import SceneNode from cura.CuraApplication import CuraApplication from cura.BuildVolume import BuildVolume from cura.CuraView import CuraView @@ -39,29 +40,58 @@ class PaintView(CuraView): super().__init__(use_empty_menu_placeholder = True) self._paint_shader: Optional[ShaderProgram] = None self._current_paint_texture: Optional[Texture] = None + self._current_painted_object: Optional[SceneNode] = 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 = "" self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {} - self._paint_undo_stacks: Dict[str, QUndoStack] = {} + self._paint_undo_stacks: Dict[Tuple[SceneNode, str], QUndoStack] = {} application = CuraApplication.getInstance() application.engineCreatedSignal.connect(self._makePaintModes) self._scene = application.getController().getScene() + self._scene.getRoot().childrenChanged.connect(self._onChildrenChanged) self._extruders_model: Optional[ExtrudersModel] = None canUndoChanged = pyqtSignal(bool) canRedoChanged = pyqtSignal(bool) + def setCurrentPaintedObject(self, current_painted_object: Optional[SceneNode]): + self._current_painted_object = current_painted_object + def canUndo(self): - return (self._current_paint_type in self._paint_undo_stacks and - self._paint_undo_stacks[self._current_paint_type].canUndo()) + stack = self._getUndoStack() + return stack.canUndo() if stack is not None else False def canRedo(self): - return (self._current_paint_type in self._paint_undo_stacks and - self._paint_undo_stacks[self._current_paint_type].canRedo()) + stack = self._getUndoStack() + return stack.canRedo() if stack is not None else False + + def _getUndoStack(self): + if self._current_painted_object is None or self._current_paint_type == "": + return None + + try: + return self._paint_undo_stacks[(self._current_painted_object, self._current_paint_type)] + except KeyError: + return None + + def _onChildrenChanged(self, root_node: SceneNode): + # Gather all the actual nodes that have one or more undo stacks + stacks_keys = {} + for painted_object, paint_mode in self._paint_undo_stacks: + if painted_object in stacks_keys: + stacks_keys[painted_object].append(paint_mode) + else: + stacks_keys[painted_object] = [paint_mode] + + # Now see if any of the nodes have been deleted, i.e. they are no more linked to the root + for painted_object, paint_modes in stacks_keys.items(): + if painted_object.getDepth() == 0: + for paint_mode in paint_modes: + del self._paint_undo_stacks[(painted_object, paint_mode)] def _makePaintModes(self): application = CuraApplication.getInstance() @@ -167,34 +197,34 @@ class PaintView(CuraView): return self._prepareDataMapping() - self._prepareUndoRedoStack() + stack = self._prepareUndoRedoStack() 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_stacks[self._current_paint_type].push( - PaintStrokeCommand(self._current_paint_texture, - stroke_path, - set_value, - (bit_range_start, bit_range_end), - merge_with_previous)) + stack.push(PaintStrokeCommand(self._current_paint_texture, + stroke_path, + set_value, + (bit_range_start, bit_range_end), + merge_with_previous)) def clearPaint(self): if self._current_paint_texture is None or self._current_paint_texture.getImage() is None: return self._prepareDataMapping() - self._prepareUndoRedoStack() - self._paint_undo_stacks[self._current_paint_type].push( - PaintClearCommand(self._current_paint_texture, self._current_bits_ranges)) + stack = self._prepareUndoRedoStack() + stack.push(PaintClearCommand(self._current_paint_texture, self._current_bits_ranges)) def undoStroke(self) -> None: - if self._current_paint_type in self._paint_undo_stacks: - self._paint_undo_stacks[self._current_paint_type].undo() + stack = self._getUndoStack() + if stack is not None: + stack.undo() def redoStroke(self) -> None: - if self._current_paint_type in self._paint_undo_stacks: - self._paint_undo_stacks[self._current_paint_type].redo() + stack = self._getUndoStack() + if stack is not None: + stack.redo() def getUvTexDimensions(self) -> Tuple[int, int]: if self._current_paint_texture is not None: @@ -208,13 +238,17 @@ class PaintView(CuraView): self._current_paint_type = paint_type self._prepareDataMapping() - def _prepareUndoRedoStack(self): - if self._current_paint_type not in self._paint_undo_stacks: + def _prepareUndoRedoStack(self) -> QUndoStack: + stack_key = (self._current_painted_object, self._current_paint_type) + + if stack_key not in self._paint_undo_stacks: stack: QUndoStack = QUndoStack() stack.setUndoLimit(32) # Set a quite low amount since some commands copy the full texture stack.canUndoChanged.connect(self.canUndoChanged) stack.canRedoChanged.connect(self.canRedoChanged) - self._paint_undo_stacks[self._current_paint_type] = stack + self._paint_undo_stacks[stack_key] = stack + + return self._paint_undo_stacks[stack_key] def _prepareDataMapping(self): node = Selection.getAllSelectedObjects()[0] @@ -259,14 +293,13 @@ class PaintView(CuraView): paint_texture = node.callDecoration("getPaintTexture") if paint_texture != self._current_paint_texture and paint_texture is not None: self._current_paint_texture = paint_texture - self._paint_shader.setTexture(0, self._current_paint_texture) self._cursor_texture = OpenGL.getInstance().createTexture(paint_texture.getWidth(), paint_texture.getHeight()) + self._paint_shader.setTexture(0, self._current_paint_texture) image = QImage(paint_texture.getWidth(), paint_texture.getHeight(), QImage.Format.Format_ARGB32) image.fill(0) self._cursor_texture.setImage(image) self._paint_shader.setTexture(1, self._cursor_texture) - self._previous_paint_texture_stroke = None self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0]) self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])