Merge branch '5.11' into CURA-12739_5.11-Changelog-and-Whats-New-Pages

This commit is contained in:
Erwan MATHIEU 2025-10-03 13:43:34 +02:00 committed by GitHub
commit 8ea4633dc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 270 additions and 70 deletions

View file

@ -6,10 +6,10 @@ from typing import Optional, Dict
from PyQt6.QtCore import QBuffer
from PyQt6.QtGui import QImage, QImageWriter
import UM.View.GL.Texture
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.Texture import Texture
from UM.Signal import Signal
class SliceableObjectDecorator(SceneNodeDecorator):
@ -18,14 +18,20 @@ class SliceableObjectDecorator(SceneNodeDecorator):
self._paint_texture = None
self._texture_data_mapping: Dict[str, tuple[int, int]] = {}
self.paintTextureChanged = Signal()
def isSliceable(self) -> bool:
return True
def getPaintTexture(self) -> Optional[Texture]:
return self._paint_texture
def getPaintTextureChangedSignal(self) -> Signal:
return self.paintTextureChanged
def setPaintTexture(self, texture: Texture) -> None:
self._paint_texture = texture
self.paintTextureChanged.emit()
def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]:
return self._texture_data_mapping
@ -39,6 +45,7 @@ class SliceableObjectDecorator(SceneNodeDecorator):
image = QImage(width, height, QImage.Format.Format_RGB32)
image.fill(0)
self._paint_texture.setImage(image)
self.paintTextureChanged.emit()
def packTexture(self) -> Optional[bytearray]:
if self._paint_texture is None:

View file

@ -0,0 +1,112 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
from weakref import WeakKeyDictionary
import functools
from typing import Optional
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.Machines.Models.ExtrudersModel import ExtrudersModel
from UM.Signal import Signal
from .PaintCommand import PaintCommand
class MultiMaterialExtruderConverter:
"""
This class is a single object living in the background, which only job is to watch when extruders of objects
are changed and to convert their multi-material painting textures accordingly.
"""
MAX_EXTRUDER_COUNT = 16
def __init__(self, extruders_model: ExtrudersModel) -> None:
application = CuraApplication.getInstance()
scene = application.getController().getScene()
scene.getRoot().childrenChanged.connect(self._onChildrenChanged)
self._extruders_model: extruders_model
self._watched_nodes: WeakKeyDictionary[SceneNode, tuple[Optional[int], Optional[functools.partial]]] = WeakKeyDictionary()
self.mainExtruderChanged = Signal()
def _onChildrenChanged(self, node: SceneNode):
if node not in self._watched_nodes and node.callDecoration("isSliceable"):
self._watched_nodes[node] = (None, None)
node.decoratorsChanged.connect(self._onDecoratorsChanged)
self._onDecoratorsChanged(node)
for child in node.getChildren():
self._onChildrenChanged(child)
def _onDecoratorsChanged(self, node: SceneNode) -> None:
if node not in self._watched_nodes:
return
current_extruder, extruder_changed_callback = self._watched_nodes[node]
if extruder_changed_callback is None:
extruder_changed_signal = node.callDecoration("getActiveExtruderChangedSignal")
if extruder_changed_signal is not None:
extruder_changed_callback = functools.partial(self._onExtruderChanged, node)
extruder_changed_signal.connect(extruder_changed_callback)
self._watched_nodes[node] = current_extruder, extruder_changed_callback
self._onExtruderChanged(node)
def _onExtruderChanged(self, node: SceneNode) -> None:
self._changeMainObjectExtruder(node)
@staticmethod
def getPaintedObjectExtruderNr(node: SceneNode) -> Optional[int]:
extruder_stack = node.getPrintingExtruder()
if extruder_stack is None:
return None
return extruder_stack.getValue("extruder_nr")
def _changeMainObjectExtruder(self, node: SceneNode) -> None:
if node not in self._watched_nodes:
return
old_extruder_nr, extruder_changed_callback = self._watched_nodes[node]
new_extruder_nr = MultiMaterialExtruderConverter.getPaintedObjectExtruderNr(node)
if new_extruder_nr == old_extruder_nr:
return
self._watched_nodes[node] = (new_extruder_nr, extruder_changed_callback)
if old_extruder_nr is None or new_extruder_nr is None:
return
texture = node.callDecoration("getPaintTexture")
if texture is None:
return
paint_data_mapping = node.callDecoration("getTextureDataMapping")
if paint_data_mapping is None or "extruder" not in paint_data_mapping:
return
bits_range = paint_data_mapping["extruder"]
image = texture.getImage()
image_ptr = image.bits()
image_ptr.setsize(image.sizeInBytes())
image_array = numpy.frombuffer(image_ptr, dtype=numpy.uint32)
bit_range_start, bit_range_end = bits_range
bit_mask = numpy.uint32(PaintCommand.getBitRangeMask(bits_range))
target_bits = (image_array & bit_mask) >> bit_range_start
target_bits[target_bits == old_extruder_nr] = MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT
target_bits[target_bits == new_extruder_nr] = old_extruder_nr
target_bits[target_bits == MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT] = new_extruder_nr
image_array &= ~bit_mask
image_array |= ((target_bits << bit_range_start) & bit_mask)
texture.updateImagePart(image.rect())
self.mainExtruderChanged.emit(node)

View file

@ -13,14 +13,20 @@ from .PaintCommand import PaintCommand
class PaintClearCommand(PaintCommand):
"""Provides the command that clears all the painting for the current mode"""
def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None:
def __init__(self, texture: Texture, bit_range: tuple[int, int], set_value: int) -> None:
super().__init__(texture, bit_range)
self._set_value = set_value
def id(self) -> int:
return 1
def redo(self) -> None:
painter = self._makeClearedTexture()
if self._set_value > 0:
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
painter.fillRect(self._texture.getImage().rect(), QBrush(self._set_value))
painter.end()
self._texture.updateImagePart(self._bounding_rect)

View file

@ -52,7 +52,11 @@ class PaintCommand(QUndoCommand):
def _clearTextureBits(self, painter: QPainter):
raise NotImplementedError()
def _getBitRangeMask(self) -> int:
bit_range_start, bit_range_end = self._bit_range
@staticmethod
def getBitRangeMask(bit_range: tuple[int, int]) -> int:
bit_range_start, bit_range_end = bit_range
return (((PaintCommand.FULL_INT32 << (32 - 1 - (bit_range_end - bit_range_start))) & PaintCommand.FULL_INT32) >>
(32 - 1 - bit_range_end))
def _getBitRangeMask(self) -> int:
return PaintCommand.getBitRangeMask(self._bit_range)

View file

@ -3,10 +3,11 @@
import os
import math
from weakref import WeakKeyDictionary
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 typing import Optional, Tuple, Dict, List
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode
@ -18,7 +19,6 @@ from UM.PluginRegistry import PluginRegistry
from UM.View.GL.ShaderProgram import ShaderProgram
from UM.View.GL.Texture import Texture
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
from UM.i18n import i18nCatalog
from UM.Math.Color import Color
@ -26,6 +26,7 @@ from UM.Math.Polygon import Polygon
from .PaintStrokeCommand import PaintStrokeCommand
from .PaintClearCommand import PaintClearCommand
from .MultiMaterialExtruderConverter import MultiMaterialExtruderConverter
catalog = i18nCatalog("cura")
@ -33,8 +34,6 @@ catalog = i18nCatalog("cura")
class PaintView(CuraView):
"""View for model-painting."""
MAX_EXTRUDER_COUNT = 16
class PaintType:
def __init__(self, display_color: Color, value: int):
self.display_color: Color = display_color
@ -43,27 +42,53 @@ class PaintView(CuraView):
def __init__(self) -> None:
super().__init__(use_empty_menu_placeholder = True)
self._paint_shader: Optional[ShaderProgram] = None
self._current_paint_texture: Optional[Texture] = None
self._current_painted_object: Optional[SceneNode] = None
self._paint_texture: Optional[Texture] = None
self._painted_object: Optional[SceneNode] = None
self._previous_paint_texture_rect: Optional[QRect] = None
self._cursor_texture: Optional[Texture] = None
self._current_bits_ranges: tuple[int, int] = (0, 0)
self._current_paint_type = ""
self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {}
self._paint_undo_stacks: Dict[Tuple[SceneNode, str], QUndoStack] = {}
self._paint_undo_stacks: WeakKeyDictionary[SceneNode, Dict[str, QUndoStack]] = WeakKeyDictionary()
application = CuraApplication.getInstance()
application.engineCreatedSignal.connect(self._makePaintModes)
self._scene = application.getController().getScene()
self._scene.getRoot().childrenChanged.connect(self._onChildrenChanged)
self._extruders_model: Optional[ExtrudersModel] = None
self._extruders_converter: Optional[MultiMaterialExtruderConverter] = None
canUndoChanged = pyqtSignal(bool)
canRedoChanged = pyqtSignal(bool)
def setCurrentPaintedObject(self, current_painted_object: Optional[SceneNode]):
self._current_painted_object = current_painted_object
if self._painted_object is not None:
texture_changed_signal = self._painted_object.callDecoration("getPaintTextureChangedSignal")
texture_changed_signal.disconnect(self._onCurrentPaintedObjectTextureChanged)
self._paint_texture = None
self._cursor_texture = None
self._painted_object = current_painted_object
if self._painted_object is not None:
texture_changed_signal = self._painted_object.callDecoration("getPaintTextureChangedSignal")
texture_changed_signal.connect(self._onCurrentPaintedObjectTextureChanged)
self._onCurrentPaintedObjectTextureChanged()
self._updateCurrentBitsRanges()
def _onCurrentPaintedObjectTextureChanged(self) -> None:
paint_texture = self._painted_object.callDecoration("getPaintTexture")
self._paint_texture = paint_texture
if paint_texture is not None:
self._cursor_texture = OpenGL.getInstance().createTexture(paint_texture.getWidth(),
paint_texture.getHeight())
image = QImage(paint_texture.getWidth(), paint_texture.getHeight(), QImage.Format.Format_ARGB32)
image.fill(0)
self._cursor_texture.setImage(image)
else:
self._cursor_texture = None
def canUndo(self):
stack = self._getUndoStack()
@ -74,35 +99,23 @@ class PaintView(CuraView):
return stack.canRedo() if stack is not None else False
def _getUndoStack(self):
if self._current_painted_object is None or self._current_paint_type == "":
if self._painted_object is None:
return None
try:
return self._paint_undo_stacks[(self._current_painted_object, self._current_paint_type)]
return self._paint_undo_stacks[self._painted_object][self._current_paint_type]
except KeyError:
return None
def _onChildrenChanged(self, root_node: SceneNode):
# Gather all the actual nodes that have one or more undo stacks
stacks_keys = {}
for painted_object, paint_mode in self._paint_undo_stacks:
if painted_object in stacks_keys:
stacks_keys[painted_object].append(paint_mode)
else:
stacks_keys[painted_object] = [paint_mode]
# Now see if any of the nodes have been deleted, i.e. they are no more linked to the root
for painted_object, paint_modes in stacks_keys.items():
if painted_object.getDepth() == 0:
for paint_mode in paint_modes:
del self._paint_undo_stacks[(painted_object, paint_mode)]
def _makePaintModes(self):
application = CuraApplication.getInstance()
self._extruders_model = application.getExtrudersModel()
self._extruders_model.modelChanged.connect(self._onExtrudersChanged)
self._extruders_converter = MultiMaterialExtruderConverter(self._extruders_model)
self._extruders_converter.mainExtruderChanged.connect(self._onMainExtruderChanged)
theme = application.getTheme()
usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0),
"preferred": self.PaintType(Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
@ -115,10 +128,18 @@ class PaintView(CuraView):
self._current_paint_type = "seam"
def _onMainExtruderChanged(self, node: SceneNode):
# Since the affected extruder has changed, the previous material painting commands become irrelevant,
# so clear the undo stack of the object, if any
try:
self._paint_undo_stacks[node]["extruder"].clear()
except KeyError:
pass
def _makeExtrudersColors(self) -> Dict[str, "PaintView.PaintType"]:
extruders_colors: Dict[str, "PaintView.PaintType"] = {}
for extruder_index in range(PaintView.MAX_EXTRUDER_COUNT):
for extruder_index in range(MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT):
extruder_item = self._extruders_model.getExtruderItem(extruder_index)
if extruder_item is None:
extruder_item = self._extruders_model.getExtruderItem(0)
@ -142,11 +163,10 @@ class PaintView(CuraView):
if controller.getActiveView() != self:
return
selected_objects = Selection.getAllSelectedObjects()
if len(selected_objects) != 1:
if self._painted_object is None:
return
controller.getScene().sceneChanged.emit(selected_objects[0])
controller.getScene().sceneChanged.emit(self._painted_object)
def _checkSetup(self):
if not self._paint_shader:
@ -192,29 +212,52 @@ class PaintView(CuraView):
return True
def _shiftTextureValue(self, value: int) -> int:
if self._current_bits_ranges is None:
return 0
bit_range_start, _ = self._current_bits_ranges
return value << bit_range_start
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:
if self._paint_texture is None or self._paint_texture.getImage() is None:
return
self._prepareDataMapping()
stack = self._prepareUndoRedoStack()
bit_range_start, bit_range_end = self._current_bits_ranges
set_value = self._paint_modes[self._current_paint_type][brush_color].value << bit_range_start
if stack is None:
return
stack.push(PaintStrokeCommand(self._current_paint_texture,
set_value = self._shiftTextureValue(self._paint_modes[self._current_paint_type][brush_color].value)
stack.push(PaintStrokeCommand(self._paint_texture,
stroke_path,
set_value,
(bit_range_start, bit_range_end),
self._current_bits_ranges,
merge_with_previous))
def clearPaint(self):
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
return
def _makeClearCommand(self) -> Optional[PaintClearCommand]:
if self._painted_object is None or self._paint_texture is None or self._current_bits_ranges is None:
return None
set_value = 0
if self._current_paint_type == "extruder":
extruder_stack = self._painted_object.getPrintingExtruder()
if extruder_stack is not None:
set_value = extruder_stack.getValue("extruder_nr")
return PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value)
def clearPaint(self):
self._prepareDataMapping()
stack = self._prepareUndoRedoStack()
stack.push(PaintClearCommand(self._current_paint_texture, self._current_bits_ranges))
if stack is None:
return
clear_command = self._makeClearCommand()
if clear_command is not None:
stack.push(clear_command)
def undoStroke(self) -> None:
stack = self._getUndoStack()
@ -227,8 +270,8 @@ class PaintView(CuraView):
stack.redo()
def getUvTexDimensions(self) -> Tuple[int, int]:
if self._current_paint_texture is not None:
return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight()
if self._paint_texture is not None:
return self._paint_texture.getWidth(), self._paint_texture.getHeight()
return 0, 0
def getPaintType(self) -> str:
@ -238,31 +281,56 @@ class PaintView(CuraView):
self._current_paint_type = paint_type
self._prepareDataMapping()
def _prepareUndoRedoStack(self) -> QUndoStack:
stack_key = (self._current_painted_object, self._current_paint_type)
def _prepareUndoRedoStack(self) -> Optional[QUndoStack]:
if self._painted_object is None:
return None
if stack_key not in self._paint_undo_stacks:
try:
return self._paint_undo_stacks[self._painted_object][self._current_paint_type]
except KeyError:
stack: QUndoStack = QUndoStack()
stack.setUndoLimit(32) # Set a quite low amount since some commands copy the full texture
stack.setUndoLimit(16) # Set a quite low amount since some commands copy the full texture
stack.canUndoChanged.connect(self.canUndoChanged)
stack.canRedoChanged.connect(self.canRedoChanged)
self._paint_undo_stacks[stack_key] = stack
return self._paint_undo_stacks[stack_key]
if self._painted_object not in self._paint_undo_stacks:
self._paint_undo_stacks[self._painted_object] = {}
def _prepareDataMapping(self):
node = Selection.getAllSelectedObjects()[0]
if node is None:
self._paint_undo_stacks[self._painted_object][self._current_paint_type] = stack
return stack
def _updateCurrentBitsRanges(self):
self._current_bits_ranges = (0, 0)
if self._painted_object is None:
return
paint_data_mapping = node.callDecoration("getTextureDataMapping")
paint_data_mapping = self._painted_object.callDecoration("getTextureDataMapping")
if paint_data_mapping is None or self._current_paint_type not in paint_data_mapping:
return
self._current_bits_ranges = paint_data_mapping[self._current_paint_type]
def _prepareDataMapping(self):
if self._painted_object is None:
return
paint_data_mapping = self._painted_object.callDecoration("getTextureDataMapping")
feature_created = False
if self._current_paint_type not in paint_data_mapping:
new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[self._current_paint_type]))
paint_data_mapping[self._current_paint_type] = new_mapping
node.callDecoration("setTextureDataMapping", paint_data_mapping)
self._painted_object.callDecoration("setTextureDataMapping", paint_data_mapping)
feature_created = True
self._current_bits_ranges = paint_data_mapping[self._current_paint_type]
self._updateCurrentBitsRanges()
if feature_created and self._current_paint_type == "extruder":
# Fill texture extruder with actual mesh extruder
clear_command = self._makeClearCommand()
if clear_command is not None:
clear_command.redo()
@staticmethod
def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]:
@ -275,7 +343,7 @@ class PaintView(CuraView):
return start_index, end_index
def beginRendering(self) -> None:
if self._current_paint_type not in self._paint_modes:
if self._painted_object is None or self._current_paint_type not in self._paint_modes:
return
self._checkSetup()
@ -288,22 +356,25 @@ class PaintView(CuraView):
paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
renderer.addRenderBatch(paint_batch)
for node in Selection.getAllSelectedObjects():
paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
paint_texture = node.callDecoration("getPaintTexture")
if paint_texture != self._current_paint_texture and paint_texture is not None:
self._current_paint_texture = paint_texture
paint_batch.addItem(self._painted_object.getWorldTransformation(copy=False),
self._painted_object.getMeshData(),
normal_transformation=self._painted_object.getCachedNormalMatrix())
self._cursor_texture = OpenGL.getInstance().createTexture(paint_texture.getWidth(), paint_texture.getHeight())
self._paint_shader.setTexture(0, self._current_paint_texture)
image = QImage(paint_texture.getWidth(), paint_texture.getHeight(), QImage.Format.Format_ARGB32)
image.fill(0)
self._cursor_texture.setImage(image)
self._paint_shader.setTexture(1, self._cursor_texture)
if self._paint_texture is not None:
self._paint_shader.setTexture(0, self._paint_texture)
if self._cursor_texture is not None:
self._paint_shader.setTexture(1, self._cursor_texture)
self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0])
self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])
colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].values()]
if self._current_bits_ranges[0] != self._current_bits_ranges[1]:
colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].values()]
elif self._current_paint_type == "extruder":
object_extruder = MultiMaterialExtruderConverter.getPaintedObjectExtruderNr(self._painted_object)
colors = [self._paint_modes[self._current_paint_type][str(object_extruder)].display_color]
else:
colors = [self._paint_modes[self._current_paint_type]["none"].display_color]
colors_values = [[int(color_part * 255) for color_part in [color.r, color.g, color.b]] for color in colors]
self._paint_shader.setUniformValueArray("u_renderColors", colors_values)