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": {