Merge pull request #20842 from Ultimaker/CURA-12661_make-more-global-paint-undo
Some checks failed
conan-package / conan-package (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
unit-test / Run unit tests (push) Has been cancelled

CURA-12661 make more global paint undo
This commit is contained in:
HellAholic 2025-08-26 13:06:04 +02:00 committed by GitHub
commit 8aee04a968
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 191 additions and 188 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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