mirror of
https://github.com/Ultimaker/Cura.git
synced 2026-01-06 22:57:47 -07:00
Make sure paint commands only change their feature
CURA-12731
This commit is contained in:
parent
a2db7b3004
commit
2eaa4fb820
5 changed files with 162 additions and 122 deletions
|
|
@ -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()))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
70
plugins/PaintTool/PaintStrokeCommand.py
Normal file
70
plugins/PaintTool/PaintStrokeCommand.py
Normal 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())))
|
||||
|
|
@ -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())))
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue