From e69a4369423aa57cd00d81a4b459fb6f4888c30b Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Wed, 6 Aug 2025 16:15:05 +0200 Subject: [PATCH 1/2] Fix sometimes wrongly displayed view CURA-12660 This required a refactoring of the management of the active view. The previous behavior was that anyone could set the active view, depending on certain conditions. But now we also have a view that is set by a tool, so sometimes the actually set view would be incorrect. Now each Stage requests an active view, and each tool CAN also request an active view. Then the Controller decides which view should actually be active depending on the active stage and tool. --- cura/CuraApplication.py | 5 +---- cura/Stages/CuraStage.py | 6 ++++-- plugins/PaintTool/PaintTool.py | 22 ++++++++++------------ plugins/PaintTool/PaintView.py | 22 ++++------------------ plugins/PreviewStage/PreviewStage.py | 19 ------------------- plugins/SimulationView/SimulationView.py | 16 +++++++++++++--- resources/qml/ViewsSelector.qml | 4 ++-- 7 files changed, 34 insertions(+), 60 deletions(-) diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 5ce358080c..57d4773cb3 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -1038,7 +1038,6 @@ class CuraApplication(QtApplication): # Initialize UI state controller.setActiveStage("PrepareStage") - controller.setActiveView("SolidView") controller.setCameraTool("CameraTool") controller.setSelectionTool("SelectionTool") @@ -2089,9 +2088,7 @@ class CuraApplication(QtApplication): is_non_sliceable = "." + file_extension in self._non_sliceable_extensions if is_non_sliceable: - # Need to switch first to the preview stage and then to layer view - self.callLater(lambda: (self.getController().setActiveStage("PreviewStage"), - self.getController().setActiveView("SimulationView"))) + self.callLater(lambda: (self.getController().setActiveStage("PreviewStage"))) block_slicing_decorator = BlockSlicingDecorator() node.addDecorator(block_slicing_decorator) diff --git a/cura/Stages/CuraStage.py b/cura/Stages/CuraStage.py index 869ed309dc..8c207db8ad 100644 --- a/cura/Stages/CuraStage.py +++ b/cura/Stages/CuraStage.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 pyqtProperty, QUrl from UM.Stage import Stage @@ -13,8 +15,8 @@ from UM.Stage import Stage # * The MainComponent is the component that will be drawn starting from the bottom of the stageBar and fills the rest # of the screen. class CuraStage(Stage): - def __init__(self, parent = None) -> None: - super().__init__(parent) + def __init__(self, parent = None, active_view: Optional[str] = "SolidView") -> None: + super().__init__(parent, active_view = active_view) @pyqtProperty(str, constant = True) def stageId(self) -> str: diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index eaeb2dc69b..7a1b25f331 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -58,7 +58,8 @@ class PaintTool(Tool): self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape") - Selection.selectionChanged.connect(self._updateIgnoreUnselectedObjects) + Selection.selectionChanged.connect(self._updateActiveView) + self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects) def _createBrushPen(self) -> QPen: pen = QPen() @@ -298,14 +299,9 @@ 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.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.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(): @@ -397,6 +393,9 @@ class PaintTool(Tool): return False + def getRequiredExtraRenderingPasses(self) -> list[str]: + return ["selection_faces", "picking_selected"] + @staticmethod def _updateScene(node: SceneNode = None): if node is None: @@ -404,11 +403,10 @@ class PaintTool(Tool): if node is not None: Application.getInstance().getController().getScene().sceneChanged.emit(node) - def getRequiredExtraRenderingPasses(self) -> list[str]: - return ["selection_faces", "picking_selected"] + def _updateActiveView(self): + self.setActiveView("PaintTool" if len(Selection.getAllSelectedObjects()) == 1 else None) 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 + ignore_unselected_objects = self._controller.getActiveView().name == "PaintTool" + 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 73e8a511a6..22629e340c 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 cura.CuraView import CuraView 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(View): +class PaintView(CuraView): """View for model-painting.""" UNDO_STACK_SIZE = 1024 @@ -33,7 +33,7 @@ class PaintView(View): self.value: int = value def __init__(self) -> None: - super().__init__() + super().__init__(use_empty_menu_placeholder = True) self._paint_shader: Optional[ShaderProgram] = None self._current_paint_texture: Optional[Texture] = None self._current_bits_ranges: tuple[int, int] = (0, 0) @@ -50,8 +50,6 @@ class PaintView(View): 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), @@ -179,18 +177,6 @@ class PaintView(View): 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 and self._solid_view is not None: - # Display the classic view until a single object is selected - self._solid_view.beginRendering() - return - self._checkSetup() renderer = self.getRenderer() @@ -201,7 +187,7 @@ class PaintView(View): paint_batch = renderer.createRenderBatch(shader=self._paint_shader) renderer.addRenderBatch(paint_batch) - for node in display_objects: + for node in Selection.getAllSelectedObjects(): 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) diff --git a/plugins/PreviewStage/PreviewStage.py b/plugins/PreviewStage/PreviewStage.py index 88f432ef9b..3f1a4423b2 100644 --- a/plugins/PreviewStage/PreviewStage.py +++ b/plugins/PreviewStage/PreviewStage.py @@ -24,25 +24,6 @@ class PreviewStage(CuraStage): super().__init__(parent) self._application = application self._application.engineCreatedSignal.connect(self._engineCreated) - self._previously_active_view = None # type: Optional[View] - - def onStageSelected(self) -> None: - """When selecting the stage, remember which was the previous view so that - - we can revert to that view when we go out of the stage later. - """ - self._previously_active_view = self._application.getController().getActiveView() - - def onStageDeselected(self) -> None: - """Called when going to a different stage (away from the Preview Stage). - - When going to a different stage, the view should be reverted to what it - was before. Normally, that just reverts it to solid view. - """ - - if self._previously_active_view is not None: - self._application.getController().setActiveView(self._previously_active_view.getPluginId()) - self._previously_active_view = None def _engineCreated(self) -> None: """Delayed load of the QML files. diff --git a/plugins/SimulationView/SimulationView.py b/plugins/SimulationView/SimulationView.py index 083fc73bf1..5d339e7f74 100644 --- a/plugins/SimulationView/SimulationView.py +++ b/plugins/SimulationView/SimulationView.py @@ -172,13 +172,20 @@ class SimulationView(CuraView): self._updateSliceWarningVisibility() self.activityChanged.emit() - def getSimulationPass(self) -> SimulationPass: + def getSimulationPass(self) -> Optional[SimulationPass]: if not self._layer_pass: + renderer = self.getRenderer() + if renderer is None: + return None + # Currently the RenderPass constructor requires a size > 0 # This should be fixed in RenderPass's constructor. self._layer_pass = SimulationPass(1, 1) self._compatibility_mode = self._evaluateCompatibilityMode() self._layer_pass.setSimulationView(self) + self._layer_pass.setEnabled(False) + renderer.addRenderPass(self._layer_pass) + return self._layer_pass def getCurrentLayer(self) -> int: @@ -734,11 +741,14 @@ class SimulationView(CuraView): # Make sure the SimulationPass is created layer_pass = self.getSimulationPass() + if layer_pass is None: + return False + renderer = self.getRenderer() if renderer is None: return False - renderer.addRenderPass(layer_pass) + layer_pass.setEnabled(True) # Make sure the NozzleNode is add to the root nozzle = self.getNozzleNode() @@ -778,7 +788,7 @@ class SimulationView(CuraView): return False if self._layer_pass is not None: - renderer.removeRenderPass(self._layer_pass) + self._layer_pass.setEnabled(False) if self._composite_pass: self._composite_pass.setLayerBindings(cast(List[str], self._old_layer_bindings)) self._composite_pass.setCompositeShader(cast(ShaderProgram, self._old_composite_shader)) diff --git a/resources/qml/ViewsSelector.qml b/resources/qml/ViewsSelector.qml index e76e5dbb67..b0e31ac532 100644 --- a/resources/qml/ViewsSelector.qml +++ b/resources/qml/ViewsSelector.qml @@ -38,7 +38,7 @@ Cura.ExpandablePopup { if (activeView == null) { - UM.Controller.setActiveView(viewModel.getItem(0).id) + UM.Controller.activeStage.setActiveView(viewModel.getItem(0).id) } } @@ -110,7 +110,7 @@ Cura.ExpandablePopup onClicked: { toggleContent() - UM.Controller.setActiveView(id) + UM.Controller.activeStage.setActiveView(id) } } } From db514f0be77dd585092ca77a1da977837b912b83 Mon Sep 17 00:00:00 2001 From: Erwan MATHIEU Date: Thu, 7 Aug 2025 11:03:51 +0200 Subject: [PATCH 2/2] Properly prepare the model for painting CURA-12660 The UV-unwrapping is now done in a background job, and the UI displays a waiting state. This fixes the issue where the user would start painting but the model was not ready yet, and the first stroke would be missing. --- plugins/PaintTool/PaintTool.py | 70 ++++++++++++++++--------- plugins/PaintTool/PaintTool.qml | 71 ++++++++++++++++++++++++++ plugins/PaintTool/PrepareTextureJob.py | 33 ++++++++++++ plugins/PaintTool/__init__.py | 1 + 4 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 plugins/PaintTool/PrepareTextureJob.py diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 7a1b25f331..82361e80ec 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -12,6 +12,7 @@ from numpy import ndarray from UM.Application import Application from UM.Event import Event, MouseEvent, KeyEvent +from UM.Job import Job from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection @@ -22,6 +23,7 @@ from cura.CuraApplication import CuraApplication from cura.PickingPass import PickingPass from UM.View.SelectionPass import SelectionPass from .PaintView import PaintView +from .PrepareTextureJob import PrepareTextureJob class PaintTool(Tool): @@ -33,6 +35,13 @@ class PaintTool(Tool): SQUARE = 0 CIRCLE = 1 + class Paint(QObject): + @pyqtEnum + class State(IntEnum): + MULTIPLE_SELECTION = 0 # Multiple objects are selected, wait until there is only one + PREPARING_MODEL = 1 # Model is being prepared (UV-unwrapping, texture generation) + READY = 2 # Ready to paint ! + def __init__(self) -> None: super().__init__() @@ -56,10 +65,13 @@ 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") + self._state: PaintTool.Paint.State = PaintTool.Paint.State.MULTIPLE_SELECTION + self._prepare_texture_job: Optional[PrepareTextureJob] = None + + self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape", "State") - Selection.selectionChanged.connect(self._updateActiveView) self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects) + self._controller.activeToolChanged.connect(self._updateState) def _createBrushPen(self) -> QPen: pen = QPen() @@ -141,6 +153,9 @@ class PaintTool(Tool): self._brush_pen = self._createBrushPen() self.propertyChanged.emit() + def getState(self) -> int: + return self._state + def undoStackAction(self, redo_instead: bool) -> bool: paint_view = self._get_paint_view() if paint_view is None: @@ -266,23 +281,6 @@ class PaintTool(Tool): self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b) self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct) - def _setupNodeForPainting(self, node: SceneNode) -> bool: - mesh = node.getMeshData() - if mesh.hasUVCoordinates(): - return True - - texture_width, texture_height = mesh.calculateUnwrappedUVCoordinates() - if texture_width <= 0 or texture_height <= 0: - return False - - node.callDecoration("prepareTexture", texture_width, texture_height) - - if hasattr(mesh, OpenGL.VertexBufferProperty): - # Force clear OpenGL buffer so that new UV coordinates will be sent - delattr(mesh, OpenGL.VertexBufferProperty) - - return True - def event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -304,6 +302,9 @@ class PaintTool(Tool): if event.type == Event.ToolDeactivateEvent: return True + if self._state != PaintTool.Paint.State.READY: + return False + if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled(): if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: return False @@ -356,9 +357,6 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - if not self._setupNodeForPainting(node): - return False - face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y) if texcoords is None: return False @@ -403,8 +401,34 @@ class PaintTool(Tool): if node is not None: Application.getInstance().getController().getScene().sceneChanged.emit(node) - def _updateActiveView(self): + def _onSelectionChanged(self): + super()._onSelectionChanged() + self.setActiveView("PaintTool" if len(Selection.getAllSelectedObjects()) == 1 else None) + self._updateState() + + def _updateState(self): + if len(Selection.getAllSelectedObjects()) == 1 and self._controller.getActiveTool() == self: + selected_object = Selection.getSelectedObject(0) + if selected_object.callDecoration("getPaintTexture") is not None: + new_state = PaintTool.Paint.State.READY + else: + new_state = PaintTool.Paint.State.PREPARING_MODEL + self._prepare_texture_job = PrepareTextureJob(selected_object) + self._prepare_texture_job.finished.connect(self._onPrepareTextureFinished) + self._prepare_texture_job.start() + else: + new_state = PaintTool.Paint.State.MULTIPLE_SELECTION + + if new_state != self._state: + self._state = new_state + self.propertyChanged.emit() + + def _onPrepareTextureFinished(self, job: Job): + if job == self._prepare_texture_job: + self._prepare_texture_job = None + self._state = PaintTool.Paint.State.READY + self.propertyChanged.emit() def _updateIgnoreUnselectedObjects(self): ignore_unselected_objects = self._controller.getActiveView().name == "PaintTool" diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index 2bb3106dd5..7b04f9e58d 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -227,4 +227,75 @@ Item } } } + + Rectangle + { + id: waitPrepareItem + anchors.fill: parent + color: UM.Theme.getColor("main_background") + visible: UM.Controller.properties.getValue("State") === Cura.PaintToolState.PREPARING_MODEL + + ColumnLayout + { + anchors.fill: parent + + UM.Label + { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.verticalStretchFactor: 2 + + text: catalog.i18nc("@label", "Preparing model for painting...") + verticalAlignment: Text.AlignBottom + horizontalAlignment: Text.AlignHCenter + } + + Item + { + Layout.preferredWidth: loadingIndicator.width + Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.verticalStretchFactor: 1 + + UM.ColorImage + { + id: loadingIndicator + + anchors.top: parent.top + anchors.left: parent.left + width: UM.Theme.getSize("card_icon").width + height: UM.Theme.getSize("card_icon").height + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("text_default") + + RotationAnimator + { + target: loadingIndicator + from: 0 + to: 360 + duration: 2000 + loops: Animation.Infinite + running: true + alwaysRunToEnd: true + } + } + } + } + } + + Rectangle + { + id: selectSingleMessageItem + anchors.fill: parent + color: UM.Theme.getColor("main_background") + visible: UM.Controller.properties.getValue("State") === Cura.PaintToolState.MULTIPLE_SELECTION + + UM.Label + { + anchors.fill: parent + text: catalog.i18nc("@label", "Select a single model to start painting") + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + } } diff --git a/plugins/PaintTool/PrepareTextureJob.py b/plugins/PaintTool/PrepareTextureJob.py new file mode 100644 index 0000000000..6c5e61c009 --- /dev/null +++ b/plugins/PaintTool/PrepareTextureJob.py @@ -0,0 +1,33 @@ +# Copyright (c) 2025 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from UM.Job import Job +from UM.Scene.SceneNode import SceneNode +from UM.View.GL.OpenGL import OpenGL + + +class PrepareTextureJob(Job): + """ + Background job to prepare a model for painting, i.e. do the UV-unwrapping and create the appropriate texture image, + which can last a few seconds + """ + + def __init__(self, node: SceneNode): + super().__init__() + self._node: SceneNode = node + + def run(self) -> None: + # If the model has already-provided UV coordinates, we can only assume that the associated texture + # should be a square + texture_width = texture_height = 4096 + + mesh = self._node.getMeshData() + if not mesh.hasUVCoordinates(): + texture_width, texture_height = mesh.calculateUnwrappedUVCoordinates() + + self._node.callDecoration("prepareTexture", texture_width, texture_height) + + if hasattr(mesh, OpenGL.VertexBufferProperty): + # Force clear OpenGL buffer so that new UV coordinates will be sent + delattr(mesh, OpenGL.VertexBufferProperty) + diff --git a/plugins/PaintTool/__init__.py b/plugins/PaintTool/__init__.py index e92c169ee6..93b47c7266 100644 --- a/plugins/PaintTool/__init__.py +++ b/plugins/PaintTool/__init__.py @@ -27,6 +27,7 @@ def getMetaData(): def register(app): qmlRegisterUncreatableType(PaintTool.PaintTool.Brush, "Cura", 1, 0, "This is an enumeration class", "PaintToolBrush") + qmlRegisterUncreatableType(PaintTool.PaintTool.Paint, "Cura", 1, 0, "This is an enumeration class", "PaintToolState") return { "tool": PaintTool.PaintTool(), "view": PaintView.PaintView()