mirror of
https://github.com/Ultimaker/Cura.git
synced 2026-02-26 22:35:46 -07:00
Merge branch '5.11' into CURA-12739_5.11-Changelog-and-Whats-New-Pages
This commit is contained in:
commit
d50e4fece6
7 changed files with 80 additions and 130 deletions
|
|
@ -14,7 +14,7 @@ DEFAULT_CURA_LATEST_URL = "https://software.ultimaker.com/latest.json"
|
|||
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
|
||||
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
|
||||
# CuraVersion.py.in template.
|
||||
CuraSDKVersion = "8.10.0"
|
||||
CuraSDKVersion = "8.11.0"
|
||||
|
||||
try:
|
||||
from cura.CuraVersion import CuraLatestURL
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue