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

+13
View File
@@ -0,0 +1,13 @@
from . import operators, panel, properties
modules = (properties, operators, panel)
def register():
for module in modules:
module.register()
def unregister():
for module in reversed(modules):
module.unregister()
+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)
+217
View File
@@ -0,0 +1,217 @@
import textwrap
import bpy
from .. import config, tools_ops
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
from ..core.runner import Status
from .operators import EXPANDED
STATUS_ICONS = {
Status.NOT_RUN: "DOT",
Status.PASSED: "CHECKMARK",
Status.INFO: "INFO",
Status.WARNING: "ERROR",
Status.ERROR: "CANCEL",
Status.SKIPPED: "REMOVE",
Status.CRASHED: "GHOST_ENABLED",
}
SEVERITY_ICONS = {
Severity.INFO: "INFO",
Severity.WARNING: "ERROR",
Severity.ERROR: "CANCEL",
}
def _wrapped_label(layout, context, text, icon="NONE", margin=0):
"""Label wrapped to the region width. ``margin`` is the room (in characters) taken by buttons."""
char_width = 6.5 * context.preferences.system.ui_scale
width = max(16, int(context.region.width / char_width) - 6 - margin)
col = layout.column(align=True)
for index, line in enumerate(textwrap.wrap(text, width) or [""]):
col.label(text=line, icon=icon if index == 0 else "BLANK1")
def draw_panel(self, context):
layout = self.layout
scene = context.scene
settings = scene.wtd_sanity
report = runner.LAST_REPORT
if report is not None and report.context_id != settings.context:
report = None
layout.prop(settings, "context", text="")
if settings.context in contexts.ASSET:
row = layout.row(align=True)
row.prop(settings, "asset_name", text="Asset")
if not settings.asset_name:
guessed = CheckContext(scene, settings.context).asset_name
row.label(text=guessed or "(from file name)")
row = layout.row(align=True)
row.scale_y = 1.4
row.operator("wtd_sanity.run_checks", text="Run Checks", icon="PLAY")
row.operator("wtd_sanity.fix_all", text="Fix All", icon="TOOL_SETTINGS")
row.operator("wtd_sanity.write_report", text="", icon="TEXT")
if settings.context == contexts.CHAR_MOD:
layout.operator("wtd_sanity.export_char_reference", icon="EXPORT")
if report is not None:
counts = report.counts()
row = layout.row()
for status in (
Status.PASSED,
Status.WARNING,
Status.ERROR,
Status.SKIPPED,
Status.CRASHED,
):
if counts[status]:
row.label(text=str(counts[status]), icon=STATUS_ICONS[status])
disabled = runner.disabled_check_ids(scene)
box = None
category = None
for check in checks_for_context(settings.context):
if check.category != category:
category = check.category
box = layout.box()
box.label(text=category)
_draw_check(box, context, check, report, check.id in disabled)
def _draw_check(layout, context, check, report, disabled):
result = report.results.get(check.id) if report else None
status = result.status if result else Status.NOT_RUN
expanded = check.id in EXPANDED
row = layout.row(align=True)
row.operator(
"wtd_sanity.toggle_check",
text="",
emboss=False,
icon="CHECKBOX_DEHLT" if disabled else "CHECKBOX_HLT",
).check_id = check.id
row.operator(
"wtd_sanity.toggle_expand",
text="",
emboss=False,
icon="DOWNARROW_HLT" if expanded else "RIGHTARROW",
).check_id = check.id
label_row = row.row(align=True)
label_row.enabled = not disabled
label_row.alert = status in {Status.ERROR, Status.CRASHED}
count = f" ({len(result.issues)})" if result and result.issues else ""
label_row.label(text=check.label + count, icon=STATUS_ICONS[status])
buttons = row.row(align=True)
if result and any(issue.id_name or issue.elements for issue in result.issues):
buttons.operator(
"wtd_sanity.select_issues", text="", icon="RESTRICT_SELECT_OFF"
).check_id = check.id
if result and result.failed and check.can_fix():
buttons.operator(
"wtd_sanity.fix_check", text="", icon="TOOL_SETTINGS"
).check_id = check.id
buttons.operator(
"wtd_sanity.run_checks", text="", icon="FILE_REFRESH"
).check_id = check.id
if not expanded:
return
col = layout.column()
col.separator(factor=0.3)
_wrapped_label(col, context, check.description, icon="QUESTION")
if result is None:
return
if result.message:
_wrapped_label(
col,
context,
result.message.strip().splitlines()[-1],
icon=STATUS_ICONS[status],
)
for index, issue in enumerate(result.issues[: config.MAX_ISSUES_DRAWN]):
severity = issue.severity or check.severity
issue_row = col.row(align=True)
selectable = bool(issue.id_name or issue.elements)
_wrapped_label(
issue_row,
context,
issue.message,
icon=SEVERITY_ICONS[severity],
margin=7 if selectable else 0,
)
if selectable:
op = issue_row.operator(
"wtd_sanity.select_issues",
text="",
icon="RESTRICT_SELECT_OFF",
emboss=False,
)
op.check_id = check.id
op.issue_index = index
if check.editable_names:
id_block = issue.get_id()
if id_block is not None and id_block.library is None:
col.prop(id_block, "name", text="", icon="BLANK1")
hidden = len(result.issues) - config.MAX_ISSUES_DRAWN
if hidden > 0:
col.label(text=f"... {hidden} more (see Write Report)", icon="BLANK1")
col.separator(factor=0.5)
class _SanityPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Sanity Check"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
def draw(self, context):
draw_panel(self, context)
class _ToolsPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Tools"
def draw(self, context) -> None:
layout = self.layout
layout.operator(
tools_ops.WTDGetOidForPath.bl_idname,
text=tools_ops.WTDGetOidForPath.bl_label,
)
# Adds a separator line
layout.separator()
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_tools_view3d(_ToolsPanel, bpy.types.Panel):
bl_idname = "WTD_PT_tools_view3d"
bl_space_type = "VIEW_3D"
class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
bl_idname = "WTD_PT_sanity_sequencer"
bl_space_type = "SEQUENCE_EDITOR"
classes = (WTD_PT_sanity_view3d, WTD_PT_tools_view3d, WTD_PT_sanity_sequencer)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
+80
View File
@@ -0,0 +1,80 @@
import bpy
from bpy.props import EnumProperty, PointerProperty, StringProperty
from .. import config
from ..core import contexts, runner
from ..core.check import ADDON_ID
class WTD_SanitySettings(bpy.types.PropertyGroup):
"""Per-file settings, saved in the .blend."""
context: EnumProperty(
name="Context",
description="Production context the file is checked against",
items=contexts.enum_items(),
update=lambda self, context: runner.clear(),
)
asset_name: StringProperty(
name="Asset Name",
description="Asset / character name. Leave empty to read it from the file name",
)
disabled_checks: StringProperty(
name="Disabled Checks",
description="Ids of the checks disabled in this file, separated by ';'",
)
def get_prefs():
addon = bpy.context.preferences.addons.get(ADDON_ID)
return addon.preferences if addon else None
def _apply_config_override(self, context):
load_config_override(self.config_path)
def load_config_override(path=""):
try:
applied = config.apply_overrides(bpy.path.abspath(path) if path else "")
except (OSError, ValueError) as exc:
print(f"[WTD Sanity] Could not load config override: {exc}")
else:
if applied:
print(f"[WTD Sanity] Config override: {', '.join(applied)}")
class WTD_SanityPreferences(bpy.types.AddonPreferences):
bl_idname = ADDON_ID
char_reference_path: StringProperty(
name="Character Reference",
description="Reference JSON exported from the master character (falls back to $WTD_CHAR_REFERENCE)",
subtype="FILE_PATH",
)
config_path: StringProperty(
name="Config Override",
description="JSON file overriding values of config.py (falls back to $WTD_SANITY_CONFIG)",
subtype="FILE_PATH",
update=_apply_config_override,
)
def draw(self, context):
layout = self.layout
layout.prop(self, "char_reference_path")
layout.prop(self, "config_path")
classes = (WTD_SanitySettings, WTD_SanityPreferences)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.wtd_sanity = PointerProperty(type=WTD_SanitySettings)
def unregister():
del bpy.types.Scene.wtd_sanity
for cls in reversed(classes):
bpy.utils.unregister_class(cls)