319 lines
12 KiB
Python
319 lines
12 KiB
Python
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)
|