import os import bmesh import bpy import numpy as np from bpy.props import IntProperty, StringProperty from bpy_extras.io_utils import ExportHelper from ..core import CheckContext, char_reference, get_check, runner REPORT_TEXT_NAME = "WTD_Sanity_Report" # Checks whose issue list is unfolded in the panel (UI state only, not saved). EXPANDED: set[str] = set() def _ensure_object_mode(context): if context.mode != "OBJECT" and context.view_layer.objects.active is not None: bpy.ops.object.mode_set(mode="OBJECT") def _report_messages(operator, messages, success): if messages: for message in messages: print(f"[WTD Sanity] {message}") operator.report({"WARNING"}, f"{len(messages)} item(s) not fixed: {messages[0]}") else: operator.report({"INFO"}, success) class WTD_OT_run_checks(bpy.types.Operator): bl_idname = "wtd_sanity.run_checks" bl_label = "Run Checks" bl_description = "Run the sanity checks of the selected context (or a single check)" check_id: StringProperty(options={"SKIP_SAVE"}) def execute(self, context): check_ids = {self.check_id} if self.check_id else None report = runner.run_checks(context.scene, check_ids=check_ids) counts = report.counts() failed = counts[runner.Status.ERROR] + counts[runner.Status.CRASHED] warnings = counts[runner.Status.WARNING] # Not {"ERROR"}: that would raise when the operator is called from a script. level = {"WARNING"} if failed or warnings else {"INFO"} self.report(level, f"Sanity check: {failed} error(s), {warnings} warning(s)") return {"FINISHED"} class WTD_OT_fix_check(bpy.types.Operator): bl_idname = "wtd_sanity.fix_check" bl_label = "Fix" bl_options = {"REGISTER", "UNDO"} check_id: StringProperty() @classmethod def description(cls, context, properties): check = get_check(properties.check_id) return check.fix_description if check else "Fix the reported issues" def execute(self, context): _ensure_object_mode(context) messages = runner.fix_check(self.check_id, context.scene) _report_messages(self, messages, "Fixed") return {"FINISHED"} class WTD_OT_fix_all(bpy.types.Operator): bl_idname = "wtd_sanity.fix_all" bl_label = "Fix All" bl_description = "Run the fix of every failing check that has one, then re-run all checks" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): report = runner.LAST_REPORT return report is not None and any( result.failed and (check := get_check(result.check_id)) and check.can_fix() for result in report.results.values() ) def execute(self, context): _ensure_object_mode(context) messages = [] for result in list(runner.LAST_REPORT.results.values()): check = get_check(result.check_id) if result.failed and check and check.can_fix(): messages += runner.fix_check(result.check_id, context.scene) runner.run_checks(context.scene) _report_messages(self, messages, "Fixed all fixable issues") return {"FINISHED"} def _objects_for_issue(issue, scene): id_block = issue.get_id() if id_block is None: return [] if isinstance(id_block, bpy.types.Object): return [id_block] if isinstance(id_block, bpy.types.Collection): return list(id_block.all_objects) if isinstance(id_block, bpy.types.Material): return [ob for ob in scene.objects if any(slot.material == id_block for slot in ob.material_slots)] return [ob for ob in scene.objects if ob.data == id_block] def _select_mesh_elements(mesh, element_type, indices): """Set the selection flags of a mesh in object mode.""" for collection in (mesh.vertices, mesh.edges, mesh.polygons): collection.foreach_set("select", np.zeros(len(collection), dtype=bool)) indices = np.asarray(sorted(indices), dtype=np.int64) if element_type == "VERT": verts = indices elif element_type == "EDGE": edge_flags = np.zeros(len(mesh.edges), dtype=bool) edge_flags[indices] = True mesh.edges.foreach_set("select", edge_flags) edge_verts = np.empty(len(mesh.edges) * 2, dtype=np.int32) mesh.edges.foreach_get("vertices", edge_verts) verts = edge_verts.reshape(-1, 2)[indices].ravel() else: # FACE poly_flags = np.zeros(len(mesh.polygons), dtype=bool) poly_flags[indices] = True mesh.polygons.foreach_set("select", poly_flags) totals = np.empty(len(mesh.polygons), dtype=np.int32) mesh.polygons.foreach_get("loop_total", totals) loop_mask = np.repeat(poly_flags, totals) loop_verts = np.empty(len(mesh.loops), dtype=np.int32) loop_edges = np.empty(len(mesh.loops), dtype=np.int32) mesh.loops.foreach_get("vertex_index", loop_verts) mesh.loops.foreach_get("edge_index", loop_edges) edge_flags = np.zeros(len(mesh.edges), dtype=bool) edge_flags[loop_edges[loop_mask]] = True mesh.edges.foreach_set("select", edge_flags) verts = loop_verts[loop_mask] vert_flags = np.zeros(len(mesh.vertices), dtype=bool) vert_flags[verts] = True mesh.vertices.foreach_set("select", vert_flags) class WTD_OT_select_issues(bpy.types.Operator): bl_idname = "wtd_sanity.select_issues" bl_label = "Select" bl_description = "Select the objects (and mesh elements) of the issues" bl_options = {"REGISTER", "UNDO"} check_id: StringProperty() issue_index: IntProperty(default=-1) def execute(self, context): result = runner.get_result(self.check_id) if result is None or not result.issues: return {"CANCELLED"} issues = result.issues if self.issue_index < 0 else result.issues[self.issue_index:self.issue_index + 1] _ensure_object_mode(context) scene = context.scene view_layer = context.view_layer objects = [] elements = {} # object name -> {element type: indices} for issue in issues: for ob in _objects_for_issue(issue, scene): if ob not in objects: objects.append(ob) if issue.elements and ob.type == "MESH": by_type = elements.setdefault(ob.name, {}) by_type.setdefault(issue.element_type, set()).update(issue.elements) selectable = [ob for ob in objects if ob.name in view_layer.objects and ob.visible_get()] if not selectable: self.report({"WARNING"}, "Nothing selectable (hidden, excluded or not an object)") return {"CANCELLED"} for ob in view_layer.objects: ob.select_set(False) for ob in selectable: ob.select_set(True) view_layer.objects.active = selectable[0] if elements: types = set() for ob in selectable: by_type = elements.get(ob.name) if not by_type: ob.select_set(False) continue # Several element types on one mesh: select their vertices. if len(by_type) == 1: (element_type, indices), = by_type.items() else: element_type, indices = "VERT", _as_vertices(ob.data, by_type) _select_mesh_elements(ob.data, element_type, indices) types.add(element_type) view_layer.objects.active = next(ob for ob in selectable if ob.name in elements) context.tool_settings.mesh_select_mode = ("VERT" in types, "EDGE" in types, "FACE" in types) bpy.ops.object.mode_set(mode="EDIT") for ob in context.objects_in_mode: bm = bmesh.from_edit_mesh(ob.data) bm.select_flush_mode() bmesh.update_edit_mesh(ob.data, loop_triangles=False, destructive=False) if context.area and context.area.type == "VIEW_3D": bpy.ops.view3d.view_selected() return {"FINISHED"} def _as_vertices(mesh, by_type): verts = set(by_type.get("VERT", ())) if "EDGE" in by_type: for index in by_type["EDGE"]: verts.update(mesh.edges[index].vertices) if "FACE" in by_type: for index in by_type["FACE"]: verts.update(mesh.polygons[index].vertices) return verts class WTD_OT_toggle_check(bpy.types.Operator): bl_idname = "wtd_sanity.toggle_check" bl_label = "Enable / Disable Check" bl_description = "Enable or disable this check for this file (saved in the file)" bl_options = {"UNDO"} check_id: StringProperty() def execute(self, context): settings = context.scene.wtd_sanity disabled = runner.disabled_check_ids(context.scene) disabled ^= {self.check_id} settings.disabled_checks = ";".join(sorted(disabled)) return {"FINISHED"} class WTD_OT_toggle_expand(bpy.types.Operator): bl_idname = "wtd_sanity.toggle_expand" bl_label = "Show Details" bl_description = "Show or hide the details of this check" check_id: StringProperty() def execute(self, context): EXPANDED.symmetric_difference_update({self.check_id}) return {"FINISHED"} class WTD_OT_write_report(bpy.types.Operator): bl_idname = "wtd_sanity.write_report" bl_label = "Write Report" bl_description = f"Write the last report to the text datablock '{REPORT_TEXT_NAME}'" @classmethod def poll(cls, context): return runner.LAST_REPORT is not None def execute(self, context): text = bpy.data.texts.get(REPORT_TEXT_NAME) or bpy.data.texts.new(REPORT_TEXT_NAME) text.clear() text.write(runner.LAST_REPORT.to_text()) self.report({"INFO"}, f"Report written to text '{REPORT_TEXT_NAME}'") return {"FINISHED"} class WTD_OT_export_char_reference(bpy.types.Operator, ExportHelper): bl_idname = "wtd_sanity.export_char_reference" bl_label = "Export Character Reference" bl_description = ( "Export the body topology and inner mouth / eye names of this character " "as the reference for every other character" ) filename_ext = ".json" filter_glob: StringProperty(default="*.json", options={"HIDDEN"}) def invoke(self, context, event): ctx = CheckContext(context.scene, context.scene.wtd_sanity.context) if not ctx.asset_name: self.report({"ERROR"}, "Set the character name first") return {"CANCELLED"} self.filepath = os.path.join( os.path.dirname(bpy.data.filepath) or os.path.expanduser("~"), "char_reference.json" ) return super().invoke(context, event) def execute(self, context): ctx = CheckContext(context.scene, context.scene.wtd_sanity.context) try: data = char_reference.export_reference(context.scene, ctx.asset_name, self.filepath) except (ValueError, OSError) as exc: self.report({"ERROR"}, str(exc)) return {"CANCELLED"} self.report( {"INFO"}, f"Reference written ({data['body']['vertices']} vertices, {len(data['parts'])} parts). " "Set it in the add-on preferences to use it.", ) return {"FINISHED"} classes = ( WTD_OT_run_checks, WTD_OT_fix_check, WTD_OT_fix_all, WTD_OT_select_issues, WTD_OT_toggle_check, WTD_OT_toggle_expand, WTD_OT_write_report, WTD_OT_export_char_reference, ) def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls)