From df8b7521b16219748046220af1444932c0869fec Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Thu, 10 Sep 2026 09:21:21 +0200 Subject: [PATCH] Port part of model-checking over to libuvula for speed. part of CURA-13259 --- plugins/ModelChecker/ModelCheckerJob.py | 4 +- plugins/ModelChecker/OverhangChecker.py | 101 ++++-------------------- 2 files changed, 17 insertions(+), 88 deletions(-) diff --git a/plugins/ModelChecker/ModelCheckerJob.py b/plugins/ModelChecker/ModelCheckerJob.py index c39b2b1a165..872ce96c8a1 100644 --- a/plugins/ModelChecker/ModelCheckerJob.py +++ b/plugins/ModelChecker/ModelCheckerJob.py @@ -1,6 +1,6 @@ # Copyright (c) 2026 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. -from typing import Generator, Set, Dict +from typing import Generator, Dict from PyQt6.QtCore import QObject, pyqtSignal @@ -123,7 +123,7 @@ def _checkForOverhangs(self) -> bool: continue # Actually check the model itself for any overhanging structures that'd need to be supported. - if OverhangChecker.checkForDownFaces(node) or OverhangChecker.checkForDownVertices(node): + if OverhangChecker.checkDownwardsFeatures(node): warning_nodes.append(node) if len(warning_nodes) <= 0: diff --git a/plugins/ModelChecker/OverhangChecker.py b/plugins/ModelChecker/OverhangChecker.py index ae7be5372fe..75583e12a32 100644 --- a/plugins/ModelChecker/OverhangChecker.py +++ b/plugins/ModelChecker/OverhangChecker.py @@ -2,15 +2,14 @@ # Cura is released under the terms of the LGPLv3 or higher. import math -from typing import Set, Tuple import numpy +import pyUvula as uvula from UM.Application import Application from UM.Scene.SceneNode import SceneNode from cura.Settings.GlobalStack import GlobalStack - def getSupportAngle() -> float: """Retrieves the minimum angle needed in order for support to be generated in radians.""" global_container_stack: GlobalStack = Application.getInstance().getGlobalContainerStack() @@ -34,91 +33,21 @@ def getMinSupportArea() -> float: return global_container_stack.getValue("minimum_support_area") if global_container_stack else 4.0 -def checkForDownFaces(node: SceneNode) -> bool: +def checkDownwardsFeatures(node: SceneNode) -> bool: support_angle = getSupportAngle() close_to_buildplate = getCloseToBuildplateDistance() min_support_area = getMinSupportArea() - - meshdata = node.getMeshDataTransformed() - face_count = meshdata.getFaceCount() - - candidate_overhangs = {} - for i_face in range(face_count): - pos, face_norm = meshdata.getFacePlane(i_face) - - # Check the angle. - angle = -math.asin(face_norm[1]) if -1.0 <= face_norm[1] <= 1.0 else 0.0 - if angle < support_angle: - continue - - # Check if the face is too close to (or underneath) the build-plate to 'count'. - a, b, c = meshdata.getFaceNodes(i_face) - if max(float(a[1]), float(b[1]), float(c[1])) < close_to_buildplate: - continue - - # Collect the area for further analysis. - area = 0.5 * numpy.linalg.norm(numpy.abs(numpy.cross(b - a, c - a))) - candidate_overhangs[i_face] = area - - if len(candidate_overhangs) <= 0: - return False - - visited = set() - def _collect_neighbours(face_idx: int) -> Set: - result = {face_idx} - visited.add(face_idx) - nb_face_ids = meshdata.getFaceNeighbourIDs(face_idx) - for nb_face in nb_face_ids: - if nb_face not in candidate_overhangs or nb_face in visited: - continue - result = result.union(_collect_neighbours(nb_face)) - return result - - # Is there a big enough area that needs to be supported? - for i_face, angle in candidate_overhangs.items(): - if i_face in visited: - continue - group = _collect_neighbours(i_face) - group_area = sum([candidate_overhangs[x] for x in group]) - if group_area >= min_support_area: - return True - - return False - - -def checkForDownVertices(node: SceneNode) -> bool: - close_to_buildplate = getCloseToBuildplateDistance() meshdata = node.getMeshDataTransformed() - face_count = meshdata.getFaceCount() - - def _to_hashable(pt: numpy.ndarray) -> Tuple[float, float, float]: - return float(pt[0]), float(pt[1]), float(pt[2]) - - verts_with_lower = dict() - def _handle_edge(va: numpy.ndarray, vb: numpy.ndarray) -> None: - if min(float(va[1]), float(vb[1])) < close_to_buildplate or va[1] == vb[1]: - verts_with_lower[_to_hashable(va)] = False - verts_with_lower[_to_hashable(vb)] = False - return - verts_with_lower[_to_hashable(va if va[1] > vb[1] else vb)] = False - - # Create a vertex adjacency graph -- but only append vertices that are _lower_ (except too close or below the BP). - for i_face in range(face_count): - a, b, c = meshdata.getFaceNodes(i_face) - - # Check the angle; NOTE: Not against the support angle this time, but whether this tri is facing up or down. - _, face_norm = meshdata.getFacePlane(i_face) - norm_down = math.asin(face_norm[1]) if -1.0 <= face_norm[1] <= 1.0 else 0.0 < 0.0 - - # Mark each vertex as handled if the norm when that's up, otherwise check each edge. - for vert in (a, b, c): - v = _to_hashable(vert) - if v not in verts_with_lower: - verts_with_lower[v] = norm_down - if norm_down: - _handle_edge(a, b) - _handle_edge(b, c) - _handle_edge(c, a) - - # Any vertex that has no adjacencies (that is, no vertices that are lower than it) is downward. - return any(verts_with_lower.values()) + mesh_vertices = meshdata.getVertices() + mesh_indices = meshdata.getIndices() + if mesh_indices is None: + mesh_indices = numpy.array([], dtype=numpy.int32) + mesh_connects = meshdata.getFacesConnections() + return uvula.checkDownwardsFeatures( + support_angle, + close_to_buildplate, + min_support_area, + mesh_vertices, + mesh_indices, + mesh_connects + )