PaintTool: Undo/Redo should be working now.

Also fix missing pen-shape I suppose.

part of CURA-12543
This commit is contained in:
Remco Burema 2025-05-28 16:43:33 +02:00
parent 4e5b0115ea
commit c9ca999f10
3 changed files with 128 additions and 10 deletions

View File

@ -41,9 +41,12 @@ class PaintTool(Tool):
self._brush_size: int = 10
self._brush_color: str = "A"
self._brush_shape: str = "A"
self._brush_pen: Optional[QPen] = None
self._brush_pen: QPen = self._createBrushPen()
self._mouse_held: bool = False
self._ctrl_held: bool = False
self._shift_held: bool = False
self._last_text_coords: Optional[Tuple[int, int]] = None
def _createBrushPen(self) -> QPen:
@ -96,6 +99,20 @@ class PaintTool(Tool):
self._brush_shape = brush_shape
self._brush_pen = self._createBrushPen()
def undoStackAction(self, redo_instead: bool) -> bool:
paintview = Application.getInstance().getController().getActiveView()
if paintview is None or paintview.getPluginId() != "PaintTool":
return False
paintview = cast(PaintView, paintview)
if redo_instead:
paintview.redoStroke()
else:
paintview.undoStroke()
nodes = Selection.getAllSelectedObjects()
if len(nodes) > 0:
Application.getInstance().getController().getScene().sceneChanged.emit(nodes[0])
return True
@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
@ -154,6 +171,10 @@ class PaintTool(Tool):
super().event(event)
controller = Application.getInstance().getController()
nodes = Selection.getAllSelectedObjects()
if len(nodes) <= 0:
return False
node = nodes[0]
# Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes
if event.type == Event.ToolActivateEvent:
@ -166,7 +187,27 @@ class PaintTool(Tool):
controller.setActiveView("SolidView")
return True
if event.type == Event.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey:
if event.type == Event.KeyPressEvent:
evt = cast(KeyEvent, event)
if evt.key == KeyEvent.ControlKey:
self._ctrl_held = True
return True
if evt.key == KeyEvent.ShiftKey:
self._shift_held = True
return True
return False
if event.type == Event.KeyReleaseEvent:
evt = cast(KeyEvent, event)
if evt.key == KeyEvent.ControlKey:
self._ctrl_held = False
return True
if evt.key == KeyEvent.ShiftKey:
self._shift_held = False
return True
if evt.key == Qt.Key.Key_L and self._ctrl_held:
# NOTE: Ctrl-L is used here instead of Ctrl-Z, as the latter is the application-wide one that takes precedence.
return self.undoStackAction(self._shift_held)
return False
if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled():
@ -201,10 +242,6 @@ class PaintTool(Tool):
if not camera:
return False
node = Selection.getAllSelectedObjects()[0]
if node is None:
return False
if node != self._node_cache:
if self._node_cache is not None:
self._node_cache.transformationChanged.disconnect(self._nodeTransformChanged)

View File

@ -176,5 +176,40 @@ Item
}
}
}
RowLayout
{
UM.ToolbarButton
{
id: undoButton
text: catalog.i18nc("@action:button", "Undo Stroke")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowReset")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("undoStackAction", false)
}
UM.ToolbarButton
{
id: redoButton
text: catalog.i18nc("@action:button", "Redo Stroke")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowDoubleCircleRight")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("undoStackAction", true)
}
}
}
}

View File

@ -2,9 +2,13 @@
# Cura is released under the terms of the LGPLv3 or higher.
import os
from PyQt6.QtGui import QImage
from typing import Optional, List, Tuple
from PyQt6.QtGui import QImage, QColor, QPainter
from UM.PluginRegistry import PluginRegistry
from UM.View.GL.ShaderProgram import ShaderProgram
from UM.View.GL.Texture import Texture
from UM.View.View import View
from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
@ -16,15 +20,23 @@ catalog = i18nCatalog("cura")
class PaintView(View):
"""View for model-painting."""
UNDO_STACK_SIZE = 1024
def __init__(self) -> None:
super().__init__()
self._paint_shader = None
self._paint_texture = None
self._paint_shader: Optional[ShaderProgram] = None
self._paint_texture: Optional[Texture] = None
# FIXME: When the texture UV-unwrapping is done, these two values will need to be set to a proper value (suggest 4096 for both).
self._tex_width = 512
self._tex_height = 512
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)
def _checkSetup(self):
if not self._paint_shader:
shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader")
@ -33,8 +45,42 @@ class PaintView(View):
self._paint_texture = OpenGL.getInstance().createTexture(self._tex_width, self._tex_height)
self._paint_shader.setTexture(0, self._paint_texture)
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_image: QImage, start_x: int, start_y: int) -> None:
self._paint_texture.setSubImage(stroke_image, start_x, start_y)
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._paint_texture.setSubImage(stroke_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:
return False
from_image, x, y = from_stack.pop()
to_image = self._forceOpaqueDeepCopy(self._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 getUvTexDimensions(self):
return self._tex_width, self._tex_height