From da0509cda35f66df6f03bb1046ff6170a7ab5c4a Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 15:58:26 +0200 Subject: [PATCH 01/13] Fix painting through an invisible object CURA-12660 --- cura/PickingPass.py | 6 ++++-- plugins/PaintTool/PaintTool.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cura/PickingPass.py b/cura/PickingPass.py index 4d6ef671df..dd91659afe 100644 --- a/cura/PickingPass.py +++ b/cura/PickingPass.py @@ -7,6 +7,7 @@ from UM.Qt.QtApplication import QtApplication from UM.Logger import Logger from UM.Math.Vector import Vector from UM.Resources import Resources +from UM.Scene.Selection import Selection from UM.View.RenderPass import RenderPass from UM.View.GL.OpenGL import OpenGL @@ -27,13 +28,14 @@ class PickingPass(RenderPass): .. note:: that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels """ - def __init__(self, width: int, height: int) -> None: + def __init__(self, width: int, height: int, only_selected_objects: bool = False) -> None: super().__init__("picking", width, height) self._renderer = QtApplication.getInstance().getRenderer() self._shader = None #type: Optional[ShaderProgram] self._scene = QtApplication.getInstance().getController().getScene() + self._only_selected_objects = only_selected_objects def render(self) -> None: if not self._shader: @@ -53,7 +55,7 @@ class PickingPass(RenderPass): # Fill up the batch with objects that can be sliced. ` for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax. - if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible(): + if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible() and (not self._only_selected_objects or Selection.isSelected(node)): batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) self.bind() diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 524011af9d..fcc5c9a3a0 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -300,7 +300,9 @@ class PaintTool(Tool): return False if not self._picking_pass: - self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight()) + self._picking_pass = PickingPass(camera.getViewportWidth(), + camera.getViewportHeight(), + only_selected_objects = True) self._picking_pass.render() self._selection_pass.renderFacesMode() From 73f5b817b438df0b03b943b3993d2dea656cdffe Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 15:59:30 +0200 Subject: [PATCH 02/13] Display build plate in paint mode CURA-12660 --- plugins/PaintTool/PaintView.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 22eb8c55f6..18dc067c5a 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -8,10 +8,13 @@ from typing import Optional, List, Tuple, Dict from PyQt6.QtGui import QImage, QColor, QPainter from cura.CuraApplication import CuraApplication +from cura.BuildVolume import BuildVolume from UM.PluginRegistry import PluginRegistry from UM.View.GL.ShaderProgram import ShaderProgram from UM.View.GL.Texture import Texture from UM.View.View import View +from UM.View.SelectionPass import SelectionPass +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 @@ -163,6 +166,11 @@ class PaintView(View): def beginRendering(self) -> None: renderer = self.getRenderer() self._checkSetup() + + for node in DepthFirstIterator(self._scene.getRoot()): + if isinstance(node, BuildVolume): + node.render(renderer) + paint_batch = renderer.createRenderBatch(shader=self._paint_shader) renderer.addRenderBatch(paint_batch) From 6bf9a8a0aeba3b7157ae4bdd695e9ffa50396bca Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 16:00:13 +0200 Subject: [PATCH 03/13] Ignore invisible object for selection in paint mode CURA-12660 --- plugins/PaintTool/PaintView.py | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 18dc067c5a..c37afd178d 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -3,7 +3,7 @@ import os from PyQt6.QtCore import QRect -from typing import Optional, List, Tuple, Dict +from typing import Optional, List, Tuple, Dict, cast from PyQt6.QtGui import QImage, QColor, QPainter @@ -47,7 +47,9 @@ class PaintView(View): self._force_opaque_mask = QImage(2, 2, QImage.Format.Format_Mono) self._force_opaque_mask.fill(1) - CuraApplication.getInstance().engineCreatedSignal.connect(self._makePaintModes) + application = CuraApplication.getInstance() + application.engineCreatedSignal.connect(self._makePaintModes) + self._scene = application.getController().getScene() def _makePaintModes(self): theme = CuraApplication.getInstance().getTheme() @@ -164,6 +166,9 @@ class PaintView(View): return start_index, end_index def beginRendering(self) -> None: + if self._current_paint_type == "": + return + renderer = self.getRenderer() self._checkSetup() @@ -174,12 +179,20 @@ class PaintView(View): paint_batch = renderer.createRenderBatch(shader=self._paint_shader) renderer.addRenderBatch(paint_batch) - node = Selection.getSelectedObject(0) - if node is None: - return + display_objects = Selection.getAllSelectedObjects().copy() + if display_objects: + selection_pass = cast(SelectionPass, renderer.getRenderPass("selection")) + if selection_pass is not None: + selection_pass.setIgnoreUnselectedObjectsDuringNextRender() + else: + for node in DepthFirstIterator(self._scene.getRoot()): + if node.callDecoration("isSliceable"): + display_objects.append(node) - if self._current_paint_type == "": - return + for node in display_objects: + paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) + self._current_paint_texture = node.callDecoration("getPaintTexture") + self._paint_shader.setTexture(0, self._current_paint_texture) self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0]) self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1]) @@ -187,8 +200,3 @@ class PaintView(View): colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].values()] colors_values = [[int(color_part * 255) for color_part in [color.r, color.g, color.b]] for color in colors] self._paint_shader.setUniformValueArray("u_renderColors", colors_values) - - self._current_paint_texture = node.callDecoration("getPaintTexture") - self._paint_shader.setTexture(0, self._current_paint_texture) - - paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) From 6896c0ed4bfb23475fb52d50d76900ec6a85906e Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 16:53:41 +0200 Subject: [PATCH 04/13] Display classic view when there is no selection CURA-12660 --- plugins/PaintTool/PaintView.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index c37afd178d..60656086ff 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -9,10 +9,10 @@ from PyQt6.QtGui import QImage, QColor, QPainter from cura.CuraApplication import CuraApplication from cura.BuildVolume import BuildVolume +from plugins.SolidView.SolidView import SolidView from UM.PluginRegistry import PluginRegistry from UM.View.GL.ShaderProgram import ShaderProgram from UM.View.GL.Texture import Texture -from UM.View.View import View from UM.View.SelectionPass import SelectionPass from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Selection import Selection @@ -23,7 +23,7 @@ from UM.Math.Color import Color catalog = i18nCatalog("cura") -class PaintView(View): +class PaintView(SolidView): """View for model-painting.""" UNDO_STACK_SIZE = 1024 @@ -62,6 +62,8 @@ class PaintView(View): } def _checkSetup(self): + super()._checkSetup() + if not self._paint_shader: shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader") self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename) @@ -169,8 +171,14 @@ class PaintView(View): if self._current_paint_type == "": return - renderer = self.getRenderer() + display_objects = Selection.getAllSelectedObjects().copy() + if not display_objects: + # Display the classic view until an object is selected + super().beginRendering() + return + self._checkSetup() + renderer = self.getRenderer() for node in DepthFirstIterator(self._scene.getRoot()): if isinstance(node, BuildVolume): @@ -179,15 +187,9 @@ class PaintView(View): paint_batch = renderer.createRenderBatch(shader=self._paint_shader) renderer.addRenderBatch(paint_batch) - display_objects = Selection.getAllSelectedObjects().copy() - if display_objects: - selection_pass = cast(SelectionPass, renderer.getRenderPass("selection")) - if selection_pass is not None: - selection_pass.setIgnoreUnselectedObjectsDuringNextRender() - else: - for node in DepthFirstIterator(self._scene.getRoot()): - if node.callDecoration("isSliceable"): - display_objects.append(node) + selection_pass = cast(SelectionPass, renderer.getRenderPass("selection")) + if selection_pass is not None: + selection_pass.setIgnoreUnselectedObjectsDuringNextRender() for node in display_objects: paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) From 91e986697d229f85fd0375424d43e57e32ce5685 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 16:54:05 +0200 Subject: [PATCH 05/13] Fix painting after changing the selected object CURA-12660 --- plugins/PaintTool/PaintTool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index fcc5c9a3a0..2099a59691 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -293,6 +293,7 @@ class PaintTool(Tool): self._node_cache.transformationChanged.disconnect(self._nodeTransformChanged) self._node_cache = node self._node_cache.transformationChanged.connect(self._nodeTransformChanged) + self._cache_dirty = True if self._cache_dirty: self._cache_dirty = False self._mesh_transformed_cache = self._node_cache.getMeshDataTransformed() From 6292f5b133f6423bd758bf1d56c738f8517d050d Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 30 Jul 2025 16:57:29 +0200 Subject: [PATCH 06/13] Hide paint-on-support option until it is implemented CURA-12660 --- plugins/PaintTool/PaintTool.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index 4cbe9d4ade..e3f244dd4c 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -57,6 +57,7 @@ Item icon: "Support" tooltipText: catalog.i18nc("@tooltip", "Refine support placement by defining preferred/avoidance areas") mode: "support" + visible: false } } From ef7bde87fa4469174fd01c5cfca8cca59c545129 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Thu, 31 Jul 2025 11:24:36 +0200 Subject: [PATCH 07/13] Allow painting only when 1 object is selected CURA-12660 --- plugins/PaintTool/PaintView.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 60656086ff..61a6d0079c 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -172,8 +172,8 @@ class PaintView(SolidView): return display_objects = Selection.getAllSelectedObjects().copy() - if not display_objects: - # Display the classic view until an object is selected + if len(display_objects) != 1: + # Display the classic view until a single object is selected super().beginRendering() return From 3cb7eb3c873ed195ae998b0dd8ed15cea884a162 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Thu, 31 Jul 2025 11:47:41 +0200 Subject: [PATCH 08/13] Avoid too dark or too light areas while painting CURA-12660 This avoid having parts of the model where you cannot see the painted areas anymore --- plugins/PaintTool/paint.shader | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/PaintTool/paint.shader b/plugins/PaintTool/paint.shader index bd769f5cb2..f2af66ffe6 100644 --- a/plugins/PaintTool/paint.shader +++ b/plugins/PaintTool/paint.shader @@ -55,7 +55,7 @@ fragment = color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart); vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0); - highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0); + highp float n_dot_l = mix(0.3, 0.7, dot(normal, light_dir)); final_color += (n_dot_l * diffuse_color); final_color.a = u_opacity; @@ -122,7 +122,7 @@ fragment41core = color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart); vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0); - highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0); + highp float n_dot_l = mix(0.3, 0.7, dot(normal, light_dir)); final_color += (n_dot_l * diffuse_color); final_color.a = u_opacity; From ab58dec5d141c12a0cde128fb702126ec39914cd Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Fri, 1 Aug 2025 13:10:03 +0200 Subject: [PATCH 09/13] Fix unability to paint with visible message box CURA-12660 When a message box is displayed, some offscreen rendering passes (face selection) render an unpredictable result and we are unable to start painting. This went through a refactoring of the rendering passes. Since doing the offscreen rendering outside the Qt rendering loop caused some troubles, we now use the rendering passes only inside the Qt rendering loop, so that they work properly. Tools also have the ability to indicate which extra passes they require, so that we don't run all the passes when they are not required. Since this issue also concerns the support blockers placement and rotation by face selection, they have been updated so that they now also always work. The face selection mechanism using the Selection class was partially working and used only by the rotation, so now it has been deprecated in favor of the new mechanism. --- cura/CuraApplication.py | 4 +++ cura/CuraRenderer.py | 46 ++++++++++++++++++++++++++ cura/PickingPass.py | 2 +- plugins/PaintTool/PaintTool.py | 40 +++++++++++++++------- plugins/PaintTool/PaintView.py | 5 --- plugins/SupportEraser/SupportEraser.py | 17 +++++++--- 6 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 cura/CuraRenderer.py diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 8af98c2d0e..660f312468 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -60,6 +60,7 @@ from cura import ApplicationMetadata from cura.API import CuraAPI from cura.API.Account import Account from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob +from cura.CuraRenderer import CuraRenderer from cura.Machines.MachineErrorChecker import MachineErrorChecker from cura.Machines.Models.BuildPlateModel import BuildPlateModel from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel @@ -362,6 +363,9 @@ class CuraApplication(QtApplication): self._machine_action_manager = MachineActionManager(self) self._machine_action_manager.initialize() + def makeRenderer(self) -> CuraRenderer: + return CuraRenderer(self) + def __sendCommandToSingleInstance(self): self._single_instance = SingleInstance(self, self._files_to_open, self._urls_to_open) diff --git a/cura/CuraRenderer.py b/cura/CuraRenderer.py new file mode 100644 index 0000000000..77030b3fe8 --- /dev/null +++ b/cura/CuraRenderer.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025 UltiMaker +# Uranium is released under the terms of the LGPLv3 or higher. + + +from typing import TYPE_CHECKING + +from cura.PickingPass import PickingPass +from UM.Qt.QtRenderer import QtRenderer +from UM.View.RenderPass import RenderPass +from UM.View.SelectionPass import SelectionPass + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + + +class CuraRenderer(QtRenderer): + """An overridden Renderer implementation that adds some behaviors specific to Cura.""" + + def __init__(self, application: "CuraApplication") -> None: + super().__init__() + + self._controller = application.getController() + self._controller.activeToolChanged.connect(self._onActiveToolChanged) + self._extra_rendering_passes: list[RenderPass] = [] + + def _onActiveToolChanged(self) -> None: + tool_extra_rendering_passes = [] + + active_tool = self._controller.getActiveTool() + if active_tool is not None: + tool_extra_rendering_passes = active_tool.getRequiredExtraRenderingPasses() + + for extra_rendering_pass in self._extra_rendering_passes: + extra_rendering_pass.setEnabled(extra_rendering_pass.getName() in tool_extra_rendering_passes) + + def _makeRenderPasses(self) -> list[RenderPass]: + self._extra_rendering_passes = [ + SelectionPass(self._viewport_width, self._viewport_height, SelectionPass.SelectionMode.FACES), + PickingPass(self._viewport_width, self._viewport_height, only_selected_objects=True), + PickingPass(self._viewport_width, self._viewport_height, only_selected_objects=False) + ] + + for extra_rendering_pass in self._extra_rendering_passes: + extra_rendering_pass.setEnabled(False) + + return super()._makeRenderPasses() + self._extra_rendering_passes diff --git a/cura/PickingPass.py b/cura/PickingPass.py index dd91659afe..e585e72269 100644 --- a/cura/PickingPass.py +++ b/cura/PickingPass.py @@ -29,7 +29,7 @@ class PickingPass(RenderPass): """ def __init__(self, width: int, height: int, only_selected_objects: bool = False) -> None: - super().__init__("picking", width, height) + super().__init__("picking" if not only_selected_objects else "picking_selected", width, height) self._renderer = QtApplication.getInstance().getRenderer() diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 2099a59691..e57c6d5a11 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -17,7 +17,9 @@ from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool +from cura.CuraApplication import CuraApplication from cura.PickingPass import PickingPass +from UM.View.SelectionPass import SelectionPass from .PaintView import PaintView @@ -34,6 +36,7 @@ class PaintTool(Tool): super().__init__() self._picking_pass: Optional[PickingPass] = None + self._faces_selection_pass: Optional[SelectionPass] = None self._shortcut_key: Qt.Key = Qt.Key.Key_P @@ -52,6 +55,8 @@ class PaintTool(Tool): self._last_mouse_coords: Optional[Tuple[int, int]] = None self._last_face_id: Optional[int] = None + Selection.selectionChanged.connect(self._updateIgnoreUnselectedObjects) + def _createBrushPen(self) -> QPen: pen = QPen() pen.setWidth(self._brush_size) @@ -179,7 +184,7 @@ class PaintTool(Tool): self._cache_dirty = True def _getTexCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray]]: - face_id = self._selection_pass.getFaceIdAtPosition(x, y) + face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y) if face_id < 0 or face_id >= node.getMeshData().getFaceCount(): return face_id, None @@ -248,11 +253,14 @@ class PaintTool(Tool): if event.type == Event.ToolActivateEvent: controller.setActiveStage("PrepareStage") controller.setActiveView("PaintTool") # Because that's the plugin-name, and the view is registered to it. + self._updateIgnoreUnselectedObjects() return True if event.type == Event.ToolDeactivateEvent: controller.setActiveStage("PrepareStage") controller.setActiveView("SolidView") + CuraApplication.getInstance().getRenderer().getRenderPass("selection").setIgnoreUnselectedObjects(False) + CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(False) return True if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled(): @@ -281,8 +289,15 @@ class PaintTool(Tool): if paintview is None: return False - if not self._selection_pass: - return False + if not self._faces_selection_pass: + self._faces_selection_pass = CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces") + if not self._faces_selection_pass: + return False + + if not self._picking_pass: + self._picking_pass = CuraApplication.getInstance().getRenderer().getRenderPass("picking_selected") + if not self._picking_pass: + return False camera = self._controller.getScene().getActiveCamera() if not camera: @@ -300,14 +315,6 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - if not self._picking_pass: - self._picking_pass = PickingPass(camera.getViewportWidth(), - camera.getViewportHeight(), - only_selected_objects = True) - self._picking_pass.render() - - self._selection_pass.renderFacesMode() - face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y) if texcoords is None: return False @@ -347,4 +354,13 @@ class PaintTool(Tool): if node is None: node = Selection.getSelectedObject(0) if node is not None: - Application.getInstance().getController().getScene().sceneChanged.emit(node) \ No newline at end of file + Application.getInstance().getController().getScene().sceneChanged.emit(node) + + def getRequiredExtraRenderingPasses(self) -> list[str]: + return ["selection_faces", "picking_selected"] + + def _updateIgnoreUnselectedObjects(self): + if self._controller.getActiveTool() is self: + ignore_unselected_objects = len(Selection.getAllSelectedObjects()) == 1 + CuraApplication.getInstance().getRenderer().getRenderPass("selection").setIgnoreUnselectedObjects(ignore_unselected_objects) + CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(ignore_unselected_objects) \ No newline at end of file diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 61a6d0079c..a3d4b36315 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -13,7 +13,6 @@ from plugins.SolidView.SolidView import SolidView from UM.PluginRegistry import PluginRegistry from UM.View.GL.ShaderProgram import ShaderProgram from UM.View.GL.Texture import Texture -from UM.View.SelectionPass import SelectionPass from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Selection import Selection from UM.View.GL.OpenGL import OpenGL @@ -187,10 +186,6 @@ class PaintView(SolidView): paint_batch = renderer.createRenderBatch(shader=self._paint_shader) renderer.addRenderBatch(paint_batch) - selection_pass = cast(SelectionPass, renderer.getRenderPass("selection")) - if selection_pass is not None: - selection_pass.setIgnoreUnselectedObjectsDuringNextRender() - for node in display_objects: paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix()) self._current_paint_texture = node.callDecoration("getPaintTexture") diff --git a/plugins/SupportEraser/SupportEraser.py b/plugins/SupportEraser/SupportEraser.py index 0a714396aa..afdad6a4d0 100644 --- a/plugins/SupportEraser/SupportEraser.py +++ b/plugins/SupportEraser/SupportEraser.py @@ -1,6 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + from PyQt6.QtCore import Qt, QTimer from PyQt6.QtWidgets import QApplication @@ -35,6 +37,7 @@ class SupportEraser(Tool): self._controller = self.getController() self._selection_pass = None + self._picking_pass: Optional[PickingPass] = None CuraApplication.getInstance().globalContainerStackChanged.connect(self._updateEnabled) # Note: if the selection is cleared with this tool active, there is no way to switch to @@ -84,12 +87,13 @@ class SupportEraser(Tool): # Only "normal" meshes can have anti_overhang_meshes added to them return - # Create a pass for picking a world-space location from the mouse location - active_camera = self._controller.getScene().getActiveCamera() - picking_pass = PickingPass(active_camera.getViewportWidth(), active_camera.getViewportHeight()) - picking_pass.render() + # Get the pass for picking a world-space location from the mouse location + if self._picking_pass is None: + self._picking_pass = Application.getInstance().getRenderer().getRenderPass("picking_selected") + if not self._picking_pass: + return - picked_position = picking_pass.getPickedPosition(event.x, event.y) + picked_position = self._picking_pass.getPickedPosition(event.x, event.y) # Add the anti_overhang_mesh cube at the picked location self._createEraserMesh(picked_node, picked_position) @@ -189,3 +193,6 @@ class SupportEraser(Tool): mesh.calculateNormals() return mesh + + def getRequiredExtraRenderingPasses(self) -> list[str]: + return ["picking_selected"] \ No newline at end of file From ea488f02029563c587abdc45bcb30d56377a1ef4 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Fri, 1 Aug 2025 14:23:02 +0200 Subject: [PATCH 10/13] Fix wrongly displayed error message CURA-12660 --- cura/XRayPass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/XRayPass.py b/cura/XRayPass.py index 965294ba89..20fe38741e 100644 --- a/cura/XRayPass.py +++ b/cura/XRayPass.py @@ -16,7 +16,7 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator class XRayPass(RenderPass): def __init__(self, width, height): - super().__init__("xray", width, height) + super().__init__("xray", width, height, -100) self._shader = None self._gl = OpenGL.getInstance().getBindingsObject() From 78daa94ebff6a76b5fa91e59483c6541a9c80a18 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Fri, 1 Aug 2025 17:08:53 +0200 Subject: [PATCH 11/13] Save and restore painting tool settings CURA-12660 --- plugins/PaintTool/BrushColorButton.qml | 22 ++++++++++--- plugins/PaintTool/BrushShapeButton.qml | 22 ++++++++++--- plugins/PaintTool/PaintModeButton.qml | 23 +++++++++++--- plugins/PaintTool/PaintTool.py | 43 ++++++++++++++++++++------ plugins/PaintTool/PaintTool.qml | 20 ++++-------- plugins/PaintTool/PaintView.py | 22 +++++++++---- plugins/SolidView/SolidView.py | 5 +-- 7 files changed, 113 insertions(+), 44 deletions(-) diff --git a/plugins/PaintTool/BrushColorButton.qml b/plugins/PaintTool/BrushColorButton.qml index 71556f2681..ae4ab6243f 100644 --- a/plugins/PaintTool/BrushColorButton.qml +++ b/plugins/PaintTool/BrushColorButton.qml @@ -13,13 +13,27 @@ UM.ToolbarButton property string color - checked: base.selectedColor === buttonBrushColor.color - onClicked: setColor() function setColor() { - base.selectedColor = buttonBrushColor.color - UM.Controller.triggerActionWithData("setBrushColor", buttonBrushColor.color) + UM.Controller.setProperty("BrushColor", buttonBrushColor.color); + } + + function isChecked() + { + return UM.Controller.properties.getValue("BrushColor") === buttonBrushColor.color; + } + + Component.onCompleted: + { + buttonBrushColor.checked = isChecked(); + } + + Binding + { + target: buttonBrushColor + property: "checked" + value: isChecked() } } diff --git a/plugins/PaintTool/BrushShapeButton.qml b/plugins/PaintTool/BrushShapeButton.qml index 5c290e4a13..ef4256792a 100644 --- a/plugins/PaintTool/BrushShapeButton.qml +++ b/plugins/PaintTool/BrushShapeButton.qml @@ -13,13 +13,27 @@ UM.ToolbarButton property int shape - checked: base.selectedShape === buttonBrushShape.shape - onClicked: setShape() function setShape() { - base.selectedShape = buttonBrushShape.shape - UM.Controller.triggerActionWithData("setBrushShape", buttonBrushShape.shape) + UM.Controller.setProperty("BrushShape", buttonBrushShape.shape) + } + + function isChecked() + { + return UM.Controller.properties.getValue("BrushShape") === buttonBrushShape.shape; + } + + Component.onCompleted: + { + buttonBrushShape.checked = isChecked(); + } + + Binding + { + target: buttonBrushShape + property: "checked" + value: isChecked() } } diff --git a/plugins/PaintTool/PaintModeButton.qml b/plugins/PaintTool/PaintModeButton.qml index 473996e04b..eb294f7ad6 100644 --- a/plugins/PaintTool/PaintModeButton.qml +++ b/plugins/PaintTool/PaintModeButton.qml @@ -6,19 +6,34 @@ import QtQuick import UM 1.7 as UM import Cura 1.0 as Cura + Cura.ModeSelectorButton { id: modeSelectorButton property string mode - selected: base.selectedMode === modeSelectorButton.mode - onClicked: setMode() function setMode() { - base.selectedMode = modeSelectorButton.mode - UM.Controller.triggerActionWithData("setPaintType", modeSelectorButton.mode) + UM.Controller.setProperty("PaintType", modeSelectorButton.mode); + } + + function isSelected() + { + return UM.Controller.properties.getValue("PaintType") === modeSelectorButton.mode; + } + + Component.onCompleted: + { + modeSelectorButton.selected = isSelected(); + } + + Binding + { + target: modeSelectorButton + property: "selected" + value: isSelected() } } diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index e57c6d5a11..a1812a5766 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -45,8 +45,8 @@ class PaintTool(Tool): self._cache_dirty: bool = True self._brush_size: int = 10 - self._brush_color: str = "" - self._brush_shape: PaintTool.Brush.Shape = PaintTool.Brush.Shape.SQUARE + self._brush_color: str = "preferred" + self._brush_shape: PaintTool.Brush.Shape = PaintTool.Brush.Shape.CIRCLE self._brush_pen: QPen = self._createBrushPen() self._mouse_held: bool = False @@ -55,6 +55,8 @@ class PaintTool(Tool): self._last_mouse_coords: Optional[Tuple[int, int]] = None self._last_face_id: Optional[int] = None + self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape") + Selection.selectionChanged.connect(self._updateIgnoreUnselectedObjects) def _createBrushPen(self) -> QPen: @@ -91,28 +93,51 @@ class PaintTool(Tool): return stroke_image, (start_x, start_y) + def getPaintType(self) -> str: + paint_view = self._get_paint_view() + if paint_view is None: + return "" + + return paint_view.getPaintType() + def setPaintType(self, paint_type: str) -> None: paint_view = self._get_paint_view() if paint_view is None: return - paint_view.setPaintType(paint_type) + if paint_type != self.getPaintType(): + paint_view.setPaintType(paint_type) - self._brush_pen = self._createBrushPen() - self._updateScene() + self._brush_pen = self._createBrushPen() + self._updateScene() + self.propertyChanged.emit() + + def getBrushSize(self) -> int: + return self._brush_size def setBrushSize(self, brush_size: float) -> None: - if brush_size != self._brush_size: - self._brush_size = int(brush_size) + brush_size_int = int(brush_size) + if brush_size_int != self._brush_size: + self._brush_size = brush_size_int self._brush_pen = self._createBrushPen() + self.propertyChanged.emit() + + def getBrushColor(self) -> str: + return self._brush_color def setBrushColor(self, brush_color: str) -> None: - self._brush_color = brush_color + if brush_color != self._brush_color: + self._brush_color = brush_color + self.propertyChanged.emit() + + def getBrushShape(self) -> int: + return self._brush_shape def setBrushShape(self, brush_shape: int) -> None: if brush_shape != self._brush_shape: self._brush_shape = brush_shape self._brush_pen = self._createBrushPen() + self.propertyChanged.emit() def undoStackAction(self, redo_instead: bool) -> bool: paint_view = self._get_paint_view() @@ -251,13 +276,11 @@ class PaintTool(Tool): # Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes if event.type == Event.ToolActivateEvent: - controller.setActiveStage("PrepareStage") controller.setActiveView("PaintTool") # Because that's the plugin-name, and the view is registered to it. self._updateIgnoreUnselectedObjects() return True if event.type == Event.ToolDeactivateEvent: - controller.setActiveStage("PrepareStage") controller.setActiveView("SolidView") CuraApplication.getInstance().getRenderer().getRenderPass("selection").setIgnoreUnselectedObjects(False) CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(False) diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index e3f244dd4c..94642b1f66 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -15,10 +15,6 @@ Item height: childrenRect.height UM.I18nCatalog { id: catalog; name: "cura"} - property string selectedMode: "" - property string selectedColor: "" - property int selectedShape: 0 - Action { id: undoAction @@ -167,15 +163,19 @@ Item from: 1 to: 40 - value: 10 onPressedChanged: function(pressed) { if(! pressed) { - UM.Controller.triggerActionWithData("setBrushSize", shapeSizeSlider.value) + UM.Controller.setProperty("BrushSize", shapeSizeSlider.value); } } + + Component.onCompleted: + { + shapeSizeSlider.value = UM.Controller.properties.getValue("BrushSize"); + } } //Line between the sections. @@ -227,12 +227,4 @@ Item } } } - - Component.onCompleted: - { - // Force first types for consistency, otherwise UI may become different from controller - rowPaintMode.children[0].setMode() - rowBrushColor.children[1].setColor() - rowBrushShape.children[1].setShape() - } } diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index a3d4b36315..c723f4321d 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -60,6 +60,8 @@ class PaintView(SolidView): "support": usual_types, } + self._current_paint_type = "seam" + def _checkSetup(self): super()._checkSetup() @@ -82,6 +84,8 @@ class PaintView(SolidView): if self._current_paint_texture is None or self._current_paint_texture.getImage() is None: return + self._prepareDataMapping() + actual_image = self._current_paint_texture.getImage() bit_range_start, bit_range_end = self._current_bits_ranges @@ -141,20 +145,26 @@ class PaintView(SolidView): return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight() return 0, 0 + def getPaintType(self) -> str: + return self._current_paint_type + def setPaintType(self, paint_type: str) -> None: + self._current_paint_type = paint_type + self._prepareDataMapping() + + def _prepareDataMapping(self): node = Selection.getAllSelectedObjects()[0] if node is None: return paint_data_mapping = node.callDecoration("getTextureDataMapping") - if paint_type not in paint_data_mapping: - new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[paint_type])) - paint_data_mapping[paint_type] = new_mapping + 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._current_paint_type = paint_type - self._current_bits_ranges = paint_data_mapping[paint_type] + self._current_bits_ranges = paint_data_mapping[self._current_paint_type] @staticmethod def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]: @@ -167,7 +177,7 @@ class PaintView(SolidView): return start_index, end_index def beginRendering(self) -> None: - if self._current_paint_type == "": + if self._current_paint_type not in self._paint_modes: return display_objects = Selection.getAllSelectedObjects().copy() diff --git a/plugins/SolidView/SolidView.py b/plugins/SolidView/SolidView.py index bffc3aa526..e25273cb13 100644 --- a/plugins/SolidView/SolidView.py +++ b/plugins/SolidView/SolidView.py @@ -289,8 +289,9 @@ class SolidView(View): def endRendering(self): # check whether the xray overlay is showing badness - if time.time() > self._next_xray_checking_time\ - and Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference): + if (time.time() > self._next_xray_checking_time + and Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference) + and self._xray_pass is not None): self._next_xray_checking_time = time.time() + self._xray_checking_update_time xray_img = self._xray_pass.getOutput() From 44d6c0a9694d6cb5ddbf78da25735e37a0e8c7e2 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Mon, 4 Aug 2025 11:28:31 +0200 Subject: [PATCH 12/13] Call SolidView dynamically instead of by inheritance CURA-12660 The previous method actually doesn't work when Cura is packaged because the plugins paths change. This method is much safer, and uses the actual SolidView instance. --- plugins/PaintTool/PaintView.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index c723f4321d..e4a8b3c493 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -9,8 +9,8 @@ from PyQt6.QtGui import QImage, QColor, QPainter from cura.CuraApplication import CuraApplication from cura.BuildVolume import BuildVolume -from plugins.SolidView.SolidView import SolidView from UM.PluginRegistry import PluginRegistry +from UM.View.View import View from UM.View.GL.ShaderProgram import ShaderProgram from UM.View.GL.Texture import Texture from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator @@ -22,7 +22,7 @@ from UM.Math.Color import Color catalog = i18nCatalog("cura") -class PaintView(SolidView): +class PaintView(View): """View for model-painting.""" UNDO_STACK_SIZE = 1024 @@ -50,6 +50,8 @@ class PaintView(SolidView): application.engineCreatedSignal.connect(self._makePaintModes) self._scene = application.getController().getScene() + self._solid_view = None + def _makePaintModes(self): theme = CuraApplication.getInstance().getTheme() usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0), @@ -63,8 +65,6 @@ class PaintView(SolidView): self._current_paint_type = "seam" def _checkSetup(self): - super()._checkSetup() - if not self._paint_shader: shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader") self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename) @@ -180,10 +180,16 @@ class PaintView(SolidView): if self._current_paint_type not in self._paint_modes: return + if self._solid_view is None: + plugin_registry = PluginRegistry.getInstance() + solid_view = plugin_registry.getPluginObject("SolidView") + if isinstance(solid_view, View): + self._solid_view = solid_view + display_objects = Selection.getAllSelectedObjects().copy() - if len(display_objects) != 1: + if len(display_objects) != 1 and self._solid_view is not None: # Display the classic view until a single object is selected - super().beginRendering() + self._solid_view.beginRendering() return self._checkSetup() From 9d97eb7d594208bde9891b515d59aa6673cf6362 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Tue, 5 Aug 2025 14:07:44 +0200 Subject: [PATCH 13/13] Fix sometimes wrong painting color display CURA-12660 --- plugins/PaintTool/paint.shader | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/PaintTool/paint.shader b/plugins/PaintTool/paint.shader index f2af66ffe6..c1b90b376b 100644 --- a/plugins/PaintTool/paint.shader +++ b/plugins/PaintTool/paint.shader @@ -132,7 +132,7 @@ fragment41core = [defaults] u_ambientColor = [0.3, 0.3, 0.3, 1.0] -u_opacity = 0.5 +u_opacity = 1.0 u_texture = 0 [bindings]