diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 7ee77795e7..7b4cbab3e5 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -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: diff --git a/plugins/PaintTool/MultiMaterialExtruderConverter.py b/plugins/PaintTool/MultiMaterialExtruderConverter.py new file mode 100644 index 0000000000..be1d0e05db --- /dev/null +++ b/plugins/PaintTool/MultiMaterialExtruderConverter.py @@ -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) diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 1e087c6b6f..1a7d95c98a 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -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) diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index 65542b1cc2..9dfae1d092 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -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) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 23ef83f54b..13f6acff3c 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -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)