From a746a60afb3057e7456c4c8dbce45155e386e406 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 12 Aug 2025 19:29:43 +0200 Subject: [PATCH 01/10] Intersection each polygon with the stroke; paint that instead of dumping it to UV directly. This prevents 'paint splatter', that is, previously, we had just a begin and an end point (actually we had a temp stopgap that iterated per triangle, but similar problems persisted), which we then mapped directly to UV and drew the stroke in that space. This causes the stroke to overlap parts of the UV-map that it didn't touch in 3D at all. Now, we instead gather each triangle, map that to the estimated stroke plane, intersect the result with the stroke-shape, then map the resulting polygon back to UV-space. At the moment this code isn't fully working yet -- but I solved all of the obvious things that can be wrong, and it could partially be that I based this branch off of a moment in time the other branch wasn't functionally properly. part of CURA-12262 --- plugins/PaintTool/PaintTool.py | 257 ++++++++++++++++++++++----------- 1 file changed, 172 insertions(+), 85 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index eaeb2dc69b..4e57bda6e9 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -3,16 +3,14 @@ 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.QtCore import Qt, QObject, pyqtEnum, QPoint +from PyQt6.QtGui import QImage, QPainter, QPen, QPolygon +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.Logger import Logger +from UM.Math.Polygon import Polygon from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool @@ -52,7 +50,7 @@ class PaintTool(Tool): self._mouse_held: bool = False - self._last_text_coords: Optional[numpy.ndarray] = None + self._last_world_coords: Optional[numpy.ndarray] = None self._last_mouse_coords: Optional[Tuple[int, int]] = None self._last_face_id: Optional[int] = None @@ -70,39 +68,40 @@ 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]]: + 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, pt) + max_pt = numpy.maximum(max_pt, pt) - 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 = QImage(int(max_pt[0] - min_pt[0]), int(max_pt[1] - min_pt[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)) + painter.setPen(self._brush_pen) # <-- TODO!: Wrong in the current context. + for poly in polys: + qpoly = QPolygon([QPoint(int(pt[0] - min_pt[0]), int(pt[1] - min_pt[1])) for pt in poly]) + painter.drawPolygon(qpoly) painter.end() - return stroke_image, (start_x, start_y) + return stroke_image, (int(min_pt[0]), int(min_pt[1])) def getPaintType(self) -> str: - paint_view = self._get_paint_view() + paint_view = self._getPaintView() if paint_view is None: return "" return paint_view.getPaintType() def setPaintType(self, paint_type: str) -> None: - paint_view = self._get_paint_view() + paint_view = self._getPaintView() if paint_view is None: return @@ -141,7 +140,7 @@ class PaintTool(Tool): self.propertyChanged.emit() def undoStackAction(self, redo_instead: bool) -> bool: - paint_view = self._get_paint_view() + paint_view = self._getPaintView() if paint_view is None: return False @@ -154,7 +153,7 @@ class PaintTool(Tool): return True def clear(self) -> None: - paintview = self._get_paint_view() + paintview = self._getPaintView() if paintview is None: return @@ -166,18 +165,32 @@ class PaintTool(Tool): self._updateScene() @staticmethod - def _get_paint_view() -> Optional[PaintView]: + def _getPaintView() -> 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: + def _getIntersectRatioViaPt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float: + """ Gets a single Barycentric coordinate of a point on a line segment. + + :param a: The start point of the line segment (one of the points of the triangle). + :param pt: The point to find the Barycentric coordinate of (the one for point c, w.r.t. the ab line segment). + :param b: The end point of the line segment (one of the points of the triangle). + :param c: The third point of the triangle. + :return: The Barycentric coordinate of pt, w.r.t. point c in the abc triangle, or 1.0 if outside that triangle. + """ + # 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): + if (a == pt).all() or (b == c).all() or (a == c).all() or (a == b).all(): return 1.0 + # force points to be 3d + def force3d(pt_: numpy.ndarray) -> numpy.ndarray: + return pt_ if pt_.size == 3 else numpy.array([pt_[0], pt_[1], 1.0]) + a, pt, b, c = force3d(a), force3d(pt), force3d(b), force3d(c) + # compute unit vectors of directions of lines A and B udir_a = a - pt udir_a /= numpy.linalg.norm(udir_a) @@ -209,10 +222,22 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True - def _getTexCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray]]: + def _getCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: + """ Retrieves coordinates based on a user's click on a 3D scene node. + + This function calculates and returns the face identifier, texture coordinates, and real-world coordinates + derived from a click on the scene associated with the provided node. + + :param node: The node in the 3D scene from which the clicks' interaction information is derived. + :param x: The horizontal position of the click. + :param y: The vertical position of the click. + :return: A tuple containing; face-id, texture (UV) coordinates, and real-world (3D) coordinates. + """ + face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y) + if face_id < 0 or face_id >= node.getMeshData().getFaceCount(): - return face_id, None + return face_id, None, None pt = self._picking_pass.getPickedPosition(x, y).getData() @@ -220,50 +245,124 @@ class PaintTool(Tool): face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id) if face_uv_coordinates is None: - return face_id, None + return face_id, None, None ta, tb, tc = face_uv_coordinates # '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) + wa = PaintTool._getIntersectRatioViaPt(va, pt, vb, vc) + wb = PaintTool._getIntersectRatioViaPt(vb, pt, vc, va) + wc = PaintTool._getIntersectRatioViaPt(vc, pt, va, vb) wt = wa + wb + wc if wt == 0: - return face_id, None + return face_id, None, None wa /= wt wb /= wt wc /= wt texcoords = wa * ta + wb * tb + wc * tc - return face_id, texcoords + realcoords = wa * va + wb * vb + wc * vc + return face_id, texcoords, realcoords - 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 + @staticmethod + def _remapBarycentric(triangle_a: Polygon, pt: numpy.ndarray, triangle_b: Polygon) -> numpy.ndarray: + wa = PaintTool._getIntersectRatioViaPt(triangle_a[0], pt, triangle_a[1], triangle_a[2]) + wb = PaintTool._getIntersectRatioViaPt(triangle_a[1], pt, triangle_a[2], triangle_a[0]) + wc = PaintTool._getIntersectRatioViaPt(triangle_a[2], pt, triangle_a[0], triangle_a[1]) + wt = wa + wb + wc + if wt == 0: + return triangle_b[0] # Shouldn't happen! + return wa/wt * triangle_b[0] + wb/wt * triangle_b[1] + wc/wt * triangle_b[2] - 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 + def _getStrokePolygon(self, mouse_a, mouse_b) -> Polygon: + a_x, a_y = mouse_a + b_x, b_y = mouse_b + shape = None + match self._brush_shape: + case PaintTool.Brush.Shape.SQUARE: + shape = Polygon.approximatedCircle(self._brush_size, 4) + case PaintTool.Brush.Shape.CIRCLE: + shape = Polygon.approximatedCircle(self._brush_size, 16) + case _: + Logger.error(f"Unknown brush shape '{self._brush_shape}'.") + if shape is None: + return Polygon() + return shape.translate(a_x, a_y).intersectionConvexHulls(shape.translate(b_x, b_y)) - 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) + # NOTE: Currently, this probably won't work 100% for non-convex brush-shapes. + def _getUvAreasForStroke(self, node: SceneNode, mouse_a, mouse_b, face_id_a, face_id_b, world_coords_a, world_coords_b) -> List[Polygon]: + """ Fetches all texture-coordinate areas within the provided stroke on the mesh (of the given node). + + Calculates intersections of the stroke with the surface of the geometry and maps them to UV-space polygons. + + :param node: The 3D scene node containing mesh data to evaluate. + :param mouse_a: The starting point of the stroke in screen-space. + :param mouse_b: The ending point of the stroke in screen-space. + :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. + """ + + if face_id_a == face_id_b: + mid, norm = self._mesh_transformed_cache.getFacePlane(face_id_a) + norm /= numpy.linalg.norm(norm) + perp = mid.cross(world_coords_a - mid) + perp /= numpy.linalg.norm(perp) else: - self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b) - self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct) + vec_ab = world_coords_b - world_coords_a + _, norm_a = self._mesh_transformed_cache.getFacePlane(face_id_a) + _, norm_b = self._mesh_transformed_cache.getFacePlane(face_id_b) + norm = (norm_a + norm_b) * 0.5 + norm /= numpy.linalg.norm(norm) + perp = numpy.cross(norm, vec_ab) + + def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray: + proj_pt = (pt - numpy.dot(norm, pt - world_coords_a) * norm) - world_coords_a + x_coord = numpy.dot(vec_ab, proj_pt) + y_coord = numpy.dot(perp, proj_pt) + return numpy.array([x_coord, y_coord]) + + def get_tri_in_stroke(a: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> Polygon: + return Polygon([ + get_projected_on_plane(a), + get_projected_on_plane(b), + get_projected_on_plane(c)]) + + def remap_polygon_to_uv(original_tri: Polygon, poly: Polygon, face_id: int) -> Polygon: + face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id) + if face_uv_coordinates is None: + return Polygon() + ta, tb, tc = face_uv_coordinates + original_uv_poly = Polygon([ta, tb, tc]) + return poly.map(lambda pt: PaintTool._remapBarycentric(original_tri, pt, original_uv_poly)) + + stroke_poly = self._getStrokePolygon(mouse_a, mouse_b) + + def get_stroke_intersect_with_tri(face_id: int) -> Polygon: + va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) + stroke_tri = get_tri_in_stroke(va, vb, vc) + return remap_polygon_to_uv(stroke_tri, stroke_poly.intersection(stroke_tri), face_id) + + candidates = set() + def add_candidates_for(face_id: int) -> None: + [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(face_id)] + add_candidates_for(face_id_a) + add_candidates_for(face_id_b) + + res = [] + seen = set() + while candidates: + candidate = candidates.pop() + if candidate in seen or candidate < 0: + continue + uv_area = get_stroke_intersect_with_tri(candidate) + if not uv_area.isValid(): + continue + res.append(uv_area) + add_candidates_for(candidate) + seen.add(candidate) + return res def _setupNodeForPainting(self, node: SceneNode) -> bool: mesh = node.getMeshData() @@ -312,7 +411,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_world_coords = None self._last_mouse_coords = None self._last_face_id = None return True @@ -330,7 +429,7 @@ class PaintTool(Tool): else: self._mouse_held = True - paintview = self._get_paint_view() + paintview = self._getPaintView() if paintview is None: return False @@ -363,33 +462,21 @@ class PaintTool(Tool): if not self._setupNodeForPainting(node): return False - face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y) - if texcoords is None: + face_id, _, world_coords = self._getCoordsFromClick(node, mouse_evt.x, mouse_evt.y) + if face_id < 0: return False - if self._last_text_coords is None: - self._last_text_coords = texcoords + if self._last_world_coords is None: + self._last_world_coords = world_coords 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))) + uv_areas = self._getUvAreasForStroke(node, self._last_mouse_coords, (mouse_evt.x, mouse_evt.y), 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) + paintview.addStroke(stroke_img, start_x, start_y, self._brush_color) - 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_world_coords = world_coords self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) self._last_face_id = face_id self._updateScene(node) From 392e6148871b2e84c1d74a220c67e1a9d05679ec Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 13 Aug 2025 11:09:57 +0200 Subject: [PATCH 02/10] Get PaintTool in a working state again after rewrite of projection. Various fixes applied; method of drawing changed a bit (use qpath, otherwise we can't fill, right-size the brush in various ways (w/h of UV-canvas taken into account, fix that 'stroke-coords' are _yet another_ coord system instead of the same as one of the existing) also, with the stroke; fix that it should have been a convex hull of the union, not a convex hull of the intersection -- also take the scale of the model into account (which we need to) (but this has a side-effect in that the brush is now always the same size, regardless of which size the object is -- makes sense, but not 100pct sure if wanted) -- various other things cleaned up etc. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 71 ++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 2285683b49..ae97416cf3 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -3,8 +3,8 @@ from enum import IntEnum import numpy -from PyQt6.QtCore import Qt, QObject, pyqtEnum, QPoint -from PyQt6.QtGui import QImage, QPainter, QPen, QPolygon +from PyQt6.QtCore import Qt, QObject, pyqtEnum +from PyQt6.QtGui import QImage, QPainter, QPen, QPainterPath from typing import cast, Optional, Tuple, List from UM.Application import Application @@ -15,7 +15,6 @@ from UM.Math.Polygon import Polygon 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 @@ -60,7 +59,6 @@ class PaintTool(Tool): self._mouse_held: bool = False self._last_world_coords: Optional[numpy.ndarray] = None - self._last_mouse_coords: Optional[Tuple[int, int]] = None self._last_face_id: Optional[int] = None self._state: PaintTool.Paint.State = PaintTool.Paint.State.MULTIPLE_SELECTION @@ -86,22 +84,28 @@ class PaintTool(Tool): return pen def _createStrokeImage(self, polys: List[Polygon]) -> Tuple[QImage, Tuple[int, int]]: + w, h = self._getPaintView().getUvTexDimensions() + if w == 0 or h == 0 or len(polys) == 0: + return QImage(w, h, QImage.Format.Format_RGB32), (0, 0) + 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, pt) - max_pt = numpy.maximum(max_pt, pt) + min_pt = numpy.minimum(min_pt, w * pt) + max_pt = numpy.maximum(max_pt, h * pt) stroke_image = QImage(int(max_pt[0] - min_pt[0]), int(max_pt[1] - min_pt[1]), QImage.Format.Format_RGB32) stroke_image.fill(0) painter = QPainter(stroke_image) painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) - painter.setPen(self._brush_pen) # <-- TODO!: Wrong in the current context. + path = QPainterPath() for poly in polys: - qpoly = QPolygon([QPoint(int(pt[0] - min_pt[0]), int(pt[1] - min_pt[1])) for pt in poly]) - painter.drawPolygon(qpoly) + path.moveTo(int(w * poly[0][0] - min_pt[0]), int(h * poly[0][1] - min_pt[1])) + for pt in poly[1:]: + path.lineTo(int(w * pt[0] - min_pt[0]), int(h * pt[1] - min_pt[1])) + painter.fillPath(path, self._brush_pen.color()) painter.end() return stroke_image, (int(min_pt[0]), int(min_pt[1])) @@ -289,30 +293,26 @@ class PaintTool(Tool): return triangle_b[0] # Shouldn't happen! return wa/wt * triangle_b[0] + wb/wt * triangle_b[1] + wc/wt * triangle_b[2] - def _getStrokePolygon(self, mouse_a, mouse_b) -> Polygon: - a_x, a_y = mouse_a - b_x, b_y = mouse_b + 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, 4) + shape = Polygon.approximatedCircle(self._brush_size * size_adjust, 4) case PaintTool.Brush.Shape.CIRCLE: - shape = Polygon.approximatedCircle(self._brush_size, 16) + 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(a_x, a_y).intersectionConvexHulls(shape.translate(b_x, b_y)) + return shape.translate(stroke_a[0], stroke_a[1]).unionConvexHulls(shape.translate(stroke_b[0], stroke_b[1])) - # NOTE: Currently, this probably won't work 100% for non-convex brush-shapes. - def _getUvAreasForStroke(self, node: SceneNode, mouse_a, mouse_b, face_id_a, face_id_b, world_coords_a, world_coords_b) -> List[Polygon]: + # NOTE: Currently, it's unclear how well this would work for non-convex brush-shapes. + def _getUvAreasForStroke(self, node: SceneNode, 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 (of the given node). Calculates intersections of the stroke with the surface of the geometry and maps them to UV-space polygons. :param node: The 3D scene node containing mesh data to evaluate. - :param mouse_a: The starting point of the stroke in screen-space. - :param mouse_b: The ending point of the stroke in screen-space. :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. @@ -320,11 +320,14 @@ class PaintTool(Tool): :return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface. """ - if face_id_a == face_id_b: + if (face_id_a == face_id_b) and (world_coords_a == world_coords_b).all(): + # TODO: this doesn't work yet... mid, norm = self._mesh_transformed_cache.getFacePlane(face_id_a) norm /= numpy.linalg.norm(norm) perp = mid.cross(world_coords_a - mid) perp /= numpy.linalg.norm(perp) + vec_ab = norm.cross(perp) + vec_ab /= numpy.linalg.norm(vec_ab) else: vec_ab = world_coords_b - world_coords_a _, norm_a = self._mesh_transformed_cache.getFacePlane(face_id_a) @@ -353,7 +356,17 @@ class PaintTool(Tool): original_uv_poly = Polygon([ta, tb, tc]) return poly.map(lambda pt: PaintTool._remapBarycentric(original_tri, pt, original_uv_poly)) - stroke_poly = self._getStrokePolygon(mouse_a, mouse_b) + stroke_len = numpy.linalg.norm(vec_ab) + + uv_a0, uv_a1, _ = node.getMeshData().getFaceUvCoords(face_id_a) + w_a0, w_a1, _ = node.getMeshData().getFaceNodes(face_id_a) + w_scale = node.getScale().getData() + world_to_uv_size_factor = numpy.linalg.norm(uv_a1 - uv_a0) / numpy.linalg.norm(w_a1 * w_scale - w_a0 * w_scale) + + stroke_poly = self._getStrokePolygon( + stroke_len * world_to_uv_size_factor, + get_projected_on_plane(world_coords_a), + get_projected_on_plane(world_coords_b)) def get_stroke_intersect_with_tri(face_id: int) -> Polygon: va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) @@ -361,10 +374,11 @@ class PaintTool(Tool): return remap_polygon_to_uv(stroke_tri, stroke_poly.intersection(stroke_tri), face_id) candidates = set() - def add_candidates_for(face_id: int) -> None: + candidates.add(face_id_a) + candidates.add(face_id_b) + + def add_adjacent_candidates(face_id: int) -> None: [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(face_id)] - add_candidates_for(face_id_a) - add_candidates_for(face_id_b) res = [] seen = set() @@ -376,7 +390,7 @@ class PaintTool(Tool): if not uv_area.isValid(): continue res.append(uv_area) - add_candidates_for(candidate) + add_adjacent_candidates(candidate) seen.add(candidate) return res @@ -409,7 +423,6 @@ class PaintTool(Tool): return False self._mouse_held = False self._last_world_coords = None - self._last_mouse_coords = None self._last_face_id = None return True @@ -461,17 +474,15 @@ class PaintTool(Tool): return False if self._last_world_coords is None: self._last_world_coords = world_coords - self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) self._last_face_id = face_id - uv_areas = self._getUvAreasForStroke(node, self._last_mouse_coords, (mouse_evt.x, mouse_evt.y), self._last_face_id, face_id, self._last_world_coords, world_coords) + uv_areas = self._getUvAreasForStroke(node, 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) paintview.addStroke(stroke_img, start_x, start_y, self._brush_color) self._last_world_coords = world_coords - self._last_mouse_coords = (mouse_evt.x, mouse_evt.y) self._last_face_id = face_id self._updateScene(node) return True @@ -520,4 +531,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) From b091cc23a1ce2b1be7c110bd1b1fb343cef7cf98 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Thu, 14 Aug 2025 14:30:12 +0200 Subject: [PATCH 03/10] Have the projection of the paint-stroke involve the camera after all. This fixes the issue that it wouldn't flood over the 'rim' whenever the endpoints of a stroke was wholly within one plane, but the stroke itself would overlapp another. Plus also handle click on single point and use transformed mesh consistantly. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 71 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index ae97416cf3..1eacc1cdab 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -12,6 +12,7 @@ from UM.Event import Event, MouseEvent from UM.Job import Job from UM.Logger import Logger from UM.Math.Polygon import Polygon +from UM.Mesh.MeshData import MeshData from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool @@ -242,7 +243,7 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True - def _getCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: + def _getCoordsFromClick(self, mesh: MeshData, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: """ Retrieves coordinates based on a user's click on a 3D scene node. This function calculates and returns the face identifier, texture coordinates, and real-world coordinates @@ -256,14 +257,14 @@ class PaintTool(Tool): face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y) - if face_id < 0 or face_id >= node.getMeshData().getFaceCount(): + if face_id < 0 or face_id >= mesh.getFaceCount(): return face_id, None, None pt = self._picking_pass.getPickedPosition(x, y).getData() va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) - face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id) + face_uv_coordinates = mesh.getFaceUvCoords(face_id) if face_uv_coordinates is None: return face_id, None, None ta, tb, tc = face_uv_coordinates @@ -307,12 +308,12 @@ class PaintTool(Tool): 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, node: SceneNode, 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 (of the given node). + def _getUvAreasForStroke(self, mesh: MeshData, 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 node: The 3D scene node containing mesh data to evaluate. + :param mesh: The 3D mesh data, pre-transformed. :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. @@ -320,26 +321,25 @@ class PaintTool(Tool): :return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface. """ - if (face_id_a == face_id_b) and (world_coords_a == world_coords_b).all(): - # TODO: this doesn't work yet... - mid, norm = self._mesh_transformed_cache.getFacePlane(face_id_a) - norm /= numpy.linalg.norm(norm) - perp = mid.cross(world_coords_a - mid) - perp /= numpy.linalg.norm(perp) - vec_ab = norm.cross(perp) - vec_ab /= numpy.linalg.norm(vec_ab) - else: - vec_ab = world_coords_b - world_coords_a - _, norm_a = self._mesh_transformed_cache.getFacePlane(face_id_a) - _, norm_b = self._mesh_transformed_cache.getFacePlane(face_id_b) - norm = (norm_a + norm_b) * 0.5 - norm /= numpy.linalg.norm(norm) - perp = numpy.cross(norm, vec_ab) + camera = Application.getInstance().getController().getScene().getActiveCamera() + cam_ray = camera.getRay(0, 0) + cam_norm = cam_ray.direction.getData() + cam_norm /= numpy.linalg.norm(cam_norm) + + if (world_coords_a == world_coords_b).all(): + world_coords_b = world_coords_a + numpy.array([0.01, -0.01, 0.01]) + + vec_ab = world_coords_b - world_coords_a + stroke_dir = vec_ab / numpy.linalg.norm(vec_ab) + norm = -cam_norm + norm /= numpy.linalg.norm(norm) + perp = numpy.cross(norm, stroke_dir) + perp /= numpy.linalg.norm(perp) def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray: proj_pt = (pt - numpy.dot(norm, pt - world_coords_a) * norm) - world_coords_a - x_coord = numpy.dot(vec_ab, proj_pt) - y_coord = numpy.dot(perp, proj_pt) + y_coord = numpy.dot(stroke_dir, proj_pt) + x_coord = numpy.dot(perp, proj_pt) return numpy.array([x_coord, y_coord]) def get_tri_in_stroke(a: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> Polygon: @@ -349,27 +349,24 @@ class PaintTool(Tool): get_projected_on_plane(c)]) def remap_polygon_to_uv(original_tri: Polygon, poly: Polygon, face_id: int) -> Polygon: - face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id) + face_uv_coordinates = mesh.getFaceUvCoords(face_id) if face_uv_coordinates is None: return Polygon() ta, tb, tc = face_uv_coordinates original_uv_poly = Polygon([ta, tb, tc]) return poly.map(lambda pt: PaintTool._remapBarycentric(original_tri, pt, original_uv_poly)) - stroke_len = numpy.linalg.norm(vec_ab) - - uv_a0, uv_a1, _ = node.getMeshData().getFaceUvCoords(face_id_a) - w_a0, w_a1, _ = node.getMeshData().getFaceNodes(face_id_a) - w_scale = node.getScale().getData() - world_to_uv_size_factor = numpy.linalg.norm(uv_a1 - uv_a0) / numpy.linalg.norm(w_a1 * w_scale - w_a0 * w_scale) + uv_a0, uv_a1, _ = mesh.getFaceUvCoords(face_id_a) + w_a0, w_a1, _ = mesh.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( - stroke_len * world_to_uv_size_factor, + world_to_uv_size_factor, get_projected_on_plane(world_coords_a), get_projected_on_plane(world_coords_b)) def get_stroke_intersect_with_tri(face_id: int) -> Polygon: - va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) + va, vb, vc = mesh.getFaceNodes(face_id) stroke_tri = get_tri_in_stroke(va, vb, vc) return remap_polygon_to_uv(stroke_tri, stroke_poly.intersection(stroke_tri), face_id) @@ -380,11 +377,15 @@ class PaintTool(Tool): def add_adjacent_candidates(face_id: int) -> None: [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(face_id)] + def wrong_face_normal(face_id: int) -> bool: + _, fnorm = mesh.getFacePlane(face_id) + return numpy.dot(fnorm, norm) < 0 + res = [] seen = set() while candidates: candidate = candidates.pop() - if candidate in seen or candidate < 0: + if candidate in seen or candidate < 0 or wrong_face_normal(candidate): continue uv_area = get_stroke_intersect_with_tri(candidate) if not uv_area.isValid(): @@ -469,14 +470,14 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - face_id, _, world_coords = self._getCoordsFromClick(node, mouse_evt.x, mouse_evt.y) + face_id, _, world_coords = self._getCoordsFromClick(self._mesh_transformed_cache, mouse_evt.x, mouse_evt.y) if face_id < 0: return False if self._last_world_coords is None: self._last_world_coords = world_coords self._last_face_id = face_id - uv_areas = self._getUvAreasForStroke(node, self._last_face_id, face_id, self._last_world_coords, world_coords) + uv_areas = self._getUvAreasForStroke(self._mesh_transformed_cache, 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) From 6da4ca66a89c2e01b92f38a42b633dc89628ee49 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Mon, 18 Aug 2025 21:05:09 +0200 Subject: [PATCH 04/10] Painting: Have a sensible coordinate system. Using the camera is more standard, and while it has some downsides ('smearing'/large skews on surfaces angled away from the camera) it seems to have the least quirks (or at least ones that users are used to already). -- Also the what was here previously was just wrong, I'm just saying it could in theory have been solved the way I originally wanted to, but that'd take a long time to get completely right and efficient. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 1eacc1cdab..207a66ad79 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -321,25 +321,22 @@ class PaintTool(Tool): :return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface. """ + # TODO: Cache this until the camera moves again. camera = Application.getInstance().getController().getScene().getActiveCamera() + cam_pos = camera.getPosition().getData() cam_ray = camera.getRay(0, 0) - cam_norm = cam_ray.direction.getData() - cam_norm /= numpy.linalg.norm(cam_norm) - - if (world_coords_a == world_coords_b).all(): - world_coords_b = world_coords_a + numpy.array([0.01, -0.01, 0.01]) - - vec_ab = world_coords_b - world_coords_a - stroke_dir = vec_ab / numpy.linalg.norm(vec_ab) - norm = -cam_norm - norm /= numpy.linalg.norm(norm) - perp = numpy.cross(norm, stroke_dir) - perp /= numpy.linalg.norm(perp) + norm = cam_ray.direction.getData() + norm /= -numpy.linalg.norm(norm) + axis_up = numpy.array([0.0, -1.0, 0.0]) if abs(norm[1]) < abs(norm[2]) else numpy.array([0.0, 0.0, 1.0]) + axis_q = numpy.cross(norm, axis_up) + axis_q /= numpy.linalg.norm(axis_q) + axis_r = numpy.cross(axis_q, norm) + axis_r /= numpy.linalg.norm(axis_r) def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray: - proj_pt = (pt - numpy.dot(norm, pt - world_coords_a) * norm) - world_coords_a - y_coord = numpy.dot(stroke_dir, proj_pt) - x_coord = numpy.dot(perp, proj_pt) + proj_pt = (pt - numpy.dot(norm, pt - cam_pos) * norm) - cam_pos + y_coord = numpy.dot(axis_r, proj_pt) + x_coord = numpy.dot(axis_q, proj_pt) return numpy.array([x_coord, y_coord]) def get_tri_in_stroke(a: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> Polygon: From d339e092e780019246b0b59b9b1b1b6396ca93bd Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Mon, 18 Aug 2025 22:35:18 +0200 Subject: [PATCH 05/10] Painting: Make fill-polygon miss less gaps. ... but one of the problems is that it doesn't give an outline. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 207a66ad79..103670980d 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -96,20 +96,20 @@ class PaintTool(Tool): min_pt = numpy.minimum(min_pt, w * pt) max_pt = numpy.maximum(max_pt, h * pt) - stroke_image = QImage(int(max_pt[0] - min_pt[0]), int(max_pt[1] - min_pt[1]), 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) path = QPainterPath() for poly in polys: - path.moveTo(int(w * poly[0][0] - min_pt[0]), int(h * poly[0][1] - min_pt[1])) + 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(w * pt[0] - min_pt[0]), int(h * pt[1] - min_pt[1])) + path.lineTo(int(0.5 + w * pt[0] - min_pt[0]), int(0.5 + h * pt[1] - min_pt[1])) painter.fillPath(path, self._brush_pen.color()) painter.end() - return stroke_image, (int(min_pt[0]), int(min_pt[1])) + return stroke_image, (int(min_pt[0] + 0.5), int(min_pt[1] + 0.5)) def getPaintType(self) -> str: paint_view = self._getPaintView() From 28dc0cd32ad51395291eb814256130970afa5286 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 19 Aug 2025 08:55:55 +0200 Subject: [PATCH 06/10] PaintTool: Optimization refactors. Don't have so many function-calls in performant code and cache camera-related variables until the camera moves again. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 91 +++++++++++++++++----------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 103670980d..4e1efd57f0 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -13,6 +13,7 @@ from UM.Job import Job from UM.Logger import Logger from UM.Math.Polygon import Polygon from UM.Mesh.MeshData import MeshData +from UM.Scene.Camera import Camera from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection from UM.Tool import Tool @@ -70,6 +71,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) -> 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) @@ -321,38 +342,12 @@ class PaintTool(Tool): :return: A list of UV-mapped polygons representing areas intersected by the stroke on the node's mesh surface. """ - # TODO: Cache this until the camera moves again. - camera = Application.getInstance().getController().getScene().getActiveCamera() - cam_pos = camera.getPosition().getData() - cam_ray = camera.getRay(0, 0) - norm = cam_ray.direction.getData() - norm /= -numpy.linalg.norm(norm) - axis_up = numpy.array([0.0, -1.0, 0.0]) if abs(norm[1]) < abs(norm[2]) else numpy.array([0.0, 0.0, 1.0]) - axis_q = numpy.cross(norm, axis_up) - axis_q /= numpy.linalg.norm(axis_q) - axis_r = numpy.cross(axis_q, norm) - axis_r /= numpy.linalg.norm(axis_r) - def get_projected_on_plane(pt: numpy.ndarray) -> numpy.ndarray: - proj_pt = (pt - numpy.dot(norm, pt - cam_pos) * norm) - cam_pos - y_coord = numpy.dot(axis_r, proj_pt) - x_coord = numpy.dot(axis_q, proj_pt) + 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]) - def get_tri_in_stroke(a: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> Polygon: - return Polygon([ - get_projected_on_plane(a), - get_projected_on_plane(b), - get_projected_on_plane(c)]) - - def remap_polygon_to_uv(original_tri: Polygon, poly: Polygon, face_id: int) -> Polygon: - face_uv_coordinates = mesh.getFaceUvCoords(face_id) - if face_uv_coordinates is None: - return Polygon() - ta, tb, tc = face_uv_coordinates - original_uv_poly = Polygon([ta, tb, tc]) - return poly.map(lambda pt: PaintTool._remapBarycentric(original_tri, pt, original_uv_poly)) - uv_a0, uv_a1, _ = mesh.getFaceUvCoords(face_id_a) w_a0, w_a1, _ = mesh.getFaceNodes(face_id_a) world_to_uv_size_factor = numpy.linalg.norm(uv_a1 - uv_a0) / numpy.linalg.norm(w_a1 - w_a0) @@ -362,33 +357,36 @@ class PaintTool(Tool): get_projected_on_plane(world_coords_a), get_projected_on_plane(world_coords_b)) - def get_stroke_intersect_with_tri(face_id: int) -> Polygon: - va, vb, vc = mesh.getFaceNodes(face_id) - stroke_tri = get_tri_in_stroke(va, vb, vc) - return remap_polygon_to_uv(stroke_tri, stroke_poly.intersection(stroke_tri), face_id) - candidates = set() candidates.add(face_id_a) candidates.add(face_id_b) - def add_adjacent_candidates(face_id: int) -> None: - [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(face_id)] - - def wrong_face_normal(face_id: int) -> bool: - _, fnorm = mesh.getFacePlane(face_id) - return numpy.dot(fnorm, norm) < 0 - res = [] seen = set() while candidates: candidate = candidates.pop() - if candidate in seen or candidate < 0 or wrong_face_normal(candidate): + if candidate in seen or candidate < 0: continue - uv_area = get_stroke_intersect_with_tri(candidate) + _, fnorm = mesh.getFacePlane(candidate) + if numpy.dot(fnorm, self._cam_norm) < 0: # <- facing away from the viewer + continue + + va, vb, vc = mesh.getFaceNodes(candidate) + stroke_tri = Polygon([ + get_projected_on_plane(va), + get_projected_on_plane(vb), + get_projected_on_plane(vc)]) + face_uv_coordinates = mesh.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) - add_adjacent_candidates(candidate) + [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(candidate)] seen.add(candidate) return res @@ -451,8 +449,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: From 9bc1294ce176e8a1f395f81c0c49983dd09728e8 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 19 Aug 2025 09:08:09 +0200 Subject: [PATCH 07/10] Remove useless parameters (use already cached values instead). part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 4e1efd57f0..3b6ba9a811 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -77,7 +77,7 @@ class PaintTool(Tool): 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) -> None: + def _updateCamera(self, *args) -> None: if self._camera is None: self._camera = Application.getInstance().getController().getScene().getActiveCamera() self._camera.transformationChanged.connect(self._updateCamera) @@ -264,7 +264,7 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True - def _getCoordsFromClick(self, mesh: MeshData, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: + def _getCoordsFromClick(self, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: """ Retrieves coordinates based on a user's click on a 3D scene node. This function calculates and returns the face identifier, texture coordinates, and real-world coordinates @@ -278,14 +278,14 @@ class PaintTool(Tool): face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y) - if face_id < 0 or face_id >= mesh.getFaceCount(): + if face_id < 0 or face_id >= self._mesh_transformed_cache.getFaceCount(): return face_id, None, None pt = self._picking_pass.getPickedPosition(x, y).getData() va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) - face_uv_coordinates = mesh.getFaceUvCoords(face_id) + face_uv_coordinates = self._node_cache.getMeshData().getFaceUvCoords(face_id) if face_uv_coordinates is None: return face_id, None, None ta, tb, tc = face_uv_coordinates @@ -329,12 +329,11 @@ class PaintTool(Tool): 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, mesh: MeshData, face_id_a: int, face_id_b: int, world_coords_a: numpy.ndarray, world_coords_b: numpy.ndarray) -> List[Polygon]: + 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 mesh: The 3D mesh data, pre-transformed. :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. @@ -348,8 +347,8 @@ class PaintTool(Tool): x_coord = numpy.dot(self._cam_axis_q, proj_pt) return numpy.array([x_coord, y_coord]) - uv_a0, uv_a1, _ = mesh.getFaceUvCoords(face_id_a) - w_a0, w_a1, _ = mesh.getFaceNodes(face_id_a) + 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( @@ -367,16 +366,16 @@ class PaintTool(Tool): candidate = candidates.pop() if candidate in seen or candidate < 0: continue - _, fnorm = mesh.getFacePlane(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 = mesh.getFaceNodes(candidate) + 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 = mesh.getFaceUvCoords(candidate) + face_uv_coordinates = self._node_cache.getMeshData().getFaceUvCoords(candidate) if face_uv_coordinates is None: continue ta, tb, tc = face_uv_coordinates @@ -466,14 +465,14 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - face_id, _, world_coords = self._getCoordsFromClick(self._mesh_transformed_cache, mouse_evt.x, mouse_evt.y) + face_id, _, world_coords = self._getCoordsFromClick(mouse_evt.x, mouse_evt.y) if face_id < 0: return False if self._last_world_coords is None: self._last_world_coords = world_coords self._last_face_id = face_id - uv_areas = self._getUvAreasForStroke(self._mesh_transformed_cache, self._last_face_id, face_id, self._last_world_coords, world_coords) + 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) From 1f60829959228f81bf46ba00e6ced883202b6776 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 20 Aug 2025 09:21:04 +0200 Subject: [PATCH 08/10] Cleaning, small optimizations. part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 54 +++++----------------------------- 1 file changed, 8 insertions(+), 46 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index 3b6ba9a811..ec6c5168c7 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -228,9 +228,9 @@ class PaintTool(Tool): if (a == pt).all() or (b == c).all() or (a == c).all() or (a == b).all(): return 1.0 - # force points to be 3d + # force points to be 3d (and double-precision) def force3d(pt_: numpy.ndarray) -> numpy.ndarray: - return pt_ if pt_.size == 3 else numpy.array([pt_[0], pt_[1], 1.0]) + return pt_.astype(dtype=numpy.float64) if pt_.size >= 3 else numpy.array([pt_[0], pt_[1], 0.0], dtype=numpy.float64) a, pt, b, c = force3d(a), force3d(pt), force3d(b), force3d(c) # compute unit vectors of directions of lines A and B @@ -264,47 +264,6 @@ class PaintTool(Tool): def _nodeTransformChanged(self, *args) -> None: self._cache_dirty = True - def _getCoordsFromClick(self, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray], Optional[numpy.ndarray]]: - """ Retrieves coordinates based on a user's click on a 3D scene node. - - This function calculates and returns the face identifier, texture coordinates, and real-world coordinates - derived from a click on the scene associated with the provided node. - - :param node: The node in the 3D scene from which the clicks' interaction information is derived. - :param x: The horizontal position of the click. - :param y: The vertical position of the click. - :return: A tuple containing; face-id, texture (UV) coordinates, and real-world (3D) coordinates. - """ - - face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y) - - if face_id < 0 or face_id >= self._mesh_transformed_cache.getFaceCount(): - return face_id, None, None - - pt = self._picking_pass.getPickedPosition(x, y).getData() - - va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id) - - face_uv_coordinates = self._node_cache.getMeshData().getFaceUvCoords(face_id) - if face_uv_coordinates is None: - return face_id, None, None - ta, tb, tc = face_uv_coordinates - - # '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._getIntersectRatioViaPt(va, pt, vb, vc) - wb = PaintTool._getIntersectRatioViaPt(vb, pt, vc, va) - wc = PaintTool._getIntersectRatioViaPt(vc, pt, va, vb) - wt = wa + wb + wc - if wt == 0: - return face_id, None, None - wa /= wt - wb /= wt - wc /= wt - texcoords = wa * ta + wb * tb + wc * tc - realcoords = wa * va + wb * vb + wc * vc - return face_id, texcoords, realcoords - @staticmethod def _remapBarycentric(triangle_a: Polygon, pt: numpy.ndarray, triangle_b: Polygon) -> numpy.ndarray: wa = PaintTool._getIntersectRatioViaPt(triangle_a[0], pt, triangle_a[1], triangle_a[2]) @@ -366,6 +325,8 @@ class PaintTool(Tool): 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 @@ -386,7 +347,6 @@ class PaintTool(Tool): continue res.append(uv_area) [candidates.add(x) for x in self._mesh_transformed_cache.getFaceNeighbourIDs(candidate)] - seen.add(candidate) return res def event(self, event: Event) -> bool: @@ -465,9 +425,11 @@ class PaintTool(Tool): if not self._mesh_transformed_cache: return False - face_id, _, world_coords = self._getCoordsFromClick(mouse_evt.x, mouse_evt.y) - if face_id < 0: + 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 + 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 From 8bab847594e078461d02b6cab22b791d94148b5e Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 20 Aug 2025 15:13:03 +0200 Subject: [PATCH 09/10] Remap-barycentric was slowing things down; optimize. It was using the old (well, relatively speaking ... from the start of development of the current paint-epic) getIntersectRatioViaPt (three times no less), which was massive overkill for what we're trying to accomplish here. done as part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 79 +++++++++++----------------------- 1 file changed, 24 insertions(+), 55 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index ec6c5168c7..e73a85f93c 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -213,66 +213,35 @@ class PaintTool(Tool): return None return cast(PaintView, paint_view) - @staticmethod - def _getIntersectRatioViaPt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float: - """ Gets a single Barycentric coordinate of a point on a line segment. - - :param a: The start point of the line segment (one of the points of the triangle). - :param pt: The point to find the Barycentric coordinate of (the one for point c, w.r.t. the ab line segment). - :param b: The end point of the line segment (one of the points of the triangle). - :param c: The third point of the triangle. - :return: The Barycentric coordinate of pt, w.r.t. point c in the abc triangle, or 1.0 if outside that triangle. - """ - - # compute the intersection of (param) A - pt with (param) B - (param) C - if (a == pt).all() or (b == c).all() or (a == c).all() or (a == b).all(): - return 1.0 - - # force points to be 3d (and double-precision) - def force3d(pt_: numpy.ndarray) -> numpy.ndarray: - return pt_.astype(dtype=numpy.float64) if pt_.size >= 3 else numpy.array([pt_[0], pt_[1], 0.0], dtype=numpy.float64) - a, pt, b, c = force3d(a), force3d(pt), force3d(b), force3d(c) - - # compute unit vectors of directions of lines A and B - udir_a = a - pt - udir_a /= numpy.linalg.norm(udir_a) - udir_b = b - c - udir_b /= numpy.linalg.norm(udir_b) - - # find unit direction vector for line C, which is perpendicular to lines A and B - udir_res = numpy.cross(udir_b, udir_a) - udir_res_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 @staticmethod def _remapBarycentric(triangle_a: Polygon, pt: numpy.ndarray, triangle_b: Polygon) -> numpy.ndarray: - wa = PaintTool._getIntersectRatioViaPt(triangle_a[0], pt, triangle_a[1], triangle_a[2]) - wb = PaintTool._getIntersectRatioViaPt(triangle_a[1], pt, triangle_a[2], triangle_a[0]) - wc = PaintTool._getIntersectRatioViaPt(triangle_a[2], pt, triangle_a[0], triangle_a[1]) - wt = wa + wb + wc - if wt == 0: - return triangle_b[0] # Shouldn't happen! - return wa/wt * triangle_b[0] + wb/wt * triangle_b[1] + wc/wt * triangle_b[2] + a1, b1, c1 = triangle_a + a2, b2, c2 = triangle_b + + area_full = 0.5 * numpy.linalg.norm(numpy.cross(b1 - a1, c1 - a1)) + + if area_full < 1e-6: # Degenerate triangle + return a2 + + # 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)) + + u = area_a / area_full + v = area_b / area_full + w = area_c / area_full + + total = u + v + w + if abs(total - 1.0) > 1e-6: + u /= total + v /= total + w /= total + + return u * a2 + v * b2 + w * c2 def _getStrokePolygon(self, size_adjust: float, stroke_a: numpy.ndarray, stroke_b: numpy.ndarray) -> Polygon: shape = None From fec24bfb19d3659022d4648b205d14febf0b8d29 Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Wed, 20 Aug 2025 16:38:02 +0200 Subject: [PATCH 10/10] Make sure the paint-strokes are as solid as before. For fixing the paint-splatter issue Iswitched to a new way of drawing the polygon, which could result in missed strokes of pixels at the edge of (UV) polygons. This change should catch most of the remaining X% where that happened. done as part of CURA-12662 --- plugins/PaintTool/PaintTool.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/PaintTool/PaintTool.py b/plugins/PaintTool/PaintTool.py index e73a85f93c..d6937e4845 100644 --- a/plugins/PaintTool/PaintTool.py +++ b/plugins/PaintTool/PaintTool.py @@ -4,7 +4,7 @@ from enum import IntEnum import numpy from PyQt6.QtCore import Qt, QObject, pyqtEnum -from PyQt6.QtGui import QImage, QPainter, QPen, QPainterPath +from PyQt6.QtGui import QImage, QPainter, QPen, QPainterPath, QPainterPathStroker from typing import cast, Optional, Tuple, List from UM.Application import Application @@ -12,7 +12,6 @@ from UM.Event import Event, MouseEvent from UM.Job import Job from UM.Logger import Logger from UM.Math.Polygon import Polygon -from UM.Mesh.MeshData import MeshData from UM.Scene.Camera import Camera from UM.Scene.SceneNode import SceneNode from UM.Scene.Selection import Selection @@ -127,7 +126,10 @@ class PaintTool(Tool): 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])) - painter.fillPath(path, self._brush_pen.color()) + 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, (int(min_pt[0] + 0.5), int(min_pt[1] + 0.5))