Cura/plugins/PaintTool/PaintTool.py
Remco Burema 704f9453f0 Properly completed drag to paint (no more just clicking points).
The most important thing to make it work is actually notifying the scene that something has changed -- the rest are just refactorings and (hopefully) optimizations.

part of CURA-12543
2025-05-27 17:03:38 +02:00

224 lines
8.2 KiB
Python

# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from copy import deepcopy
import numpy
from PyQt6.QtCore import Qt
from typing import cast, Dict, List, Optional, Tuple
from UM.Application import Application
from UM.Event import Event, MouseEvent, KeyEvent
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
from UM.Tool import Tool
from UM.View.View import View
from cura.PickingPass import PickingPass
class PaintTool(Tool):
"""Provides the tool to paint meshes.
"""
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._color_str_to_rgba: Dict[str, List[int]] = {
"A": [192, 0, 192, 255],
"B": [232, 128, 0, 255],
"C": [0, 255, 0, 255],
"D": [255, 255, 255, 255],
}
self._brush_size: int = 10
self._brush_color: str = "A"
self._brush_shape: str = "A"
self._mouse_held: bool = False
self._last_mouse_drag: Optional[Tuple[int, int]] = None
def setPaintType(self, paint_type: str) -> None:
Logger.warning(f"TODO: Implement paint-types ({paint_type}).")
pass
def setBrushSize(self, brush_size: float) -> None:
self._brush_size = int(brush_size)
def setBrushColor(self, brush_color: str) -> None:
self._brush_color = brush_color
def setBrushShape(self, brush_shape: str) -> None:
self._brush_shape = brush_shape
@staticmethod
def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float:
# compute the intersection of (param) A - pt with (param) B - (param) C
# compute unit vectors of directions of lines A and B
udir_a = a - pt
udir_a /= numpy.linalg.norm(udir_a)
udir_b = b - c
udir_b /= numpy.linalg.norm(udir_b)
# find unit direction vector for line C, which is perpendicular to lines A and B
udir_res = numpy.cross(udir_b, udir_a)
udir_res /= numpy.linalg.norm(udir_res)
# solve system of equations
rhs = b - a
lhs = numpy.array([udir_a, -udir_b, udir_res]).T
solved = numpy.linalg.solve(lhs, rhs)
# get the ratio
intersect = ((a + solved[0] * udir_a) + (b + solved[1] * udir_b)) * 0.5
return numpy.linalg.norm(pt - intersect) / numpy.linalg.norm(a - intersect)
def _nodeTransformChanged(self, *args) -> None:
self._cache_dirty = True
def _getBrushPixels(self, mid_x: float, mid_y: float, w: float, h: float) -> List[Tuple[float, float]]:
res = []
include = False
for y in range(-self._brush_size//2, (self._brush_size + 1)//2):
for x in range(-self._brush_size//2, (self._brush_size + 1)//2):
match self._brush_shape:
case "A":
include = True
case "B":
include = x * x + y * y <= self._brush_size * self._brush_size
if include:
xx = mid_x + (x / w)
yy = mid_y + (y / h)
if xx < 0 or xx > 1 or yy < 0 or yy > 1:
continue
res.append((xx, yy))
return res
def _handleMouseAction(self, node: SceneNode, paintview: View, x: int, y: int) -> bool:
face_id = self._selection_pass.getFaceIdAtPosition(x, y)
if face_id < 0:
return False
pt = self._picking_pass.getPickedPosition(x, y).getData()
va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id)
ta, tb, tc = node.getMeshData().getFaceUvCoords(face_id)
# 'Weight' of each vertex that would produce point pt, so we can generate the texture coordinates from the uv ones of the vertices.
# See (also) https://mathworld.wolfram.com/BarycentricCoordinates.html
wa = PaintTool._get_intersect_ratio_via_pt(va, pt, vb, vc)
wb = PaintTool._get_intersect_ratio_via_pt(vb, pt, vc, va)
wc = PaintTool._get_intersect_ratio_via_pt(vc, pt, va, vb)
wt = wa + wb + wc
wa /= wt
wb /= wt
wc /= wt
texcoords = wa * ta + wb * tb + wc * tc
color = self._color_str_to_rgba[self._brush_color]
w, h = paintview.getUvTexDimensions()
for (x, y) in self._getBrushPixels(texcoords[0], texcoords[1], float(w), float(h)):
paintview.setUvPixel(x, y, color)
return True
def event(self, event: Event) -> bool:
"""Handle mouse and keyboard events.
: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()
# 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.KeyPressEvent and cast(KeyEvent, event).key == KeyEvent.ShiftKey:
return False
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_mouse_drag = None
return True
is_moved = event.type == Event.MouseMoveEvent
is_pressed = event.type == Event.MousePressEvent
if (is_moved or is_pressed) and self._controller.getToolsEnabled():
if is_moved and not self._mouse_held:
return False
evt = cast(MouseEvent, event)
if is_pressed:
if MouseEvent.LeftButton not in evt.buttons:
return False
else:
self._mouse_held = True
drags = ([self._last_mouse_drag] if self._last_mouse_drag else []) + [(evt.x, evt.y)]
self._last_mouse_drag = (evt.x, evt.y)
paintview = controller.getActiveView()
if paintview is None or paintview.getPluginId() != "PaintTool":
return False
if not self._selection_pass:
return False
camera = self._controller.getScene().getActiveCamera()
if not camera:
return False
node = Selection.getAllSelectedObjects()[0]
if node is None:
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
evt = cast(MouseEvent, event)
drags.append((evt.x, evt.y))
if not self._picking_pass:
self._picking_pass = PickingPass(camera.getViewportWidth(), camera.getViewportHeight())
self._picking_pass.render()
self._selection_pass.renderFacesMode()
res = False
for (x, y) in drags:
res |= self._handleMouseAction(node, paintview, x, y)
if res:
Application.getInstance().getController().getScene().sceneChanged.emit(node)
return res
return False