Make sure paint commands only change their feature
Some checks are pending
conan-package-resources / conan-package (push) Waiting to run
conan-package-resources / signal-curator (push) Blocked by required conditions
conan-package / conan-package (push) Waiting to run
unit-test / Run unit tests (push) Waiting to run

CURA-12731
This commit is contained in:
Erwan MATHIEU 2025-09-22 14:34:39 +02:00
parent a2db7b3004
commit 2eaa4fb820
5 changed files with 162 additions and 122 deletions

View file

@ -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()))

View file

@ -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

View file

@ -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())))

View file

@ -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())))

View file

@ -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: