diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 18b762bb2c..e931b7c056 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -116,7 +116,8 @@ class BuildVolume(SceneNode): self._application.engineCreatedSignal.connect(self._onEngineCreated) self._has_errors = False - self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) + scene = self._application.getController().getScene() + scene.sceneChanged.connect(self._onSceneChanged) # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] @@ -655,7 +656,7 @@ class BuildVolume(SceneNode): extra_z = retraction_hop return extra_z - def _onStackChanged(self): + def _onStackChanged(self, *args) -> None: self._stack_change_timer.start() def _onStackChangeTimerFinished(self) -> None: diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 7b4cbab3e5..3e7d9901ad 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -1,9 +1,12 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. import copy import json +import numpy -from typing import Optional, Dict +from typing import Optional, Dict, List -from PyQt6.QtCore import QBuffer +from PyQt6.QtCore import QBuffer, QTimer from PyQt6.QtGui import QImage, QImageWriter from UM.Scene.SceneNodeDecorator import SceneNodeDecorator @@ -18,8 +21,15 @@ class SliceableObjectDecorator(SceneNodeDecorator): self._paint_texture = None self._texture_data_mapping: Dict[str, tuple[int, int]] = {} + self._painted_extruders: Optional[List[int]] = None + self.paintTextureChanged = Signal() + self._texture_change_timer = QTimer() + self._texture_change_timer.setInterval(500) # Long interval to avoid triggering during painting + self._texture_change_timer.setSingleShot(True) + self._texture_change_timer.timeout.connect(self._onTextureChangeTimerFinished) + def isSliceable(self) -> bool: return True @@ -29,6 +39,32 @@ class SliceableObjectDecorator(SceneNodeDecorator): def getPaintTextureChangedSignal(self) -> Signal: return self.paintTextureChanged + def setPaintedExtrudersCountDirty(self) -> None: + self._texture_change_timer.start() + + def _onTextureChangeTimerFinished(self) -> None: + self._painted_extruders = None + + if (self._paint_texture is None or self._paint_texture.getImage() is None or + "extruder" not in self._texture_data_mapping): + return + + image = self._paint_texture.getImage() + image_bits = image.constBits() + image_bits.setsize(image.sizeInBytes()) + image_array = numpy.frombuffer(image_bits, dtype=numpy.uint32) + + bit_range_start, bit_range_end = self._texture_data_mapping["extruder"] + full_int32 = 0xffffffff + bit_mask = (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> ( + 32 - 1 - bit_range_end)) + + texel_counts = numpy.bincount((image_array & bit_mask) >> bit_range_start) + self._painted_extruders = [extruder_nr for extruder_nr, count in enumerate(texel_counts) if count > 0] + + from cura.CuraApplication import CuraApplication + CuraApplication.getInstance().globalContainerStackChanged.emit() + def setPaintTexture(self, texture: Texture) -> None: self._paint_texture = texture self.paintTextureChanged.emit() @@ -63,6 +99,9 @@ class SliceableObjectDecorator(SceneNodeDecorator): return texture_buffer.data() + def getPaintedExtruders(self) -> Optional[List[int]]: + return self._painted_extruders + def __deepcopy__(self, memo) -> "SliceableObjectDecorator": copied_decorator = SliceableObjectDecorator() copied_decorator.setPaintTexture(copy.deepcopy(self.getPaintTexture())) diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index cd39947bf8..8e950350b3 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 Ultimaker B.V. +# Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. from PyQt6.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt. @@ -254,6 +254,11 @@ class ExtruderManager(QObject): if not support_roof_enabled: support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value") + painted_extruders = node.callDecoration("getPaintedExtruders") + if painted_extruders is not None: + for extruder_nr in painted_extruders: + used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)]) + # Check limit to extruders limit_to_extruder_feature_list = ["wall_0_extruder_nr", "wall_x_extruder_nr", diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 72a2a203e8..b376aaaf78 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -416,7 +416,7 @@ class StartSliceJob(Job): # Only check if the printing extruder is enabled for printing meshes is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh") if not is_non_printing_mesh: - for used_extruder in StartSliceJob._getUsedExtruders(node): + for used_extruder in StartSliceJob._getMainExtruders(node): if not extruders_enabled[used_extruder]: skip_group = True has_model_with_disabled_extruders = True @@ -763,28 +763,11 @@ class StartSliceJob(Job): self._addRelations(relations_set, relation.target.relations) @staticmethod - def _getUsedExtruders(node: SceneNode) -> List[int]: - used_extruders = [] - - # Look at extruders used in painted texture - node_texture = node.callDecoration("getPaintTexture") - texture_data_mapping = node.callDecoration("getTextureDataMapping") - if node_texture is not None and texture_data_mapping is not None and "extruder" in texture_data_mapping: - texture_image = node_texture.getImage() - image_ptr = texture_image.constBits() - image_ptr.setsize(texture_image.sizeInBytes()) - image_size = texture_image.height(), texture_image.width() - image_array = numpy.frombuffer(image_ptr, dtype=numpy.uint32).reshape(image_size) - - bit_range_start, bit_range_end = texture_data_mapping["extruder"] - full_int32 = 0xffffffff - bit_mask = (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> ( - 32 - 1 - bit_range_end)) - - used_extruders = (numpy.unique(image_array & bit_mask) >> bit_range_start).tolist() + def _getMainExtruders(node: SceneNode) -> List[int]: + used_extruders = node.callDecoration("getPaintedExtruders") # There is no relevant painting data, just take the extruder associated to the model if not used_extruders: used_extruders = [int(node.callDecoration("getActiveExtruderPosition"))] - return used_extruders \ No newline at end of file + return used_extruders diff --git a/plugins/PaintTool/MultiMaterialExtruderConverter.py b/plugins/PaintTool/MultiMaterialExtruderConverter.py index be1d0e05db..8dbf56893e 100644 --- a/plugins/PaintTool/MultiMaterialExtruderConverter.py +++ b/plugins/PaintTool/MultiMaterialExtruderConverter.py @@ -109,4 +109,6 @@ class MultiMaterialExtruderConverter: texture.updateImagePart(image.rect()) + node.callDecoration("setPaintedExtrudersCountDirty") + self.mainExtruderChanged.emit(node) diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 1a7d95c98a..1be8b9bcbf 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -5,6 +5,7 @@ from typing import Optional from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from .PaintCommand import PaintCommand @@ -13,8 +14,12 @@ 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], set_value: int) -> None: - super().__init__(texture, bit_range) + def __init__(self, + texture: Texture, + bit_range: tuple[int, int], + set_value: int, + sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None: + super().__init__(texture, bit_range, sliceable_object_decorator=sliceable_object_decorator) self._set_value = set_value def id(self) -> int: @@ -22,13 +27,12 @@ class PaintClearCommand(PaintCommand): 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._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: @@ -38,6 +42,6 @@ class PaintClearCommand(PaintCommand): # There is actually nothing more to do here, both clear commands already have the same original texture return True - def _clearTextureBits(self, painter: QPainter): + def _clearTextureBits(self, painter: QPainter, extended = False): painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination) painter.fillRect(self._texture.getImage().rect(), QBrush(self._getBitRangeMask())) \ No newline at end of file diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index 9dfae1d092..5367d0e0ca 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -1,12 +1,14 @@ # Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. -from typing import Tuple, Optional +from typing import Tuple, Optional, Dict -from PyQt6.QtCore import QRect +import numpy from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush from UM.View.GL.Texture import Texture +from cura.CuraApplication import CuraApplication +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator class PaintCommand(QUndoCommand): @@ -14,7 +16,11 @@ class PaintCommand(QUndoCommand): FULL_INT32 = 0xffffffff - def __init__(self, texture: Texture, bit_range: tuple[int, int], make_original_image = True) -> None: + def __init__(self, + texture: Texture, + bit_range: tuple[int, int], + make_original_image = True, + sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None: super().__init__() self._texture: Texture = texture @@ -22,6 +28,8 @@ class PaintCommand(QUndoCommand): self._original_texture_image = None self._bounding_rect = texture.getImage().rect() + self._sliceable_object_decorator: Optional[SliceableObjectDecorator] = sliceable_object_decorator + if make_original_image: self._original_texture_image = self._texture.getImage().copy() painter = QPainter(self._original_texture_image) @@ -35,21 +43,26 @@ class PaintCommand(QUndoCommand): if self._original_texture_image is None: return - painter = self._makeClearedTexture() + painter = self._makeClearedTexture(extended=True) painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) painter.drawImage(0, 0, self._original_texture_image) painter.end() + self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) - def _makeClearedTexture(self) -> QPainter: + def _setPaintedExtrudersCountDirty(self) -> None: + if self._sliceable_object_decorator is not None: + self._sliceable_object_decorator.setPaintedExtrudersCountDirty() + + def _makeClearedTexture(self, extended = False) -> QPainter: painter = QPainter(self._texture.getImage()) painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - self._clearTextureBits(painter) + self._clearTextureBits(painter, extended) return painter - def _clearTextureBits(self, painter: QPainter): + def _clearTextureBits(self, painter: QPainter, extended = False): raise NotImplementedError() @staticmethod diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index 8d4a5c2dbd..bcb38a19bc 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -7,6 +7,7 @@ import math from PyQt6.QtCore import QRect, QRectF, QPoint from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from UM.Math.Polygon import Polygon @@ -16,14 +17,16 @@ class PaintStrokeCommand(PaintCommand): """Provides the command that does the actual painting on objects with undo/redo mechanisms""" PEN_OVERLAP_WIDTH = 2.5 + PEN_OVERLAP_WIDTH_EXTENDED = PEN_OVERLAP_WIDTH + 0.5 def __init__(self, texture: Texture, 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) + mergeable: bool, + sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None: + super().__init__(texture, bit_range, make_original_image = not mergeable, sliceable_object_decorator=sliceable_object_decorator) self._stroke_polygons: List[Polygon] = stroke_polygons self._calculateBoundingRect() self._set_value: int = set_value @@ -40,6 +43,7 @@ class PaintStrokeCommand(PaintCommand): painter.drawPath(self._makePainterPath()) painter.end() + self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: @@ -55,9 +59,9 @@ class PaintStrokeCommand(PaintCommand): return True - def _clearTextureBits(self, painter: QPainter): + def _clearTextureBits(self, painter: QPainter, extended = False): painter.setBrush(QBrush(self._getBitRangeMask())) - painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) + painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH_EXTENDED if extended else self.PEN_OVERLAP_WIDTH)) painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination) painter.drawPath(self._makePainterPath()) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index ff2f196431..72796837e0 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -412,7 +412,7 @@ class PaintTool(Tool): Logger.logException("e", "Error when adding paint stroke") self._last_world_coords = world_coords - self._updateScene(painted_object, update_node = self._mouse_held) + self._updateScene(painted_object, update_node = event_caught) return event_caught return False diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 6750c5b33e..d7bfb6f1c9 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -23,6 +23,7 @@ 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 cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from .PaintStrokeCommand import PaintStrokeCommand from .PaintClearCommand import PaintClearCommand @@ -242,7 +243,14 @@ class PaintView(CuraView): stroke_path, set_value, self._current_bits_ranges, - merge_with_previous)) + merge_with_previous, + self._getSliceableObjectDecorator())) + + def _getSliceableObjectDecorator(self) -> Optional[SliceableObjectDecorator]: + if self._painted_object is None or self._current_paint_type != "extruder": + return None + + return self._painted_object.getDecorator(SliceableObjectDecorator) def _makeClearCommand(self) -> Optional[PaintClearCommand]: if self._painted_object is None or self._paint_texture is None or self._current_bits_ranges is None: @@ -254,7 +262,10 @@ class PaintView(CuraView): if extruder_stack is not None: set_value = extruder_stack.getValue("extruder_nr") - return PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value) + return PaintClearCommand(self._paint_texture, + self._current_bits_ranges, + set_value, + self._getSliceableObjectDecorator()) def clearPaint(self): self._prepareDataMapping()