# TODO: # - Exempt mirror-plane verts. You should not get penalized for them. # - Check for intersected faces?? # - Would probably be O(n^m) or something. # - Would need to check the post-modified mesh (e.g., Armature-deformed) # - Coplanar check, especially good for Ngons. # - Check Normal consistency? I've had several people request this, though I # still feel like the Ctrl+n tool has problems solving it, so I am # unconfident that I will be able to do as good or better. It is true, # though, that you can simply allow the user to enable the check, and if it # is acting wonky they can disable it. # - Consider adding to the 'n' Properties Panel instead of Object Data. Or, # perhaps, a user preference. # - Maybe add a "Skip to Next" option. So far at least 1 user has reported # this. Personally, I think you should hit Tab and deselect the one you # want to skip, but I haven't thought it through too far. bl_info = { "name": "MeshLint: Like Spell-checking for your Meshes", "author": "rking", "version": (1, 0), "blender": (2, 6, 5), "location": "Object Data properties > MeshLint", "description": "Check objects for: Tris / Ngons / Nonmanifoldness / etc", "warning": "", "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/Scripts/Modeling/MeshLint", "tracker_url": "https://github.com/ryanjosephking/meshlint/issues", "category": "Mesh"} # For the ./mkblenderwiki script mkblenderwiki_info = { "license": "GPL", "py_download": "https://raw.github.com/ryanjosephking/meshlint/master/meshlint.py", "git_download": "https://github.com/ryanjosephking/meshlint.git", "input_img_prefix": "meshlint/raw/master/img/", "wiki_img_prefix": "Scripts-Modeling-MeshLint-"} # Look for the "seeing error text", below. Something is super-fishy, but this # is the workaround. try: import bpy import bmesh import time import re from mathutils import Vector SUBPANEL_LABEL = 'MeshLint' COMPLAINT_TIMEOUT = 3 # seconds ELEM_TYPES = [ 'verts', 'edges', 'faces' ] N_A_STR = '(N/A - disabled)' TBD_STR = '...' def is_edit_mode(): return 'EDIT_MESH' == bpy.context.mode def ensure_edit_mode(): if not is_edit_mode(): bpy.ops.object.editmode_toggle() def ensure_not_edit_mode(): if is_edit_mode(): bpy.ops.object.editmode_toggle() def has_active_mesh(context): obj = context.active_object return obj and 'MESH' == obj.type class MeshLintAnalyzer: CHECKS = [] def __init__(self): self.obj = bpy.context.active_object ensure_edit_mode() self.b = bmesh.from_edit_mesh(self.obj.data) self.num_problems_found = None def find_problems(self): analysis = [] self.num_problems_found = 0 for lint in MeshLintAnalyzer.CHECKS: sym = lint['symbol'] should_check = getattr(bpy.context.scene, lint['check_prop']) if not should_check: lint['count'] = N_A_STR continue lint['count'] = 0 check_method_name = 'check_' + sym check_method = getattr(type(self), check_method_name) bad = check_method(self) report = { 'lint': lint } for elemtype in ELEM_TYPES: indices = bad.get(elemtype, []) report[elemtype] = indices lint['count'] += len(indices) self.num_problems_found += len(indices) analysis.append(report) return analysis def found_zero_problems(self): return 0 == self.num_problems_found @classmethod def none_analysis(cls): analysis = [] for lint in cls.CHECKS: row = { elemtype: [] for elemtype in ELEM_TYPES } row['lint'] = lint analysis.append(row) return analysis CHECKS.append({ 'symbol': 'tris', 'label': 'Tris', 'definition': 'A face with 3 edges. Often bad for modeling because it stops edge loops and does not deform well around bent areas. A mesh might look good until you animate, so beware!', 'default': True }) def check_tris(self): bad = { 'faces': [] } for f in self.b.faces: if 3 == len(f.verts): bad['faces'].append(f.index) return bad CHECKS.append({ 'symbol': 'ngons', 'label': 'Ngons', 'definition': 'A face with >4 edges. Is generally bad in exactly the same ways as Tris', 'default': True }) def check_ngons(self): bad = { 'faces': [] } for f in self.b.faces: if 4 < len(f.verts): bad['faces'].append(f.index) return bad CHECKS.append({ 'symbol': 'nonmanifold', 'label': 'Nonmanifold Elements', 'definition': 'Simply, shapes that won\'t hold water. More precisely, nonmanifold edges are those that do not have exactly 2 faces attached to them (either more or less). Nonmanifold verts are more complicated -- you can see their definition in BM_vert_is_manifold() in bmesh_queries.c', 'default': True }) def check_nonmanifold(self): bad = {} for elemtype in 'verts', 'edges': bad[elemtype] = [] for elem in getattr(self.b, elemtype): if not elem.is_manifold: bad[elemtype].append(elem.index) # TODO: Exempt mirror-plane verts. # Plus: ...anybody wanna tackle Mirrors with an Object Offset? return bad CHECKS.append({ 'symbol': 'interior_faces', 'label': 'Interior Faces', 'definition': 'This confuses people. It is very specific: A face whose edges ALL have >2 faces attached. The simplest way to see this is to Ctrl+r a Default Cube and hit \'f\'', 'default': True }) def check_interior_faces(self): # translated from editmesh_select.c bad = { 'faces': [] } for f in self.b.faces: if not any(3 > len(e.link_faces) for e in f.edges): bad['faces'].append(f.index) return bad CHECKS.append({ 'symbol': 'sixplus_poles', 'label': '6+-edge Poles', 'definition': 'A vertex with 6 or more edges connected to it. Generally this is not something you want, but since some kinds of extrusions will legitimately cause such a pole (imagine extruding each face of a Cube outward, the inner corners are rightful 6+-poles). Still, if you don\'t know for sure that you want them, it is good to enable this', 'default': False }) def check_sixplus_poles(self): bad = { 'verts': [] } for v in self.b.verts: if 5 < len(v.link_edges): bad['verts'].append(v.index) return bad # [Your great new idea here] -> Tell me about it: rking@panoptic.com # ...plus the 'Default Name' check. def enable_anything_select_mode(self): self.b.select_mode = {'VERT', 'EDGE', 'FACE'} def select_indices(self, elemtype, indices): for i in indices: if 'verts' == elemtype: self.select_vert(i) elif 'edges' == elemtype: self.select_edge(i) elif 'faces' == elemtype: self.select_face(i) else: print("MeshLint says: Huh?? → elemtype of %s." % elemtype) def select_vert(self, index): self.b.verts[index].select = True def select_edge(self, index): edge = self.b.edges[index] edge.select = True for each in edge.verts: self.select_vert(each.index) def select_face(self, index): face = self.b.faces[index] face.select = True for each in face.edges: self.select_edge(each.index) def topology_counts(self): data = self.obj.data return { 'data': self.obj.data, 'faces': len(self.b.faces), 'edges': len(self.b.edges), 'verts': len(self.b.verts) } for lint in CHECKS: sym = lint['symbol'] lint['count'] = TBD_STR prop = 'meshlint_check_' + sym lint['check_prop'] = prop 'meshlint_check_' + sym setattr( bpy.types.Scene, prop, bpy.props.BoolProperty( default=lint['default'], description=lint['definition'])) @bpy.app.handlers.persistent def global_repeated_check(dummy): MeshLintContinuousChecker.check() class MeshLintContinuousChecker(): current_message = '' time_complained = 0 previous_topology_counts = None previous_analysis = None @classmethod def check(cls): if not is_edit_mode(): return analyzer = MeshLintAnalyzer() now_counts = analyzer.topology_counts() previous_topology_counts = \ cls.previous_topology_counts if not None is previous_topology_counts: previous_data_name = previous_topology_counts['data'].name else: previous_data_name = None now_name = now_counts['data'].name if None is previous_topology_counts \ or now_counts != previous_topology_counts: if not previous_data_name == now_name: before = MeshLintAnalyzer.none_analysis() analysis = analyzer.find_problems() diff_msg = cls.diff_analyses( cls.previous_analysis, analysis) if not None is diff_msg: cls.announce(diff_msg) cls.time_complained = time.time() cls.previous_topology_counts = now_counts cls.previous_analysis = analysis if not None is cls.time_complained \ and COMPLAINT_TIMEOUT < time.time() - cls.time_complained: cls.announce(None) cls.time_complained = None @classmethod def diff_analyses(cls, before, after): if None is before: before = MeshLintAnalyzer.none_analysis() report_strings = [] dict_before = cls.make_labels_dict(before) dict_now = cls.make_labels_dict(after) for check in MeshLintAnalyzer.CHECKS: check_name = check['label'] if not check_name in dict_now.keys(): continue report = dict_now[check_name] report_before = dict_before.get(check_name, {}) check_elem_strings = [] for elemtype, elem_list in report.items(): elem_list_before = report_before.get(elemtype, []) if len(elem_list) > len(elem_list_before): count_diff = len(elem_list) - len(elem_list_before) elem_string = depluralize( count=count_diff, string=elemtype) check_elem_strings.append( str(count_diff) + ' ' + elem_string) if len(check_elem_strings): report_strings.append( check_name + ': ' + ', '.join(check_elem_strings)) if len(report_strings): return 'Found ' + ', '.join(report_strings) return None @classmethod def make_labels_dict(cls, analysis): if None is analysis: return {} labels_dict = {} for check in analysis: label = check['lint']['label'] new_val = check.copy() del new_val['lint'] labels_dict[label] = new_val return labels_dict @classmethod def announce(cls, message): for area in bpy.context.screen.areas: if 'INFO' != area.type: continue if None is message: area.header_text_set() else: area.header_text_set('MeshLint: ' + message) class MeshLintVitalizer(bpy.types.Operator): 'Toggles the real-time execution of the checks (Edit Mode only)' bl_idname = 'meshlint.live_toggle' bl_label = 'MeshLint Live Toggle' is_live = False @classmethod def poll(cls, context): return has_active_mesh(context) and is_edit_mode() def execute(self, context): if MeshLintVitalizer.is_live: bpy.app.handlers.scene_update_post.remove(global_repeated_check) MeshLintVitalizer.is_live = False else: bpy.app.handlers.scene_update_post.append(global_repeated_check) MeshLintVitalizer.is_live = True return {'FINISHED'} def activate(obj): bpy.context.scene.objects.active = obj class MeshLintObjectLooper: def examine_active_object(self): analyzer = MeshLintAnalyzer() analyzer.enable_anything_select_mode() self.select_none() analysis = analyzer.find_problems() for lint in analysis: for elemtype in ELEM_TYPES: indices = lint[elemtype] analyzer.select_indices(elemtype, indices) return analyzer.found_zero_problems() def examine_all_selected_meshes(self): self.original_active = bpy.context.active_object self.troubled_meshes = [] examinees = [self.original_active] + bpy.context.selected_objects for obj in examinees: if 'MESH' != obj.type: continue activate(obj) good = self.examine_active_object() ensure_not_edit_mode() if not good: self.troubled_meshes.append(obj) priorities = [self.original_active] + self.troubled_meshes for obj in priorities: if obj.select: activate(obj) break self.handle_troubled_meshes() bpy.context.area.tag_redraw() def select_none(self): bpy.ops.mesh.select_all(action='DESELECT') class MeshLintSelector(MeshLintObjectLooper, bpy.types.Operator): 'Uncheck boxes below to prevent those checks from running' bl_idname = 'meshlint.select' bl_label = 'MeshLint Select' bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): return has_active_mesh(context) def execute(self, context): original_mode = bpy.context.mode if is_edit_mode(): self.examine_active_object() else: self.examine_all_selected_meshes() if len(self.troubled_meshes): ensure_edit_mode() elif 'EDIT_MESH' != original_mode: ensure_not_edit_mode() return {'FINISHED'} def handle_troubled_meshes(self): pass class MeshLintObjectDeselector(MeshLintObjectLooper, bpy.types.Operator): 'Uncheck boxes below to prevent those checks from running (Object Mode only)' bl_idname = 'meshlint.objects_deselect' bl_label = 'MeshLint Objects Deselect' bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): selected_meshses = [ o for o in bpy.context.selected_objects if o.type == 'MESH'] return 1 < len(selected_meshses) and not is_edit_mode() def execute(self, context): self.examine_all_selected_meshes() return {'FINISHED'} def handle_troubled_meshes(self): for obj in bpy.context.selected_objects: if not obj in self.troubled_meshes: obj.select = False class MeshLintControl(bpy.types.Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = 'data' bl_label = SUBPANEL_LABEL @classmethod def poll(cls, context): return has_active_mesh(context) def draw(self, context): layout = self.layout self.add_main_buttons(layout) self.add_criticism(layout, context) self.add_toggle_buttons(layout, context) def add_main_buttons(self, layout): split = layout.split() left = split.column() left.operator( 'meshlint.select', text='Select Lint', icon='EDITMODE_HLT') right = split.column() if MeshLintVitalizer.is_live: live_label = 'Pause Checking...' play_pause = 'PAUSE' else: live_label = 'Continuous Check!' play_pause = 'PLAY' right.operator( 'meshlint.live_toggle', text=live_label, icon=play_pause) layout.split().operator( 'meshlint.objects_deselect', text='Deselect all Lint-free Objects', icon='UV_ISLANDSEL') def add_criticism(self, layout, context): col = layout.column() active = context.active_object if not has_active_mesh(context): return total_problems = 0 for lint in MeshLintAnalyzer.CHECKS: count = lint['count'] if count in (TBD_STR, N_A_STR): label = str(count) + ' ' + lint['label'] reward = 'SOLO_OFF' elif 0 == count: label = 'No %s!' % lint['label'] reward = 'SOLO_ON' else: total_problems += count label = str(count) + 'x ' + lint['label'] label = depluralize(count=count, string=label) reward = 'ERROR' col.row().label(text=label, icon=reward) name_crits = MeshLintControl.build_object_criticisms( bpy.context.selected_objects, total_problems) for crit in name_crits: col.row().label(crit) def add_toggle_buttons(self, layout, context): col = layout.column() col.row().label('Toggle:') for lint in MeshLintAnalyzer.CHECKS: prop_name = lint['check_prop'] is_enabled = getattr(context.scene, prop_name) label = 'Check ' + lint['label'] col.row().prop(context.scene, prop_name, text=label) @classmethod def build_object_criticisms(cls, objects, total_problems): already_complained = total_problems > 0 criticisms = [] def add_crit(crit): if already_complained: conjunction = 'and also' else: conjunction = 'but' criticisms.append('...%s "%s" %s.' % ( conjunction, obj.name, crit)) for obj in objects: if MeshLintControl.has_unapplied_scale(obj.scale): add_crit('has an unapplied scale') already_complained = True if MeshLintControl.is_bad_name(obj.name): add_crit('is not a great name') already_complained = True return criticisms @classmethod def has_unapplied_scale(cls, scale): return 3 != len([c for c in scale if c == 1.0]) @classmethod def is_bad_name(cls, name): default_names = [ 'BezierCircle', 'BezierCurve', 'Circle', 'Cone', 'Cube', 'CurvePath', 'Cylinder', 'Grid', 'Icosphere', 'Mball', 'Monkey', 'NurbsCircle', 'NurbsCurve', 'NurbsPath', 'Plane', 'Sphere', 'Surface', 'SurfCircle', 'SurfCurve', 'SurfCylinder', 'SurfPatch', 'SurfSphere', 'SurfTorus', 'Text', 'Torus', ] pat = '(%s)\.?\d*$' % '|'.join(default_names) return not None is re.match(pat, name) def depluralize(**args): if 1 == args['count']: return args['string'].rstrip('s') else: return args['string'] # Hrm. Why does it work for some Blender's but not others? try: import unittest import warnings class TestControl(unittest.TestCase): def test_scale_application(self): for bad in [ [0,0,0], [1,2,3], [1,1,1.1] ]: self.assertEqual( True, MeshLintControl.has_unapplied_scale(bad), "Unapplied scale: %s" % bad) self.assertEqual( False, MeshLintControl.has_unapplied_scale([1,1,1]), "Applied scale (1,1,1)") def test_bad_names(self): for bad in [ 'Cube', 'Cube.001', 'Sphere.123' ]: self.assertEqual( True, MeshLintControl.is_bad_name(bad), "Bad name: %s" % bad) for ok in [ 'Whatever', 'NumbersOkToo.001' ]: self.assertEqual( False, MeshLintControl.is_bad_name(ok), "OK name: %s" % ok) class TestUtilities(unittest.TestCase): def test_depluralize(self): self.assertEqual( 'foo', depluralize(count=1, string='foos')) self.assertEqual( 'foos', depluralize(count=2, string='foos')) class TestAnalysis(unittest.TestCase): def test_make_labels_dict(self): self.assertEqual( { 'Label One': { 'edges': [1,2], 'verts': [], 'faces': [] }, 'Label Two': { 'edges': [], 'verts': [5], 'faces': [3] } }, MeshLintContinuousChecker.make_labels_dict( [ { 'lint': { 'label': 'Label One' }, 'edges': [1,2], 'verts': [], 'faces': [] }, { 'lint': { 'label': 'Label Two' }, 'edges': [], 'verts': [5], 'faces': [3] } ]), 'Conversion of incoming analysis into label-keyed dict') self.assertEqual( {}, MeshLintContinuousChecker.make_labels_dict(None), 'Handles "None" OK.') def test_comparison(self): self.assertEqual( None, MeshLintContinuousChecker.diff_analyses( MeshLintAnalyzer.none_analysis(), MeshLintAnalyzer.none_analysis()), 'Two none_analysis()s') self.assertEqual( 'Found Tris: 4 verts', MeshLintContinuousChecker.diff_analyses( None, [ { 'lint': { 'label': 'Tris' }, 'verts': [1,2,3,4], 'edges': [], 'faces': [], }, ]), 'When there was no previous analysis') self.assertEqual( 'Found Tris: 2 edges, ' +\ 'Nonmanifold Elements: 4 verts, 1 face', MeshLintContinuousChecker.diff_analyses( [ { 'lint': { 'label': 'Tris' }, 'verts': [], 'edges': [1,4], 'faces': [], }, { 'lint': { 'label': 'CheckB' }, 'verts': [], 'edges': [2,3], 'faces': [], }, { 'lint': { 'label': 'Nonmanifold Elements' }, 'verts': [], 'edges': [], 'faces': [2,3], }, ], [ { 'lint': { 'label': 'Tris' }, 'verts': [], 'edges': [1,4,5,6], 'faces': [], }, { 'lint': { 'label': 'CheckB' }, 'verts': [], 'edges': [2,3], 'faces': [], }, { 'lint': { 'label': 'Nonmanifold Elements' }, 'verts': [1,2,3,4], 'edges': [], 'faces': [2,3,5], }, ]), 'Complex comparison of analyses') self.assertEqual( 'Found Tris: 1 vert, Ngons: 2 faces, ' + 'Nonmanifold Elements: 2 edges', MeshLintContinuousChecker.diff_analyses( [ { 'lint': { 'label': '6+-edge Poles' }, 'verts': [], 'edges': [2,3], 'faces': [], }, { 'lint': { 'label': 'Nonmanifold Elements' }, 'verts': [], 'edges': [2,3], 'faces': [], }, ], [ { 'lint': { 'label': 'Tris' }, 'verts': [55], 'edges': [], 'faces': [], }, { 'lint': { 'label': 'Ngons' }, 'verts': [], 'edges': [], 'faces': [5,6], }, { 'lint': { 'label': 'Nonmanifold Elements' }, 'verts': [], 'edges': [2,3,4,5], 'faces': [], }, ]), 'User picked a different set of checks since last run.') class MockBlenderObject: def __init__(self, name, scale=Vector([1,1,1])): self.name = name self.scale = scale class TestUI(unittest.TestCase): def test_complaints(self): f = MeshLintControl.build_object_criticisms self.assertEqual([], f([], 0), 'Nothing selected') self.assertEqual( [], f([MockBlenderObject('lsmft')], 0), 'Ok name') self.assertEqual( ['...but "Cube" is not a great name.'], f([MockBlenderObject('Cube')], 0), 'Bad name, otherwise problem-free.') self.assertEqual( [], f([MockBlenderObject('Hassenfrass')], 12), 'Good name, but with problems.') self.assertEqual( ['...and also "Cube" is not a great name.'], f([MockBlenderObject('Cube')], 23), 'Bad name, and problems, too.') self.assertEqual( [ '...but "Sphere" is not a great name.', '...and also "Cube" is not a great name.' ], f([ MockBlenderObject('Sphere'), MockBlenderObject('Cube') ], 0), 'Two bad names.') scaled = MockBlenderObject('Solartech', scale=Vector([.2,2,1])) self.assertEqual( [ '...but "Solartech" has an unapplied scale.' ], f([scaled], 0), 'Only problem is unapplied scale.' ) class QuietOnSuccessTestResult(unittest.TextTestResult): def startTest(self, test): pass def addSuccess(self, test): pass class QuietTestRunner(unittest.TextTestRunner): resultclass = QuietOnSuccessTestResult # Ugh. I really shouldn't have to include this much code, but they # left it so unrefactored I don't know what else to do. My other # option is to override the stream and substitute out the success # case, but that's a mess, too. - rking def run(self, test): "Run the given test case or test suite." result = self._makeResult() unittest.registerResult(result) result.failfast = self.failfast result.buffer = self.buffer with warnings.catch_warnings(): if self.warnings: # if self.warnings is set, use it to filter all the # warnings warnings.simplefilter(self.warnings) # if the filter is 'default' or 'always', special-case # the warnings from the deprecated unittest methods to # show them no more than once per module, because they # can be fairly noisy. The -Wd and -Wa flags can be # used to bypass this only when self.warnings is None. if self.warnings in ['default', 'always']: warnings.filterwarnings('module', category=DeprecationWarning, message='Please use assert\w+ instead.') startTime = time.time() startTestRun = getattr(result, 'startTestRun', None) if startTestRun is not None: startTestRun() try: test(result) finally: stopTestRun = getattr(result, 'stopTestRun', None) if stopTestRun is not None: stopTestRun() stopTime = time.time() timeTaken = stopTime - startTime result.printErrors() run = result.testsRun expectedFails = unexpectedSuccesses = skipped = 0 try: results = map(len, (result.expectedFailures, result.unexpectedSuccesses, result.skipped)) except AttributeError: pass else: expectedFails, unexpectedSuccesses, skipped = results infos = [] if not result.wasSuccessful(): self.stream.write("FAILED") failed, errored = len(result.failures), len(result.errors) if failed: infos.append("failures=%d" % failed) if errored: infos.append("errors=%d" % errored) if skipped: infos.append("skipped=%d" % skipped) if expectedFails: infos.append("expected failures=%d" % expectedFails) if unexpectedSuccesses: infos.append( "unexpected successes=%d" % unexpectedSuccesses) return result if __name__ == '__main__': unittest.main( testRunner=QuietTestRunner, argv=['dummy'], exit=False, verbosity=0) except ImportError: print( "MeshLint complains over missing unittest module.", """ No harm, but it is odd. If you want to send a message to rking@panoptic.com describing your system, he'd like to track down this condition.""") def register(): bpy.utils.register_module(__name__) def unregister(): bpy.utils.unregister_module(__name__) if __name__ == '__main__': register() except: # OK, I totally don't get why this is necessary. But otherwise I am not # seeing error text. Causes the extra indent over all above code. =( import sys exc = sys.exc_info() print("MeshLint Oops: ", exc[1], exc[2]) # vim:ts=4 sw=4 sts=4