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)

View file

@ -17,7 +17,7 @@ Rectangle
color: mouseArea.containsMouse || selected ? UM.Theme.getColor("background_3") : UM.Theme.getColor("background_1")
property bool selected: false
property string profileName: ""
property alias text: mainLabel.text
property string icon: ""
property string custom_icon: ""
property alias tooltipText: tooltip.text
@ -42,18 +42,18 @@ Rectangle
Item
{
width: intentIcon.width
width: mainIcon.width
anchors
{
top: parent.top
bottom: qualityLabel.top
bottom: mainLabel.top
horizontalCenter: parent.horizontalCenter
topMargin: UM.Theme.getSize("narrow_margin").height
}
Item
{
id: intentIcon
id: mainIcon
width: UM.Theme.getSize("recommended_button_icon").width
height: UM.Theme.getSize("recommended_button_icon").height
@ -90,7 +90,7 @@ Rectangle
{
id: initialLabel
anchors.centerIn: parent
text: profileName.charAt(0).toUpperCase()
text: base.text.charAt(0).toUpperCase()
font: UM.Theme.getFont("small_bold")
horizontalAlignment: Text.AlignHCenter
}
@ -102,8 +102,7 @@ Rectangle
UM.Label
{
id: qualityLabel
text: profileName
id: mainLabel
anchors
{
bottom: parent.bottom

View file

@ -7,7 +7,6 @@ import QtQuick.Layouts 2.10
import UM 1.5 as UM
import Cura 1.7 as Cura
import ".."
Item
{
@ -28,9 +27,9 @@ Item
id: intentSelectionRepeater
model: Cura.IntentSelectionModel {}
RecommendedQualityProfileSelectorButton
Cura.ModeSelectorButton
{
profileName: model.name
text: model.name
icon: model.icon ? model.icon : ""
custom_icon: model.custom_icon ? model.custom_icon : ""
tooltipText: model.description ? model.description : ""

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M 12,2 C 6.5,2 2,6.5 2,12 2,17.5 6.5,22 12,22 17.5,22 22,17.5 22,12 22,6.5 17.5,2 12,2 Z m 0,18 C 7.6,20 4,16.4 4,12 4,7.6 7.6,4 12,4 c 4.4,0 8,3.6 8,8 0,4.4 -3.6,8 -8,8 z" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="22" height="21" viewBox="0 0 22 21" xmlns="http://www.w3.org/2000/svg">
<path d="M21.5 19.25H4.24999V20.75H21.5V19.25Z" />
<path d="M19.535 6.88249L13.5875 0.942493C13.4482 0.803029 13.2827 0.69239 13.1006 0.616904C12.9185 0.541417 12.7234 0.502563 12.5262 0.502563C12.3291 0.502563 12.1339 0.541417 11.9518 0.616904C11.7697 0.69239 11.6043 0.803029 11.465 0.942493L0.964985 11.4425C0.82552 11.5818 0.714882 11.7472 0.639395 11.9293C0.563909 12.1114 0.525055 12.3066 0.525055 12.5037C0.525055 12.7009 0.563909 12.8961 0.639395 13.0782C0.714882 13.2603 0.82552 13.4257 0.964985 13.565L4.34749 17H11.54L19.535 9.00499C19.6745 8.86568 19.7851 8.70025 19.8606 8.51815C19.9361 8.33606 19.9749 8.14087 19.9749 7.94374C19.9749 7.74662 19.9361 7.55143 19.8606 7.36933C19.7851 7.18724 19.6745 7.0218 19.535 6.88249ZM10.9175 15.5H4.99999L1.99998 12.5L6.73249 7.76749L12.68 13.7075L10.9175 15.5ZM13.7375 12.68L7.79749 6.73249L12.5 1.99999L18.5 7.94749L13.7375 12.68Z" />
</svg>

After

Width:  |  Height:  |  Size: 1,018 B

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
id="path2"
d="M 6 3 C 4.3000017 3 3 4.3000017 3 6 L 3 18 C 3 19.699998 4.3000017 21 6 21 L 18 21 C 19.699998 21 21 19.699998 21 18 L 21 6 C 21 4.3000017 19.699998 3 18 3 L 6 3 z M 5 5 L 7.6972656 5 L 7.6972656 19 L 5 19 L 5 5 z M 9.5957031 5 L 19 5 L 19 19 L 9.5957031 19 L 9.5957031 16.517578 L 11.369141 16.517578 L 11.369141 14.617188 L 9.5957031 14.617188 L 9.5957031 12.958984 L 11.802734 12.958984 L 11.802734 11.058594 L 9.5957031 11.058594 L 9.5957031 8.890625 L 11.369141 8.890625 L 11.369141 6.9902344 L 9.5957031 6.9902344 L 9.5957031 5 z " />
</svg>

After

Width:  |  Height:  |  Size: 694 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<path
d="M 6 1 C 3.2385791 1 1 3.2385791 1 6 C 1 8.7614209 3.2385791 11 6 11 C 8.7614209 11 11 8.7614209 11 6 C 11 3.2385791 8.7614209 1 6 1 z M 3.9179688 3.0332031 L 6 5.1171875 L 8.0820312 3.0332031 L 8.9667969 3.9179688 L 6.8828125 6 L 8.9667969 8.0820312 L 8.0820312 8.9667969 L 6 6.8828125 L 3.9179688 8.9667969 L 3.0332031 8.0820312 L 5.1171875 6 L 3.0332031 3.9179688 L 3.9179688 3.0332031 z " />
</svg>

After

Width:  |  Height:  |  Size: 532 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<path
d="M 6 1 A 5 5 0 0 0 1 6 A 5 5 0 0 0 6 11 A 5 5 0 0 0 11 6 A 5 5 0 0 0 6 1 z M 8.4921875 3.6542969 L 9.3027344 4.4648438 L 5.4199219 8.3457031 L 2.6972656 5.6230469 L 3.5078125 4.8125 L 5.4199219 6.7246094 L 8.4921875 3.6542969 z " />
</svg>

After

Width:  |  Height:  |  Size: 369 B