Merge remote-tracking branch 'origin/CURA-12543_painting_ux' into CURA-12544_saving-and-loading-painted-files-in-Cura

This commit is contained in:
Erwan MATHIEU 2025-05-28 15:16:43 +02:00
commit 5873222c15
7 changed files with 663 additions and 5 deletions

View File

@ -0,0 +1,246 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import numpy
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QImage, QPainter, QColor, QBrush, QPen
from typing import cast, Dict, List, Optional, Tuple
from UM.Application import Application
from UM.Event import Event, MouseEvent, KeyEvent
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
from UM.Tool import Tool
from cura.PickingPass import PickingPass
from .PaintView import PaintView
class PaintTool(Tool):
"""Provides the tool to paint meshes."""
def __init__(self) -> None:
super().__init__()
self._picking_pass: Optional[PickingPass] = None
self._shortcut_key: Qt.Key = Qt.Key.Key_P
self._node_cache: Optional[SceneNode] = None
self._mesh_transformed_cache = None
self._cache_dirty: bool = True
self._color_str_to_rgba: Dict[str, List[int]] = {
"A": [192, 0, 192, 255],
"B": [232, 128, 0, 255],
"C": [0, 255, 0, 255],
"D": [255, 255, 255, 255],
}
self._brush_size: int = 10
self._brush_color: str = "A"
self._brush_shape: str = "A"
self._brush_pen: Optional[QPen] = None
self._mouse_held: bool = False
self._last_text_coords: Optional[Tuple[int, int]] = None
def _createBrushPen(self) -> QPen:
pen = QPen()
pen.setWidth(self._brush_size)
color = self._color_str_to_rgba[self._brush_color]
pen.setColor(QColor(color[0], color[1], color[2], color[3]))
match self._brush_shape:
case "A":
pen.setCapStyle(Qt.PenCapStyle.SquareCap)
case "B":
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _createStrokeImage(self, x0: float, y0: float, x1: float, y1: float) -> Tuple[QImage, Tuple[int, int]]:
xdiff = int(x1 - x0)
ydiff = int(y1 - y0)
half_brush_size = self._brush_size // 2
start_x = int(min(x0, x1) - half_brush_size)
start_y = int(min(y0, y1) - half_brush_size)
stroke_image = QImage(abs(xdiff) + self._brush_size, abs(ydiff) + self._brush_size, QImage.Format.Format_RGBA8888)
stroke_image.fill(QColor(0,0,0,0))
painter = QPainter(stroke_image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(self._brush_pen)
painter.drawLine(int(x0 - start_x), int(y0 - start_y), int(x1 - start_x), int(y1 - start_y))
painter.end()
return stroke_image, (start_x, start_y)
def setPaintType(self, paint_type: str) -> None:
Logger.warning(f"TODO: Implement paint-types ({paint_type}).")
pass # FIXME: ... and also please call `self._brush_pen = self._createBrushPen()` (see other funcs).
def setBrushSize(self, brush_size: float) -> None:
if brush_size != self._brush_size:
self._brush_size = int(brush_size)
self._brush_pen = self._createBrushPen()
def setBrushColor(self, brush_color: str) -> None:
if brush_color != self._brush_color:
self._brush_color = brush_color
self._brush_pen = self._createBrushPen()
def setBrushShape(self, brush_shape: str) -> None:
if brush_shape != self._brush_shape:
self._brush_shape = brush_shape
self._brush_pen = self._createBrushPen()
@staticmethod
def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float:
# compute the intersection of (param) A - pt with (param) B - (param) C
# compute unit vectors of directions of lines A and B
udir_a = a - pt
udir_a /= numpy.linalg.norm(udir_a)
udir_b = b - c
udir_b /= numpy.linalg.norm(udir_b)
# find unit direction vector for line C, which is perpendicular to lines A and B
udir_res = numpy.cross(udir_b, udir_a)
udir_res /= numpy.linalg.norm(udir_res)
# solve system of equations
rhs = b - a
lhs = numpy.array([udir_a, -udir_b, udir_res]).T
solved = numpy.linalg.solve(lhs, rhs)
# get the ratio
intersect = ((a + solved[0] * udir_a) + (b + solved[1] * udir_b)) * 0.5
return numpy.linalg.norm(pt - intersect) / numpy.linalg.norm(a - intersect)
def _nodeTransformChanged(self, *args) -> None:
self._cache_dirty = True
def _getTexCoordsFromClick(self, node: SceneNode, x: int, y: int) -> Optional[Tuple[float, float]]:
face_id = self._selection_pass.getFaceIdAtPosition(x, y)
if face_id < 0 or face_id >= node.getMeshData().getFaceCount():
return None
pt = self._picking_pass.getPickedPosition(x, y).getData()
va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id)
ta, tb, tc = node.getMeshData().getFaceUvCoords(face_id)
# 'Weight' of each vertex that would produce point pt, so we can generate the texture coordinates from the uv ones of the vertices.
# See (also) https://mathworld.wolfram.com/BarycentricCoordinates.html
wa = PaintTool._get_intersect_ratio_via_pt(va, pt, vb, vc)
wb = PaintTool._get_intersect_ratio_via_pt(vb, pt, vc, va)
wc = PaintTool._get_intersect_ratio_via_pt(vc, pt, va, vb)
wt = wa + wb + wc
wa /= wt
wb /= wt
wc /= wt
texcoords = wa * ta + wb * tb + wc * tc
return texcoords
def event(self, event: Event) -> bool:
"""Handle mouse and keyboard events.
:param event: The event to handle.
:return: Whether this event has been caught by this tool (True) or should
be passed on (False).
"""
super().event(event)
controller = Application.getInstance().getController()
# Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes
if event.type == Event.ToolActivateEvent:
controller.setActiveStage("PrepareStage")
controller.setActiveView("PaintTool") # Because that's the plugin-name, and the view is registered to it.
return True
if event.type == Event.ToolDeactivateEvent:
controller.setActiveStage("PrepareStage")
controller.setActiveView("SolidView")
return True
if event.type == Event.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey:
return False
if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled():
if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons:
return False
self._mouse_held = False
self._last_text_coords = None
return True
is_moved = event.type == Event.MouseMoveEvent
is_pressed = event.type == Event.MousePressEvent
if (is_moved or is_pressed) and self._controller.getToolsEnabled():
if is_moved and not self._mouse_held:
return False
evt = cast(MouseEvent, event)
if is_pressed:
if MouseEvent.LeftButton not in evt.buttons:
return False
else:
self._mouse_held = True
paintview = controller.getActiveView()
if paintview is None or paintview.getPluginId() != "PaintTool":
return False
paintview = cast(PaintView, paintview)
if not self._selection_pass:
return False
camera = self._controller.getScene().getActiveCamera()
if not camera:
return False
node = Selection.getAllSelectedObjects()[0]
if node is None:
return False
if node != self._node_cache:
if self._node_cache is not None:
self._node_cache.transformationChanged.disconnect(self._nodeTransformChanged)
self._node_cache = node
self._node_cache.transformationChanged.connect(self._nodeTransformChanged)
if self._cache_dirty:
self._cache_dirty = False
self._mesh_transformed_cache = self._node_cache.getMeshDataTransformed()
if not self._mesh_transformed_cache:
return False
evt = cast(MouseEvent, event)
if not self._picking_pass:
self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight())
self._picking_pass.render()
self._selection_pass.renderFacesMode()
texcoords = self._getTexCoordsFromClick(node, evt.x, evt.y)
if texcoords is None:
return False
if self._last_text_coords is None:
self._last_text_coords = texcoords
w, h = paintview.getUvTexDimensions()
sub_image, (start_x, start_y) = self._createStrokeImage(
self._last_text_coords[0] * w,
self._last_text_coords[1] * h,
texcoords[0] * w,
texcoords[1] * h
)
paintview.addStroke(sub_image, start_x, start_y)
self._last_text_coords = texcoords
Application.getInstance().getController().getScene().sceneChanged.emit(node)
return True
return False

View File

@ -0,0 +1,180 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import QtQuick.Layouts
import UM 1.7 as UM
Item
{
id: base
width: childrenRect.width
height: childrenRect.height
UM.I18nCatalog { id: catalog; name: "cura"}
ColumnLayout
{
RowLayout
{
UM.ToolbarButton
{
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")
}
UM.ToolbarButton
{
id: paintTypeB
text: catalog.i18nc("@action:button", "Paint Type B")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("BlackMagic")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setPaintType", "B")
}
}
RowLayout
{
UM.ToolbarButton
{
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")
}
UM.ToolbarButton
{
id: colorButtonB
text: catalog.i18nc("@action:button", "Color B")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eye")
color: "orange"
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushColor", "B")
}
UM.ToolbarButton
{
id: colorButtonC
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")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("MeshTypeNormal")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushShape", "A")
}
UM.ToolbarButton
{
id: shapeCircleButton
text: catalog.i18nc("@action:button", "Round Brush")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("CircleOutline")
color: UM.Theme.getColor("icon")
}
property bool needBorder: true
z: 2
onClicked: UM.Controller.triggerActionWithData("setBrushShape", "B")
}
UM.Slider
{
id: shapeSizeSlider
from: 1
to: 40
value: 10
onPressedChanged: function(pressed)
{
if(! pressed)
{
UM.Controller.triggerActionWithData("setBrushSize", shapeSizeSlider.value)
}
}
}
}
}
}

View File

@ -0,0 +1,51 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import os
from PyQt6.QtGui import QImage
from UM.PluginRegistry import PluginRegistry
from UM.View.View import View
from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
class PaintView(View):
"""View for model-painting."""
def __init__(self) -> None:
super().__init__()
self._paint_shader = None
self._paint_texture = None
# FIXME: When the texture UV-unwrapping is done, these two values will need to be set to a proper value (suggest 4096 for both).
self._tex_width = 512
self._tex_height = 512
def _checkSetup(self):
if not self._paint_shader:
shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader")
self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename)
if not self._paint_texture:
self._paint_texture = OpenGL.getInstance().createTexture(self._tex_width, self._tex_height)
self._paint_shader.setTexture(0, self._paint_texture)
def addStroke(self, stroke_image: QImage, start_x: int, start_y: int) -> None:
self._paint_texture.setSubImage(stroke_image, start_x, start_y)
def getUvTexDimensions(self):
return self._tex_width, self._tex_height
def beginRendering(self) -> None:
renderer = self.getRenderer()
self._checkSetup()
paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
renderer.addRenderBatch(paint_batch)
node = Selection.getAllSelectedObjects()[0]
if node is None:
return
paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from . import PaintTool
from . import PaintView
from UM.i18n import i18nCatalog
i18n_catalog = i18nCatalog("cura")
def getMetaData():
return {
"tool": {
"name": i18n_catalog.i18nc("@action:button", "Paint"),
"description": i18n_catalog.i18nc("@info:tooltip", "Paint Model"),
"icon": "Visual",
"tool_panel": "PaintTool.qml",
"weight": 0
},
"view": {
"name": i18n_catalog.i18nc("@item:inmenu", "Paint view"),
"weight": 0,
"visible": False
}
}
def register(app):
return {
"tool": PaintTool.PaintTool(),
"view": PaintView.PaintView()
}

View File

@ -0,0 +1,142 @@
[shaders]
vertex =
uniform highp mat4 u_modelMatrix;
uniform highp mat4 u_viewMatrix;
uniform highp mat4 u_projectionMatrix;
uniform highp mat4 u_normalMatrix;
attribute highp vec4 a_vertex;
attribute highp vec4 a_normal;
attribute highp vec2 a_uvs;
varying highp vec3 v_vertex;
varying highp vec3 v_normal;
varying highp vec2 v_uvs;
void main()
{
vec4 world_space_vert = u_modelMatrix * a_vertex;
gl_Position = u_projectionMatrix * u_viewMatrix * world_space_vert;
v_vertex = world_space_vert.xyz;
v_normal = (u_normalMatrix * normalize(a_normal)).xyz;
v_uvs = a_uvs;
}
fragment =
uniform mediump vec4 u_ambientColor;
uniform mediump vec4 u_diffuseColor;
uniform highp vec3 u_lightPosition;
uniform highp vec3 u_viewPosition;
uniform mediump float u_opacity;
uniform sampler2D u_texture;
varying highp vec3 v_vertex;
varying highp vec3 v_normal;
varying highp vec2 v_uvs;
void main()
{
mediump vec4 final_color = vec4(0.0);
/* Ambient Component */
final_color += u_ambientColor;
highp vec3 normal = normalize(v_normal);
highp vec3 light_dir = normalize(u_lightPosition - v_vertex);
/* Diffuse Component */
highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0);
final_color += (n_dot_l * u_diffuseColor);
final_color.a = u_opacity;
lowp vec4 texture = texture2D(u_texture, v_uvs);
final_color = mix(final_color, texture, texture.a);
gl_FragColor = final_color;
}
vertex41core =
#version 410
uniform highp mat4 u_modelMatrix;
uniform highp mat4 u_viewMatrix;
uniform highp mat4 u_projectionMatrix;
uniform highp mat4 u_normalMatrix;
in highp vec4 a_vertex;
in highp vec4 a_normal;
in highp vec2 a_uvs;
out highp vec3 v_vertex;
out highp vec3 v_normal;
out highp vec2 v_uvs;
void main()
{
vec4 world_space_vert = u_modelMatrix * a_vertex;
gl_Position = u_projectionMatrix * u_viewMatrix * world_space_vert;
v_vertex = world_space_vert.xyz;
v_normal = (u_normalMatrix * normalize(a_normal)).xyz;
v_uvs = a_uvs;
}
fragment41core =
#version 410
uniform mediump vec4 u_ambientColor;
uniform mediump vec4 u_diffuseColor;
uniform highp vec3 u_lightPosition;
uniform highp vec3 u_viewPosition;
uniform mediump float u_opacity;
uniform sampler2D u_texture;
in highp vec3 v_vertex;
in highp vec3 v_normal;
in highp vec2 v_uvs;
out vec4 frag_color;
void main()
{
mediump vec4 final_color = vec4(0.0);
/* Ambient Component */
final_color += u_ambientColor;
highp vec3 normal = normalize(v_normal);
highp vec3 light_dir = normalize(u_lightPosition - v_vertex);
/* Diffuse Component */
highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0);
final_color += (n_dot_l * u_diffuseColor);
final_color.a = u_opacity;
lowp vec4 texture = texture(u_texture, v_uvs);
final_color = mix(final_color, texture, texture.a);
frag_color = final_color;
}
[defaults]
u_ambientColor = [0.3, 0.3, 0.3, 1.0]
u_diffuseColor = [1.0, 1.0, 1.0, 1.0]
u_opacity = 0.5
u_texture = 0
[bindings]
u_modelMatrix = model_matrix
u_viewMatrix = view_matrix
u_projectionMatrix = projection_matrix
u_normalMatrix = normal_matrix
u_lightPosition = light_0_position
u_viewPosition = camera_position
[attributes]
a_vertex = vertex
a_normal = normal
a_uvs = uv0

View File

@ -0,0 +1,8 @@
{
"name": "Paint Tools",
"author": "UltiMaker",
"version": "1.0.0",
"description": "Provides the paint tools.",
"api": 8,
"i18n-catalog": "cura"
}

View File

@ -1,13 +1,12 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.View.View import View
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.Selection import Selection
from UM.Resources import Resources
from PyQt6.QtGui import QOpenGLContext, QDesktopServices, QImage
from PyQt6.QtCore import QSize, QUrl
from PyQt6.QtGui import QDesktopServices, QImage
from PyQt6.QtCore import QUrl
import numpy as np
import time
@ -36,11 +35,13 @@ class SolidView(View):
"""Standard view for mesh models."""
_show_xray_warning_preference = "view/show_xray_warning"
_show_overhang_preference = "view/show_overhang"
_paint_active_preference = "view/paint_active"
def __init__(self):
super().__init__()
application = Application.getInstance()
application.getPreferences().addPreference("view/show_overhang", True)
application.getPreferences().addPreference(self._show_overhang_preference, True)
application.globalContainerStackChanged.connect(self._onGlobalContainerChanged)
self._enabled_shader = None
self._disabled_shader = None
@ -212,7 +213,7 @@ class SolidView(View):
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack:
if Application.getInstance().getPreferences().getValue("view/show_overhang"):
if Application.getInstance().getPreferences().getValue(self._show_overhang_preference):
# Make sure the overhang angle is valid before passing it to the shader
if self._support_angle >= 0 and self._support_angle <= 90:
self._enabled_shader.setUniformValue("u_overhangAngle", math.cos(math.radians(90 - self._support_angle)))