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_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:
@ -72,7 +75,10 @@ class PaintTool(Tool):
painter = QPainter(stroke_image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(self._brush_pen)
painter.drawLine(int(x0 - start_x), int(y0 - start_y), int(x1 - start_x), int(y1 - start_y))
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.end()
return stroke_image, (start_x, start_y)
@ -96,6 +102,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 +174,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 +190,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 +245,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,19 +20,63 @@ 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._current_paint_texture = None
self._paint_shader: Optional[ShaderProgram] = 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):
if not self._paint_shader:
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_image: QImage, start_x: int, start_y: int) -> None:
if self._current_paint_texture is not None:
self._current_paint_texture.setSubImage(stroke_image, start_x, start_y)
if self._current_paint_texture is None:
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):
if self._current_paint_texture is not None: