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: