Merge remote-tracking branch 'origin/CURA-12543_painting_ux' into CURA-12544_saving-and-loading-painted-files-in-Cura

This commit is contained in:
Erwan MATHIEU 2025-06-03 16:08:40 +02:00
commit 21443faa92
3 changed files with 135 additions and 12 deletions

View File

@ -41,9 +41,12 @@ class PaintTool(Tool):
self._brush_size: int = 10 self._brush_size: int = 10
self._brush_color: str = "A" self._brush_color: str = "A"
self._brush_shape: 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._mouse_held: bool = False
self._ctrl_held: bool = False
self._shift_held: bool = False
self._last_text_coords: Optional[Tuple[int, int]] = None self._last_text_coords: Optional[Tuple[int, int]] = None
def _createBrushPen(self) -> QPen: def _createBrushPen(self) -> QPen:
@ -72,6 +75,9 @@ class PaintTool(Tool):
painter = QPainter(stroke_image) painter = QPainter(stroke_image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(self._brush_pen) painter.setPen(self._brush_pen)
if xdiff == 0 and ydiff == 0:
painter.drawPoint(int(x0 - start_x), int(y0 - start_y))
else:
painter.drawLine(int(x0 - start_x), int(y0 - start_y), int(x1 - start_x), int(y1 - start_y)) painter.drawLine(int(x0 - start_x), int(y0 - start_y), int(x1 - start_x), int(y1 - start_y))
painter.end() painter.end()
@ -96,6 +102,20 @@ class PaintTool(Tool):
self._brush_shape = brush_shape self._brush_shape = brush_shape
self._brush_pen = self._createBrushPen() 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 @staticmethod
def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float: 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 # compute the intersection of (param) A - pt with (param) B - (param) C
@ -154,6 +174,10 @@ class PaintTool(Tool):
super().event(event) super().event(event)
controller = Application.getInstance().getController() 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 # Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes
if event.type == Event.ToolActivateEvent: if event.type == Event.ToolActivateEvent:
@ -166,7 +190,27 @@ class PaintTool(Tool):
controller.setActiveView("SolidView") controller.setActiveView("SolidView")
return True 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 return False
if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled(): if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled():
@ -201,10 +245,6 @@ class PaintTool(Tool):
if not camera: if not camera:
return False return False
node = Selection.getAllSelectedObjects()[0]
if node is None:
return False
if node != self._node_cache: if node != self._node_cache:
if self._node_cache is not None: if self._node_cache is not None:
self._node_cache.transformationChanged.disconnect(self._nodeTransformChanged) 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. # Cura is released under the terms of the LGPLv3 or higher.
import os 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.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.View.View import View
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL from UM.View.GL.OpenGL import OpenGL
@ -16,19 +20,63 @@ catalog = i18nCatalog("cura")
class PaintView(View): class PaintView(View):
"""View for model-painting.""" """View for model-painting."""
UNDO_STACK_SIZE = 1024
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._paint_shader = None self._paint_shader: Optional[ShaderProgram] = None
self._current_paint_texture = None self._current_paint_texture: Optional[Texture] = None
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): def _checkSetup(self):
if not self._paint_shader: if not self._paint_shader:
shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader") shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader")
self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename) 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_image: QImage, start_x: int, start_y: int) -> None: def addStroke(self, stroke_image: QImage, start_x: int, start_y: int) -> None:
if self._current_paint_texture is not None: if self._current_paint_texture is None:
self._current_paint_texture.setSubImage(stroke_image, start_x, start_y) return
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(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 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 getUvTexDimensions(self): def getUvTexDimensions(self):
if self._current_paint_texture is not None: if self._current_paint_texture is not None: