Files
Nero Frame 13 4617c47ad0 Added sanity check baseline made by claude.
Also changed the location to a panel in VIEW_3D instead of menu.
2026-10-13 16:33:03 +02:00

110 lines
3.8 KiB
Python

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"
)