diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 743566fb79..a2d8e2abce 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -4,20 +4,18 @@ 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 PyQt6.QtGui import QImage, QPainter, QPen, QPainterPath, QPainterPathStroker +from typing import cast, Optional, Tuple, List from UM.Application import Application -from UM.Event import Event, MouseEvent, KeyEvent +from UM.Event import Event, MouseEvent from UM.Job import Job from UM.Logger import Logger +from UM.Math.Polygon import Polygon +from UM.Scene.Camera import Camera from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool -from UM.View.GL.OpenGL import OpenGL from cura.CuraApplication import CuraApplication from cura.PickingPass import PickingPass @@ -66,8 +64,7 @@ class PaintTool(Tool): self._mouse_held: bool = False - self._last_text_coords: Optional[numpy.ndarray] = None - self._last_mouse_coords: Optional[Tuple[int, int]] = None + self._last_world_coords: Optional[numpy.ndarray] = None self._last_face_id: Optional[int] = None self._state: PaintTool.Paint.State = PaintTool.Paint.State.MULTIPLE_SELECTION @@ -78,6 +75,26 @@ class PaintTool(Tool): self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects) self._controller.activeToolChanged.connect(self._updateState) + self._camera: Optional[Camera] = None + self._cam_pos: numpy.ndarray = numpy.array([0.0, 0.0, 0.0]) + self._cam_norm: numpy.ndarray = numpy.array([0.0, 0.0, 1.0]) + self._cam_axis_q: numpy.ndarray = numpy.array([1.0, 0.0, 0.0]) + self._cam_axis_r: numpy.ndarray = numpy.array([0.0, -1.0, 0.0]) + + def _updateCamera(self, *args) -> None: + if self._camera is None: + self._camera = Application.getInstance().getController().getScene().getActiveCamera() + self._camera.transformationChanged.connect(self._updateCamera) + self._cam_pos = self._camera.getPosition().getData() + cam_ray = self._camera.getRay(0, 0) + self._cam_norm = cam_ray.direction.getData() + self._cam_norm /= -numpy.linalg.norm(self._cam_norm) + axis_up = numpy.array([0.0, -1.0, 0.0]) if abs(self._cam_norm[1]) < abs(self._cam_norm[2]) else numpy.array([0.0, 0.0, 1.0]) + self._cam_axis_q = numpy.cross(self._cam_norm, axis_up) + self._cam_axis_q /= numpy.linalg.norm(self._cam_axis_q) + self._cam_axis_r = numpy.cross(self._cam_axis_q, self._cam_norm) + self._cam_axis_r /= numpy.linalg.norm(self._cam_axis_r) + def _createBrushPen(self) -> QPen: pen = QPen() pen.setWidth(self._brush_size) @@ -88,29 +105,39 @@ class PaintTool(Tool): pen.setCapStyle(Qt.PenCapStyle.SquareCap) case PaintTool.Brush.Shape.CIRCLE: pen.setCapStyle(Qt.PenCapStyle.RoundCap) + case _: + Logger.error(f"Unknown brush shape '{self._brush_shape}', painting may not work.") 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) + def _createStrokeImage(self, polys: List[Polygon]) -> Tuple[QImage, Tuple[int, int]]: + w, h = self._view.getUvTexDimensions() + if w == 0 or h == 0 or len(polys) == 0: + return QImage(w, h, QImage.Format.Format_RGB32), (0, 0) - 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) + min_pt = numpy.array([numpy.inf, numpy.inf]) + max_pt = numpy.array([-numpy.inf, -numpy.inf]) + for poly in polys: + for pt in poly: + min_pt = numpy.minimum(min_pt, w * pt) + max_pt = numpy.maximum(max_pt, h * pt) - stroke_image = QImage(abs(xdiff) + self._brush_size, abs(ydiff) + self._brush_size, QImage.Format.Format_RGB32) + stroke_image = QImage(int(max_pt[0] - min_pt[0]) + 1, int(max_pt[1] - min_pt[1]) + 1, 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)) + path = QPainterPath() + for poly in polys: + path.moveTo(int(0.5 + w * poly[0][0] - min_pt[0]), int(0.5 + h * poly[0][1] - min_pt[1])) + for pt in poly[1:]: + path.lineTo(int(0.5 + w * pt[0] - min_pt[0]), int(0.5 + h * pt[1] - min_pt[1])) + path.lineTo(int(0.5 + w * poly[0][0] - min_pt[0]), int(0.5 + h * poly[0][1] - min_pt[1])) + stroker = QPainterPathStroker() + stroker.setWidth(2) + painter.fillPath(stroker.createStroke(path).united(path), self._brush_pen.color()) painter.end() - return stroke_image, (start_x, start_y) + return stroke_image, (int(min_pt[0] + 0.5), int(min_pt[1] + 0.5)) def getPaintType(self) -> str: return self._view.getPaintType() @@ -189,98 +216,110 @@ class PaintTool(Tool): self._updateScene() - @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._faces_selection_pass.getFaceIdAtPosition(x, y) - if face_id < 0 or face_id >= node.getMeshData().getFaceCount(): - return face_id, None + @staticmethod + def _remapBarycentric(triangle_a: Polygon, pt: numpy.ndarray, triangle_b: Polygon) -> numpy.ndarray: + a1, b1, c1 = triangle_a + a2, b2, c2 = triangle_b - pt = self._picking_pass.getPickedPosition(x, y).getData() + area_full = 0.5 * numpy.linalg.norm(numpy.cross(b1 - a1, c1 - a1)) - va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) + if area_full < 1e-6: # Degenerate triangle + return a2 - face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id) - if face_uv_coordinates is None: - return face_id, None - ta, tb, tc = face_uv_coordinates + # Area of sub-triangle opposite to vertex [a,b,c]1 + area_a = 0.5 * numpy.linalg.norm(numpy.cross(b1 - pt, c1 - pt)) + area_b = 0.5 * numpy.linalg.norm(numpy.cross(pt - a1, c1 - a1)) + area_c = 0.5 * numpy.linalg.norm(numpy.cross(b1 - a1, pt - a1)) - # '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 + u = area_a / area_full + v = area_b / area_full + w = area_c / area_full - 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 + total = u + v + w + if abs(total - 1.0) > 1e-6: + u /= total + v /= total + w /= total - 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 + return u * a2 + v * b2 + w * c2 - 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 _getStrokePolygon(self, size_adjust: float, stroke_a: numpy.ndarray, stroke_b: numpy.ndarray) -> Polygon: + shape = None + match self._brush_shape: + case PaintTool.Brush.Shape.SQUARE: + shape = Polygon.approximatedCircle(self._brush_size * size_adjust, 4) + case PaintTool.Brush.Shape.CIRCLE: + shape = Polygon.approximatedCircle(self._brush_size * size_adjust, 16) + case _: + Logger.error(f"Unknown brush shape '{self._brush_shape}'.") + if shape is None: + return Polygon() + return shape.translate(stroke_a[0], stroke_a[1]).unionConvexHulls(shape.translate(stroke_b[0], stroke_b[1])) + + # NOTE: Currently, it's unclear how well this would work for non-convex brush-shapes. + def _getUvAreasForStroke(self, face_id_a: int, face_id_b: int, world_coords_a: numpy.ndarray, world_coords_b: numpy.ndarray) -> List[Polygon]: + """ Fetches all texture-coordinate areas within the provided stroke on the mesh. + + Calculates intersections of the stroke with the surface of the geometry and maps them to UV-space polygons. + + :param face_id_a: ID of the face where the stroke starts. + :param face_id_b: ID of the face where the stroke ends. + :param world_coords_a: 3D ('world') coordinates corresponding to the starting stroke point. + :param world_coords_b: 3D ('world') coordinates corresponding to the ending stroke point. + :return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface. + """ + + def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray: + proj_pt = (pt - numpy.dot(self._cam_norm, pt - self._cam_pos) * self._cam_norm) - self._cam_pos + y_coord = numpy.dot(self._cam_axis_r, proj_pt) + x_coord = numpy.dot(self._cam_axis_q, proj_pt) + return numpy.array([x_coord, y_coord]) + + uv_a0, uv_a1, _ = self._node_cache.getMeshData().getFaceUvCoords(face_id_a) + w_a0, w_a1, _ = self._mesh_transformed_cache.getFaceNodes(face_id_a) + world_to_uv_size_factor = numpy.linalg.norm(uv_a1 - uv_a0) / numpy.linalg.norm(w_a1 - w_a0) + + stroke_poly = self._getStrokePolygon( + world_to_uv_size_factor, + get_projected_on_plane(world_coords_a), + get_projected_on_plane(world_coords_b)) + + candidates = set() + candidates.add(face_id_a) + candidates.add(face_id_b) + + res = [] + seen = set() + while candidates: + candidate = candidates.pop() + if candidate in seen or candidate < 0: + continue + seen.add(candidate) + + _, fnorm = self._mesh_transformed_cache.getFacePlane(candidate) + if numpy.dot(fnorm, self._cam_norm) < 0: # <- facing away from the viewer + continue + + va, vb, vc = self._mesh_transformed_cache.getFaceNodes(candidate) + stroke_tri = Polygon([ + get_projected_on_plane(va), + get_projected_on_plane(vb), + get_projected_on_plane(vc)]) + face_uv_coordinates = self._node_cache.getMeshData().getFaceUvCoords(candidate) + if face_uv_coordinates is None: + continue + ta, tb, tc = face_uv_coordinates + original_uv_poly = Polygon([ta, tb, tc]) + uv_area = stroke_poly.intersection(stroke_tri).map(lambda pt: PaintTool._remapBarycentric(stroke_tri, pt, original_uv_poly)) + + if not uv_area.isValid(): + continue + res.append(uv_area) + [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(candidate)] + return res def event(self, event: Event) -> bool: """Handle mouse and keyboard events. @@ -291,7 +330,6 @@ class PaintTool(Tool): """ super().event(event) - controller = Application.getInstance().getController() node = Selection.getSelectedObject(0) if node is None: return False @@ -310,8 +348,7 @@ class PaintTool(Tool): 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_world_coords = None self._last_face_id = None return True @@ -338,8 +375,9 @@ class PaintTool(Tool): if not self._picking_pass: return False - camera = self._controller.getScene().getActiveCamera() - if not camera: + if self._camera is None: + self._updateCamera() + if self._camera is None: return False if node != self._node_cache: @@ -354,35 +392,23 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y) - if texcoords is None: + face_id = self._faces_selection_pass.getFaceIdAtPosition(mouse_evt.x, mouse_evt.y) + if face_id < 0 or face_id >= self._mesh_transformed_cache.getFaceCount(): return False - if self._last_text_coords is None: - self._last_text_coords = texcoords - self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) + world_coords = self._picking_pass.getPickedPosition(mouse_evt.x, mouse_evt.y).getData() + + if self._last_world_coords is None: + self._last_world_coords = world_coords 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))) - + uv_areas = self._getUvAreasForStroke(self._last_face_id, face_id, self._last_world_coords, world_coords) + if len(uv_areas) == 0: + return False + stroke_img, (start_x, start_y) = self._createStrokeImage(uv_areas) brush_color = self._brush_color if self.getPaintType() != "extruder" else str(self._brush_extruder) - w, h = self._view.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 - ) - self._view.addStroke(sub_image, start_x, start_y, brush_color, is_moved) + self._view.addStroke(stroke_img, start_x, start_y, brush_color, is_moved) - self._last_text_coords = texcoords - self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) + self._last_world_coords = world_coords self._last_face_id = face_id self._updateScene(node) return True @@ -431,4 +457,4 @@ class PaintTool(Tool): def _updateIgnoreUnselectedObjects(self): ignore_unselected_objects = self._controller.getActiveView().name == "PaintTool" CuraApplication.getInstance().getRenderer().getRenderPass("selection").setIgnoreUnselectedObjects(ignore_unselected_objects) - CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(ignore_unselected_objects) \ No newline at end of file + CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(ignore_unselected_objects)