Merge pull request #21002 from Ultimaker/CURA-12743_slow-performance-while-painting
Some checks failed
conan-package-resources / conan-package (push) Waiting to run
conan-package-resources / signal-curator (push) Blocked by required conditions
conan-package / conan-package (push) Waiting to run
unit-test / Run unit tests (push) Waiting to run
printer-linter-format / Printer linter auto format (push) Has been cancelled

CURA-12743 optimize painting performance
This commit is contained in:
HellAholic 2025-10-03 11:03:55 +02:00 committed by GitHub
commit 8c38c5c782
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 79 additions and 129 deletions

View file

@ -20,10 +20,10 @@ class PaintClearCommand(PaintCommand):
return 1
def redo(self) -> None:
cleared_image, painter = self._makeClearedTexture()
painter = self._makeClearedTexture()
painter.end()
self._texture.setSubImage(cleared_image, 0, 0)
self._texture.updateImagePart(self._bounding_rect)
def mergeWith(self, command: QUndoCommand) -> bool:
if not isinstance(command, PaintClearCommand):

View file

@ -23,9 +23,8 @@ class PaintCommand(QUndoCommand):
self._bounding_rect = texture.getImage().rect()
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()))
self._original_texture_image = self._texture.getImage().copy()
painter = QPainter(self._original_texture_image)
# 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)
@ -36,19 +35,19 @@ class PaintCommand(QUndoCommand):
if self._original_texture_image is None:
return
cleared_image, painter = self._makeClearedTexture()
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())
self._texture.updateImagePart(self._bounding_rect)
def _makeClearedTexture(self) -> QPainter:
painter = QPainter(self._texture.getImage())
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
def _makeClearedTexture(self) -> Tuple[QImage, QPainter]:
dest_image, painter = self._preparePainting()
self._clearTextureBits(painter)
return dest_image, painter
return painter
def _clearTextureBits(self, painter: QPainter):
raise NotImplementedError()
@ -57,16 +56,3 @@ class PaintCommand(QUndoCommand):
bit_range_start, bit_range_end = self._bit_range
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

View file

@ -1,13 +1,14 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from typing import cast, Optional
from typing import cast, Optional, List
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 UM.Math.Polygon import Polygon
from .PaintCommand import PaintCommand
@ -18,13 +19,12 @@ class PaintStrokeCommand(PaintCommand):
def __init__(self,
texture: Texture,
stroke_path: QPainterPath,
stroke_polygons: List[Polygon],
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._stroke_polygons: List[Polygon] = stroke_polygons
self._calculateBoundingRect()
self._set_value: int = set_value
self._mergeable: bool = mergeable
@ -33,16 +33,14 @@ class PaintStrokeCommand(PaintCommand):
return 0
def redo(self) -> None:
stroked_image, painter = self._makeClearedTexture()
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.drawPath(self._makePainterPath())
painter.end()
self._texture.setSubImage(stroked_image, self._bounding_rect.left(), self._bounding_rect.top())
self._texture.updateImagePart(self._bounding_rect)
def mergeWith(self, command: QUndoCommand) -> bool:
if not isinstance(command, PaintStrokeCommand):
@ -52,7 +50,7 @@ class PaintStrokeCommand(PaintCommand):
if not paint_undo_command._mergeable:
return False
self._stroke_path = self._stroke_path.united(paint_undo_command._stroke_path)
self._stroke_polygons = Polygon.union(self._stroke_polygons + paint_undo_command._stroke_polygons)
self._calculateBoundingRect()
return True
@ -61,10 +59,26 @@ class PaintStrokeCommand(PaintCommand):
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)
painter.drawPath(self._makePainterPath())
def _makePainterPath(self) -> QPainterPath:
path = QPainterPath()
for polygon in self._stroke_polygons:
path.moveTo(polygon[0][0], polygon[0][1])
for point in polygon:
path.lineTo(point[0], point[1])
path.closeSubpath()
return 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())))
bounding_box = Polygon.getGlobalBoundingBox(self._stroke_polygons)
if bounding_box is None:
self._bounding_rect = QRect()
else:
self._bounding_rect = QRect(
QPoint(math.floor(bounding_box.left - PaintStrokeCommand.PEN_OVERLAP_WIDTH),
math.floor(bounding_box.bottom - PaintStrokeCommand.PEN_OVERLAP_WIDTH)),
QPoint(math.ceil(bounding_box.right + PaintStrokeCommand.PEN_OVERLAP_WIDTH),
math.ceil(bounding_box.top + PaintStrokeCommand.PEN_OVERLAP_WIDTH)))
self._bounding_rect &= self._texture.getImage().rect()

View file

@ -7,6 +7,7 @@ import numpy
from PyQt6.QtCore import Qt, QObject, pyqtEnum, QPointF
from PyQt6.QtGui import QImage, QPainter, QPen, QBrush, QPolygonF, QPainterPath
from typing import cast, Optional, Tuple, List
import pyUvula as uvula
from UM.Application import Application
from UM.Event import Event, MouseEvent
@ -15,6 +16,7 @@ from UM.Logger import Logger
from UM.Math.AxisAlignedBox2D import AxisAlignedBox2D
from UM.Math.Polygon import Polygon
from UM.Math.Vector import Vector
from UM.Mesh.MeshData import MeshData
from UM.Scene.Camera import Camera
from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
@ -56,7 +58,7 @@ class PaintTool(Tool):
self._shortcut_key: Qt.Key = Qt.Key.Key_P
self._node_cache: Optional[SceneNode] = None
self._mesh_transformed_cache = None
self._mesh_transformed_cache: Optional[MeshData] = None
self._cache_dirty: bool = True
self._brush_size: int = 10
@ -269,90 +271,42 @@ class PaintTool(Tool):
return Polygon()
return shape.translate(stroke_a[0], stroke_a[1]).unionConvexHulls(shape.translate(stroke_b[0], stroke_b[1]))
@staticmethod
def _rasterizePolygons(polygons: List[Polygon], pen: QPen, brush: QBrush) -> Tuple[QImage, Tuple[int, int]]:
if not polygons:
return QImage(), (0, 0)
bounding_box = polygons[0].getBoundingBox()
for polygon in polygons[1:]:
bounding_box += polygon.getBoundingBox()
bounding_box = AxisAlignedBox2D(numpy.array([math.floor(bounding_box.left), math.floor(bounding_box.top)]),
numpy.array([math.ceil(bounding_box.right), math.ceil(bounding_box.bottom)]))
# Use RGB32 which is more optimized for drawing to
image = QImage(int(bounding_box.width), int(bounding_box.height), QImage.Format.Format_RGB32)
image.fill(0)
painter = QPainter(image)
painter.translate(-bounding_box.left, -bounding_box.bottom)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(pen)
painter.setBrush(brush)
for polygon in polygons:
painter.drawPolygon(QPolygonF([QPointF(point[0], point[1]) for point in polygon]))
painter.end()
return image, (int(bounding_box.left), int(bounding_box.bottom))
# NOTE: Currently, it's unclear how well this would work for non-convex brush-shapes.
def _getUvAreasForStroke(self, world_coords_a: numpy.ndarray, world_coords_b: numpy.ndarray) -> List[Polygon]:
def _getUvAreasForStroke(self, world_coords_a: numpy.ndarray, world_coords_b: numpy.ndarray, face_id: int) -> List[Polygon]:
""" Fetches all texture-coordinate areas within the provided stroke on the mesh.
Calculates intersections of the stroke with the surface of the geometry and maps them to UV-space polygons.
:param face_id_a: ID of the face where the stroke starts.
:param face_id_b: ID of the face where the stroke ends.
:param world_coords_a: 3D ('world') coordinates corresponding to the starting stroke point.
:param world_coords_b: 3D ('world') coordinates corresponding to the ending stroke point.
:param face_id: the ID of the face at the center of the stroke
:return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface.
"""
def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray:
return numpy.array([*self._camera.projectToViewport(Vector(*pt))], dtype=numpy.float32)
def get_projected_on_viewport_image(pt: numpy) -> numpy.ndarray:
return numpy.array([pt[0] + self._camera.getViewportWidth() / 2.0,
self._camera.getViewportHeight() - (pt[1] + self._camera.getViewportHeight() / 2.0)],
dtype=numpy.float32)
stroke_poly = self._getStrokePolygon(get_projected_on_plane(world_coords_a), get_projected_on_plane(world_coords_b))
stroke_poly_viewport = Polygon([get_projected_on_viewport_image(point) for point in stroke_poly])
stroke_poly.toType(numpy.float32)
faces_image, (faces_x, faces_y) = PaintTool._rasterizePolygons([stroke_poly_viewport],
QPen(Qt.PenStyle.NoPen),
QBrush(Qt.GlobalColor.white))
faces = self._faces_selection_pass.getFacesIdsUnderMask(faces_image, faces_x, faces_y)
mesh_indices = self._mesh_transformed_cache.getIndices()
if mesh_indices is None:
mesh_indices = numpy.array([], dtype=numpy.int32)
texture_dimensions = numpy.array(list(self._view.getUvTexDimensions()))
res = []
for face in faces:
_, fnorm = self._mesh_transformed_cache.getFacePlane(face)
if numpy.dot(fnorm, self._cam_norm) < 0: # <- facing away from the viewer
continue
va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face)
stroke_tri = Polygon([
get_projected_on_plane(va),
get_projected_on_plane(vb),
get_projected_on_plane(vc)])
face_uv_coordinates = self._node_cache.getMeshData().getFaceUvCoords(face)
if face_uv_coordinates is None:
continue
ta, tb, tc = face_uv_coordinates
original_uv_poly = numpy.array([ta, tb, tc])
uv_area = stroke_poly.intersection(stroke_tri)
if uv_area.isValid():
uv_area_barycentric = PaintTool._getBarycentricCoordinates(uv_area.getPoints(), stroke_tri.getPoints())
if uv_area_barycentric is not None:
res.append(Polygon((uv_area_barycentric @ original_uv_poly) * texture_dimensions))
return res
res = uvula.project(stroke_poly.getPoints(),
self._mesh_transformed_cache.getVertices(),
mesh_indices,
self._node_cache.getMeshData().getUVCoordinates(),
self._node_cache.getMeshData().getFacesConnections(),
self._view.getUvTexDimensions()[0],
self._view.getUvTexDimensions()[1],
self._camera.getProjectToViewMatrix().getData(),
self._camera.isPerspective(),
self._camera.getViewportWidth(),
self._camera.getViewportHeight(),
self._cam_norm,
face_id)
return [Polygon(points) for points in res]
def event(self, event: Event) -> bool:
"""Handle mouse and keyboard events.
@ -437,7 +391,7 @@ class PaintTool(Tool):
event_caught = False # Propagate mouse event if only moving the cursor, not to block e.g. rotation
try:
brush_color = self._brush_color if self.getPaintType() != "extruder" else str(self._brush_extruder)
uv_areas_cursor = self._getUvAreasForStroke(world_coords, world_coords)
uv_areas_cursor = self._getUvAreasForStroke(world_coords, world_coords, face_id)
if len(uv_areas_cursor) > 0:
cursor_path = self._createStrokePath(uv_areas_cursor)
self._view.setCursorStroke(cursor_path, brush_color)
@ -445,12 +399,11 @@ class PaintTool(Tool):
self._view.clearCursorStroke()
if self._mouse_held:
uv_areas = self._getUvAreasForStroke(self._last_world_coords, world_coords)
uv_areas = self._getUvAreasForStroke(self._last_world_coords, world_coords, face_id)
if len(uv_areas) == 0:
return False
event_caught = True
stroke_path = self._createStrokePath(uv_areas)
self._view.addStroke(stroke_path, brush_color, is_moved)
self._view.addStroke(uv_areas, brush_color, is_moved)
except:
Logger.logException("e", "Error when adding paint stroke")

View file

@ -8,6 +8,7 @@ from PyQt6.QtCore import QRect, pyqtSignal, Qt, QPoint
from PyQt6.QtGui import QImage, QUndoStack, QPainter, QColor, QPainterPath, QBrush, QPen
from typing import Optional, List, Tuple, Dict
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.BuildVolume import BuildVolume
@ -21,6 +22,7 @@ from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
from UM.i18n import i18nCatalog
from UM.Math.Color import Color
from UM.Math.Polygon import Polygon
from .PaintStrokeCommand import PaintStrokeCommand
from .PaintClearCommand import PaintClearCommand
@ -162,23 +164,17 @@ class PaintView(CuraView):
QPoint(math.floor(bounding_rect.left()), math.floor(bounding_rect.top())),
QPoint(math.ceil(bounding_rect.right()), math.ceil(bounding_rect.bottom())))
cursor_image = QImage(bounding_rect_rounded.width(), bounding_rect_rounded.height(), QImage.Format.Format_ARGB32)
cursor_image.fill(0)
painter = QPainter(cursor_image)
painter = QPainter(self._cursor_texture.getImage())
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.translate(-bounding_rect.left(), -bounding_rect.top())
display_color = self._paint_modes[self._current_paint_type][brush_color].display_color
paint_color = QColor(*[int(color_part * 255) for color_part in [display_color.r, display_color.g, display_color.b]])
paint_color.setAlpha(255)
painter.setBrush(QBrush(paint_color))
painter.setPen(QPen(Qt.PenStyle.NoPen))
painter.drawPath(cursor_path)
painter.end()
self._cursor_texture.setSubImage(cursor_image, bounding_rect_rounded.left(), bounding_rect_rounded.top())
self._cursor_texture.updateImagePart(bounding_rect_rounded)
self._previous_paint_texture_rect = bounding_rect_rounded
def clearCursorStroke(self) -> bool:
@ -186,18 +182,17 @@ class PaintView(CuraView):
self._cursor_texture is None or self._cursor_texture.getImage() is None):
return False
clear_image = QImage(self._previous_paint_texture_rect.width(),
self._previous_paint_texture_rect.height(),
QImage.Format.Format_ARGB32)
clear_image.fill(0)
self._cursor_texture.setSubImage(clear_image,
self._previous_paint_texture_rect.left(),
self._previous_paint_texture_rect.top())
painter = QPainter(self._cursor_texture.getImage())
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
painter.fillRect(self._previous_paint_texture_rect, QBrush(QColor(0, 0, 0, 0)))
painter.end()
self._cursor_texture.updateImagePart(self._previous_paint_texture_rect)
self._previous_paint_texture_rect = None
return True
def addStroke(self, stroke_path: QPainterPath, brush_color: str, merge_with_previous: bool) -> None:
def addStroke(self, stroke_path: List[Polygon], brush_color: str, merge_with_previous: bool) -> None:
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
return

View file

@ -31,3 +31,5 @@ class PrepareTextureJob(Job):
# Force clear OpenGL buffer so that new UV coordinates will be sent
delattr(mesh, OpenGL.VertexBufferProperty)
# Also cache the faces connection, can be quite long to compute
self._node.getMeshData().getFacesConnections()