Proper paint-on-seam UI
Some checks failed
conan-package / conan-package (push) Has been cancelled
unit-test / Run unit tests (push) Has been cancelled

CURA-12578
This commit is contained in:
Erwan MATHIEU 2025-07-02 14:04:41 +02:00
parent a1d1dc2ea0
commit bbddcab4e9
13 changed files with 274 additions and 165 deletions

View file

@ -0,0 +1,25 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import UM 1.7 as UM
import Cura 1.0 as Cura
UM.ToolbarButton
{
id: buttonBrushColor
property string color
checked: base.selectedColor === buttonBrushColor.color
onClicked: setColor()
function setColor()
{
base.selectedColor = buttonBrushColor.color
UM.Controller.triggerActionWithData("setBrushColor", buttonBrushColor.color)
}
}

View file

@ -0,0 +1,25 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import UM 1.7 as UM
import Cura 1.0 as Cura
UM.ToolbarButton
{
id: buttonBrushShape
property int shape
checked: base.selectedShape === buttonBrushShape.shape
onClicked: setShape()
function setShape()
{
base.selectedShape = buttonBrushShape.shape
UM.Controller.triggerActionWithData("setBrushShape", buttonBrushShape.shape)
}
}

View file

@ -0,0 +1,24 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
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)
}
}

View file

@ -42,7 +42,7 @@ class PaintTool(Tool):
self._cache_dirty: bool = True
self._brush_size: int = 10
self._brush_color: str = "A"
self._brush_color: str = ""
self._brush_shape: PaintTool.Brush.Shape = PaintTool.Brush.Shape.SQUARE
self._brush_pen: QPen = self._createBrushPen()
@ -122,6 +122,18 @@ class PaintTool(Tool):
self._updateScene()
return True
def clear(self) -> None:
paintview = self._get_paint_view()
if paintview is None:
return
width, height = paintview.getUvTexDimensions()
clear_image = QImage(width, height, QImage.Format.Format_RGB32)
clear_image.fill(Qt.GlobalColor.white)
paintview.addStroke(clear_image, 0, 0, "none")
self._updateScene()
@staticmethod
def _get_paint_view() -> Optional[PaintView]:
paint_view = Application.getInstance().getController().getActiveView()
@ -265,10 +277,9 @@ class PaintTool(Tool):
else:
self._mouse_held = True
paintview = controller.getActiveView()
if paintview is None or paintview.getPluginId() != "PaintTool":
paintview = self._get_paint_view()
if paintview is None:
return False
paintview = cast(PaintView, paintview)
if not self._selection_pass:
return False

View file

@ -15,6 +15,10 @@ Item
height: childrenRect.height
UM.I18nCatalog { id: catalog; name: "cura"}
property string selectedMode: ""
property string selectedColor: ""
property int selectedShape: 0
Action
{
id: undoAction
@ -29,170 +33,158 @@ Item
onTriggered: UM.Controller.triggerActionWithData("undoStackAction", true)
}
ColumnLayout
Column
{
id: mainColumn
spacing: UM.Theme.getSize("default_margin").height
RowLayout
{
UM.ToolbarButton
id: rowPaintMode
width: parent.width
PaintModeButton
{
id: paintTypeA
text: catalog.i18nc("@action:button", "Paint Type A")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Buildplate")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setPaintType", "A")
text: catalog.i18nc("@action:button", "Seam")
icon: "Seam"
tooltipText: catalog.i18nc("@tooltip", "Refine seam placement by defining preferred/avoidance areas")
mode: "seam"
}
UM.ToolbarButton
PaintModeButton
{
id: paintTypeB
text: catalog.i18nc("@action:button", "Support")
icon: "Support"
tooltipText: catalog.i18nc("@tooltip", "Refine support placement by defining preferred/avoidance areas")
mode: "support"
}
}
text: catalog.i18nc("@action:button", "Paint Type B")
//Line between the sections.
Rectangle
{
width: parent.width
height: UM.Theme.getSize("default_lining").height
color: UM.Theme.getColor("lining")
}
RowLayout
{
id: rowBrushColor
UM.Label
{
text: catalog.i18nc("@label", "Mark as")
}
BrushColorButton
{
id: buttonPreferredArea
color: "preferred"
text: catalog.i18nc("@action:button", "Preferred")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("BlackMagic")
source: UM.Theme.getIcon("CheckBadge", "low")
color: UM.Theme.getColor("paint_preferred_area")
}
}
BrushColorButton
{
id: buttonAvoidArea
color: "avoid"
text: catalog.i18nc("@action:button", "Avoid")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("CancelBadge", "low")
color: UM.Theme.getColor("paint_avoid_area")
}
}
BrushColorButton
{
id: buttonEraseArea
color: "none"
text: catalog.i18nc("@action:button", "Erase")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eraser")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setPaintType", "B")
}
}
RowLayout
{
UM.ToolbarButton
id: rowBrushShape
UM.Label
{
id: colorButtonA
text: catalog.i18nc("@action:button", "Color A")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eye")
color: "purple"
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushColor", "A")
text: catalog.i18nc("@label", "Brush Shape")
}
UM.ToolbarButton
BrushShapeButton
{
id: colorButtonB
id: buttonBrushCircle
shape: Cura.PaintToolBrush.CIRCLE
text: catalog.i18nc("@action:button", "Color B")
text: catalog.i18nc("@action:button", "Circle")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eye")
color: "orange"
source: UM.Theme.getIcon("Circle")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushColor", "B")
}
UM.ToolbarButton
BrushShapeButton
{
id: colorButtonC
id: buttonBrushSquare
shape: Cura.PaintToolBrush.SQUARE
text: catalog.i18nc("@action:button", "Color C")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eye")
color: "green"
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushColor", "C")
}
UM.ToolbarButton
{
id: colorButtonD
text: catalog.i18nc("@action:button", "Color D")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eye")
color: "ghostwhite"
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushColor", "D")
}
}
RowLayout
{
UM.ToolbarButton
{
id: shapeSquareButton
text: catalog.i18nc("@action:button", "Square Brush")
text: catalog.i18nc("@action:button", "Square")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("MeshTypeNormal")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushShape", Cura.PaintToolBrush.SQUARE)
}
}
UM.ToolbarButton
UM.Label
{
text: catalog.i18nc("@label", "Brush Size")
}
UM.Slider
{
id: shapeSizeSlider
width: parent.width
indicatorVisible: false
from: 1
to: 40
value: 10
onPressedChanged: function(pressed)
{
id: shapeCircleButton
text: catalog.i18nc("@action:button", "Round Brush")
toolItem: UM.ColorImage
if(! pressed)
{
source: UM.Theme.getIcon("CircleOutline")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushShape", Cura.PaintToolBrush.CIRCLE)
}
UM.Slider
{
id: shapeSizeSlider
from: 1
to: 40
value: 10
onPressedChanged: function(pressed)
{
if(! pressed)
{
UM.Controller.triggerActionWithData("setBrushSize", shapeSizeSlider.value)
}
UM.Controller.triggerActionWithData("setBrushSize", shapeSizeSlider.value)
}
}
}
//Line between the sections.
Rectangle
{
width: parent.width
height: UM.Theme.getSize("default_lining").height
color: UM.Theme.getColor("lining")
}
RowLayout
{
UM.ToolbarButton
@ -203,10 +195,8 @@ Item
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowReset")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: undoAction.trigger()
}
@ -218,14 +208,30 @@ Item
text: catalog.i18nc("@action:button", "Redo Stroke")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowDoubleCircleRight")
source: UM.Theme.getIcon("ArrowReset")
color: UM.Theme.getColor("icon")
transform: [
Scale { xScale: -1; origin.x: width/2 }
]
}
property bool needBorder: true
z: 2
onClicked: redoAction.trigger()
}
Cura.SecondaryButton
{
id: clearButton
text: catalog.i18nc("@button", "Clear all")
onClicked: UM.Controller.triggerAction("clear")
}
}
}
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()
}
}

View file

@ -26,23 +26,17 @@ class PaintView(View):
UNDO_STACK_SIZE = 1024
class PaintType:
def __init__(self, icon: str, display_color: Color, value: int):
self.icon: str = icon
def __init__(self, display_color: Color, value: int):
self.display_color: Color = display_color
self.value: int = value
class PaintMode:
def __init__(self, icon: str, types: Dict[str, "PaintView.PaintType"]):
self.icon: str = icon
self.types = types
def __init__(self) -> None:
super().__init__()
self._paint_shader: Optional[ShaderProgram] = None
self._current_paint_texture: Optional[Texture] = None
self._current_bits_ranges: tuple[int, int] = (0, 0)
self._current_paint_type = ""
self._paint_modes: Dict[str, PaintView.PaintMode] = {}
self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {}
self._stroke_undo_stack: List[Tuple[QImage, int, int]] = []
self._stroke_redo_stack: List[Tuple[QImage, int, int]] = []
@ -54,12 +48,12 @@ class PaintView(View):
def _makePaintModes(self):
theme = CuraApplication.getInstance().getTheme()
usual_types = {"A": self.PaintType("Buildplate", Color(*theme.getColor("paint_normal_area").getRgb()), 0),
"B": self.PaintType("BlackMagic", Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
"C": self.PaintType("Eye", Color(*theme.getColor("paint_avoid_area").getRgb()), 2)}
usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0),
"preferred": self.PaintType(Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
"avoid": self.PaintType(Color(*theme.getColor("paint_avoid_area").getRgb()), 2)}
self._paint_modes = {
"A": self.PaintMode("MeshTypeNormal", usual_types),
"B": self.PaintMode("CircleOutline", usual_types),
"seam": usual_types,
"support": usual_types,
}
def _checkSetup(self):
@ -78,32 +72,32 @@ class PaintView(View):
res.setAlphaChannel(self._force_opaque_mask.scaled(image.width(), image.height()))
return res
def addStroke(self, stroke_image: QImage, start_x: int, start_y: int, brush_color: str) -> None:
def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str) -> None:
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
return
actual_image = self._current_paint_texture.getImage()
bit_range_start, bit_range_end = self._current_bits_ranges
set_value = self._paint_modes[self._current_paint_type].types[brush_color].value << self._current_bits_ranges[0]
set_value = self._paint_modes[self._current_paint_type][brush_color].value << self._current_bits_ranges[0]
full_int32 = 0xffffffff
clear_mask = full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (32 - 1 - bit_range_end))
image_rect = QRect(0, 0, stroke_image.width(), stroke_image.height())
image_rect = QRect(0, 0, stroke_mask.width(), stroke_mask.height())
clear_bits_image = stroke_image.copy()
clear_bits_image = stroke_mask.copy()
clear_bits_image.invertPixels()
painter = QPainter(clear_bits_image)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten)
painter.fillRect(image_rect, clear_mask)
painter.end()
set_value_image = stroke_image.copy()
set_value_image = stroke_mask.copy()
painter = QPainter(set_value_image)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply)
painter.fillRect(image_rect, set_value)
painter.end()
stroked_image = actual_image.copy(start_x, start_y, stroke_image.width(), stroke_image.height())
stroked_image = actual_image.copy(start_x, start_y, stroke_mask.width(), stroke_mask.height())
painter = QPainter(stroked_image)
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination)
painter.drawImage(0, 0, clear_bits_image)
@ -149,7 +143,7 @@ class PaintView(View):
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].types))
new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[paint_type]))
paint_data_mapping[paint_type] = new_mapping
node.callDecoration("setTextureDataMapping", paint_data_mapping)
@ -177,12 +171,12 @@ class PaintView(View):
return
if self._current_paint_type == "":
self.setPaintType("A")
return
self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0])
self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])
colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].types.values()]
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)