Skip to content

Commit be88d74

Browse files
authored
Merge pull request #21001 from Ultimaker/CURA-12752_multimat_paint_prime_tower
[CURA-12752] Fix prime-tower for multi-material painting
2 parents 3656330 + 3367108 commit be88d74

File tree

10 files changed

+107
-45
lines changed

10 files changed

+107
-45
lines changed

cura/BuildVolume.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def __init__(self, application: "CuraApplication", parent: Optional[SceneNode] =
116116
self._application.engineCreatedSignal.connect(self._onEngineCreated)
117117

118118
self._has_errors = False
119-
self._application.getController().getScene().sceneChanged.connect(self._onSceneChanged)
119+
scene = self._application.getController().getScene()
120+
scene.sceneChanged.connect(self._onSceneChanged)
120121

121122
# Objects loaded at the moment. We are connected to the property changed events of these objects.
122123
self._scene_objects = set() # type: Set[SceneNode]
@@ -655,7 +656,7 @@ def _calculateExtraZClearance(self, extruders: List["ContainerStack"]) -> float:
655656
extra_z = retraction_hop
656657
return extra_z
657658

658-
def _onStackChanged(self):
659+
def _onStackChanged(self, *args) -> None:
659660
self._stack_change_timer.start()
660661

661662
def _onStackChangeTimerFinished(self) -> None:

cura/Scene/SliceableObjectDecorator.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
# Copyright (c) 2025 UltiMaker
2+
# Cura is released under the terms of the LGPLv3 or higher.
13
import copy
24
import json
5+
import numpy
36

4-
from typing import Optional, Dict
7+
from typing import Optional, Dict, List
58

6-
from PyQt6.QtCore import QBuffer
9+
from PyQt6.QtCore import QBuffer, QTimer
710
from PyQt6.QtGui import QImage, QImageWriter
811

912
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
@@ -18,8 +21,15 @@ def __init__(self) -> None:
1821
self._paint_texture = None
1922
self._texture_data_mapping: Dict[str, tuple[int, int]] = {}
2023

24+
self._painted_extruders: Optional[List[int]] = None
25+
2126
self.paintTextureChanged = Signal()
2227

28+
self._texture_change_timer = QTimer()
29+
self._texture_change_timer.setInterval(500) # Long interval to avoid triggering during painting
30+
self._texture_change_timer.setSingleShot(True)
31+
self._texture_change_timer.timeout.connect(self._onTextureChangeTimerFinished)
32+
2333
def isSliceable(self) -> bool:
2434
return True
2535

@@ -29,6 +39,32 @@ def getPaintTexture(self) -> Optional[Texture]:
2939
def getPaintTextureChangedSignal(self) -> Signal:
3040
return self.paintTextureChanged
3141

42+
def setPaintedExtrudersCountDirty(self) -> None:
43+
self._texture_change_timer.start()
44+
45+
def _onTextureChangeTimerFinished(self) -> None:
46+
self._painted_extruders = None
47+
48+
if (self._paint_texture is None or self._paint_texture.getImage() is None or
49+
"extruder" not in self._texture_data_mapping):
50+
return
51+
52+
image = self._paint_texture.getImage()
53+
image_bits = image.constBits()
54+
image_bits.setsize(image.sizeInBytes())
55+
image_array = numpy.frombuffer(image_bits, dtype=numpy.uint32)
56+
57+
bit_range_start, bit_range_end = self._texture_data_mapping["extruder"]
58+
full_int32 = 0xffffffff
59+
bit_mask = (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (
60+
32 - 1 - bit_range_end))
61+
62+
texel_counts = numpy.bincount((image_array & bit_mask) >> bit_range_start)
63+
self._painted_extruders = [extruder_nr for extruder_nr, count in enumerate(texel_counts) if count > 0]
64+
65+
from cura.CuraApplication import CuraApplication
66+
CuraApplication.getInstance().globalContainerStackChanged.emit()
67+
3268
def setPaintTexture(self, texture: Texture) -> None:
3369
self._paint_texture = texture
3470
self.paintTextureChanged.emit()
@@ -63,6 +99,9 @@ def packTexture(self) -> Optional[bytearray]:
6399

64100
return texture_buffer.data()
65101

102+
def getPaintedExtruders(self) -> Optional[List[int]]:
103+
return self._painted_extruders
104+
66105
def __deepcopy__(self, memo) -> "SliceableObjectDecorator":
67106
copied_decorator = SliceableObjectDecorator()
68107
copied_decorator.setPaintTexture(copy.deepcopy(self.getPaintTexture()))

cura/Settings/ExtruderManager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022 Ultimaker B.V.
1+
# Copyright (c) 2025 UltiMaker
22
# Cura is released under the terms of the LGPLv3 or higher.
33

44
from PyQt6.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt.
@@ -254,6 +254,11 @@ def getUsedExtruderStacks(self) -> List["ExtruderStack"]:
254254
if not support_roof_enabled:
255255
support_roof_enabled |= stack_to_use.getProperty("support_roof_enable", "value")
256256

257+
painted_extruders = node.callDecoration("getPaintedExtruders")
258+
if painted_extruders is not None:
259+
for extruder_nr in painted_extruders:
260+
used_extruder_stack_ids.add(self.extruderIds[str(extruder_nr)])
261+
257262
# Check limit to extruders
258263
limit_to_extruder_feature_list = ["wall_0_extruder_nr",
259264
"wall_x_extruder_nr",

plugins/CuraEngineBackend/StartSliceJob.py

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ def run(self) -> None:
416416
# Only check if the printing extruder is enabled for printing meshes
417417
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
418418
if not is_non_printing_mesh:
419-
for used_extruder in StartSliceJob._getUsedExtruders(node):
419+
for used_extruder in StartSliceJob._getMainExtruders(node):
420420
if not extruders_enabled[used_extruder]:
421421
skip_group = True
422422
has_model_with_disabled_extruders = True
@@ -763,28 +763,11 @@ def _addRelations(self, relations_set: Set[str], relations: List[SettingRelation
763763
self._addRelations(relations_set, relation.target.relations)
764764

765765
@staticmethod
766-
def _getUsedExtruders(node: SceneNode) -> List[int]:
767-
used_extruders = []
768-
769-
# Look at extruders used in painted texture
770-
node_texture = node.callDecoration("getPaintTexture")
771-
texture_data_mapping = node.callDecoration("getTextureDataMapping")
772-
if node_texture is not None and texture_data_mapping is not None and "extruder" in texture_data_mapping:
773-
texture_image = node_texture.getImage()
774-
image_ptr = texture_image.constBits()
775-
image_ptr.setsize(texture_image.sizeInBytes())
776-
image_size = texture_image.height(), texture_image.width()
777-
image_array = numpy.frombuffer(image_ptr, dtype=numpy.uint32).reshape(image_size)
778-
779-
bit_range_start, bit_range_end = texture_data_mapping["extruder"]
780-
full_int32 = 0xffffffff
781-
bit_mask = (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (
782-
32 - 1 - bit_range_end))
783-
784-
used_extruders = (numpy.unique(image_array & bit_mask) >> bit_range_start).tolist()
766+
def _getMainExtruders(node: SceneNode) -> List[int]:
767+
used_extruders = node.callDecoration("getPaintedExtruders")
785768

786769
# There is no relevant painting data, just take the extruder associated to the model
787770
if not used_extruders:
788771
used_extruders = [int(node.callDecoration("getActiveExtruderPosition"))]
789772

790-
return used_extruders
773+
return used_extruders

plugins/PaintTool/MultiMaterialExtruderConverter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,6 @@ def _changeMainObjectExtruder(self, node: SceneNode) -> None:
109109

110110
texture.updateImagePart(image.rect())
111111

112+
node.callDecoration("setPaintedExtrudersCountDirty")
113+
112114
self.mainExtruderChanged.emit(node)

plugins/PaintTool/PaintClearCommand.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush
77

8+
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
89
from UM.View.GL.Texture import Texture
910

1011
from .PaintCommand import PaintCommand
@@ -13,22 +14,25 @@
1314
class PaintClearCommand(PaintCommand):
1415
"""Provides the command that clears all the painting for the current mode"""
1516

16-
def __init__(self, texture: Texture, bit_range: tuple[int, int], set_value: int) -> None:
17-
super().__init__(texture, bit_range)
17+
def __init__(self,
18+
texture: Texture,
19+
bit_range: tuple[int, int],
20+
set_value: int,
21+
sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None:
22+
super().__init__(texture, bit_range, sliceable_object_decorator=sliceable_object_decorator)
1823
self._set_value = set_value
1924

2025
def id(self) -> int:
2126
return 1
2227

2328
def redo(self) -> None:
2429
painter = self._makeClearedTexture()
25-
2630
if self._set_value > 0:
2731
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
2832
painter.fillRect(self._texture.getImage().rect(), QBrush(self._set_value))
29-
3033
painter.end()
3134

35+
self._setPaintedExtrudersCountDirty()
3236
self._texture.updateImagePart(self._bounding_rect)
3337

3438
def mergeWith(self, command: QUndoCommand) -> bool:
@@ -38,6 +42,6 @@ def mergeWith(self, command: QUndoCommand) -> bool:
3842
# There is actually nothing more to do here, both clear commands already have the same original texture
3943
return True
4044

41-
def _clearTextureBits(self, painter: QPainter):
45+
def _clearTextureBits(self, painter: QPainter, extended = False):
4246
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination)
4347
painter.fillRect(self._texture.getImage().rect(), QBrush(self._getBitRangeMask()))

plugins/PaintTool/PaintCommand.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,35 @@
11
# Copyright (c) 2025 UltiMaker
22
# Cura is released under the terms of the LGPLv3 or higher.
33

4-
from typing import Tuple, Optional
4+
from typing import Tuple, Optional, Dict
55

6-
from PyQt6.QtCore import QRect
6+
import numpy
77
from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QBrush
88

99
from UM.View.GL.Texture import Texture
10+
from cura.CuraApplication import CuraApplication
11+
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
1012

1113

1214
class PaintCommand(QUndoCommand):
1315
"""Provides a command that somehow modifies the actual painting on objects with undo/redo mechanisms"""
1416

1517
FULL_INT32 = 0xffffffff
1618

17-
def __init__(self, texture: Texture, bit_range: tuple[int, int], make_original_image = True) -> None:
19+
def __init__(self,
20+
texture: Texture,
21+
bit_range: tuple[int, int],
22+
make_original_image = True,
23+
sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None:
1824
super().__init__()
1925

2026
self._texture: Texture = texture
2127
self._bit_range: tuple[int, int] = bit_range
2228
self._original_texture_image = None
2329
self._bounding_rect = texture.getImage().rect()
2430

31+
self._sliceable_object_decorator: Optional[SliceableObjectDecorator] = sliceable_object_decorator
32+
2533
if make_original_image:
2634
self._original_texture_image = self._texture.getImage().copy()
2735
painter = QPainter(self._original_texture_image)
@@ -35,21 +43,26 @@ def undo(self) -> None:
3543
if self._original_texture_image is None:
3644
return
3745

38-
painter = self._makeClearedTexture()
46+
painter = self._makeClearedTexture(extended=True)
3947
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
4048
painter.drawImage(0, 0, self._original_texture_image)
4149
painter.end()
4250

51+
self._setPaintedExtrudersCountDirty()
4352
self._texture.updateImagePart(self._bounding_rect)
4453

45-
def _makeClearedTexture(self) -> QPainter:
54+
def _setPaintedExtrudersCountDirty(self) -> None:
55+
if self._sliceable_object_decorator is not None:
56+
self._sliceable_object_decorator.setPaintedExtrudersCountDirty()
57+
58+
def _makeClearedTexture(self, extended = False) -> QPainter:
4659
painter = QPainter(self._texture.getImage())
4760
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
4861

49-
self._clearTextureBits(painter)
62+
self._clearTextureBits(painter, extended)
5063
return painter
5164

52-
def _clearTextureBits(self, painter: QPainter):
65+
def _clearTextureBits(self, painter: QPainter, extended = False):
5366
raise NotImplementedError()
5467

5568
@staticmethod

plugins/PaintTool/PaintStrokeCommand.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from PyQt6.QtCore import QRect, QRectF, QPoint
88
from PyQt6.QtGui import QUndoCommand, QImage, QPainter, QPainterPath, QPen, QBrush
99

10+
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
1011
from UM.View.GL.Texture import Texture
1112
from UM.Math.Polygon import Polygon
1213

@@ -16,14 +17,16 @@ class PaintStrokeCommand(PaintCommand):
1617
"""Provides the command that does the actual painting on objects with undo/redo mechanisms"""
1718

1819
PEN_OVERLAP_WIDTH = 2.5
20+
PEN_OVERLAP_WIDTH_EXTENDED = PEN_OVERLAP_WIDTH + 0.5
1921

2022
def __init__(self,
2123
texture: Texture,
2224
stroke_polygons: List[Polygon],
2325
set_value: int,
2426
bit_range: tuple[int, int],
25-
mergeable: bool) -> None:
26-
super().__init__(texture, bit_range, make_original_image = not mergeable)
27+
mergeable: bool,
28+
sliceable_object_decorator: Optional[SliceableObjectDecorator] = None) -> None:
29+
super().__init__(texture, bit_range, make_original_image = not mergeable, sliceable_object_decorator=sliceable_object_decorator)
2730
self._stroke_polygons: List[Polygon] = stroke_polygons
2831
self._calculateBoundingRect()
2932
self._set_value: int = set_value
@@ -40,6 +43,7 @@ def redo(self) -> None:
4043
painter.drawPath(self._makePainterPath())
4144
painter.end()
4245

46+
self._setPaintedExtrudersCountDirty()
4347
self._texture.updateImagePart(self._bounding_rect)
4448

4549
def mergeWith(self, command: QUndoCommand) -> bool:
@@ -55,9 +59,9 @@ def mergeWith(self, command: QUndoCommand) -> bool:
5559

5660
return True
5761

58-
def _clearTextureBits(self, painter: QPainter):
62+
def _clearTextureBits(self, painter: QPainter, extended = False):
5963
painter.setBrush(QBrush(self._getBitRangeMask()))
60-
painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH))
64+
painter.setPen(QPen(painter.brush(), self.PEN_OVERLAP_WIDTH_EXTENDED if extended else self.PEN_OVERLAP_WIDTH))
6165
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceAndDestination)
6266
painter.drawPath(self._makePainterPath())
6367

plugins/PaintTool/PaintTool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def event(self, event: Event) -> bool:
412412
Logger.logException("e", "Error when adding paint stroke")
413413

414414
self._last_world_coords = world_coords
415-
self._updateScene(painted_object, update_node = self._mouse_held)
415+
self._updateScene(painted_object, update_node = event_caught)
416416
return event_caught
417417

418418
return False

plugins/PaintTool/PaintView.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from UM.i18n import i18nCatalog
2424
from UM.Math.Color import Color
2525
from UM.Math.Polygon import Polygon
26+
from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
2627

2728
from .PaintStrokeCommand import PaintStrokeCommand
2829
from .PaintClearCommand import PaintClearCommand
@@ -242,7 +243,14 @@ def addStroke(self, stroke_path: List[Polygon], brush_color: str, merge_with_pre
242243
stroke_path,
243244
set_value,
244245
self._current_bits_ranges,
245-
merge_with_previous))
246+
merge_with_previous,
247+
self._getSliceableObjectDecorator()))
248+
249+
def _getSliceableObjectDecorator(self) -> Optional[SliceableObjectDecorator]:
250+
if self._painted_object is None or self._current_paint_type != "extruder":
251+
return None
252+
253+
return self._painted_object.getDecorator(SliceableObjectDecorator)
246254

247255
def _makeClearCommand(self) -> Optional[PaintClearCommand]:
248256
if self._painted_object is None or self._paint_texture is None or self._current_bits_ranges is None:
@@ -254,7 +262,10 @@ def _makeClearCommand(self) -> Optional[PaintClearCommand]:
254262
if extruder_stack is not None:
255263
set_value = extruder_stack.getValue("extruder_nr")
256264

257-
return PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value)
265+
return PaintClearCommand(self._paint_texture,
266+
self._current_bits_ranges,
267+
set_value,
268+
self._getSliceableObjectDecorator())
258269

259270
def clearPaint(self):
260271
self._prepareDataMapping()

0 commit comments

Comments
 (0)