From 93694e2da4090103ce121621efd0fa0a6b978882 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 20 May 2025 15:51:13 +0200 Subject: [PATCH 1/9] W.I.P.: Add paint-shader/layer so it can be used for the UV-painting feature. Currently replacing the 'disabled' batch until we can get it to switch out on command (when we have the painting stage/tool/... pluging up and running. part of CURA-12543 --- plugins/SolidView/SolidView.py | 27 +++++-- resources/shaders/paint.shader | 142 +++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 resources/shaders/paint.shader diff --git a/plugins/SolidView/SolidView.py b/plugins/SolidView/SolidView.py index 7f32b0df7f..1f2dabdbdd 100644 --- a/plugins/SolidView/SolidView.py +++ b/plugins/SolidView/SolidView.py @@ -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,16 +35,20 @@ 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.getPreferences().addPreference(self._paint_active_preference, False) application.globalContainerStackChanged.connect(self._onGlobalContainerChanged) self._enabled_shader = None self._disabled_shader = None self._non_printing_shader = None self._support_mesh_shader = None + self._paint_shader = None self._xray_shader = None self._xray_pass = None @@ -139,6 +142,11 @@ class SolidView(View): min_height = max(min_height, init_layer_height) return min_height + def _setPaintTexture(self): + self._paint_texture = OpenGL.getInstance().createTexture(256, 256) + if self._paint_shader: + self._paint_shader.setTexture(0, self._paint_texture) + def _checkSetup(self): if not self._extruders_model: self._extruders_model = Application.getInstance().getExtrudersModel() @@ -167,6 +175,10 @@ class SolidView(View): self._support_mesh_shader.setUniformValue("u_vertical_stripes", True) self._support_mesh_shader.setUniformValue("u_width", 5.0) + if not self._paint_shader: + self._paint_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "paint.shader")) + self._setPaintTexture() + if not Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference): self._xray_shader = None self._xray_composite_shader = None @@ -204,6 +216,9 @@ class SolidView(View): self._old_composite_shader = self._composite_pass.getCompositeShader() self._composite_pass.setCompositeShader(self._xray_composite_shader) + def setUvPixel(self, x, y, color): + self._paint_texture.setPixel(x, y, color) + def beginRendering(self): scene = self.getController().getScene() renderer = self.getRenderer() @@ -212,7 +227,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))) @@ -221,7 +236,7 @@ class SolidView(View): else: self._enabled_shader.setUniformValue("u_overhangAngle", math.cos(math.radians(0))) self._enabled_shader.setUniformValue("u_lowestPrintableHeight", self._lowest_printable_height) - disabled_batch = renderer.createRenderBatch(shader = self._disabled_shader) + disabled_batch = renderer.createRenderBatch(shader = self._paint_shader) #### TODO: put back to 'self._disabled_shader' normal_object_batch = renderer.createRenderBatch(shader = self._enabled_shader) renderer.addRenderBatch(disabled_batch) renderer.addRenderBatch(normal_object_batch) diff --git a/resources/shaders/paint.shader b/resources/shaders/paint.shader new file mode 100644 index 0000000000..83682c7222 --- /dev/null +++ b/resources/shaders/paint.shader @@ -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 From 19ea88a8ce474968b53b06f992cccd5964f58fd6 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 20 May 2025 15:56:21 +0200 Subject: [PATCH 2/9] W.I.P. Start of paint-tool plugin UX work. Should be able to paint pixels now if the tools is active, and the model loaded is with UV-coords (that rules out our current impl. of 3MF at the moment -- use OBJ instead), and you position the model outside of the build-plate so the paint-shadr that is temporarily replacing the 'disabled' one is showing. Will need a lot of extra features and optimizations still! part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 135 ++++++++++++++++++++++++++++++++ plugins/PaintTool/PaintTool.qml | 14 ++++ plugins/PaintTool/__init__.py | 21 +++++ plugins/PaintTool/plugin.json | 8 ++ 4 files changed, 178 insertions(+) create mode 100644 plugins/PaintTool/PaintTool.py create mode 100644 plugins/PaintTool/PaintTool.qml create mode 100644 plugins/PaintTool/__init__.py create mode 100644 plugins/PaintTool/plugin.json diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py new file mode 100644 index 0000000000..d7a70581fb --- /dev/null +++ b/plugins/PaintTool/PaintTool.py @@ -0,0 +1,135 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from typing import cast, Optional + +import numpy +from PyQt6.QtCore import Qt + +from UM.Application import Application +from UM.Event import Event, MouseEvent, KeyEvent +from UM.Tool import Tool +from cura.PickingPass import PickingPass + + +class PaintTool(Tool): + """Provides the tool to paint meshes. + """ + + def __init__(self) -> None: + super().__init__() + + self._shortcut_key = Qt.Key.Key_P + + """ + # CURA-5966 Make sure to render whenever objects get selected/deselected. + Selection.selectionChanged.connect(self.propertyChanged) + """ + + @staticmethod + def _get_intersect_ratio_via_pt(a, pt, b, c): + # 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 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) + + # Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes + if event.type == Event.ToolActivateEvent: + return False + + if event.type == Event.ToolDeactivateEvent: + return False + + if event.type == Event.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey: + return False + + if event.type == Event.MousePressEvent and self._controller.getToolsEnabled(): + if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: + return False + if not self._selection_pass: + return False + + camera = self._controller.getScene().getActiveCamera() + if not camera: + return False + + evt = cast(MouseEvent, event) + + ppass = PickingPass(self._selection_pass._width, self._selection_pass._height) + ppass.render() + pt = ppass.getPickedPosition(evt.x, evt.y).getData() + + self._selection_pass._renderObjectsMode() # TODO: <- Fix this! + + node_id = self._selection_pass.getIdAtPosition(evt.x, evt.y) + if node_id is None: + return False + node = Application.getInstance().getController().getScene().findObject(node_id) + if node is None: + return False + + self._selection_pass._renderFacesMode() # TODO: <- Fix this! + + face_id = self._selection_pass.getFaceIdAtPosition(evt.x, evt.y) + if face_id < 0: + return False + + meshdata = node.getMeshDataTransformed() # TODO: <- don't forget to optimize, if the mesh hasn't changed (transforms) then it should be reused! + if not meshdata: + return False + + va, vb, vc = meshdata.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 + + solidview = Application.getInstance().getController().getActiveView() + if solidview.getPluginId() != "SolidView": + return False + + solidview.setUvPixel(texcoords[0], texcoords[1], [255, 128, 0, 255]) + + return True + + if event.type == Event.MouseMoveEvent: + evt = cast(MouseEvent, event) + return False #True + + if event.type == Event.MouseReleaseEvent: + return False #True + + return False diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml new file mode 100644 index 0000000000..2e634790c2 --- /dev/null +++ b/plugins/PaintTool/PaintTool.qml @@ -0,0 +1,14 @@ +// Copyright (c) 2025 UltiMaker +// Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.2 + +import UM 1.7 as UM + +Item +{ + id: base + width: childrenRect.width + height: childrenRect.height + UM.I18nCatalog { id: catalog; name: "cura"} +} diff --git a/plugins/PaintTool/__init__.py b/plugins/PaintTool/__init__.py new file mode 100644 index 0000000000..38eac5bc45 --- /dev/null +++ b/plugins/PaintTool/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. + +from . import PaintTool + +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 + } + } + +def register(app): + return { "tool": PaintTool.PaintTool() } diff --git a/plugins/PaintTool/plugin.json b/plugins/PaintTool/plugin.json new file mode 100644 index 0000000000..2a55d677d2 --- /dev/null +++ b/plugins/PaintTool/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "Paint Tools", + "author": "UltiMaker", + "version": "1.0.0", + "description": "Provides the paint tools.", + "api": 8, + "i18n-catalog": "cura" +} From c5592eea83ec188bf6a8f5956c304f22f7c396b5 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 21 May 2025 15:19:07 +0200 Subject: [PATCH 3/9] Slightly optimize and refactor the w.i.p. paint-tool. Just enought so that the truly ugly things are out of it. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 47 ++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index d7a70581fb..3ee94db1d9 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import Qt from UM.Application import Application from UM.Event import Event, MouseEvent, KeyEvent +from UM.Scene.Selection import Selection from UM.Tool import Tool from cura.PickingPass import PickingPass @@ -21,10 +22,9 @@ class PaintTool(Tool): self._shortcut_key = Qt.Key.Key_P - """ - # CURA-5966 Make sure to render whenever objects get selected/deselected. - Selection.selectionChanged.connect(self.propertyChanged) - """ + self._node_cache = None + self._mesh_transformed_cache = None + self._cache_dirty = True @staticmethod def _get_intersect_ratio_via_pt(a, pt, b, c): @@ -49,6 +49,9 @@ class PaintTool(Tool): 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 event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -78,32 +81,33 @@ class PaintTool(Tool): if not camera: return False - evt = cast(MouseEvent, event) - - ppass = PickingPass(self._selection_pass._width, self._selection_pass._height) - ppass.render() - pt = ppass.getPickedPosition(evt.x, evt.y).getData() - - self._selection_pass._renderObjectsMode() # TODO: <- Fix this! - - node_id = self._selection_pass.getIdAtPosition(evt.x, evt.y) - if node_id is None: - return False - node = Application.getInstance().getController().getScene().findObject(node_id) + node = Selection.getAllSelectedObjects()[0] if node is None: return False - self._selection_pass._renderFacesMode() # TODO: <- Fix this! + 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) + + self._selection_pass.renderFacesMode() face_id = self._selection_pass.getFaceIdAtPosition(evt.x, evt.y) if face_id < 0: return False - meshdata = node.getMeshDataTransformed() # TODO: <- don't forget to optimize, if the mesh hasn't changed (transforms) then it should be reused! - if not meshdata: - return False + ppass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight()) + ppass.render() + pt = ppass.getPickedPosition(evt.x, evt.y).getData() - va, vb, vc = meshdata.getFaceNodes(face_id) + 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. @@ -120,7 +124,6 @@ class PaintTool(Tool): solidview = Application.getInstance().getController().getActiveView() if solidview.getPluginId() != "SolidView": return False - solidview.setUvPixel(texcoords[0], texcoords[1], [255, 128, 0, 255]) return True From 3ae85e3e2aee73d60f068f0286c9720e71338d46 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 21 May 2025 21:50:17 +0200 Subject: [PATCH 4/9] Refactored paint-view into its own thing. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 18 +++++--- plugins/PaintTool/PaintView.py | 42 +++++++++++++++++++ plugins/PaintTool/__init__.py | 11 ++++- .../PaintTool}/paint.shader | 0 plugins/SolidView/SolidView.py | 16 +------ 5 files changed, 65 insertions(+), 22 deletions(-) create mode 100644 plugins/PaintTool/PaintView.py rename {resources/shaders => plugins/PaintTool}/paint.shader (100%) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 3ee94db1d9..2d204ceff9 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -27,7 +27,7 @@ class PaintTool(Tool): self._cache_dirty = True @staticmethod - def _get_intersect_ratio_via_pt(a, pt, b, c): + def _get_intersect_ratio_via_pt(a, pt, b, c) -> float: # compute the intersection of (param) A - pt with (param) B - (param) C # compute unit vectors of directions of lines A and B @@ -61,12 +61,18 @@ class PaintTool(Tool): """ 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: - return False + 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: - return False + controller.setActiveStage("PrepareStage") + controller.setActiveView("SolidView") + return True if event.type == Event.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey: return False @@ -121,10 +127,10 @@ class PaintTool(Tool): wc /= wt texcoords = wa * ta + wb * tb + wc * tc - solidview = Application.getInstance().getController().getActiveView() - if solidview.getPluginId() != "SolidView": + paintview = controller.getActiveView() + if paintview.getPluginId() != "PaintTool": return False - solidview.setUvPixel(texcoords[0], texcoords[1], [255, 128, 0, 255]) + paintview.setUvPixel(texcoords[0], texcoords[1], [255, 128, 0, 255]) return True diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py new file mode 100644 index 0000000000..31a1a7f5f6 --- /dev/null +++ b/plugins/PaintTool/PaintView.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 UltiMaker +# Cura is released under the terms of the LGPLv3 or higher. +import os + +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 + + 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(256, 256) + self._paint_shader.setTexture(0, self._paint_texture) + + def setUvPixel(self, x, y, color) -> None: + self._paint_texture.setPixel(x, y, color) + + 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()) diff --git a/plugins/PaintTool/__init__.py b/plugins/PaintTool/__init__.py index 38eac5bc45..301bc49e0d 100644 --- a/plugins/PaintTool/__init__.py +++ b/plugins/PaintTool/__init__.py @@ -2,6 +2,7 @@ # 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") @@ -14,8 +15,16 @@ def getMetaData(): "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() } + return { + "tool": PaintTool.PaintTool(), + "view": PaintView.PaintView() + } diff --git a/resources/shaders/paint.shader b/plugins/PaintTool/paint.shader similarity index 100% rename from resources/shaders/paint.shader rename to plugins/PaintTool/paint.shader diff --git a/plugins/SolidView/SolidView.py b/plugins/SolidView/SolidView.py index 1f2dabdbdd..e115267720 100644 --- a/plugins/SolidView/SolidView.py +++ b/plugins/SolidView/SolidView.py @@ -42,13 +42,11 @@ class SolidView(View): super().__init__() application = Application.getInstance() application.getPreferences().addPreference(self._show_overhang_preference, True) - application.getPreferences().addPreference(self._paint_active_preference, False) application.globalContainerStackChanged.connect(self._onGlobalContainerChanged) self._enabled_shader = None self._disabled_shader = None self._non_printing_shader = None self._support_mesh_shader = None - self._paint_shader = None self._xray_shader = None self._xray_pass = None @@ -142,11 +140,6 @@ class SolidView(View): min_height = max(min_height, init_layer_height) return min_height - def _setPaintTexture(self): - self._paint_texture = OpenGL.getInstance().createTexture(256, 256) - if self._paint_shader: - self._paint_shader.setTexture(0, self._paint_texture) - def _checkSetup(self): if not self._extruders_model: self._extruders_model = Application.getInstance().getExtrudersModel() @@ -175,10 +168,6 @@ class SolidView(View): self._support_mesh_shader.setUniformValue("u_vertical_stripes", True) self._support_mesh_shader.setUniformValue("u_width", 5.0) - if not self._paint_shader: - self._paint_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "paint.shader")) - self._setPaintTexture() - if not Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference): self._xray_shader = None self._xray_composite_shader = None @@ -216,9 +205,6 @@ class SolidView(View): self._old_composite_shader = self._composite_pass.getCompositeShader() self._composite_pass.setCompositeShader(self._xray_composite_shader) - def setUvPixel(self, x, y, color): - self._paint_texture.setPixel(x, y, color) - def beginRendering(self): scene = self.getController().getScene() renderer = self.getRenderer() @@ -236,7 +222,7 @@ class SolidView(View): else: self._enabled_shader.setUniformValue("u_overhangAngle", math.cos(math.radians(0))) self._enabled_shader.setUniformValue("u_lowestPrintableHeight", self._lowest_printable_height) - disabled_batch = renderer.createRenderBatch(shader = self._paint_shader) #### TODO: put back to 'self._disabled_shader' + disabled_batch = renderer.createRenderBatch(shader = self._disabled_shader) normal_object_batch = renderer.createRenderBatch(shader = self._enabled_shader) renderer.addRenderBatch(disabled_batch) renderer.addRenderBatch(normal_object_batch) From a176957fa7b0f7e57ff47c372fbbeb37ad1e8d95 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Thu, 22 May 2025 10:14:00 +0200 Subject: [PATCH 5/9] Painting: Set color, brush-size, brush-shape. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 48 ++++++++- plugins/PaintTool/PaintTool.qml | 168 +++++++++++++++++++++++++++++++- plugins/PaintTool/PaintView.py | 7 +- 3 files changed, 219 insertions(+), 4 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 2d204ceff9..5bd0c2187d 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -5,9 +5,11 @@ from typing import cast, Optional import numpy from PyQt6.QtCore import Qt +from typing import List, Tuple from UM.Application import Application from UM.Event import Event, MouseEvent, KeyEvent +from UM.Logger import Logger from UM.Scene.Selection import Selection from UM.Tool import Tool from cura.PickingPass import PickingPass @@ -26,6 +28,31 @@ class PaintTool(Tool): self._mesh_transformed_cache = None self._cache_dirty = True + self._color_str_to_rgba = { + "A": [192, 0, 192, 255], + "B": [232, 128, 0, 255], + "C": [0, 255, 0, 255], + "D": [255, 255, 255, 255], + } + + self._brush_size = 10 + self._brush_color = "A" + self._brush_shape = "A" + + def setPaintType(self, paint_type: str) -> None: + Logger.warning(f"TODO: Implement paint-types ({paint_type}).") + pass + + def setBrushSize(self, brush_size: float) -> None: + self._brush_size = int(brush_size) + print(self._brush_size) + + def setBrushColor(self, brush_color: str) -> None: + self._brush_color = brush_color + + def setBrushShape(self, brush_shape: str) -> None: + self._brush_shape = brush_shape + @staticmethod def _get_intersect_ratio_via_pt(a, pt, b, c) -> float: # compute the intersection of (param) A - pt with (param) B - (param) C @@ -52,6 +79,20 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True + def _getBrushPixels(self, mid_x: float, mid_y: float, w: float, h: float) -> List[Tuple[float, float]]: + res = [] + include = False + for y in range(-self._brush_size, self._brush_size + 1): + for x in range(-self._brush_size, self._brush_size + 1): + match self._brush_shape: + case "A": + include = True + case "B": + include = x * x + y * y <= self._brush_size * self._brush_size + if include: + res.append((mid_x + (x / w), mid_y + (y / h))) + return res + def event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -128,9 +169,12 @@ class PaintTool(Tool): texcoords = wa * ta + wb * tb + wc * tc paintview = controller.getActiveView() - if paintview.getPluginId() != "PaintTool": + if paintview is None or paintview.getPluginId() != "PaintTool": return False - paintview.setUvPixel(texcoords[0], texcoords[1], [255, 128, 0, 255]) + color = self._color_str_to_rgba[self._brush_color] + w, h = paintview.getUvTexDimensions() + for (x, y) in self._getBrushPixels(texcoords[0], texcoords[1], float(w), float(h)): + paintview.setUvPixel(x, y, color) return True diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index 2e634790c2..902d5a700d 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -1,7 +1,8 @@ // Copyright (c) 2025 UltiMaker // Cura is released under the terms of the LGPLv3 or higher. -import QtQuick 2.2 +import QtQuick +import QtQuick.Layouts import UM 1.7 as UM @@ -11,4 +12,169 @@ Item 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: 50 + value: 10 + + onPressedChanged: function(pressed) + { + if(! pressed) + { + UM.Controller.triggerActionWithData("setBrushSize", shapeSizeSlider.value) + } + } + } + } + } } diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 31a1a7f5f6..0923d007d7 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -18,18 +18,23 @@ class PaintView(View): super().__init__() self._paint_shader = None self._paint_texture = None + self._tex_width = 256 + self._tex_height = 256 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(256, 256) + self._paint_texture = OpenGL.getInstance().createTexture(self._tex_width, self._tex_height) self._paint_shader.setTexture(0, self._paint_texture) def setUvPixel(self, x, y, color) -> None: self._paint_texture.setPixel(x, y, color) + def getUvTexDimensions(self): + return self._tex_width, self._tex_height + def beginRendering(self) -> None: renderer = self.getRenderer() self._checkSetup() From 33b5918acd65e2c14db5dd86893d3247b9932a5a Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Thu, 22 May 2025 11:02:08 +0200 Subject: [PATCH 6/9] Painting: Sort-of able to drag the mouse now, not just click. Also typing. The way it now works is way too slow though, and it doesn't add 'inbetween' the moude-move-positions yet. Also several other things of course. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 121 +++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 5bd0c2187d..bbcd80cef0 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -5,13 +5,15 @@ from typing import cast, Optional import numpy from PyQt6.QtCore import Qt -from typing import List, Tuple +from typing import Dict, List, 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 UM.View.View import View from cura.PickingPass import PickingPass @@ -22,22 +24,27 @@ class PaintTool(Tool): def __init__(self) -> None: super().__init__() - self._shortcut_key = Qt.Key.Key_P + self._picking_pass: Optional[PickingPass] = None - self._node_cache = None + self._shortcut_key: Qt.Key = Qt.Key.Key_P + + self._node_cache: Optional[SceneNode] = None self._mesh_transformed_cache = None - self._cache_dirty = True + self._cache_dirty: bool = True - self._color_str_to_rgba = { + 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 = 10 - self._brush_color = "A" - self._brush_shape = "A" + self._brush_size: int = 10 + self._brush_color: str = "A" + self._brush_shape: str = "A" + + self._mouse_held: bool = False + self._mouse_drags: List[Tuple[int, int]] = [] def setPaintType(self, paint_type: str) -> None: Logger.warning(f"TODO: Implement paint-types ({paint_type}).") @@ -54,7 +61,7 @@ class PaintTool(Tool): self._brush_shape = brush_shape @staticmethod - def _get_intersect_ratio_via_pt(a, pt, b, c) -> float: + 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 @@ -90,9 +97,41 @@ class PaintTool(Tool): case "B": include = x * x + y * y <= self._brush_size * self._brush_size if include: - res.append((mid_x + (x / w), mid_y + (y / h))) + xx = mid_x + (x / w) + yy = mid_y + (y / h) + if xx < 0 or xx > 1 or yy < 0 or yy > 1: + continue + res.append((xx, yy)) return res + def _handleMouseAction(self, node: SceneNode, paintview: View, x: int, y: int) -> bool: + face_id = self._selection_pass.getFaceIdAtPosition(x, y) + if face_id < 0: + return False + + 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 + + color = self._color_str_to_rgba[self._brush_color] + w, h = paintview.getUvTexDimensions() + for (x, y) in self._getBrushPixels(texcoords[0], texcoords[1], float(w), float(h)): + paintview.setUvPixel(x, y, color) + + return True + def event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -118,9 +157,18 @@ class PaintTool(Tool): if event.type == Event.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey: return False - if event.type == Event.MousePressEvent and self._controller.getToolsEnabled(): + if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled(): if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: return False + + self._mouse_held = False + drags = self._mouse_drags.copy() + self._mouse_drags.clear() + + paintview = controller.getActiveView() + if paintview is None or paintview.getPluginId() != "PaintTool": + return False + if not self._selection_pass: return False @@ -144,45 +192,30 @@ class PaintTool(Tool): return False evt = cast(MouseEvent, event) + drags.append((evt.x, evt.y)) + + if not self._picking_pass: + self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight()) + self._picking_pass.render() self._selection_pass.renderFacesMode() - face_id = self._selection_pass.getFaceIdAtPosition(evt.x, evt.y) - if face_id < 0: + + res = False + for (x, y) in drags: + res |= self._handleMouseAction(node, paintview, x, y) + return res + + if event.type == Event.MousePressEvent and self._controller.getToolsEnabled(): + if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: return False - - ppass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight()) - ppass.render() - pt = ppass.getPickedPosition(evt.x, evt.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 - - paintview = controller.getActiveView() - if paintview is None or paintview.getPluginId() != "PaintTool": - return False - color = self._color_str_to_rgba[self._brush_color] - w, h = paintview.getUvTexDimensions() - for (x, y) in self._getBrushPixels(texcoords[0], texcoords[1], float(w), float(h)): - paintview.setUvPixel(x, y, color) - + self._mouse_held = True return True if event.type == Event.MouseMoveEvent: + if not self._mouse_held: + return False evt = cast(MouseEvent, event) - return False #True - - if event.type == Event.MouseReleaseEvent: - return False #True + self._mouse_drags.append((evt.x, evt.y)) + return True return False From 704f9453f0a1cfd554eb4b18cb31b293fa4c917e Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 27 May 2025 17:03:38 +0200 Subject: [PATCH 7/9] Properly completed drag to paint (no more just clicking points). The most important thing to make it work is actually notifying the scene that something has changed -- the rest are just refactorings and (hopefully) optimizations. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 48 +++++++++++++++++---------------- plugins/PaintTool/PaintTool.qml | 2 +- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index bbcd80cef0..503d380987 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -1,11 +1,10 @@ # Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. - -from typing import cast, Optional +from copy import deepcopy import numpy from PyQt6.QtCore import Qt -from typing import Dict, List, Tuple +from typing import cast, Dict, List, Optional, Tuple from UM.Application import Application from UM.Event import Event, MouseEvent, KeyEvent @@ -14,6 +13,7 @@ from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool from UM.View.View import View + from cura.PickingPass import PickingPass @@ -44,7 +44,7 @@ class PaintTool(Tool): self._brush_shape: str = "A" self._mouse_held: bool = False - self._mouse_drags: List[Tuple[int, int]] = [] + self._last_mouse_drag: Optional[Tuple[int, int]] = None def setPaintType(self, paint_type: str) -> None: Logger.warning(f"TODO: Implement paint-types ({paint_type}).") @@ -52,7 +52,6 @@ class PaintTool(Tool): def setBrushSize(self, brush_size: float) -> None: self._brush_size = int(brush_size) - print(self._brush_size) def setBrushColor(self, brush_color: str) -> None: self._brush_color = brush_color @@ -89,8 +88,8 @@ class PaintTool(Tool): def _getBrushPixels(self, mid_x: float, mid_y: float, w: float, h: float) -> List[Tuple[float, float]]: res = [] include = False - for y in range(-self._brush_size, self._brush_size + 1): - for x in range(-self._brush_size, self._brush_size + 1): + for y in range(-self._brush_size//2, (self._brush_size + 1)//2): + for x in range(-self._brush_size//2, (self._brush_size + 1)//2): match self._brush_shape: case "A": include = True @@ -160,10 +159,24 @@ class PaintTool(Tool): if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled(): if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: return False - self._mouse_held = False - drags = self._mouse_drags.copy() - self._mouse_drags.clear() + self._last_mouse_drag = 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 + drags = ([self._last_mouse_drag] if self._last_mouse_drag else []) + [(evt.x, evt.y)] + self._last_mouse_drag = (evt.x, evt.y) paintview = controller.getActiveView() if paintview is None or paintview.getPluginId() != "PaintTool": @@ -203,19 +216,8 @@ class PaintTool(Tool): res = False for (x, y) in drags: res |= self._handleMouseAction(node, paintview, x, y) + if res: + Application.getInstance().getController().getScene().sceneChanged.emit(node) return res - if event.type == Event.MousePressEvent and self._controller.getToolsEnabled(): - if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: - return False - self._mouse_held = True - return True - - if event.type == Event.MouseMoveEvent: - if not self._mouse_held: - return False - evt = cast(MouseEvent, event) - self._mouse_drags.append((evt.x, evt.y)) - return True - return False diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml index 902d5a700d..82d6d90ffd 100644 --- a/plugins/PaintTool/PaintTool.qml +++ b/plugins/PaintTool/PaintTool.qml @@ -164,7 +164,7 @@ Item id: shapeSizeSlider from: 1 - to: 50 + to: 40 value: 10 onPressedChanged: function(pressed) From 109f37657b8417671846398de6bb39edfc7861bf Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 28 May 2025 12:32:36 +0200 Subject: [PATCH 8/9] Painting UI work: Update image-part(s) instead of pixel(s) w.r.t. render-backend. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 134 ++++++++++++++++++++++----------- plugins/PaintTool/PaintView.py | 12 ++- 2 files changed, 98 insertions(+), 48 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 503d380987..90482d0055 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -1,9 +1,9 @@ # Copyright (c) 2025 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. -from copy import deepcopy import numpy from PyQt6.QtCore import Qt +from PyQt6.QtGui import QImage, QPainter, QColor, QBrush from typing import cast, Dict, List, Optional, Tuple from UM.Application import Application @@ -12,9 +12,9 @@ from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool -from UM.View.View import View from cura.PickingPass import PickingPass +from .PaintView import PaintView class PaintTool(Tool): @@ -42,22 +42,82 @@ class PaintTool(Tool): self._brush_size: int = 10 self._brush_color: str = "A" self._brush_shape: str = "A" + self._brush_image = self._createBrushImage() self._mouse_held: bool = False - self._last_mouse_drag: Optional[Tuple[int, int]] = None + self._last_text_coords: Optional[Tuple[int, int]] = None + + def _createBrushImage(self) -> QImage: + brush_image = QImage(self._brush_size, self._brush_size, QImage.Format.Format_RGBA8888) + brush_image.fill(QColor(255,255,255,0)) + + color = self._color_str_to_rgba[self._brush_color] + qcolor = QColor(color[0], color[1], color[2], color[3]) + + painter = QPainter(brush_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(qcolor)) + match self._brush_shape: + case "A": # Square brush + painter.drawRect(0, 0, self._brush_size, self._brush_size) + case "B": # Circle brush + painter.drawEllipse(0, 0, self._brush_size, self._brush_size) + case _: + painter.drawRect(0, 0, self._brush_size, self._brush_size) + painter.end() + + return brush_image + + def _createStrokeImage(self, x0: float, y0: float, x1: float, y1: float) -> Tuple[QImage, Tuple[int, int]]: + distance = numpy.hypot(x1 - x0, y1 - y0) + angle = numpy.arctan2(y1 - y0, x1 - x0) + stroke_width = self._brush_size + stroke_height = int(distance) + self._brush_size + + half_brush_size = self._brush_size // 2 + start_x = int(x0 - half_brush_size) + start_y = int(y0 - half_brush_size) + + stroke_image = QImage(stroke_height, stroke_width, QImage.Format.Format_RGBA8888) + stroke_image.fill(QColor(255,255,255,0)) + + painter = QPainter(stroke_image) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # rotate the brush-image to follow the stroke-direction + transform = painter.transform() + transform.translate(0, stroke_width / 2) # translate to match the brush-alignment + transform.rotate(-numpy.degrees(angle)) + painter.setTransform(transform) + + # tile the brush along the stroke-length + brush_stride = max(1, half_brush_size) + for i in range(0, int(distance) + brush_stride, brush_stride): + painter.drawImage(i, -stroke_width, self._brush_image) + 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 + pass # FIXME: ... and also please call `self._stroke_image = self._createBrushStrokeImage()` (see other funcs). def setBrushSize(self, brush_size: float) -> None: - self._brush_size = int(brush_size) + if brush_size != self._brush_size: + self._brush_size = int(brush_size) + self._brush_image = self._createBrushImage() def setBrushColor(self, brush_color: str) -> None: - self._brush_color = brush_color + if brush_color != self._brush_color: + self._brush_color = brush_color + self._brush_image = self._createBrushImage() def setBrushShape(self, brush_shape: str) -> None: - self._brush_shape = brush_shape + if brush_shape != self._brush_shape: + self._brush_shape = brush_shape + self._brush_image = self._createBrushImage() @staticmethod def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float: @@ -85,28 +145,10 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True - def _getBrushPixels(self, mid_x: float, mid_y: float, w: float, h: float) -> List[Tuple[float, float]]: - res = [] - include = False - for y in range(-self._brush_size//2, (self._brush_size + 1)//2): - for x in range(-self._brush_size//2, (self._brush_size + 1)//2): - match self._brush_shape: - case "A": - include = True - case "B": - include = x * x + y * y <= self._brush_size * self._brush_size - if include: - xx = mid_x + (x / w) - yy = mid_y + (y / h) - if xx < 0 or xx > 1 or yy < 0 or yy > 1: - continue - res.append((xx, yy)) - return res - - def _handleMouseAction(self, node: SceneNode, paintview: View, x: int, y: int) -> bool: + 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: - return False + return None pt = self._picking_pass.getPickedPosition(x, y).getData() @@ -123,13 +165,7 @@ class PaintTool(Tool): wb /= wt wc /= wt texcoords = wa * ta + wb * tb + wc * tc - - color = self._color_str_to_rgba[self._brush_color] - w, h = paintview.getUvTexDimensions() - for (x, y) in self._getBrushPixels(texcoords[0], texcoords[1], float(w), float(h)): - paintview.setUvPixel(x, y, color) - - return True + return texcoords def event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -160,7 +196,7 @@ class PaintTool(Tool): if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons: return False self._mouse_held = False - self._last_mouse_drag = None + self._last_text_coords = None return True is_moved = event.type == Event.MouseMoveEvent @@ -175,12 +211,11 @@ class PaintTool(Tool): return False else: self._mouse_held = True - drags = ([self._last_mouse_drag] if self._last_mouse_drag else []) + [(evt.x, evt.y)] - self._last_mouse_drag = (evt.x, evt.y) paintview = controller.getActiveView() if paintview is None or paintview.getPluginId() != "PaintTool": return False + paintview = cast(PaintView, paintview) if not self._selection_pass: return False @@ -205,7 +240,6 @@ class PaintTool(Tool): return False evt = cast(MouseEvent, event) - drags.append((evt.x, evt.y)) if not self._picking_pass: self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight()) @@ -213,11 +247,23 @@ class PaintTool(Tool): self._selection_pass.renderFacesMode() - res = False - for (x, y) in drags: - res |= self._handleMouseAction(node, paintview, x, y) - if res: - Application.getInstance().getController().getScene().sceneChanged.emit(node) - return res + 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 diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py index 0923d007d7..b86d59b9df 100644 --- a/plugins/PaintTool/PaintView.py +++ b/plugins/PaintTool/PaintView.py @@ -1,6 +1,8 @@ # 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 @@ -18,8 +20,10 @@ class PaintView(View): super().__init__() self._paint_shader = None self._paint_texture = None - self._tex_width = 256 - self._tex_height = 256 + + # 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: @@ -29,8 +33,8 @@ class PaintView(View): self._paint_texture = OpenGL.getInstance().createTexture(self._tex_width, self._tex_height) self._paint_shader.setTexture(0, self._paint_texture) - def setUvPixel(self, x, y, color) -> None: - self._paint_texture.setPixel(x, y, color) + 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 From 4e5b0115ea39e01dee33c9d11a3a1c5d81244f9d Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 28 May 2025 14:39:07 +0200 Subject: [PATCH 9/9] Painting: Separate brush image didn't work properly, construct stroke-image by pen instead. This also simplifies things nicely. part of CURA-12543 --- plugins/PaintTool/PaintTool.py | 73 ++++++++++++---------------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 90482d0055..648ad9e1b3 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -3,7 +3,7 @@ import numpy from PyQt6.QtCore import Qt -from PyQt6.QtGui import QImage, QPainter, QColor, QBrush +from PyQt6.QtGui import QImage, QPainter, QColor, QBrush, QPen from typing import cast, Dict, List, Optional, Tuple from UM.Application import Application @@ -18,8 +18,7 @@ from .PaintView import PaintView class PaintTool(Tool): - """Provides the tool to paint meshes. - """ + """Provides the tool to paint meshes.""" def __init__(self) -> None: super().__init__() @@ -42,82 +41,60 @@ class PaintTool(Tool): self._brush_size: int = 10 self._brush_color: str = "A" self._brush_shape: str = "A" - self._brush_image = self._createBrushImage() + self._brush_pen: Optional[QPen] = None self._mouse_held: bool = False self._last_text_coords: Optional[Tuple[int, int]] = None - def _createBrushImage(self) -> QImage: - brush_image = QImage(self._brush_size, self._brush_size, QImage.Format.Format_RGBA8888) - brush_image.fill(QColor(255,255,255,0)) - + def _createBrushPen(self) -> QPen: + pen = QPen() + pen.setWidth(self._brush_size) color = self._color_str_to_rgba[self._brush_color] - qcolor = QColor(color[0], color[1], color[2], color[3]) - - painter = QPainter(brush_image) - painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(qcolor)) + pen.setColor(QColor(color[0], color[1], color[2], color[3])) match self._brush_shape: - case "A": # Square brush - painter.drawRect(0, 0, self._brush_size, self._brush_size) - case "B": # Circle brush - painter.drawEllipse(0, 0, self._brush_size, self._brush_size) - case _: - painter.drawRect(0, 0, self._brush_size, self._brush_size) - painter.end() - - return brush_image + 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]]: - distance = numpy.hypot(x1 - x0, y1 - y0) - angle = numpy.arctan2(y1 - y0, x1 - x0) - stroke_width = self._brush_size - stroke_height = int(distance) + self._brush_size + xdiff = int(x1 - x0) + ydiff = int(y1 - y0) half_brush_size = self._brush_size // 2 - start_x = int(x0 - half_brush_size) - start_y = int(y0 - half_brush_size) + start_x = int(min(x0, x1) - half_brush_size) + start_y = int(min(y0, y1) - half_brush_size) - stroke_image = QImage(stroke_height, stroke_width, QImage.Format.Format_RGBA8888) - stroke_image.fill(QColor(255,255,255,0)) + 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.SmoothPixmapTransform) painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - - # rotate the brush-image to follow the stroke-direction - transform = painter.transform() - transform.translate(0, stroke_width / 2) # translate to match the brush-alignment - transform.rotate(-numpy.degrees(angle)) - painter.setTransform(transform) - - # tile the brush along the stroke-length - brush_stride = max(1, half_brush_size) - for i in range(0, int(distance) + brush_stride, brush_stride): - painter.drawImage(i, -stroke_width, self._brush_image) + 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._stroke_image = self._createBrushStrokeImage()` (see other funcs). + 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_image = self._createBrushImage() + 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_image = self._createBrushImage() + 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_image = self._createBrushImage() + 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: @@ -147,7 +124,7 @@ class PaintTool(Tool): 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: + if face_id < 0 or face_id >= node.getMeshData().getFaceCount(): return None pt = self._picking_pass.getPickedPosition(x, y).getData()