From e3204707dbbc576984007994d769d33c526f520b Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 1 Oct 2025 22:39:22 +0200 Subject: [PATCH 01/13] Move 'get painted on extruders' method, use for build-volume. (Not sure I'm happy with this, but) now we can use this _both_ in the slicing itself _and_ the bounds. The big downsides are a) I had to connect the scene changed signal to the on-stack-changed method, that seems ugly and potentially slow b) I'm not sure this method belongs in the ExtruderManager -- otoh, where else is it going to live (unless we want to make a new type of plugin-object?). CURA-12752 --- cura/BuildVolume.py | 3 ++- cura/Settings/ExtruderManager.py | 23 ++++++++++++++++++++++ plugins/CuraEngineBackend/StartSliceJob.py | 18 ++--------------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 18b762bb2c..1dd50b5a0d 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -117,6 +117,7 @@ class BuildVolume(SceneNode): self._has_errors = False self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) + self._application.getController().getScene().sceneChanged.connect(self._onStackChanged) # 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/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index cd39947bf8..4be5c3121c 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -19,6 +19,8 @@ from cura.Settings.ExtruderStack import ExtruderStack from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union +import numpy + if TYPE_CHECKING: from cura.Settings.ExtruderStack import ExtruderStack @@ -196,6 +198,24 @@ class ExtruderManager(QObject): else: return value + @staticmethod + def getPaintedExtruders(node: "SceneNode") -> List[int]: + 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().copy() + image_ptr = texture_image.bits() + 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"] + image_array = (image_array << (32 - 1 - (bit_range_end - bit_range_start))) >> (32 - 1 - bit_range_end) + + return numpy.unique(image_array).tolist() + else: + return [] + def getUsedExtruderStacks(self) -> List["ExtruderStack"]: """Gets the extruder stacks that are actually being used at the moment. @@ -254,6 +274,9 @@ class ExtruderManager(QObject): if not support_roof_enabled: support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value") + for extruder_nr in ExtruderManager.getPaintedExtruders(node): + 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 8f312a4afb..78f2f3054d 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -764,24 +764,10 @@ class StartSliceJob(Job): @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().copy() - image_ptr = texture_image.bits() - 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"] - image_array = (image_array << (32 - 1 - (bit_range_end - bit_range_start))) >> (32 - 1 - bit_range_end) - used_extruders = numpy.unique(image_array).tolist() + used_extruders = ExtruderManager.getInstance().getPaintedExtruders(node) # 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 From f910983616741f8a554cc9fdb831fd85c5f76ac2 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 1 Oct 2025 23:12:27 +0200 Subject: [PATCH 02/13] Spam the signals a bit less. done as part of CURA-12752 --- plugins/PaintTool/PaintTool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 087119e3d6..a3acbb38b1 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -455,7 +455,7 @@ class PaintTool(Tool): Logger.logException("e", "Error when adding paint stroke") self._last_world_coords = world_coords - self._updateScene(node) + self._updateScene(node if event_caught else None) return event_caught return False From 9d52e5a2a6b2085b7736b7e225f1c8826353e021 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 1 Oct 2025 23:14:11 +0200 Subject: [PATCH 03/13] Fix ordering of texture-mappings. ... unless little vs. big endian-ness was the problem. shoved into CURA-12752 since I was in the code anyway. --- cura/Settings/ExtruderManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index 4be5c3121c..9c12d6d92b 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -210,7 +210,7 @@ class ExtruderManager(QObject): image_array = numpy.frombuffer(image_ptr, dtype=numpy.uint32).reshape(image_size) bit_range_start, bit_range_end = texture_data_mapping["extruder"] - image_array = (image_array << (32 - 1 - (bit_range_end - bit_range_start))) >> (32 - 1 - bit_range_end) + image_array = (image_array << (32 - 1 - bit_range_end)) >> (32 - 1 - (bit_range_end - bit_range_start)) return numpy.unique(image_array).tolist() else: From ec38c8d4c67d1d47280c995270540144bc5ad7bd Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Thu, 2 Oct 2025 14:15:45 +0200 Subject: [PATCH 04/13] Optimize function by not coping the image CURA-12752 --- cura/Settings/ExtruderManager.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index 9c12d6d92b..bd8d956988 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -203,16 +203,17 @@ class ExtruderManager(QObject): 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().copy() - image_ptr = texture_image.bits() + 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"] - image_array = (image_array << (32 - 1 - bit_range_end)) >> (32 - 1 - (bit_range_end - bit_range_start)) + full_int32 = 0xffffffff + bit_mask = (((full_int32 << ( 32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (32 - 1 - bit_range_end)) - return numpy.unique(image_array).tolist() + return (numpy.unique(image_array & bit_mask) >> bit_range_start).tolist() else: return [] From c412def982bd3188dc16eea273a51539b2943cd7 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 8 Oct 2025 09:58:05 +0200 Subject: [PATCH 05/13] Extruder counts in image was too slow, cache and do per bounding-rect. Rewrite the whole 'count pixels to get extruders for paint on materials' so that it's cached outside of the extruder manager instead, so that counting pixels in a 4096x4096 image isn't called xx of times per second. part of CURA-12752 --- cura/BuildVolume.py | 1 - cura/Scene/SliceableObjectDecorator.py | 22 ++++++++++++++++++++ cura/Settings/ExtruderManager.py | 17 +++------------- plugins/PaintTool/PaintClearCommand.py | 5 +++-- plugins/PaintTool/PaintCommand.py | 27 +++++++++++++++++++++++-- plugins/PaintTool/PaintStrokeCommand.py | 3 +++ plugins/PaintTool/PaintView.py | 13 +++++++++--- 7 files changed, 66 insertions(+), 22 deletions(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 1dd50b5a0d..929a1425b4 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -117,7 +117,6 @@ class BuildVolume(SceneNode): self._has_errors = False self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) - self._application.getController().getScene().sceneChanged.connect(self._onStackChanged) # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 7b4cbab3e5..eb33979dfe 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. import copy import json @@ -18,6 +20,8 @@ class SliceableObjectDecorator(SceneNodeDecorator): self._paint_texture = None self._texture_data_mapping: Dict[str, tuple[int, int]] = {} + self._extruder_texel_counts: Dict[int, int] = {} + self.paintTextureChanged = Signal() def isSliceable(self) -> bool: @@ -29,8 +33,15 @@ class SliceableObjectDecorator(SceneNodeDecorator): def getPaintTextureChangedSignal(self) -> Signal: return self.paintTextureChanged + def _initTexelCounts(self) -> None: + if "extruder" in self._texture_data_mapping: + full_rect = self._paint_texture.getImage().rect() + bit_range = self._texture_data_mapping["extruder"] + self._extruder_texel_counts = self._paint_texture.getTexelCountsInRect(full_rect, bit_range) + def setPaintTexture(self, texture: Texture) -> None: self._paint_texture = texture + self._initTexelCounts() self.paintTextureChanged.emit() def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]: @@ -38,12 +49,14 @@ class SliceableObjectDecorator(SceneNodeDecorator): def setTextureDataMapping(self, mapping: Dict[str, tuple[int, int]]) -> None: self._texture_data_mapping = mapping + self._initTexelCounts() def prepareTexture(self, width: int, height: int) -> None: if self._paint_texture is None: self._paint_texture = OpenGL.getInstance().createTexture(width, height) image = QImage(width, height, QImage.Format.Format_RGB32) image.fill(0) + self._extruder_texel_counts = {0: self._paint_texture.getWidth() * self._paint_texture.getHeight()} self._paint_texture.setImage(image) self.paintTextureChanged.emit() @@ -63,6 +76,15 @@ class SliceableObjectDecorator(SceneNodeDecorator): return texture_buffer.data() + def changeExtruderTexelCounts(self, texel_changes: Dict[int, int]) -> None: + for extruder_id, texel_count in texel_changes.items(): + if extruder_id not in self._extruder_texel_counts: + self._extruder_texel_counts[extruder_id] = 0 + self._extruder_texel_counts[extruder_id] += texel_count + + def getExtruderTexelCounts(self) -> Dict[int, int]: + return self._extruder_texel_counts + 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 bd8d956988..a3b91d2fbe 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. @@ -19,8 +19,6 @@ from cura.Settings.ExtruderStack import ExtruderStack from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union -import numpy - if TYPE_CHECKING: from cura.Settings.ExtruderStack import ExtruderStack @@ -203,17 +201,8 @@ class ExtruderManager(QObject): 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)) - - return (numpy.unique(image_array & bit_mask) >> bit_range_start).tolist() + texel_counts_per_extruder = node.callDecoration("getExtruderTexelCounts") + return [extruder_id for extruder_id, count in texel_counts_per_extruder.items() if count > 0] else: return [] diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 1a7d95c98a..7e80b9571c 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -21,14 +21,15 @@ class PaintClearCommand(PaintCommand): return 1 def redo(self) -> None: - painter = self._makeClearedTexture() + texel_counts_before = self._countTexels() + 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._pushTexelDifference(texel_counts_before) self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index 9dfae1d092..0fc57bbcc4 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -1,12 +1,13 @@ # 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.Scene.SliceableObjectDecorator import SliceableObjectDecorator class PaintCommand(QUndoCommand): @@ -22,6 +23,8 @@ class PaintCommand(QUndoCommand): self._original_texture_image = None self._bounding_rect = texture.getImage().rect() + self._texel_count_object: Optional[SliceableObjectDecorator] = None + if make_original_image: self._original_texture_image = self._texture.getImage().copy() painter = QPainter(self._original_texture_image) @@ -31,17 +34,37 @@ class PaintCommand(QUndoCommand): painter.fillRect(self._original_texture_image.rect(), QBrush(self._getBitRangeMask())) painter.end() + def enableTexelCounting(self, texel_count_object: Optional[SliceableObjectDecorator] = None): + self._texel_count_object = texel_count_object + def undo(self) -> None: if self._original_texture_image is None: return + texel_counts_before = self._countTexels() + painter = self._makeClearedTexture() painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) painter.drawImage(0, 0, self._original_texture_image) painter.end() + self._pushTexelDifference(texel_counts_before) self._texture.updateImagePart(self._bounding_rect) + def _pushTexelDifference(self, texel_counts_before: Dict[int, int]) -> None: + if self._texel_count_object is None: + return + texel_counts_changed = {} + texel_counts_after = self._countTexels() + for bit in self._bit_range: + texel_counts_changed[bit] = texel_counts_after.get(bit, 0) - texel_counts_before.get(bit, 0) + self._texel_count_object.changeExtruderTexelCounts(texel_counts_changed) + + def _countTexels(self) -> Dict[int, int]: + if self._texel_count_object is None: + return {} + return self._texture.getTexelCountsInRect(self._bounding_rect, self._bit_range) + def _makeClearedTexture(self) -> QPainter: painter = QPainter(self._texture.getImage()) painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index 8d4a5c2dbd..570b8af70b 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -33,6 +33,8 @@ class PaintStrokeCommand(PaintCommand): return 0 def redo(self) -> None: + texel_counts_before = self._countTexels() + painter = self._makeClearedTexture() painter.setBrush(QBrush(self._set_value)) painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) @@ -40,6 +42,7 @@ class PaintStrokeCommand(PaintCommand): painter.drawPath(self._makePainterPath()) painter.end() + self._pushTexelDifference(texel_counts_before) self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 13f6acff3c..032d843266 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 @@ -230,11 +231,14 @@ class PaintView(CuraView): return set_value = self._shiftTextureValue(self._paint_modes[self._current_paint_type][brush_color].value) - stack.push(PaintStrokeCommand(self._paint_texture, + res = PaintStrokeCommand(self._paint_texture, stroke_path, set_value, self._current_bits_ranges, - merge_with_previous)) + merge_with_previous) + if self._current_paint_type == "extruder": + res.enableTexelCounting(self._painted_object.getDecorator(SliceableObjectDecorator)) + stack.push(res) def _makeClearCommand(self) -> Optional[PaintClearCommand]: if self._painted_object is None or self._paint_texture is None or self._current_bits_ranges is None: @@ -246,7 +250,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) + res = PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value) + if self._current_paint_type == "extruder": + res.enableTexelCounting(self._painted_object.getDecorator(SliceableObjectDecorator)) + return res def clearPaint(self): self._prepareDataMapping() From 0e5442d5706c55bffdc82677290621c27856628f Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 13 Oct 2025 13:33:51 +0200 Subject: [PATCH 06/13] Fix extruders counting method CURA-12752 --- plugins/PaintTool/PaintCommand.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index 0fc57bbcc4..fb26f04181 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -56,8 +56,8 @@ class PaintCommand(QUndoCommand): return texel_counts_changed = {} texel_counts_after = self._countTexels() - for bit in self._bit_range: - texel_counts_changed[bit] = texel_counts_after.get(bit, 0) - texel_counts_before.get(bit, 0) + for extruder_nr in range(2 ** (self._bit_range[1] - self._bit_range[0] + 1)): + texel_counts_changed[extruder_nr] = texel_counts_after.get(extruder_nr, 0) - texel_counts_before.get(extruder_nr, 0) self._texel_count_object.changeExtruderTexelCounts(texel_counts_changed) def _countTexels(self) -> Dict[int, int]: From 375f030c0915a7ee214d90d3521b3b094d409df7 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 13 Oct 2025 14:22:39 +0200 Subject: [PATCH 07/13] Update extruders count only when inactive CURA-12752 The previous method was not efficient enough in case of large models, where a single painting stroke can easily cover almost the whole texture (in bounding box). Reverted to the version where the whole texture is counted, but cached in the SliceableObjectDecorator and updated on timer so that it is not done during painting. --- cura/BuildVolume.py | 1 + cura/Scene/SliceableObjectDecorator.py | 52 ++++++++++++------- cura/Settings/ExtruderManager.py | 16 ++---- plugins/CuraEngineBackend/StartSliceJob.py | 2 +- .../MultiMaterialExtruderConverter.py | 2 + plugins/PaintTool/PaintClearCommand.py | 13 +++-- plugins/PaintTool/PaintCommand.py | 31 ++++------- plugins/PaintTool/PaintStrokeCommand.py | 10 ++-- plugins/PaintTool/PaintView.py | 22 ++++---- 9 files changed, 77 insertions(+), 72 deletions(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 929a1425b4..1dd50b5a0d 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -117,6 +117,7 @@ class BuildVolume(SceneNode): self._has_errors = False self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) + self._application.getController().getScene().sceneChanged.connect(self._onStackChanged) # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index eb33979dfe..56b7ed555c 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -2,10 +2,11 @@ # 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 @@ -20,10 +21,15 @@ class SliceableObjectDecorator(SceneNodeDecorator): self._paint_texture = None self._texture_data_mapping: Dict[str, tuple[int, int]] = {} - self._extruder_texel_counts: Dict[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 @@ -33,15 +39,31 @@ class SliceableObjectDecorator(SceneNodeDecorator): def getPaintTextureChangedSignal(self) -> Signal: return self.paintTextureChanged - def _initTexelCounts(self) -> None: - if "extruder" in self._texture_data_mapping: - full_rect = self._paint_texture.getImage().rect() - bit_range = self._texture_data_mapping["extruder"] - self._extruder_texel_counts = self._paint_texture.getTexelCountsInRect(full_rect, bit_range) + 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] def setPaintTexture(self, texture: Texture) -> None: self._paint_texture = texture - self._initTexelCounts() self.paintTextureChanged.emit() def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]: @@ -49,14 +71,12 @@ class SliceableObjectDecorator(SceneNodeDecorator): def setTextureDataMapping(self, mapping: Dict[str, tuple[int, int]]) -> None: self._texture_data_mapping = mapping - self._initTexelCounts() def prepareTexture(self, width: int, height: int) -> None: if self._paint_texture is None: self._paint_texture = OpenGL.getInstance().createTexture(width, height) image = QImage(width, height, QImage.Format.Format_RGB32) image.fill(0) - self._extruder_texel_counts = {0: self._paint_texture.getWidth() * self._paint_texture.getHeight()} self._paint_texture.setImage(image) self.paintTextureChanged.emit() @@ -76,14 +96,8 @@ class SliceableObjectDecorator(SceneNodeDecorator): return texture_buffer.data() - def changeExtruderTexelCounts(self, texel_changes: Dict[int, int]) -> None: - for extruder_id, texel_count in texel_changes.items(): - if extruder_id not in self._extruder_texel_counts: - self._extruder_texel_counts[extruder_id] = 0 - self._extruder_texel_counts[extruder_id] += texel_count - - def getExtruderTexelCounts(self) -> Dict[int, int]: - return self._extruder_texel_counts + def getPaintedExtruders(self) -> Optional[List[int]]: + return self._painted_extruders def __deepcopy__(self, memo) -> "SliceableObjectDecorator": copied_decorator = SliceableObjectDecorator() diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index a3b91d2fbe..8e950350b3 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -196,16 +196,6 @@ class ExtruderManager(QObject): else: return value - @staticmethod - def getPaintedExtruders(node: "SceneNode") -> List[int]: - 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: - texel_counts_per_extruder = node.callDecoration("getExtruderTexelCounts") - return [extruder_id for extruder_id, count in texel_counts_per_extruder.items() if count > 0] - else: - return [] - def getUsedExtruderStacks(self) -> List["ExtruderStack"]: """Gets the extruder stacks that are actually being used at the moment. @@ -264,8 +254,10 @@ class ExtruderManager(QObject): if not support_roof_enabled: support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value") - for extruder_nr in ExtruderManager.getPaintedExtruders(node): - used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)]) + 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", diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 78f2f3054d..7f8a069509 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -764,7 +764,7 @@ class StartSliceJob(Job): @staticmethod def _getUsedExtruders(node: SceneNode) -> List[int]: - used_extruders = ExtruderManager.getInstance().getPaintedExtruders(node) + used_extruders = node.callDecoration("getPaintedExtruders") # There is no relevant painting data, just take the extruder associated to the model if not 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 7e80b9571c..2c9f3f216d 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 Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from .PaintCommand import PaintCommand @@ -13,23 +14,25 @@ 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: return 1 def redo(self) -> None: - texel_counts_before = self._countTexels() - 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._pushTexelDifference(texel_counts_before) + self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index fb26f04181..ad4afa7fa3 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -15,7 +15,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 @@ -23,7 +27,7 @@ class PaintCommand(QUndoCommand): self._original_texture_image = None self._bounding_rect = texture.getImage().rect() - self._texel_count_object: Optional[SliceableObjectDecorator] = None + self._sliceable_object_decorator: Optional[SliceableObjectDecorator] = sliceable_object_decorator if make_original_image: self._original_texture_image = self._texture.getImage().copy() @@ -34,36 +38,21 @@ class PaintCommand(QUndoCommand): painter.fillRect(self._original_texture_image.rect(), QBrush(self._getBitRangeMask())) painter.end() - def enableTexelCounting(self, texel_count_object: Optional[SliceableObjectDecorator] = None): - self._texel_count_object = texel_count_object - def undo(self) -> None: if self._original_texture_image is None: return - texel_counts_before = self._countTexels() - painter = self._makeClearedTexture() painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination) painter.drawImage(0, 0, self._original_texture_image) painter.end() - self._pushTexelDifference(texel_counts_before) + self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) - def _pushTexelDifference(self, texel_counts_before: Dict[int, int]) -> None: - if self._texel_count_object is None: - return - texel_counts_changed = {} - texel_counts_after = self._countTexels() - for extruder_nr in range(2 ** (self._bit_range[1] - self._bit_range[0] + 1)): - texel_counts_changed[extruder_nr] = texel_counts_after.get(extruder_nr, 0) - texel_counts_before.get(extruder_nr, 0) - self._texel_count_object.changeExtruderTexelCounts(texel_counts_changed) - - def _countTexels(self) -> Dict[int, int]: - if self._texel_count_object is None: - return {} - return self._texture.getTexelCountsInRect(self._bounding_rect, self._bit_range) + def _setPaintedExtrudersCountDirty(self) -> None: + if self._sliceable_object_decorator is not None: + self._sliceable_object_decorator.setPaintedExtrudersCountDirty() def _makeClearedTexture(self) -> QPainter: painter = QPainter(self._texture.getImage()) diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index 570b8af70b..fa995f2aac 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 Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from UM.Math.Polygon import Polygon @@ -22,8 +23,9 @@ class PaintStrokeCommand(PaintCommand): 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 @@ -33,8 +35,6 @@ class PaintStrokeCommand(PaintCommand): return 0 def redo(self) -> None: - texel_counts_before = self._countTexels() - painter = self._makeClearedTexture() painter.setBrush(QBrush(self._set_value)) painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH)) @@ -42,7 +42,7 @@ class PaintStrokeCommand(PaintCommand): painter.drawPath(self._makePainterPath()) painter.end() - self._pushTexelDifference(texel_counts_before) + self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) def mergeWith(self, command: QUndoCommand) -> bool: diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 032d843266..c40724c6cb 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -231,14 +231,18 @@ class PaintView(CuraView): return set_value = self._shiftTextureValue(self._paint_modes[self._current_paint_type][brush_color].value) - res = PaintStrokeCommand(self._paint_texture, + stack.push(PaintStrokeCommand(self._paint_texture, stroke_path, set_value, self._current_bits_ranges, - merge_with_previous) - if self._current_paint_type == "extruder": - res.enableTexelCounting(self._painted_object.getDecorator(SliceableObjectDecorator)) - stack.push(res) + 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: @@ -250,10 +254,10 @@ class PaintView(CuraView): if extruder_stack is not None: set_value = extruder_stack.getValue("extruder_nr") - res = PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value) - if self._current_paint_type == "extruder": - res.enableTexelCounting(self._painted_object.getDecorator(SliceableObjectDecorator)) - return res + return PaintClearCommand(self._paint_texture, + self._current_bits_ranges, + set_value, + self._getSliceableObjectDecorator()) def clearPaint(self): self._prepareDataMapping() From 0396a782b721c4e5d3363a4be4ba459586f65812 Mon Sep 17 00:00:00 2001 From: Remco Burema <41987080+rburema@users.noreply.github.com> Date: Tue, 14 Oct 2025 11:35:28 +0200 Subject: [PATCH 08/13] Change from code-review. Easier to read. done as part or CURA-12752 Co-authored-by: Casper Lamboo --- cura/BuildVolume.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 1dd50b5a0d..9037320238 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -116,8 +116,9 @@ class BuildVolume(SceneNode): self._application.engineCreatedSignal.connect(self._onEngineCreated) self._has_errors = False - self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged) - self._application.getController().getScene().sceneChanged.connect(self._onStackChanged) + scene = self._application.getController().getScene() + scene.sceneChanged.connect(self._onSceneChanged) + scene.sceneChanged.connect(self._onStackChanged) # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] From 0f18b5e32392225886866de77df69a43f68cfb37 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 14 Oct 2025 11:37:14 +0200 Subject: [PATCH 09/13] Rename method to better cover intended meaning. done as part of CURA-12752 --- plugins/CuraEngineBackend/StartSliceJob.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index 7f8a069509..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,7 +763,7 @@ class StartSliceJob(Job): self._addRelations(relations_set, relation.target.relations) @staticmethod - def _getUsedExtruders(node: SceneNode) -> List[int]: + 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 From d5fc04684afbbbfc39c1fb78944afec88ac3ae60 Mon Sep 17 00:00:00 2001 From: Remco Burema <41987080+rburema@users.noreply.github.com> Date: Wed, 15 Oct 2025 08:55:10 +0200 Subject: [PATCH 10/13] Apply suggestions from code review (imports). done as part of CURA-12752 Co-authored-by: HellAholic --- plugins/PaintTool/PaintClearCommand.py | 2 +- plugins/PaintTool/PaintStrokeCommand.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 2c9f3f216d..1fb5ce4467 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -5,7 +5,7 @@ from typing import Optional from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush -from Scene.SliceableObjectDecorator import SliceableObjectDecorator +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from .PaintCommand import PaintCommand diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index fa995f2aac..ec61864838 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -7,7 +7,7 @@ import math from PyQt6.QtCore import QRect, QRectF, QPoint from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush -from Scene.SliceableObjectDecorator import SliceableObjectDecorator +from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from UM.View.GL.Texture import Texture from UM.Math.Polygon import Polygon From f9c77f87307ab6b4c00fe259b7fff24933756023 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 15 Oct 2025 11:15:08 +0200 Subject: [PATCH 11/13] For multi-material painting; stack needs to be updated. ... because the extruders used for the current object can change (clear all bits of extruder #2 paint on a single object, which results in the object printed with extruder #1 only, which could result in the prime-tower needing to be gone -- or the other way around). The _previous_ way of doing that was just spamming the stack changes, but that gave other problems. part of CURA-12752 --- cura/BuildVolume.py | 1 - plugins/PaintTool/PaintClearCommand.py | 2 ++ plugins/PaintTool/PaintCommand.py | 6 ++++++ plugins/PaintTool/PaintStrokeCommand.py | 2 ++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cura/BuildVolume.py b/cura/BuildVolume.py index 9037320238..e931b7c056 100755 --- a/cura/BuildVolume.py +++ b/cura/BuildVolume.py @@ -118,7 +118,6 @@ class BuildVolume(SceneNode): self._has_errors = False scene = self._application.getController().getScene() scene.sceneChanged.connect(self._onSceneChanged) - scene.sceneChanged.connect(self._onStackChanged) # Objects loaded at the moment. We are connected to the property changed events of these objects. self._scene_objects = set() # type: Set[SceneNode] diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 1fb5ce4467..5b809d2c1f 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -35,6 +35,8 @@ class PaintClearCommand(PaintCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) + self._signalUpdated() + def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintClearCommand): return False diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index ad4afa7fa3..a1dce6b7d0 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -7,6 +7,7 @@ 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 @@ -50,6 +51,8 @@ class PaintCommand(QUndoCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) + self._signalUpdated() + def _setPaintedExtrudersCountDirty(self) -> None: if self._sliceable_object_decorator is not None: self._sliceable_object_decorator.setPaintedExtrudersCountDirty() @@ -72,3 +75,6 @@ class PaintCommand(QUndoCommand): def _getBitRangeMask(self) -> int: return PaintCommand.getBitRangeMask(self._bit_range) + + def _signalUpdated(self): + CuraApplication.getInstance().globalContainerStackChanged.emit() diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index ec61864838..b71b98e942 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -45,6 +45,8 @@ class PaintStrokeCommand(PaintCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) + self._signalUpdated() + def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintStrokeCommand): return False From 06a75924839e56f431a1882f4e4c4924935613c1 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 15 Oct 2025 16:01:03 +0200 Subject: [PATCH 12/13] Do not update stacks at every stroke, but under the anti-bounce timer CURA-12752 --- cura/Scene/SliceableObjectDecorator.py | 3 +++ plugins/PaintTool/PaintClearCommand.py | 2 -- plugins/PaintTool/PaintCommand.py | 5 ----- plugins/PaintTool/PaintStrokeCommand.py | 2 -- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py index 56b7ed555c..3e7d9901ad 100644 --- a/cura/Scene/SliceableObjectDecorator.py +++ b/cura/Scene/SliceableObjectDecorator.py @@ -62,6 +62,9 @@ class SliceableObjectDecorator(SceneNodeDecorator): 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() diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 5b809d2c1f..1fb5ce4467 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -35,8 +35,6 @@ class PaintClearCommand(PaintCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) - self._signalUpdated() - def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintClearCommand): return False diff --git a/plugins/PaintTool/PaintCommand.py b/plugins/PaintTool/PaintCommand.py index a1dce6b7d0..c4596405cc 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -51,8 +51,6 @@ class PaintCommand(QUndoCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) - self._signalUpdated() - def _setPaintedExtrudersCountDirty(self) -> None: if self._sliceable_object_decorator is not None: self._sliceable_object_decorator.setPaintedExtrudersCountDirty() @@ -75,6 +73,3 @@ class PaintCommand(QUndoCommand): def _getBitRangeMask(self) -> int: return PaintCommand.getBitRangeMask(self._bit_range) - - def _signalUpdated(self): - CuraApplication.getInstance().globalContainerStackChanged.emit() diff --git a/plugins/PaintTool/PaintStrokeCommand.py b/plugins/PaintTool/PaintStrokeCommand.py index b71b98e942..ec61864838 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -45,8 +45,6 @@ class PaintStrokeCommand(PaintCommand): self._setPaintedExtrudersCountDirty() self._texture.updateImagePart(self._bounding_rect) - self._signalUpdated() - def mergeWith(self, command: QUndoCommand) -> bool: if not isinstance(command, PaintStrokeCommand): return False From 33671083cdaf3c4e2dd5d58fef02bddeeaabbdd4 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 15 Oct 2025 16:43:44 +0200 Subject: [PATCH 13/13] Make sure undo stroke properly clears all the set pixels CURA-12752 Otherwise, when merging the polygons and undo-ing the whole stroke, there may be some remaining pixels outside the mesh triangles that would not be cleared, because the rasterizing is not 100% identical --- plugins/PaintTool/PaintClearCommand.py | 2 +- plugins/PaintTool/PaintCommand.py | 8 ++++---- plugins/PaintTool/PaintStrokeCommand.py | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/plugins/PaintTool/PaintClearCommand.py b/plugins/PaintTool/PaintClearCommand.py index 1fb5ce4467..1be8b9bcbf 100644 --- a/plugins/PaintTool/PaintClearCommand.py +++ b/plugins/PaintTool/PaintClearCommand.py @@ -42,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 c4596405cc..5367d0e0ca 100644 --- a/plugins/PaintTool/PaintCommand.py +++ b/plugins/PaintTool/PaintCommand.py @@ -43,7 +43,7 @@ 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() @@ -55,14 +55,14 @@ class PaintCommand(QUndoCommand): if self._sliceable_object_decorator is not None: self._sliceable_object_decorator.setPaintedExtrudersCountDirty() - def _makeClearedTexture(self) -> QPainter: + 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 ec61864838..bcb38a19bc 100644 --- a/plugins/PaintTool/PaintStrokeCommand.py +++ b/plugins/PaintTool/PaintStrokeCommand.py @@ -17,6 +17,7 @@ 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, @@ -58,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())