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:
1 parent
71b1a61b5c
commit
4617c47ad0
33 files changed
+2771
-52
No files matched your search
+256
@@ -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()
|
||||
Reference in new issue
Block a user