import bmesh import numpy as np from ... import config from ...core import Check, Issue, contexts from ...core.mesh import loop_polygon_index, polygon_arrays, polygon_normals, vertex_coords class NgonCheck(Check): id = "ngons" label = "N-gons" description = "Meshes contain only triangles and quads (no face with more than 4 sides)." category = "Geometry" contexts = contexts.ASSET order = 10 def run(self, ctx): for ob in ctx.objects("MESH"): _, totals, _ = polygon_arrays(ob.data) bad = np.flatnonzero(totals > config.MAX_FACE_SIDES) if bad.size: yield Issue.for_id( f"{ob.name}: {bad.size} face(s) with more than {config.MAX_FACE_SIDES} sides", ob, elements=bad.tolist(), element_type="FACE", ) def concave_polygons(mesh, tolerance): """Indices of polygons with at least one reflex corner.""" if not len(mesh.polygons): return np.empty(0, dtype=np.int32) starts, totals, loop_verts = polygon_arrays(mesh) co = vertex_coords(mesh) normals = polygon_normals(mesh) poly_of_loop = loop_polygon_index(totals) start = starts[poly_of_loop] total = totals[poly_of_loop] local = np.arange(len(loop_verts)) - start prev_loop = start + (local - 1) % total next_loop = start + (local + 1) % total point = co[loop_verts] incoming = point - co[loop_verts[prev_loop]] outgoing = co[loop_verts[next_loop]] - point turn = np.einsum("ij,ij->i", np.cross(incoming, outgoing), normals[poly_of_loop]) scale = np.linalg.norm(incoming, axis=1) * np.linalg.norm(outgoing, axis=1) + 1e-12 reflex = (turn / scale < -tolerance) & (total > 3) return np.unique(poly_of_loop[reflex]) class ConcaveFaceCheck(Check): id = "concave_faces" label = "Concave faces" description = "Quads and n-gons are convex (no corner angle above 180 degrees)." category = "Geometry" contexts = contexts.ASSET order = 20 def run(self, ctx): for ob in ctx.objects("MESH"): bad = concave_polygons(ob.data, config.CONCAVE_TOLERANCE) if bad.size: yield Issue.for_id( f"{ob.name}: {bad.size} concave face(s)", ob, elements=bad.tolist(), element_type="FACE", ) class NonManifoldCheck(Check): id = "non_manifold" label = "Non-manifold geometry" description = ( "No wire edges, edges shared by more than two faces, inconsistent normals, " "bow-tie vertices or (unless allowed in config) open borders." ) category = "Geometry" contexts = contexts.ASSET order = 30 def run(self, ctx): allow_boundary = config.NON_MANIFOLD_ALLOW_BOUNDARY for ob in ctx.objects("MESH"): bm = bmesh.new() try: bm.from_mesh(ob.data) edges = [ e.index for e in bm.edges if not e.is_contiguous and not (allow_boundary and e.is_boundary) ] verts = [ v.index for v in bm.verts if not v.is_manifold and not (allow_boundary and v.is_boundary) and not any(not e.is_contiguous for e in v.link_edges) ] finally: bm.free() if edges: yield Issue.for_id( f"{ob.name}: {len(edges)} non-manifold edge(s)", ob, elements=edges, element_type="EDGE" ) if verts: yield Issue.for_id( f"{ob.name}: {len(verts)} non-manifold vertex(es)", ob, elements=verts, element_type="VERT" )