Merge pull request #21001 from Ultimaker/CURA-12752_multimat_paint_prime_tower
Some checks failed
conan-package / conan-package (push) Has been cancelled
unit-test / Run unit tests (push) Has been cancelled

[CURA-12752] Fix prime-tower for multi-material painting
This commit is contained in:
HellAholic 2025-10-17 13:21:45 +02:00 committed by GitHub
commit be88d745ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 107 additions and 45 deletions

View file

@ -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:

View file

@ -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()))

View file

@ -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",

View file

@ -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
return used_extruders

View file

@ -109,4 +109,6 @@ class MultiMaterialExtruderConverter:
texture.updateImagePart(image.rect())
node.callDecoration("setPaintedExtrudersCountDirty")
self.mainExtruderChanged.emit(node)

View file

@ -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()))

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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()