diff --git a/.github/workflows/find-packages.yml b/.github/workflows/find-packages.yml
index 81f68857e5..93a5bdde2b 100644
--- a/.github/workflows/find-packages.yml
+++ b/.github/workflows/find-packages.yml
@@ -1,14 +1,16 @@
-name: Find packages for Jira ticket and create installers
+name: All installers (based on Jira ticket)
+run-name: ${{ inputs.jira_ticket_number }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
jira_ticket_number:
- description: 'Jira ticket number for Conan package discovery (e.g., cura_12345)'
+ description: 'Jira ticket number (e.g. CURA-15432 or cura_12345)'
required: true
type: string
start_builds:
- default: false
+ description: 'Start installers build based on found packages'
+ default: true
required: false
type: boolean
conan_args:
diff --git a/cura/Scene/SliceableObjectDecorator.py b/cura/Scene/SliceableObjectDecorator.py
index ad51f7d755..cc611b17af 100644
--- a/cura/Scene/SliceableObjectDecorator.py
+++ b/cura/Scene/SliceableObjectDecorator.py
@@ -1,12 +1,65 @@
-from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
+import copy
+import json
+from typing import Optional, Dict
+
+from PyQt6.QtCore import QBuffer
+from PyQt6.QtGui import QImage, QImageWriter
+
+import UM.View.GL.Texture
+from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
+from UM.View.GL.OpenGL import OpenGL
+from UM.View.GL.Texture import Texture
+
+
+# FIXME: When the texture UV-unwrapping is done, these two values will need to be set to a proper value (suggest 4096 for both).
+TEXTURE_WIDTH = 512
+TEXTURE_HEIGHT = 512
class SliceableObjectDecorator(SceneNodeDecorator):
def __init__(self) -> None:
super().__init__()
+ self._paint_texture = None
+ self._texture_data_mapping: Dict[str, tuple[int, int]] = {}
def isSliceable(self) -> bool:
return True
+ def getPaintTexture(self, create_if_required: bool = True) -> Optional[UM.View.GL.Texture.Texture]:
+ if self._paint_texture is None and create_if_required:
+ self._paint_texture = OpenGL.getInstance().createTexture(TEXTURE_WIDTH, TEXTURE_HEIGHT)
+ image = QImage(TEXTURE_WIDTH, TEXTURE_HEIGHT, QImage.Format.Format_RGB32)
+ image.fill(0)
+ self._paint_texture.setImage(image)
+ return self._paint_texture
+
+ def setPaintTexture(self, texture: UM.View.GL.Texture) -> None:
+ self._paint_texture = texture
+
+ def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]:
+ return self._texture_data_mapping
+
+ def setTextureDataMapping(self, mapping: Dict[str, tuple[int, int]]) -> None:
+ self._texture_data_mapping = mapping
+
+ def packTexture(self) -> Optional[bytearray]:
+ if self._paint_texture is None:
+ return None
+
+ texture_image = self._paint_texture.getImage()
+ if texture_image is None:
+ return None
+
+ texture_buffer = QBuffer()
+ texture_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
+ image_writer = QImageWriter(texture_buffer, b"png")
+ image_writer.setText("Description", json.dumps(self._texture_data_mapping))
+ image_writer.write(texture_image)
+
+ return texture_buffer.data()
+
def __deepcopy__(self, memo) -> "SliceableObjectDecorator":
- return type(self)()
+ copied_decorator = SliceableObjectDecorator()
+ copied_decorator.setPaintTexture(copy.deepcopy(self.getPaintTexture(create_if_required = False)))
+ copied_decorator.setTextureDataMapping(copy.deepcopy(self.getTextureDataMapping()))
+ return copied_decorator
diff --git a/plugins/3MFReader/ThreeMFReader.py b/plugins/3MFReader/ThreeMFReader.py
index 45ab2e7d2f..aae6ea56ae 100755
--- a/plugins/3MFReader/ThreeMFReader.py
+++ b/plugins/3MFReader/ThreeMFReader.py
@@ -1,12 +1,14 @@
# Copyright (c) 2021-2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
-
+import json
import os.path
import zipfile
from typing import List, Optional, Union, TYPE_CHECKING, cast
import pySavitar as Savitar
import numpy
+from PyQt6.QtCore import QBuffer
+from PyQt6.QtGui import QImage, QImageReader
from UM.Logger import Logger
from UM.Math.Matrix import Matrix
@@ -18,6 +20,8 @@ from UM.Scene.GroupDecorator import GroupDecorator
from UM.Scene.SceneNode import SceneNode # For typing.
from UM.Scene.SceneNodeSettings import SceneNodeSettings
from UM.Util import parseBool
+from UM.View.GL.OpenGL import OpenGL
+from UM.View.GL.Texture import Texture
from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
@@ -94,14 +98,14 @@ class ThreeMFReader(MeshReader):
return temp_mat
@staticmethod
- def _convertSavitarNodeToUMNode(savitar_node: Savitar.SceneNode, file_name: str = "", archive: zipfile.ZipFile = None) -> Optional[SceneNode]:
+ def _convertSavitarNodeToUMNode(savitar_node: Savitar.SceneNode, file_name: str = "", archive: zipfile.ZipFile = None, scene: Savitar.Scene = None) -> Optional[SceneNode]:
"""Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node.
:returns: Scene node.
"""
try:
node_name = savitar_node.getName()
- node_id = savitar_node.getId()
+ node_id = str(savitar_node.getId())
except AttributeError:
Logger.log("e", "Outdated version of libSavitar detected! Please update to the newest version!")
node_name = ""
@@ -131,12 +135,19 @@ class ThreeMFReader(MeshReader):
um_node.setTransformation(transformation)
mesh_builder = MeshBuilder()
- data = numpy.fromstring(savitar_node.getMeshData().getFlatVerticesAsBytes(), dtype=numpy.float32)
+ mesh_data = savitar_node.getMeshData()
+
+ vertices_data = numpy.fromstring(mesh_data.getFlatVerticesAsBytes(), dtype=numpy.float32)
+ vertices = numpy.resize(vertices_data, (int(vertices_data.size / 3), 3))
+
+ texture_path = mesh_data.getTexturePath(scene)
+ uv_data = numpy.fromstring(mesh_data.getUVCoordinatesPerVertexAsBytes(scene), dtype=numpy.float32)
+ uv_coordinates = numpy.resize(uv_data, (int(uv_data.size / 2), 2))
- vertices = numpy.resize(data, (int(data.size / 3), 3))
mesh_builder.setVertices(vertices)
mesh_builder.calculateNormals(fast=True)
mesh_builder.setMeshId(node_id)
+ mesh_builder.setUVCoordinates(uv_coordinates)
if file_name:
# The filename is used to give the user the option to reload the file if it is changed on disk
# It is only set for the root node of the 3mf file
@@ -147,7 +158,7 @@ class ThreeMFReader(MeshReader):
um_node.setMeshData(mesh_data)
for child in savitar_node.getChildren():
- child_node = ThreeMFReader._convertSavitarNodeToUMNode(child, archive=archive)
+ child_node = ThreeMFReader._convertSavitarNodeToUMNode(child, archive=archive, scene=scene)
if child_node:
um_node.addChild(child_node)
@@ -219,6 +230,30 @@ class ThreeMFReader(MeshReader):
# affects (auto) slicing
sliceable_decorator = SliceableObjectDecorator()
um_node.addDecorator(sliceable_decorator)
+
+ if texture_path != "" and archive is not None:
+ texture_data = archive.open(texture_path).read()
+ texture_buffer = QBuffer()
+ texture_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
+ texture_buffer.write(texture_data)
+
+ image_reader = QImageReader(texture_buffer, b"png")
+
+ texture_buffer.seek(0)
+ texture_image = image_reader.read()
+ texture = Texture(OpenGL.getInstance())
+ texture.setImage(texture_image)
+ sliceable_decorator.setPaintTexture(texture)
+
+ texture_buffer.seek(0)
+ data_mapping_desc = image_reader.text("Description")
+ if data_mapping_desc != "":
+ data_mapping = json.loads(data_mapping_desc)
+ for key, value in data_mapping.items():
+ # Tuples are stored as lists in json, restore them back to tuples
+ data_mapping[key] = tuple(value)
+ sliceable_decorator.setTextureDataMapping(data_mapping)
+
return um_node
def _read(self, file_name: str) -> Union[SceneNode, List[SceneNode]]:
@@ -236,7 +271,7 @@ class ThreeMFReader(MeshReader):
CuraApplication.getInstance().getController().getScene().setMetaDataEntry(key, value)
for node in scene_3mf.getSceneNodes():
- um_node = ThreeMFReader._convertSavitarNodeToUMNode(node, file_name, archive)
+ um_node = ThreeMFReader._convertSavitarNodeToUMNode(node, file_name, archive, scene_3mf)
if um_node is None:
continue
@@ -336,7 +371,7 @@ class ThreeMFReader(MeshReader):
# Convert the scene to scene nodes
nodes = []
for savitar_node in scene.getSceneNodes():
- scene_node = ThreeMFReader._convertSavitarNodeToUMNode(savitar_node, "file_name")
+ scene_node = ThreeMFReader._convertSavitarNodeToUMNode(savitar_node, "file_name", scene=scene)
if scene_node is None:
continue
nodes.append(scene_node)
diff --git a/plugins/3MFWriter/ThreeMFWriter.py b/plugins/3MFWriter/ThreeMFWriter.py
index 558b36576f..37345b16b0 100644
--- a/plugins/3MFWriter/ThreeMFWriter.py
+++ b/plugins/3MFWriter/ThreeMFWriter.py
@@ -58,6 +58,8 @@ catalog = i18nCatalog("cura")
MODEL_PATH = "3D/3dmodel.model"
PACKAGE_METADATA_PATH = "Cura/packages.json"
+TEXTURES_PATH = "3D/Textures"
+MODEL_RELATIONS_PATH = "3D/_rels/3dmodel.model.rels"
class ThreeMFWriter(MeshWriter):
def __init__(self):
@@ -109,7 +111,11 @@ class ThreeMFWriter(MeshWriter):
def _convertUMNodeToSavitarNode(um_node,
transformation = Matrix(),
exported_settings: Optional[Dict[str, Set[str]]] = None,
- center_mesh = False):
+ center_mesh = False,
+ scene: Savitar.Scene = None,
+ archive: zipfile.ZipFile = None,
+ model_relations_element: ET.Element = None,
+ content_types_element: ET.Element = None):
"""Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
:returns: Uranium Scene node.
@@ -150,7 +156,28 @@ class ThreeMFWriter(MeshWriter):
if indices_array is not None:
savitar_node.getMeshData().setFacesFromBytes(indices_array)
else:
- savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tostring())
+ savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tobytes())
+
+ packed_texture = um_node.callDecoration("packTexture")
+ uv_coordinates_array = mesh_data.getUVCoordinatesAsByteArray()
+ if packed_texture is not None and archive is not None and uv_coordinates_array is not None and len(uv_coordinates_array) > 0:
+ texture_path = f"{TEXTURES_PATH}/{id(um_node)}.png"
+ texture_file = zipfile.ZipInfo(texture_path)
+ # Don't try to compress texture file, because the PNG is pretty much as compact as it will get
+ archive.writestr(texture_file, packed_texture)
+
+ savitar_node.getMeshData().setUVCoordinatesPerVertexAsBytes(uv_coordinates_array, texture_path, scene)
+
+ # Add texture relation to model relations file
+ if model_relations_element is not None:
+ ET.SubElement(model_relations_element, "Relationship",
+ Target=texture_path, Id=f"rel{len(model_relations_element)+1}",
+ Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dtexture")
+
+ if content_types_element is not None:
+ ET.SubElement(content_types_element, "Override", PartName=texture_path,
+ ContentType="application/vnd.ms-package.3dmanufacturing-3dmodeltexture")
+
# Handle per object settings (if any)
stack = um_node.callDecoration("getStack")
@@ -187,7 +214,11 @@ class ThreeMFWriter(MeshWriter):
if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
continue
savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node,
- exported_settings = exported_settings)
+ exported_settings = exported_settings,
+ scene = scene,
+ archive = archive,
+ model_relations_element = model_relations_element,
+ content_types_element = content_types_element)
if savitar_child_node is not None:
savitar_node.addChild(savitar_child_node)
@@ -249,6 +280,9 @@ class ThreeMFWriter(MeshWriter):
# Create Metadata/_rels/model_settings.config.rels
metadata_relations_element = self._makeRelationsTree()
+ # Create model relations
+ model_relations_element = self._makeRelationsTree()
+
# Let the variant add its specific files
variant.add_extra_files(archive, metadata_relations_element)
@@ -320,13 +354,21 @@ class ThreeMFWriter(MeshWriter):
savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child,
transformation_matrix,
exported_model_settings,
- center_mesh = True)
+ center_mesh = True,
+ scene = savitar_scene,
+ archive = archive,
+ model_relations_element = model_relations_element,
+ content_types_element = content_types)
if savitar_node:
savitar_scene.addSceneNode(savitar_node)
else:
savitar_node = self._convertUMNodeToSavitarNode(node,
transformation_matrix,
- exported_model_settings)
+ exported_model_settings,
+ scene = savitar_scene,
+ archive = archive,
+ model_relations_element = model_relations_element,
+ content_types_element = content_types)
if savitar_node:
savitar_scene.addSceneNode(savitar_node)
@@ -338,6 +380,8 @@ class ThreeMFWriter(MeshWriter):
self._storeElementTree(archive, "_rels/.rels", relations_element)
if len(metadata_relations_element) > 0:
self._storeElementTree(archive, "Metadata/_rels/model_settings.config.rels", metadata_relations_element)
+ if len(model_relations_element) > 0:
+ self._storeElementTree(archive, MODEL_RELATIONS_PATH, model_relations_element)
except Exception as error:
Logger.logException("e", "Error writing zip file")
self.setInformation(str(error))
@@ -500,7 +544,7 @@ class ThreeMFWriter(MeshWriter):
def sceneNodesToString(scene_nodes: [SceneNode]) -> str:
savitar_scene = Savitar.Scene()
for scene_node in scene_nodes:
- savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node, center_mesh = True)
+ savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node, center_mesh = True, scene = savitar_scene)
savitar_scene.addSceneNode(savitar_node)
parser = Savitar.ThreeMFParser()
scene_string = parser.sceneToString(savitar_scene)
diff --git a/plugins/CuraEngineBackend/Cura.proto b/plugins/CuraEngineBackend/Cura.proto
index cdbe463d81..1636c56c20 100644
--- a/plugins/CuraEngineBackend/Cura.proto
+++ b/plugins/CuraEngineBackend/Cura.proto
@@ -53,6 +53,8 @@ message Object
bytes indices = 4; //An array of ints.
repeated Setting settings = 5; // Setting override per object, overruling the global settings.
string name = 6; //Mesh name
+ bytes uv_coordinates = 7; //An array of 2 floats.
+ bytes texture = 8; //PNG-encoded texture data
}
message Progress
diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py
index b276469d09..8b27a0319a 100644
--- a/plugins/CuraEngineBackend/StartSliceJob.py
+++ b/plugins/CuraEngineBackend/StartSliceJob.py
@@ -509,6 +509,14 @@ class StartSliceJob(Job):
obj.vertices = flat_verts
+ uv_coordinates = mesh_data.getUVCoordinates()
+ if uv_coordinates is not None:
+ obj.uv_coordinates = uv_coordinates.flatten()
+
+ packed_texture = object.callDecoration("packTexture")
+ if packed_texture is not None:
+ obj.texture = packed_texture
+
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
Job.yieldThread()
diff --git a/plugins/PaintTool/BrushColorButton.qml b/plugins/PaintTool/BrushColorButton.qml
new file mode 100644
index 0000000000..71556f2681
--- /dev/null
+++ b/plugins/PaintTool/BrushColorButton.qml
@@ -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)
+ }
+}
diff --git a/plugins/PaintTool/BrushShapeButton.qml b/plugins/PaintTool/BrushShapeButton.qml
new file mode 100644
index 0000000000..5c290e4a13
--- /dev/null
+++ b/plugins/PaintTool/BrushShapeButton.qml
@@ -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)
+ }
+}
diff --git a/plugins/PaintTool/PaintModeButton.qml b/plugins/PaintTool/PaintModeButton.qml
new file mode 100644
index 0000000000..473996e04b
--- /dev/null
+++ b/plugins/PaintTool/PaintModeButton.qml
@@ -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)
+ }
+}
diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py
new file mode 100644
index 0000000000..524011af9d
--- /dev/null
+++ b/plugins/PaintTool/PaintTool.py
@@ -0,0 +1,347 @@
+# Copyright (c) 2025 UltiMaker
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from enum import IntEnum
+import numpy
+from PyQt6.QtCore import Qt, QObject, pyqtEnum
+from PyQt6.QtGui import QImage, QPainter, QColor, QPen
+from PyQt6 import QtWidgets
+from typing import cast, Dict, List, Optional, Tuple
+
+from numpy import ndarray
+
+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."""
+
+ class Brush(QObject):
+ @pyqtEnum
+ class Shape(IntEnum):
+ SQUARE = 0
+ CIRCLE = 1
+
+ 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._brush_size: int = 10
+ self._brush_color: str = ""
+ self._brush_shape: PaintTool.Brush.Shape = PaintTool.Brush.Shape.SQUARE
+ self._brush_pen: QPen = self._createBrushPen()
+
+ self._mouse_held: bool = False
+
+ self._last_text_coords: Optional[numpy.ndarray] = None
+ self._last_mouse_coords: Optional[Tuple[int, int]] = None
+ self._last_face_id: Optional[int] = None
+
+ def _createBrushPen(self) -> QPen:
+ pen = QPen()
+ pen.setWidth(self._brush_size)
+ pen.setColor(Qt.GlobalColor.white)
+
+ match self._brush_shape:
+ case PaintTool.Brush.Shape.SQUARE:
+ pen.setCapStyle(Qt.PenCapStyle.SquareCap)
+ case PaintTool.Brush.Shape.CIRCLE:
+ 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_RGB32)
+ stroke_image.fill(0)
+
+ painter = QPainter(stroke_image)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
+ painter.setPen(self._brush_pen)
+ if xdiff == 0 and ydiff == 0:
+ painter.drawPoint(int(x0 - start_x), int(y0 - start_y))
+ else:
+ 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:
+ paint_view = self._get_paint_view()
+ if paint_view is None:
+ return
+
+ paint_view.setPaintType(paint_type)
+
+ self._brush_pen = self._createBrushPen()
+ self._updateScene()
+
+ 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:
+ self._brush_color = brush_color
+
+ def setBrushShape(self, brush_shape: int) -> None:
+ if brush_shape != self._brush_shape:
+ self._brush_shape = brush_shape
+ self._brush_pen = self._createBrushPen()
+
+ def undoStackAction(self, redo_instead: bool) -> bool:
+ paint_view = self._get_paint_view()
+ if paint_view is None:
+ return False
+
+ if redo_instead:
+ paint_view.redoStroke()
+ else:
+ paint_view.undoStroke()
+
+ 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()
+ if paint_view is None or paint_view.getPluginId() != "PaintTool":
+ return None
+ return cast(PaintView, paint_view)
+
+ @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
+ if all(a == pt) or all(b == c) or all(a == c) or all(a == b):
+ return 1.0
+
+ # 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_len = numpy.linalg.norm(udir_res)
+ if udir_res_len == 0:
+ return 1.0
+ udir_res /= udir_res_len
+
+ # solve system of equations
+ rhs = b - a
+ lhs = numpy.array([udir_a, -udir_b, udir_res]).T
+ try:
+ solved = numpy.linalg.solve(lhs, rhs)
+ except numpy.linalg.LinAlgError:
+ return 1.0
+
+ # get the ratio
+ intersect = ((a + solved[0] * udir_a) + (b + solved[1] * udir_b)) * 0.5
+ a_intersect_dist = numpy.linalg.norm(a - intersect)
+ if a_intersect_dist == 0:
+ return 1.0
+ return numpy.linalg.norm(pt - intersect) / a_intersect_dist
+
+ def _nodeTransformChanged(self, *args) -> None:
+ self._cache_dirty = True
+
+ def _getTexCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray]]:
+ face_id = self._selection_pass.getFaceIdAtPosition(x, y)
+ if face_id < 0 or face_id >= node.getMeshData().getFaceCount():
+ return face_id, 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
+ if wt == 0:
+ return face_id, None
+ wa /= wt
+ wb /= wt
+ wc /= wt
+ texcoords = wa * ta + wb * tb + wc * tc
+ return face_id, texcoords
+
+ def _iteratateSplitSubstroke(self, node, substrokes,
+ info_a: Tuple[Tuple[float, float], Tuple[int, Optional[numpy.ndarray]]],
+ info_b: Tuple[Tuple[float, float], Tuple[int, Optional[numpy.ndarray]]]) -> None:
+ click_a, (face_a, texcoords_a) = info_a
+ click_b, (face_b, texcoords_b) = info_b
+
+ if (abs(click_a[0] - click_b[0]) < 0.0001 and abs(click_a[1] - click_b[1]) < 0.0001) or (face_a < 0 and face_b < 0):
+ return
+ if face_b < 0 or face_a == face_b:
+ substrokes.append((self._last_text_coords, texcoords_a))
+ return
+ if face_a < 0:
+ substrokes.append((self._last_text_coords, texcoords_b))
+ return
+
+ mouse_mid = (click_a[0] + click_b[0]) / 2.0, (click_a[1] + click_b[1]) / 2.0
+ face_mid, texcoords_mid = self._getTexCoordsFromClick(node, mouse_mid[0], mouse_mid[1])
+ mid_struct = (mouse_mid, (face_mid, texcoords_mid))
+ if face_mid == face_a:
+ substrokes.append((texcoords_a, texcoords_mid))
+ self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b)
+ elif face_mid == face_b:
+ substrokes.append((texcoords_mid, texcoords_b))
+ self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct)
+ else:
+ self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b)
+ self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct)
+
+ 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()
+ node = Selection.getSelectedObject(0)
+ if node is None:
+ return False
+
+ # 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.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
+ self._last_mouse_coords = None
+ self._last_face_id = 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
+
+ mouse_evt = cast(MouseEvent, event)
+ if is_pressed:
+ if MouseEvent.LeftButton not in mouse_evt.buttons:
+ return False
+ else:
+ self._mouse_held = True
+
+ paintview = self._get_paint_view()
+ if paintview is None:
+ return False
+
+ if not self._selection_pass:
+ return False
+
+ camera = self._controller.getScene().getActiveCamera()
+ if not camera:
+ 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
+
+ if not self._picking_pass:
+ self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight())
+ self._picking_pass.render()
+
+ self._selection_pass.renderFacesMode()
+
+ face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y)
+ if texcoords is None:
+ return False
+ if self._last_text_coords is None:
+ self._last_text_coords = texcoords
+ self._last_mouse_coords = (mouse_evt.x, mouse_evt.y)
+ self._last_face_id = face_id
+
+ substrokes = []
+ if face_id == self._last_face_id:
+ substrokes.append((self._last_text_coords, texcoords))
+ else:
+ self._iteratateSplitSubstroke(node, substrokes,
+ (self._last_mouse_coords, (self._last_face_id, self._last_text_coords)),
+ ((mouse_evt.x, mouse_evt.y), (face_id, texcoords)))
+
+ w, h = paintview.getUvTexDimensions()
+ for start_coords, end_coords in substrokes:
+ sub_image, (start_x, start_y) = self._createStrokeImage(
+ start_coords[0] * w,
+ start_coords[1] * h,
+ end_coords[0] * w,
+ end_coords[1] * h
+ )
+ paintview.addStroke(sub_image, start_x, start_y, self._brush_color)
+
+ self._last_text_coords = texcoords
+ self._last_mouse_coords = (mouse_evt.x, mouse_evt.y)
+ self._last_face_id = face_id
+ self._updateScene(node)
+ return True
+
+ return False
+
+ @staticmethod
+ def _updateScene(node: SceneNode = None):
+ if node is None:
+ node = Selection.getSelectedObject(0)
+ if node is not None:
+ Application.getInstance().getController().getScene().sceneChanged.emit(node)
\ No newline at end of file
diff --git a/plugins/PaintTool/PaintTool.qml b/plugins/PaintTool/PaintTool.qml
new file mode 100644
index 0000000000..4cbe9d4ade
--- /dev/null
+++ b/plugins/PaintTool/PaintTool.qml
@@ -0,0 +1,237 @@
+// Copyright (c) 2025 UltiMaker
+// Cura is released under the terms of the LGPLv3 or higher.
+
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+import UM 1.7 as UM
+import Cura 1.0 as Cura
+
+Item
+{
+ id: base
+ width: childrenRect.width
+ height: childrenRect.height
+ UM.I18nCatalog { id: catalog; name: "cura"}
+
+ property string selectedMode: ""
+ property string selectedColor: ""
+ property int selectedShape: 0
+
+ Action
+ {
+ id: undoAction
+ shortcut: "Ctrl+L"
+ onTriggered: UM.Controller.triggerActionWithData("undoStackAction", false)
+ }
+
+ Action
+ {
+ id: redoAction
+ shortcut: "Ctrl+Shift+L"
+ onTriggered: UM.Controller.triggerActionWithData("undoStackAction", true)
+ }
+
+ Column
+ {
+ id: mainColumn
+ spacing: UM.Theme.getSize("default_margin").height
+
+ RowLayout
+ {
+ id: rowPaintMode
+ width: parent.width
+
+ PaintModeButton
+ {
+ text: catalog.i18nc("@action:button", "Seam")
+ icon: "Seam"
+ tooltipText: catalog.i18nc("@tooltip", "Refine seam placement by defining preferred/avoidance areas")
+ mode: "seam"
+ }
+
+ PaintModeButton
+ {
+ text: catalog.i18nc("@action:button", "Support")
+ icon: "Support"
+ tooltipText: catalog.i18nc("@tooltip", "Refine support placement by defining preferred/avoidance areas")
+ mode: "support"
+ }
+ }
+
+ //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("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")
+ }
+ }
+ }
+
+ RowLayout
+ {
+ id: rowBrushShape
+
+ UM.Label
+ {
+ text: catalog.i18nc("@label", "Brush Shape")
+ }
+
+ BrushShapeButton
+ {
+ id: buttonBrushCircle
+ shape: Cura.PaintToolBrush.CIRCLE
+
+ text: catalog.i18nc("@action:button", "Circle")
+ toolItem: UM.ColorImage
+ {
+ source: UM.Theme.getIcon("Circle")
+ color: UM.Theme.getColor("icon")
+ }
+ }
+
+ BrushShapeButton
+ {
+ id: buttonBrushSquare
+ shape: Cura.PaintToolBrush.SQUARE
+
+ text: catalog.i18nc("@action:button", "Square")
+ toolItem: UM.ColorImage
+ {
+ source: UM.Theme.getIcon("MeshTypeNormal")
+ color: UM.Theme.getColor("icon")
+ }
+ }
+ }
+
+ 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)
+ {
+ if(! pressed)
+ {
+ 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
+ {
+ id: undoButton
+
+ text: catalog.i18nc("@action:button", "Undo Stroke")
+ toolItem: UM.ColorImage
+ {
+ source: UM.Theme.getIcon("ArrowReset")
+ color: UM.Theme.getColor("icon")
+ }
+
+ onClicked: undoAction.trigger()
+ }
+
+ UM.ToolbarButton
+ {
+ id: redoButton
+
+ text: catalog.i18nc("@action:button", "Redo Stroke")
+ toolItem: UM.ColorImage
+ {
+ source: UM.Theme.getIcon("ArrowReset")
+ color: UM.Theme.getColor("icon")
+ transform: [
+ Scale { xScale: -1; origin.x: width/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()
+ }
+}
diff --git a/plugins/PaintTool/PaintView.py b/plugins/PaintTool/PaintView.py
new file mode 100644
index 0000000000..22eb8c55f6
--- /dev/null
+++ b/plugins/PaintTool/PaintView.py
@@ -0,0 +1,186 @@
+# Copyright (c) 2025 UltiMaker
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import os
+from PyQt6.QtCore import QRect
+from typing import Optional, List, Tuple, Dict
+
+from PyQt6.QtGui import QImage, QColor, QPainter
+
+from cura.CuraApplication import CuraApplication
+from UM.PluginRegistry import PluginRegistry
+from UM.View.GL.ShaderProgram import ShaderProgram
+from UM.View.GL.Texture import Texture
+from UM.View.View import View
+from UM.Scene.Selection import Selection
+from UM.View.GL.OpenGL import OpenGL
+from UM.i18n import i18nCatalog
+from UM.Math.Color import Color
+
+catalog = i18nCatalog("cura")
+
+
+class PaintView(View):
+ """View for model-painting."""
+
+ UNDO_STACK_SIZE = 1024
+
+ class PaintType:
+ def __init__(self, display_color: Color, value: int):
+ self.display_color: Color = display_color
+ self.value: int = value
+
+ 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, Dict[str, "PaintView.PaintType"]] = {}
+
+ self._stroke_undo_stack: List[Tuple[QImage, int, int]] = []
+ self._stroke_redo_stack: List[Tuple[QImage, int, int]] = []
+
+ self._force_opaque_mask = QImage(2, 2, QImage.Format.Format_Mono)
+ self._force_opaque_mask.fill(1)
+
+ CuraApplication.getInstance().engineCreatedSignal.connect(self._makePaintModes)
+
+ def _makePaintModes(self):
+ theme = CuraApplication.getInstance().getTheme()
+ 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 = {
+ "seam": usual_types,
+ "support": usual_types,
+ }
+
+ 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)
+
+ def _forceOpaqueDeepCopy(self, image: QImage):
+ res = QImage(image.width(), image.height(), QImage.Format.Format_RGBA8888)
+ res.fill(QColor(255, 255, 255, 255))
+ painter = QPainter(res)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
+ painter.drawImage(0, 0, image)
+ painter.end()
+ res.setAlphaChannel(self._force_opaque_mask.scaled(image.width(), image.height()))
+ return res
+
+ 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][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_mask.width(), stroke_mask.height())
+
+ 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_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_mask.width(), stroke_mask.height())
+ painter = QPainter(stroked_image)
+ painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination)
+ painter.drawImage(0, 0, clear_bits_image)
+ painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
+ painter.drawImage(0, 0, set_value_image)
+ painter.end()
+
+ self._stroke_redo_stack.clear()
+ if len(self._stroke_undo_stack) >= PaintView.UNDO_STACK_SIZE:
+ self._stroke_undo_stack.pop(0)
+ undo_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(stroked_image, start_x, start_y))
+ if undo_image is not None:
+ self._stroke_undo_stack.append((undo_image, start_x, start_y))
+
+ def _applyUndoStacksAction(self, from_stack: List[Tuple[QImage, int, int]], to_stack: List[Tuple[QImage, int, int]]) -> bool:
+ if len(from_stack) <= 0 or self._current_paint_texture is None:
+ return False
+ from_image, x, y = from_stack.pop()
+ to_image = self._forceOpaqueDeepCopy(self._current_paint_texture.setSubImage(from_image, x, y))
+ if to_image is None:
+ return False
+ if len(to_stack) >= PaintView.UNDO_STACK_SIZE:
+ to_stack.pop(0)
+ to_stack.append((to_image, x, y))
+ return True
+
+ def undoStroke(self) -> bool:
+ return self._applyUndoStacksAction(self._stroke_undo_stack, self._stroke_redo_stack)
+
+ def redoStroke(self) -> bool:
+ return self._applyUndoStacksAction(self._stroke_redo_stack, self._stroke_undo_stack)
+
+ def getUvTexDimensions(self):
+ if self._current_paint_texture is not None:
+ return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight()
+ return 0, 0
+
+ def setPaintType(self, paint_type: str) -> None:
+ node = Selection.getAllSelectedObjects()[0]
+ if node is None:
+ return
+
+ 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]))
+ paint_data_mapping[paint_type] = new_mapping
+ node.callDecoration("setTextureDataMapping", paint_data_mapping)
+
+ self._current_paint_type = paint_type
+ self._current_bits_ranges = paint_data_mapping[paint_type]
+
+ @staticmethod
+ def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]:
+ start_index = 0
+ if actual_mapping:
+ start_index = max(end_index for _, end_index in actual_mapping.values()) + 1
+
+ end_index = start_index + int.bit_length(nb_storable_values - 1) - 1
+
+ return start_index, end_index
+
+ def beginRendering(self) -> None:
+ renderer = self.getRenderer()
+ self._checkSetup()
+ paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
+ renderer.addRenderBatch(paint_batch)
+
+ node = Selection.getSelectedObject(0)
+ if node is None:
+ return
+
+ if self._current_paint_type == "":
+ 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].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)
+
+ self._current_paint_texture = node.callDecoration("getPaintTexture")
+ self._paint_shader.setTexture(0, self._current_paint_texture)
+
+ 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
new file mode 100644
index 0000000000..e92c169ee6
--- /dev/null
+++ b/plugins/PaintTool/__init__.py
@@ -0,0 +1,33 @@
+# Copyright (c) 2025 UltiMaker
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from . import PaintTool
+from . import PaintView
+
+from PyQt6.QtQml import qmlRegisterUncreatableType
+
+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):
+ qmlRegisterUncreatableType(PaintTool.PaintTool.Brush, "Cura", 1, 0, "This is an enumeration class", "PaintToolBrush")
+ return {
+ "tool": PaintTool.PaintTool(),
+ "view": PaintView.PaintView()
+ }
diff --git a/plugins/PaintTool/paint.shader b/plugins/PaintTool/paint.shader
new file mode 100644
index 0000000000..bd769f5cb2
--- /dev/null
+++ b/plugins/PaintTool/paint.shader
@@ -0,0 +1,149 @@
+[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 highp vec3 u_lightPosition;
+ uniform highp vec3 u_viewPosition;
+ uniform mediump float u_opacity;
+ uniform sampler2D u_texture;
+ uniform mediump int u_bitsRangesStart;
+ uniform mediump int u_bitsRangesEnd;
+ uniform mediump vec3 u_renderColors[16];
+
+ 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 */
+ ivec4 texture = ivec4(texture(u_texture, v_uvs) * 255.0);
+ uint color_index = (texture.r << 16) | (texture.g << 8) | texture.b;
+ color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart);
+
+ vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0);
+ highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0);
+ final_color += (n_dot_l * diffuse_color);
+
+ final_color.a = u_opacity;
+
+ frag_color = 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 highp vec3 u_lightPosition;
+ uniform highp vec3 u_viewPosition;
+ uniform mediump float u_opacity;
+ uniform sampler2D u_texture;
+ uniform mediump int u_bitsRangesStart;
+ uniform mediump int u_bitsRangesEnd;
+ uniform mediump vec3 u_renderColors[16];
+
+ 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 */
+ ivec4 texture = ivec4(texture(u_texture, v_uvs) * 255.0);
+ uint color_index = (texture.r << 16) | (texture.g << 8) | texture.b;
+ color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart);
+
+ vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0);
+ highp float n_dot_l = clamp(dot(normal, light_dir), 0.0, 1.0);
+ final_color += (n_dot_l * diffuse_color);
+
+ final_color.a = u_opacity;
+
+ frag_color = final_color;
+ }
+
+[defaults]
+u_ambientColor = [0.3, 0.3, 0.3, 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
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"
+}
diff --git a/plugins/SolidView/SolidView.py b/plugins/SolidView/SolidView.py
index 7f32b0df7f..bffc3aa526 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,11 +35,12 @@ class SolidView(View):
"""Standard view for mesh models."""
_show_xray_warning_preference = "view/show_xray_warning"
+ _show_overhang_preference = "view/show_overhang"
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 +212,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)))
diff --git a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelectorButton.qml b/resources/qml/ModeSelectorButton.qml
similarity index 91%
rename from resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelectorButton.qml
rename to resources/qml/ModeSelectorButton.qml
index 1bbc726b9d..65a6ee4a75 100644
--- a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelectorButton.qml
+++ b/resources/qml/ModeSelectorButton.qml
@@ -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
diff --git a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml b/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml
index 19c57e5130..1559f6cec3 100644
--- a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml
+++ b/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml
@@ -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 : ""
diff --git a/resources/themes/cura-light/icons/default/Circle.svg b/resources/themes/cura-light/icons/default/Circle.svg
new file mode 100644
index 0000000000..c69b5a4e31
--- /dev/null
+++ b/resources/themes/cura-light/icons/default/Circle.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/resources/themes/cura-light/icons/default/Eraser.svg b/resources/themes/cura-light/icons/default/Eraser.svg
new file mode 100644
index 0000000000..fbe5103993
--- /dev/null
+++ b/resources/themes/cura-light/icons/default/Eraser.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/resources/themes/cura-light/icons/default/Seam.svg b/resources/themes/cura-light/icons/default/Seam.svg
new file mode 100644
index 0000000000..a9615832d6
--- /dev/null
+++ b/resources/themes/cura-light/icons/default/Seam.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/resources/themes/cura-light/icons/low/CancelBadge.svg b/resources/themes/cura-light/icons/low/CancelBadge.svg
new file mode 100644
index 0000000000..25c4198083
--- /dev/null
+++ b/resources/themes/cura-light/icons/low/CancelBadge.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/resources/themes/cura-light/icons/low/CheckBadge.svg b/resources/themes/cura-light/icons/low/CheckBadge.svg
new file mode 100644
index 0000000000..a10a92c6af
--- /dev/null
+++ b/resources/themes/cura-light/icons/low/CheckBadge.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json
index 2e33e56bbf..436aaceb3c 100644
--- a/resources/themes/cura-light/theme.json
+++ b/resources/themes/cura-light/theme.json
@@ -463,8 +463,6 @@
"layerview_support_infill": [0, 230, 230, 127],
"layerview_move_combing": [0, 0, 255, 255],
"layerview_move_retraction": [128, 127, 255, 255],
- "layerview_move_while_retracting": [127, 255, 255, 255],
- "layerview_move_while_unretracting": [255, 127, 255, 255],
"layerview_support_interface": [63, 127, 255, 127],
"layerview_prime_tower": [0, 255, 255, 255],
"layerview_nozzle": [224, 192, 16, 64],
@@ -504,7 +502,11 @@
"error_badge_background": [255, 255, 255, 255],
"border_field_light": [180, 180, 180, 255],
- "border_main_light": [212, 212, 212, 255]
+ "border_main_light": [212, 212, 212, 255],
+
+ "paint_normal_area": "background_3",
+ "paint_preferred_area": "um_green_5",
+ "paint_avoid_area": "um_red_5"
},
"sizes": {