Merge remote-tracking branch 'origin/CURA-12660_painting-UI-improvements' into CURA-12662_paint_splatter_issue

This commit is contained in:
Remco Burema 2025-08-12 20:31:50 +02:00
commit 4f465bc30f
10 changed files with 184 additions and 81 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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