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

+256
View File
@@ -0,0 +1,256 @@
"""Base classes for sanity checks.
Writing a check
---------------
Subclass ``Check`` in any module under ``checks/``. A class that defines its
own ``id`` is registered automatically::
class NoSuzanne(Check):
id = "no_suzanne"
label = "No Suzanne"
description = "Monkeys are not allowed in production files."
category = "Objects"
contexts = contexts.ASSET
def run(self, ctx):
for ob in ctx.objects("MESH"):
if "Suzanne" in ob.name:
yield Issue.for_id(f"{ob.name} looks like a monkey", ob)
# Optional. Receives the issues returned by run().
def fix(self, ctx, issues):
for issue in issues:
bpy.data.objects.remove(issue.get_id())
``run`` yields (or returns a list of) ``Issue``. No issues means passed.
Raise ``SkipCheck("reason")`` when the check cannot be evaluated.
``fix`` may return messages describing what could not be fixed.
"""
from __future__ import annotations
import enum
import functools
import os
import re
from dataclasses import dataclass, field
from typing import Any, ClassVar, Iterable
import bpy
from .. import config
ADDON_ID = __package__.rpartition(".")[0]
class Severity(enum.IntEnum):
INFO = 1
WARNING = 2
ERROR = 3
class SkipCheck(Exception):
"""Raised from ``Check.run`` when the check cannot be evaluated."""
# bpy.types.ID.id_type -> bpy.data collection name
_ID_COLLECTIONS = {
"ACTION": "actions",
"ARMATURE": "armatures",
"CAMERA": "cameras",
"COLLECTION": "collections",
"CURVE": "curves",
"GREASEPENCIL": "grease_pencils",
"GREASEPENCIL_V3": "grease_pencils",
"IMAGE": "images",
"KEY": "shape_keys",
"LATTICE": "lattices",
"LIBRARY": "libraries",
"LIGHT": "lights",
"MATERIAL": "materials",
"MESH": "meshes",
"NODETREE": "node_groups",
"OBJECT": "objects",
"SCENE": "scenes",
"SOUND": "sounds",
"TEXT": "texts",
"WORLD": "worlds",
}
def id_collection_name(id_block):
return _ID_COLLECTIONS.get(getattr(id_block, "id_type", ""), None)
@dataclass
class Issue:
"""One problem found by a check.
``id_type``/``id_name`` point at the offending datablock (stored by name so
the issue survives undo). ``elements`` are mesh element indices of
``element_type`` ("VERT", "EDGE" or "FACE") used by the Select button.
``data`` is free storage for the check's own ``fix``.
"""
message: str
severity: Severity | None = None
id_type: str | None = None
id_name: str | None = None
elements: list[int] = field(default_factory=list)
element_type: str | None = None
data: dict[str, Any] = field(default_factory=dict)
@classmethod
def for_id(cls, message, id_block, **kwargs):
return cls(message, id_type=id_collection_name(id_block), id_name=id_block.name, **kwargs)
def get_id(self):
if not self.id_type or not self.id_name:
return None
collection = getattr(bpy.data, self.id_type, None)
if collection is None:
return None
for id_block in collection:
if id_block.name == self.id_name and id_block.library is None:
return id_block
return collection.get(self.id_name)
def to_dict(self):
return {
"message": self.message,
"severity": self.severity.name if self.severity else None,
"id_type": self.id_type,
"id_name": self.id_name,
"element_type": self.element_type,
"element_count": len(self.elements),
}
def is_local(id_block):
"""True for datablocks owned by this file (not linked, not overridden)."""
return id_block.library is None and id_block.override_library is None
@dataclass
class CheckContext:
"""What a check receives: the scene and the selected production context."""
scene: bpy.types.Scene
context_id: str
@property
def filepath(self):
return bpy.data.filepath
@property
def filename(self):
return os.path.basename(bpy.data.filepath)
@property
def settings(self):
return getattr(self.scene, "wtd_sanity", None)
@property
def prefs(self):
addon = bpy.context.preferences.addons.get(ADDON_ID)
return addon.preferences if addon else None
@functools.cached_property
def filename_match(self):
pattern = config.FILENAME_PATTERNS.get(self.context_id)
if not pattern or not self.filename:
return None
return re.match(pattern, self.filename)
@functools.cached_property
def asset_name(self):
"""Asset name from the panel override, else from the file name."""
settings = self.settings
if settings is not None and settings.asset_name.strip():
return settings.asset_name.strip()
match = self.filename_match
if match and "asset" in match.groupdict() and match.group("asset"):
return match.group("asset")
return None
def objects(self, *types, local=True):
"""Scene objects, optionally filtered by type and excluding linked ones."""
result = []
for ob in self.scene.objects:
if types and ob.type not in types:
continue
if local and not is_local(ob):
continue
result.append(ob)
return result
class Check:
"""Base class of every sanity check. See module docstring."""
id: ClassVar[str] = ""
label: ClassVar[str] = ""
description: ClassVar[str] = ""
category: ClassVar[str] = "General"
contexts: ClassVar[frozenset] = frozenset()
severity: ClassVar[Severity] = Severity.ERROR
# Sort key inside the category.
order: ClassVar[int] = 100
# Tooltip of the Fix button.
fix_description: ClassVar[str] = "Fix the reported issues"
# Show an inline name field for the issue datablock (renaming in the panel).
editable_names: ClassVar[bool] = False
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# Only classes declaring their own id are concrete checks; this lets a
# check subclass another one without registering twice.
if cls.__dict__.get("id"):
_register(cls)
def run(self, ctx: CheckContext) -> Iterable[Issue] | None:
raise NotImplementedError
def fix(self, ctx: CheckContext, issues: list[Issue]) -> Iterable[str] | None:
raise NotImplementedError
@classmethod
def can_fix(cls):
return cls.fix is not Check.fix
# --------------------------------------------------------------------------- #
# Registry
# --------------------------------------------------------------------------- #
_REGISTRY: dict[str, type[Check]] = {}
def _register(cls):
if not cls.label:
cls.label = cls.id.replace("_", " ").capitalize()
previous = _REGISTRY.get(cls.id)
if previous is not None and previous.__qualname__ != cls.__qualname__:
print(f"[WTD Sanity] Check id '{cls.id}' of {cls.__qualname__} replaces {previous.__qualname__}")
_REGISTRY[cls.id] = cls
def _sort_key(cls):
order = config.CATEGORY_ORDER
category_index = order.index(cls.category) if cls.category in order else len(order)
return (category_index, cls.category, cls.order, cls.label)
def all_checks():
return sorted(_REGISTRY.values(), key=_sort_key)
def checks_for_context(context_id):
return [cls for cls in all_checks() if context_id in cls.contexts]
def get_check(check_id):
return _REGISTRY.get(check_id)
def clear_registry():
_REGISTRY.clear()