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 4e57bda6e9..2285683b49 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -9,6 +9,7 @@ from typing import cast, Optional, Tuple, List from UM.Application import Application from UM.Event import Event, MouseEvent +from UM.Job import Job from UM.Logger import Logger from UM.Math.Polygon import Polygon from UM.Scene.SceneNode import SceneNode @@ -20,6 +21,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): @@ -31,6 +33,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__() @@ -54,9 +63,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 - Selection.selectionChanged.connect(self._updateIgnoreUnselectedObjects) + self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape", "State") + + self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects) + self._controller.activeToolChanged.connect(self._updateState) def _createBrushPen(self) -> QPen: pen = QPen() @@ -139,6 +152,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._getPaintView() if paint_view is None: @@ -364,23 +380,6 @@ class PaintTool(Tool): seen.add(candidate) return res - 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. @@ -397,16 +396,14 @@ 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 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 @@ -459,9 +456,6 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - if not self._setupNodeForPainting(node): - return False - face_id, _, world_coords = self._getCoordsFromClick(node, mouse_evt.x, mouse_evt.y) if face_id < 0: return False @@ -484,6 +478,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: @@ -491,11 +488,36 @@ 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 _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): - 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/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/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/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() 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) } } }