diff --git a/plugins/PaintTool/BrushColorButton.qml b/plugins/PaintTool/BrushColorButton.qml index ae4ab6243f..b62ab09e92 100644 --- a/plugins/PaintTool/BrushColorButton.qml +++ b/plugins/PaintTool/BrushColorButton.qml @@ -13,27 +13,6 @@ UM.ToolbarButton property string color - onClicked: setColor() - - function setColor() - { - UM.Controller.setProperty("BrushColor", buttonBrushColor.color); - } - - function isChecked() - { - return UM.Controller.properties.getValue("BrushColor") === buttonBrushColor.color; - } - - Component.onCompleted: - { - buttonBrushColor.checked = isChecked(); - } - - Binding - { - target: buttonBrushColor - property: "checked" - value: isChecked() - } + checked: UM.Controller.properties.getValue("BrushColor") === buttonBrushColor.color + onClicked: UM.Controller.setProperty("BrushColor", buttonBrushColor.color) } diff --git a/plugins/PaintTool/BrushShapeButton.qml b/plugins/PaintTool/BrushShapeButton.qml index ef4256792a..e05cd206f3 100644 --- a/plugins/PaintTool/BrushShapeButton.qml +++ b/plugins/PaintTool/BrushShapeButton.qml @@ -13,27 +13,6 @@ UM.ToolbarButton property int shape - onClicked: setShape() - - function setShape() - { - UM.Controller.setProperty("BrushShape", buttonBrushShape.shape) - } - - function isChecked() - { - return UM.Controller.properties.getValue("BrushShape") === buttonBrushShape.shape; - } - - Component.onCompleted: - { - buttonBrushShape.checked = isChecked(); - } - - Binding - { - target: buttonBrushShape - property: "checked" - value: isChecked() - } + checked: UM.Controller.properties.getValue("BrushShape") === buttonBrushShape.shape + onClicked: UM.Controller.setProperty("BrushShape", buttonBrushShape.shape) } diff --git a/plugins/PaintTool/PaintModeButton.qml b/plugins/PaintTool/PaintModeButton.qml index eb294f7ad6..833a009551 100644 --- a/plugins/PaintTool/PaintModeButton.qml +++ b/plugins/PaintTool/PaintModeButton.qml @@ -13,27 +13,6 @@ Cura.ModeSelectorButton property string mode - onClicked: setMode() - - function setMode() - { - UM.Controller.setProperty("PaintType", modeSelectorButton.mode); - } - - function isSelected() - { - return UM.Controller.properties.getValue("PaintType") === modeSelectorButton.mode; - } - - Component.onCompleted: - { - modeSelectorButton.selected = isSelected(); - } - - Binding - { - target: modeSelectorButton - property: "selected" - value: isSelected() - } + selected: UM.Controller.properties.getValue("PaintType") === modeSelectorButton.mode + onClicked: UM.Controller.setProperty("PaintType", modeSelectorButton.mode) } diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 82361e80ec..e67795301d 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -42,9 +42,13 @@ class PaintTool(Tool): PREPARING_MODEL = 1 # Model is being prepared (UV-unwrapping, texture generation) READY = 2 # Ready to paint ! - def __init__(self) -> None: + def __init__(self, view: PaintView) -> None: super().__init__() + self._view: PaintView = view + self._view.canUndoChanged.connect(self._onCanUndoChanged) + self._view.canRedoChanged.connect(self._onCanRedoChanged) + self._picking_pass: Optional[PickingPass] = None self._faces_selection_pass: Optional[SelectionPass] = None @@ -68,7 +72,7 @@ class PaintTool(Tool): self._state: PaintTool.Paint.State = PaintTool.Paint.State.MULTIPLE_SELECTION self._prepare_texture_job: Optional[PrepareTextureJob] = None - self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape", "State") + self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape", "State", "CanUndo", "CanRedo") self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects) self._controller.activeToolChanged.connect(self._updateState) @@ -108,19 +112,11 @@ class PaintTool(Tool): return stroke_image, (start_x, start_y) def getPaintType(self) -> str: - paint_view = self._get_paint_view() - if paint_view is None: - return "" - - return paint_view.getPaintType() + return self._view.getPaintType() def setPaintType(self, paint_type: str) -> None: - paint_view = self._get_paint_view() - if paint_view is None: - return - if paint_type != self.getPaintType(): - paint_view.setPaintType(paint_type) + self._view.setPaintType(paint_type) self._brush_pen = self._createBrushPen() self._updateScene() @@ -153,41 +149,37 @@ class PaintTool(Tool): self._brush_pen = self._createBrushPen() self.propertyChanged.emit() + def getCanUndo(self) -> bool: + return self._view.canUndo() + def getState(self) -> int: return self._state - def undoStackAction(self, redo_instead: bool) -> bool: - paint_view = self._get_paint_view() - if paint_view is None: - return False + def _onCanUndoChanged(self): + self.propertyChanged.emit() - if redo_instead: - paint_view.redoStroke() - else: - paint_view.undoStroke() + def getCanRedo(self) -> bool: + return self._view.canRedo() + def _onCanRedoChanged(self): + self.propertyChanged.emit() + + def undoStackAction(self) -> None: + self._view.undoStroke() + self._updateScene() + + def redoStackAction(self) -> None: + self._view.redoStroke() self._updateScene() - return True def clear(self) -> None: - paintview = self._get_paint_view() - if paintview is None: - return - - width, height = paintview.getUvTexDimensions() + width, height = self._view.getUvTexDimensions() clear_image = QImage(width, height, QImage.Format.Format_RGB32) clear_image.fill(Qt.GlobalColor.white) - paintview.addStroke(clear_image, 0, 0, "none") + self._view.addStroke(clear_image, 0, 0, "none", False) self._updateScene() - @staticmethod - def _get_paint_view() -> Optional[PaintView]: - paint_view = Application.getInstance().getController().getActiveView() - if paint_view is None or paint_view.getPluginId() != "PaintTool": - return None - return cast(PaintView, paint_view) - @staticmethod def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float: # compute the intersection of (param) A - pt with (param) B - (param) C @@ -327,10 +319,6 @@ class PaintTool(Tool): else: self._mouse_held = True - paintview = self._get_paint_view() - if paintview is None: - return False - if not self._faces_selection_pass: self._faces_selection_pass = CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces") if not self._faces_selection_pass: @@ -373,7 +361,7 @@ class PaintTool(Tool): (self._last_mouse_coords, (self._last_face_id, self._last_text_coords)), ((mouse_evt.x, mouse_evt.y), (face_id, texcoords))) - w, h = paintview.getUvTexDimensions() + w, h = self._view.getUvTexDimensions() for start_coords, end_coords in substrokes: sub_image, (start_x, start_y) = self._createStrokeImage( start_coords[0] * w, @@ -381,7 +369,7 @@ class PaintTool(Tool): end_coords[0] * w, end_coords[1] * h ) - paintview.addStroke(sub_image, start_x, start_y, self._brush_color) + self._view.addStroke(sub_image, start_x, start_y, self._brush_color, is_moved) self._last_text_coords = texcoords self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index 7b04f9e58d..548b6b047e 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -19,14 +19,16 @@ Item { id: undoAction shortcut: "Ctrl+L" - onTriggered: UM.Controller.triggerActionWithData("undoStackAction", false) + enabled: UM.Controller.properties.getValue("CanUndo") + onTriggered: UM.Controller.triggerAction("undoStackAction") } Action { id: redoAction shortcut: "Ctrl+Shift+L" - onTriggered: UM.Controller.triggerActionWithData("undoStackAction", true) + enabled: UM.Controller.properties.getValue("CanRedo") + onTriggered: UM.Controller.triggerAction("redoStackAction") } Column @@ -163,6 +165,7 @@ Item from: 10 to: 1000 + value: UM.Controller.properties.getValue("BrushSize") onPressedChanged: function(pressed) { @@ -171,11 +174,6 @@ Item UM.Controller.setProperty("BrushSize", shapeSizeSlider.value); } } - - Component.onCompleted: - { - shapeSizeSlider.value = UM.Controller.properties.getValue("BrushSize"); - } } //Line between the sections. @@ -192,6 +190,7 @@ Item { id: undoButton + enabled: undoAction.enabled text: catalog.i18nc("@action:button", "Undo Stroke") toolItem: UM.ColorImage { @@ -206,6 +205,7 @@ Item { id: redoButton + enabled: redoAction.enabled text: catalog.i18nc("@action:button", "Redo Stroke") toolItem: UM.ColorImage { diff --git a/plugins/PaintTool/PaintUndoCommand.py b/plugins/PaintTool/PaintUndoCommand.py new file mode 100644 index 0000000000..50bfb787b7 --- /dev/null +++ b/plugins/PaintTool/PaintUndoCommand.py @@ -0,0 +1,104 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from typing import cast, Optional + +from PyQt6.QtCore import QRect, QPoint +from PyQt6.QtGui import QUndoCommand, QImage, QPainter + +from UM.View.GL.Texture import Texture + + +class PaintUndoCommand(QUndoCommand): + """Provides the command that does the actual painting on objects with undo/redo mechanisms""" + + def __init__(self, + texture: Texture, + stroke_mask: QImage, + x: int, + y: int, + set_value: int, + bit_range: tuple[int, int], + mergeable: bool) -> None: + super().__init__() + + 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._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: + actual_image = self._texture.getImage() + + 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()) + painter = QPainter(stroked_image) + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) + painter.drawImage(0, 0, clear_bits_image) + painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) + painter.drawImage(0, 0, set_value_image) + painter.end() + + self._texture.setSubImage(stroked_image, self._x, self._y) + + 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) + + 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_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 + + return True diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 22629e340c..b3bc9867c3 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -2,10 +2,10 @@ # Cura is released under the terms of the LGPLv3 or higher. import os -from PyQt6.QtCore import QRect -from typing import Optional, List, Tuple, Dict, cast +from PyQt6.QtCore import QRect, pyqtSignal +from typing import Optional, Dict -from PyQt6.QtGui import QImage, QColor, QPainter +from PyQt6.QtGui import QImage, QUndoStack from cura.CuraApplication import CuraApplication from cura.BuildVolume import BuildVolume @@ -19,14 +19,14 @@ from UM.View.GL.OpenGL import OpenGL from UM.i18n import i18nCatalog from UM.Math.Color import Color +from .PaintUndoCommand import PaintUndoCommand + catalog = i18nCatalog("cura") class PaintView(CuraView): """View for model-painting.""" - UNDO_STACK_SIZE = 1024 - class PaintType: def __init__(self, display_color: Color, value: int): self.display_color: Color = display_color @@ -40,16 +40,24 @@ class PaintView(CuraView): self._current_paint_type = "" self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {} - self._stroke_undo_stack: List[Tuple[QImage, int, int]] = [] - self._stroke_redo_stack: List[Tuple[QImage, int, int]] = [] - - self._force_opaque_mask = QImage(2, 2, QImage.Format.Format_Mono) - self._force_opaque_mask.fill(1) + 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) application = CuraApplication.getInstance() application.engineCreatedSignal.connect(self._makePaintModes) self._scene = application.getController().getScene() + canUndoChanged = pyqtSignal(bool) + canRedoChanged = pyqtSignal(bool) + + def canUndo(self): + return self._paint_undo_stack.canUndo() + + def canRedo(self): + return self._paint_undo_stack.canRedo() + def _makePaintModes(self): theme = CuraApplication.getInstance().getTheme() usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0), @@ -67,76 +75,41 @@ class PaintView(CuraView): shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader") self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename) - def _forceOpaqueDeepCopy(self, image: QImage): - res = QImage(image.width(), image.height(), QImage.Format.Format_RGBA8888) - res.fill(QColor(255, 255, 255, 255)) - painter = QPainter(res) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) - painter.drawImage(0, 0, image) - painter.end() - res.setAlphaChannel(self._force_opaque_mask.scaled(image.width(), image.height())) - return res - - def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str) -> None: + def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, 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() - actual_image = self._current_paint_texture.getImage() + 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 << self._current_bits_ranges[0] - full_int32 = 0xffffffff - clear_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, stroke_mask.width(), stroke_mask.height()) + set_value = self._paint_modes[self._current_paint_type][brush_color].value << bit_range_start - clear_bits_image = stroke_mask.copy() - clear_bits_image.invertPixels() - painter = QPainter(clear_bits_image) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten) - painter.fillRect(image_rect, clear_mask) - painter.end() + self._paint_undo_stack.push(PaintUndoCommand(self._current_paint_texture, + stroke_mask, + start_x, + start_y, + set_value, + (bit_range_start, bit_range_end), + merge_with_previous)) - set_value_image = stroke_mask.copy() - painter = QPainter(set_value_image) - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply) - painter.fillRect(image_rect, set_value) - painter.end() + def undoStroke(self) -> None: + self._paint_undo_stack.undo() - stroked_image = actual_image.copy(start_x, start_y, stroke_mask.width(), stroke_mask.height()) - painter = QPainter(stroked_image) - painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination) - painter.drawImage(0, 0, clear_bits_image) - painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) - painter.drawImage(0, 0, set_value_image) - painter.end() - - self._stroke_redo_stack.clear() - if len(self._stroke_undo_stack) >= PaintView.UNDO_STACK_SIZE: - self._stroke_undo_stack.pop(0) - undo_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(stroked_image, start_x, start_y)) - if undo_image is not None: - self._stroke_undo_stack.append((undo_image, start_x, start_y)) - - def _applyUndoStacksAction(self, from_stack: List[Tuple[QImage, int, int]], to_stack: List[Tuple[QImage, int, int]]) -> bool: - if len(from_stack) <= 0 or self._current_paint_texture is None: - return False - from_image, x, y = from_stack.pop() - to_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(from_image, x, y)) - if to_image is None: - return False - if len(to_stack) >= PaintView.UNDO_STACK_SIZE: - to_stack.pop(0) - to_stack.append((to_image, x, y)) - return True - - def undoStroke(self) -> bool: - return self._applyUndoStacksAction(self._stroke_undo_stack, self._stroke_redo_stack) - - def redoStroke(self) -> bool: - return self._applyUndoStacksAction(self._stroke_redo_stack, self._stroke_undo_stack) + def redoStroke(self) -> None: + self._paint_undo_stack.redo() def getUvTexDimensions(self): if self._current_paint_texture is not None: diff --git a/plugins/PaintTool/__init__.py b/plugins/PaintTool/__init__.py index 93b47c7266..a95559ff0f 100644 --- a/plugins/PaintTool/__init__.py +++ b/plugins/PaintTool/__init__.py @@ -28,7 +28,8 @@ def getMetaData(): def register(app): qmlRegisterUncreatableType(PaintTool.PaintTool.Brush, "Cura", 1, 0, "This is an enumeration class", "PaintToolBrush") qmlRegisterUncreatableType(PaintTool.PaintTool.Paint, "Cura", 1, 0, "This is an enumeration class", "PaintToolState") + view = PaintView.PaintView() return { - "tool": PaintTool.PaintTool(), - "view": PaintView.PaintView() + "tool": PaintTool.PaintTool(view), + "view": view }