Handle material painting i.r.t to affected object extruder

CURA-12740
This commit is contained in:
Erwan MATHIEU 2025-09-29 10:06:39 +02:00
parent 0b4ebe57de
commit 9fa9a7395b
5 changed files with 228 additions and 46 deletions

View file

@ -6,10 +6,10 @@ from typing import Optional, Dict
from PyQt6.QtCore import QBuffer
from PyQt6.QtGui import QImage, QImageWriter
import UM.View.GL.Texture
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.Texture import Texture
from UM.Signal import Signal
class SliceableObjectDecorator(SceneNodeDecorator):
@ -18,14 +18,20 @@ class SliceableObjectDecorator(SceneNodeDecorator):
self._paint_texture = None
self._texture_data_mapping: Dict[str, tuple[int, int]] = {}
self.paintTextureChanged = Signal()
def isSliceable(self) -> bool:
return True
def getPaintTexture(self) -> Optional[Texture]:
return self._paint_texture
def getPaintTextureChangedSignal(self) -> Signal:
return self.paintTextureChanged
def setPaintTexture(self, texture: Texture) -> None:
self._paint_texture = texture
self.paintTextureChanged.emit()
def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]:
return self._texture_data_mapping
@ -39,6 +45,7 @@ class SliceableObjectDecorator(SceneNodeDecorator):
image = QImage(width, height, QImage.Format.Format_RGB32)
image.fill(0)
self._paint_texture.setImage(image)
self.paintTextureChanged.emit()
def packTexture(self) -> Optional[bytearray]:
if self._paint_texture is None:

View file

@ -0,0 +1,112 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
from weakref import WeakKeyDictionary
import functools
from typing import Optional
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from cura.Machines.Models.ExtrudersModel import ExtrudersModel
from UM.Signal import Signal
from .PaintCommand import PaintCommand
class MultiMaterialExtruderConverter:
"""
This class is a single object living in the background, which only job is to watch when extruders of objects
are changed and to convert their multi-material painting textures accordingly.
"""
MAX_EXTRUDER_COUNT = 16
def __init__(self, extruders_model: ExtrudersModel) -> None:
application = CuraApplication.getInstance()
scene = application.getController().getScene()
scene.getRoot().childrenChanged.connect(self._onChildrenChanged)
self._extruders_model: extruders_model
self._watched_nodes: WeakKeyDictionary[SceneNode, tuple[Optional[int], Optional[functools.partial]]] = WeakKeyDictionary()
self.mainExtruderChanged = Signal()
def _onChildrenChanged(self, node: SceneNode):
if node not in self._watched_nodes and node.callDecoration("isSliceable"):
self._watched_nodes[node] = (None, None)
node.decoratorsChanged.connect(self._onDecoratorsChanged)
self._onDecoratorsChanged(node)
for child in node.getChildren():
self._onChildrenChanged(child)
def _onDecoratorsChanged(self, node: SceneNode) -> None:
if node not in self._watched_nodes:
return
current_extruder, extruder_changed_callback = self._watched_nodes[node]
if extruder_changed_callback is None:
extruder_changed_signal = node.callDecoration("getActiveExtruderChangedSignal")
if extruder_changed_signal is not None:
extruder_changed_callback = functools.partial(self._onExtruderChanged, node)
extruder_changed_signal.connect(extruder_changed_callback)
self._watched_nodes[node] = current_extruder, extruder_changed_callback
self._onExtruderChanged(node)
def _onExtruderChanged(self, node: SceneNode) -> None:
self._changeMainObjectExtruder(node)
@staticmethod
def _getPaintedObjectExtruderNr(node: SceneNode) -> Optional[int]:
extruder_stack = node.getPrintingExtruder()
if extruder_stack is None:
return None
return extruder_stack.getValue("extruder_nr")
def _changeMainObjectExtruder(self, node: SceneNode) -> None:
if node not in self._watched_nodes:
return
old_extruder_nr, extruder_changed_callback = self._watched_nodes[node]
new_extruder_nr = MultiMaterialExtruderConverter._getPaintedObjectExtruderNr(node)
if new_extruder_nr == old_extruder_nr:
return
self._watched_nodes[node] = (new_extruder_nr, extruder_changed_callback)
if old_extruder_nr is None or new_extruder_nr is None:
return
texture = node.callDecoration("getPaintTexture")
if texture is None:
return
paint_data_mapping = node.callDecoration("getTextureDataMapping")
if paint_data_mapping is None or "extruder" not in paint_data_mapping:
return
bits_range = paint_data_mapping["extruder"]
image = texture.getImage()
image_ptr = image.bits()
image_ptr.setsize(image.sizeInBytes())
image_array = numpy.frombuffer(image_ptr, dtype=numpy.uint32)
bit_range_start, bit_range_end = bits_range
bit_mask = numpy.uint32(PaintCommand.getBitRangeMask(bits_range))
target_bits = (image_array & bit_mask) >> bit_range_start
target_bits[target_bits == old_extruder_nr] = MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT
target_bits[target_bits == new_extruder_nr] = old_extruder_nr
target_bits[target_bits == MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT] = new_extruder_nr
image_array &= ~bit_mask
image_array |= ((target_bits << bit_range_start) & bit_mask)
texture.setSubImage(image, 0, 0)
self.mainExtruderChanged.emit(node)

View file

@ -13,14 +13,20 @@ from .PaintCommand import PaintCommand
class PaintClearCommand(PaintCommand):
"""Provides the command that clears all the painting for the current mode"""
def __init__(self, texture: Texture, bit_range: tuple[int, int]) -> None:
def __init__(self, texture: Texture, bit_range: tuple[int, int], set_value: int) -> None:
super().__init__(texture, bit_range)
self._set_value = set_value
def id(self) -> int:
return 1
def redo(self) -> None:
cleared_image, painter = self._makeClearedTexture()
if self._set_value > 0:
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
painter.fillRect(self._texture.getImage().rect(), QBrush(self._set_value))
painter.end()
self._texture.setSubImage(cleared_image, 0, 0)

View file

@ -53,11 +53,15 @@ class PaintCommand(QUndoCommand):
def _clearTextureBits(self, painter: QPainter):
raise NotImplementedError()
def _getBitRangeMask(self) -> int:
bit_range_start, bit_range_end = self._bit_range
@staticmethod
def getBitRangeMask(bit_range: tuple[int, int]) -> int:
bit_range_start, bit_range_end = bit_range
return (((PaintCommand.FULL_INT32 << (32 - 1 - (bit_range_end - bit_range_start))) & PaintCommand.FULL_INT32) >>
(32 - 1 - bit_range_end))
def _getBitRangeMask(self) -> int:
return PaintCommand.getBitRangeMask(self._bit_range)
def _preparePainting(self,
specific_source_image: Optional[QImage] = None,
specific_bounding_rect: Optional[QRect] = None) -> Tuple[QImage, QPainter]:

View file

@ -6,7 +6,7 @@ import math
from PyQt6.QtCore import QRect, pyqtSignal, Qt, QPoint
from PyQt6.QtGui import QImage, QUndoStack, QPainter, QColor, QPainterPath, QBrush, QPen
from typing import Optional, List, Tuple, Dict
from typing import Optional, Tuple, Dict
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
@ -17,13 +17,13 @@ from UM.PluginRegistry import PluginRegistry
from UM.View.GL.ShaderProgram import ShaderProgram
from UM.View.GL.Texture import Texture
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
from UM.i18n import i18nCatalog
from UM.Math.Color import Color
from .PaintStrokeCommand import PaintStrokeCommand
from .PaintClearCommand import PaintClearCommand
from .MultiMaterialExtruderConverter import MultiMaterialExtruderConverter
catalog = i18nCatalog("cura")
@ -31,8 +31,6 @@ catalog = i18nCatalog("cura")
class PaintView(CuraView):
"""View for model-painting."""
MAX_EXTRUDER_COUNT = 16
class PaintType:
def __init__(self, display_color: Color, value: int):
self.display_color: Color = display_color
@ -41,8 +39,8 @@ class PaintView(CuraView):
def __init__(self) -> None:
super().__init__(use_empty_menu_placeholder = True)
self._paint_shader: Optional[ShaderProgram] = None
self._current_paint_texture: Optional[Texture] = None
self._current_painted_object: Optional[SceneNode] = None
self._paint_texture: Optional[Texture] = None
self._painted_object: Optional[SceneNode] = None
self._previous_paint_texture_rect: Optional[QRect] = None
self._cursor_texture: Optional[Texture] = None
self._current_bits_ranges: tuple[int, int] = (0, 0)
@ -56,12 +54,37 @@ class PaintView(CuraView):
self._scene.getRoot().childrenChanged.connect(self._onChildrenChanged)
self._extruders_model: Optional[ExtrudersModel] = None
self._extruders_converter: Optional[MultiMaterialExtruderConverter] = None
canUndoChanged = pyqtSignal(bool)
canRedoChanged = pyqtSignal(bool)
def setCurrentPaintedObject(self, current_painted_object: Optional[SceneNode]):
self._current_painted_object = current_painted_object
if self._painted_object is not None:
texture_changed_signal = self._painted_object.callDecoration("getPaintTextureChangedSignal")
texture_changed_signal.disconnect(self._onCurrentPaintedObjectTextureChanged)
self._paint_texture = None
self._cursor_texture = None
self._painted_object = current_painted_object
if self._painted_object is not None:
texture_changed_signal = self._painted_object.callDecoration("getPaintTextureChangedSignal")
texture_changed_signal.connect(self._onCurrentPaintedObjectTextureChanged)
self._onCurrentPaintedObjectTextureChanged()
def _onCurrentPaintedObjectTextureChanged(self) -> None:
paint_texture = self._painted_object.callDecoration("getPaintTexture")
self._paint_texture = paint_texture
if paint_texture is not None:
self._cursor_texture = OpenGL.getInstance().createTexture(paint_texture.getWidth(),
paint_texture.getHeight())
image = QImage(paint_texture.getWidth(), paint_texture.getHeight(), QImage.Format.Format_ARGB32)
image.fill(0)
self._cursor_texture.setImage(image)
else:
self._cursor_texture = None
def canUndo(self):
stack = self._getUndoStack()
@ -72,11 +95,11 @@ class PaintView(CuraView):
return stack.canRedo() if stack is not None else False
def _getUndoStack(self):
if self._current_painted_object is None or self._current_paint_type == "":
if self._painted_object is None or self._current_paint_type == "":
return None
try:
return self._paint_undo_stacks[(self._current_painted_object, self._current_paint_type)]
return self._paint_undo_stacks[(self._painted_object, self._current_paint_type)]
except KeyError:
return None
@ -101,6 +124,9 @@ class PaintView(CuraView):
self._extruders_model = application.getExtrudersModel()
self._extruders_model.modelChanged.connect(self._onExtrudersChanged)
self._extruders_converter = MultiMaterialExtruderConverter(self._extruders_model)
self._extruders_converter.mainExtruderChanged.connect(self._onMainExtruderChanged)
theme = application.getTheme()
usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0),
"preferred": self.PaintType(Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
@ -113,10 +139,17 @@ class PaintView(CuraView):
self._current_paint_type = "seam"
def _onMainExtruderChanged(self, node: SceneNode):
# Since the affected extruder has changed, the previous material painting commands become irrelevant,
# so clear the stack
for (painted_object, paint_mode), stack in self._paint_undo_stacks.items():
if painted_object == node and paint_mode == "extruder":
stack.clear()
def _makeExtrudersColors(self) -> Dict[str, "PaintView.PaintType"]:
extruders_colors: Dict[str, "PaintView.PaintType"] = {}
for extruder_index in range(PaintView.MAX_EXTRUDER_COUNT):
for extruder_index in range(MultiMaterialExtruderConverter.MAX_EXTRUDER_COUNT):
extruder_item = self._extruders_model.getExtruderItem(extruder_index)
if extruder_item is None:
extruder_item = self._extruders_model.getExtruderItem(0)
@ -140,11 +173,10 @@ class PaintView(CuraView):
if controller.getActiveView() != self:
return
selected_objects = Selection.getAllSelectedObjects()
if len(selected_objects) != 1:
if self._painted_object is None:
return
controller.getScene().sceneChanged.emit(selected_objects[0])
controller.getScene().sceneChanged.emit(self._painted_object)
def _checkSetup(self):
if not self._paint_shader:
@ -197,29 +229,46 @@ class PaintView(CuraView):
return True
def _shiftTextureValue(self, value: int) -> int:
if self._current_bits_ranges is None:
return 0
bit_range_start, _ = self._current_bits_ranges
return value << bit_range_start
def addStroke(self, stroke_path: QPainterPath, brush_color: str, merge_with_previous: bool) -> None:
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
if self._paint_texture is None or self._paint_texture.getImage() is None:
return
self._prepareDataMapping()
stack = self._prepareUndoRedoStack()
bit_range_start, bit_range_end = self._current_bits_ranges
set_value = self._paint_modes[self._current_paint_type][brush_color].value << bit_range_start
stack.push(PaintStrokeCommand(self._current_paint_texture,
set_value = self._shiftTextureValue(self._paint_modes[self._current_paint_type][brush_color].value)
stack.push(PaintStrokeCommand(self._paint_texture,
stroke_path,
set_value,
(bit_range_start, bit_range_end),
self._current_bits_ranges,
merge_with_previous))
def clearPaint(self):
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
return
def _makeClearCommand(self) -> Optional[PaintClearCommand]:
if self._painted_object is None or self._paint_texture is None or self._current_bits_ranges is None:
return None
set_value = 0
if self._current_paint_type == "extruder":
extruder_stack = self._painted_object.getPrintingExtruder()
if extruder_stack is not None:
set_value = extruder_stack.getValue("extruder_nr")
return PaintClearCommand(self._paint_texture, self._current_bits_ranges, set_value)
def clearPaint(self):
self._prepareDataMapping()
stack = self._prepareUndoRedoStack()
stack.push(PaintClearCommand(self._current_paint_texture, self._current_bits_ranges))
clear_command = self._makeClearCommand()
if clear_command is not None:
stack.push(clear_command)
def undoStroke(self) -> None:
stack = self._getUndoStack()
@ -232,8 +281,8 @@ class PaintView(CuraView):
stack.redo()
def getUvTexDimensions(self) -> Tuple[int, int]:
if self._current_paint_texture is not None:
return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight()
if self._paint_texture is not None:
return self._paint_texture.getWidth(), self._paint_texture.getHeight()
return 0, 0
def getPaintType(self) -> str:
@ -244,7 +293,7 @@ class PaintView(CuraView):
self._prepareDataMapping()
def _prepareUndoRedoStack(self) -> QUndoStack:
stack_key = (self._current_painted_object, self._current_paint_type)
stack_key = (self._painted_object, self._current_paint_type)
if stack_key not in self._paint_undo_stacks:
stack: QUndoStack = QUndoStack()
@ -256,19 +305,26 @@ class PaintView(CuraView):
return self._paint_undo_stacks[stack_key]
def _prepareDataMapping(self):
node = Selection.getAllSelectedObjects()[0]
if node is None:
if self._painted_object is None:
return
paint_data_mapping = node.callDecoration("getTextureDataMapping")
paint_data_mapping = self._painted_object.callDecoration("getTextureDataMapping")
feature_created = False
if self._current_paint_type not in paint_data_mapping:
new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[self._current_paint_type]))
paint_data_mapping[self._current_paint_type] = new_mapping
node.callDecoration("setTextureDataMapping", paint_data_mapping)
self._painted_object.callDecoration("setTextureDataMapping", paint_data_mapping)
feature_created = True
self._current_bits_ranges = paint_data_mapping[self._current_paint_type]
if feature_created and self._current_paint_type == "extruder":
# Fill texture extruder with actual mesh extruder
clear_command = self._makeClearCommand()
if clear_command is not None:
clear_command.redo()
@staticmethod
def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]:
start_index = 0
@ -280,7 +336,7 @@ class PaintView(CuraView):
return start_index, end_index
def beginRendering(self) -> None:
if self._current_paint_type not in self._paint_modes:
if self._painted_object is None or self._current_paint_type not in self._paint_modes:
return
self._checkSetup()
@ -293,18 +349,15 @@ class PaintView(CuraView):
paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
renderer.addRenderBatch(paint_batch)
for node in Selection.getAllSelectedObjects():
paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
paint_texture = node.callDecoration("getPaintTexture")
if paint_texture != self._current_paint_texture and paint_texture is not None:
self._current_paint_texture = paint_texture
# for node in Selection.getAllSelectedObjects():
paint_batch.addItem(self._painted_object.getWorldTransformation(copy=False),
self._painted_object.getMeshData(),
normal_transformation=self._painted_object.getCachedNormalMatrix())
self._cursor_texture = OpenGL.getInstance().createTexture(paint_texture.getWidth(), paint_texture.getHeight())
self._paint_shader.setTexture(0, self._current_paint_texture)
image = QImage(paint_texture.getWidth(), paint_texture.getHeight(), QImage.Format.Format_ARGB32)
image.fill(0)
self._cursor_texture.setImage(image)
self._paint_shader.setTexture(1, self._cursor_texture)
if self._paint_texture is not None:
self._paint_shader.setTexture(0, self._paint_texture)
if self._cursor_texture is not None:
self._paint_shader.setTexture(1, self._cursor_texture)
self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0])
self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])