Added sanity check baseline made by claude.

Also changed the location to a panel in VIEW_3D instead of menu.
This commit is contained in:
Nero Frame 13 committed 2026-10-13 16:33:03 +02:00
1 parent 71b1a61b5c
commit 4617c47ad0
33 files changed
+2771 -52

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
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)