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

+37
View File
@@ -0,0 +1,37 @@
"""Public API of the sanity check framework.
Checks only need::
from ...core import Check, Issue, Severity, SkipCheck, contexts
"""
from . import contexts
from .check import (
Check,
CheckContext,
Issue,
Severity,
SkipCheck,
all_checks,
checks_for_context,
get_check,
is_local,
)
from .runner import Report, Status, fix_check, run_checks
__all__ = [
"Check",
"CheckContext",
"Issue",
"Report",
"Severity",
"SkipCheck",
"Status",
"all_checks",
"checks_for_context",
"contexts",
"fix_check",
"get_check",
"is_local",
"run_checks",
]
+110
View File
@@ -0,0 +1,110 @@
"""Character reference: topology and part names exported from a master character.
The reference JSON lets every character file be compared with the master
character without opening it:
- ``parts``: inner mouth / eye object names, with the character name replaced
by ``{asset}`` (e.g. ``GEO_{asset}_Eye_L``).
- ``body``: vertex/edge/face counts and the face vertex indices of the body
mesh, used to compare vertex order.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import zlib
import bpy
import numpy as np
from .. import config
from .mesh import polygon_arrays
FORMAT_VERSION = 1
def templated(name, asset):
"""Replace the ``asset`` token of an underscore separated name by ``{asset}``."""
return "_".join("{asset}" if token == asset else token for token in name.split("_"))
def part_objects(objects):
keywords = [k.lower() for k in config.CHAR_PART_KEYWORDS]
return [ob for ob in objects if any(k in ob.name.lower() for k in keywords)]
def body_name(asset):
return config.CHAR_BODY_NAME.format(asset=asset)
def _encode(array):
return base64.b64encode(zlib.compress(np.ascontiguousarray(array, dtype=np.int32).tobytes())).decode("ascii")
def _decode(text):
return np.frombuffer(zlib.decompress(base64.b64decode(text)), dtype=np.int32)
def topology_signature(mesh):
_, totals, loop_verts = polygon_arrays(mesh)
digest = hashlib.sha1(totals.tobytes() + loop_verts.tobytes()).hexdigest()
return {
"vertices": len(mesh.vertices),
"edges": len(mesh.edges),
"faces": len(mesh.polygons),
"hash": digest,
"loop_totals": _encode(totals),
"loop_vertices": _encode(loop_verts),
}
def decode_topology(body):
return _decode(body["loop_totals"]), _decode(body["loop_vertices"])
def reference_path(prefs=None):
if prefs is not None and getattr(prefs, "char_reference_path", ""):
return bpy.path.abspath(prefs.char_reference_path)
return os.environ.get("WTD_CHAR_REFERENCE", "") or config.CHAR_REFERENCE_PATH
_cache: dict[str, tuple[float, dict]] = {}
def load_reference(path):
"""Load a reference JSON, cached by modification time. None if missing."""
if not path or not os.path.isfile(path):
return None
mtime = os.path.getmtime(path)
cached = _cache.get(path)
if cached and cached[0] == mtime:
return cached[1]
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
_cache[path] = (mtime, data)
return data
def export_reference(scene, asset, path):
"""Write the reference of the character ``asset`` found in ``scene``."""
body = scene.objects.get(body_name(asset))
if body is None or body.type != "MESH":
raise ValueError(f"Body mesh '{body_name(asset)}' not found in the scene")
if body.mode == "EDIT":
body.update_from_editmode()
parts = sorted({templated(ob.name, asset) for ob in part_objects(scene.objects)})
data = {
"format_version": FORMAT_VERSION,
"source_asset": asset,
"source_file": bpy.data.filepath,
"parts": parts,
"body": topology_signature(body.data),
}
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
_cache.pop(path, None)
return data
+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()
+44
View File
@@ -0,0 +1,44 @@
"""Production contexts a file can be checked against.
To add a context, append a ``Context`` to ``CONTEXTS`` and list its id in the
``contexts`` attribute of the checks that apply to it.
"""
from dataclasses import dataclass
GENERAL = "GENERAL"
CHAR_MOD = "CHAR_MOD"
CHAR_LINEART = "CHAR_LINEART"
LAYOUT = "LAYOUT"
# Every asset context gets the general asset checks.
ASSET = frozenset({GENERAL, CHAR_MOD, CHAR_LINEART})
CHARACTER = frozenset({CHAR_MOD, CHAR_LINEART})
ALL = frozenset({GENERAL, CHAR_MOD, CHAR_LINEART, LAYOUT})
@dataclass(frozen=True)
class Context:
id: str
label: str
description: str
icon: str = "NONE"
CONTEXTS = [
Context(GENERAL, "Asset: General", "Props, sets and generic assets (modeling and lineart)", "MESH_CUBE"),
Context(CHAR_MOD, "Character: Modeling", "Character modeling file", "USER"),
Context(CHAR_LINEART, "Character: Lineart / Shading", "Character lineart and shading file", "OUTLINER_OB_GREASEPENCIL"),
Context(LAYOUT, "Shot: Layout", "Shot layout file", "SEQUENCE"),
]
def get_context(context_id):
for context in CONTEXTS:
if context.id == context_id:
return context
return None
def enum_items():
return [(c.id, c.label, c.description, c.icon, i) for i, c in enumerate(CONTEXTS)]
+32
View File
@@ -0,0 +1,32 @@
"""Fast mesh data access with numpy."""
import numpy as np
def polygon_arrays(mesh):
"""Return (loop_start, loop_total, loop_vertex_index) as int arrays."""
n_polys = len(mesh.polygons)
starts = np.empty(n_polys, dtype=np.int32)
totals = np.empty(n_polys, dtype=np.int32)
mesh.polygons.foreach_get("loop_start", starts)
mesh.polygons.foreach_get("loop_total", totals)
loop_verts = np.empty(len(mesh.loops), dtype=np.int32)
mesh.loops.foreach_get("vertex_index", loop_verts)
return starts, totals, loop_verts
def loop_polygon_index(totals):
"""Polygon index of every loop."""
return np.repeat(np.arange(len(totals), dtype=np.int32), totals)
def vertex_coords(mesh):
co = np.empty(len(mesh.vertices) * 3, dtype=np.float64)
mesh.vertices.foreach_get("co", co)
return co.reshape(-1, 3)
def polygon_normals(mesh):
normals = np.empty(len(mesh.polygons) * 3, dtype=np.float64)
mesh.polygon_normals.foreach_get("vector", normals)
return normals.reshape(-1, 3)
+233
View File
@@ -0,0 +1,233 @@
"""Run checks and keep the last report for the UI."""
from __future__ import annotations
import enum
import time
import traceback
from dataclasses import dataclass, field
import bpy
from . import contexts
from .check import CheckContext, Issue, Severity, SkipCheck, checks_for_context, get_check
class Status(enum.Enum):
NOT_RUN = "Not run"
PASSED = "Passed"
INFO = "Info"
WARNING = "Warning"
ERROR = "Error"
SKIPPED = "Skipped"
CRASHED = "Crashed"
_SEVERITY_STATUS = {Severity.INFO: Status.INFO, Severity.WARNING: Status.WARNING, Severity.ERROR: Status.ERROR}
@dataclass
class CheckResult:
check_id: str
status: Status = Status.NOT_RUN
issues: list[Issue] = field(default_factory=list)
message: str = "" # skip reason or traceback
duration: float = 0.0
@property
def failed(self):
return self.status in {Status.WARNING, Status.ERROR}
def to_dict(self):
return {
"check_id": self.check_id,
"status": self.status.name,
"message": self.message,
"duration": round(self.duration, 4),
"issues": [issue.to_dict() for issue in self.issues],
}
@dataclass
class Report:
context_id: str
filepath: str
results: dict[str, CheckResult] = field(default_factory=dict)
def counts(self):
counts = {status: 0 for status in Status}
for result in self.results.values():
counts[result.status] += 1
return counts
@property
def has_errors(self):
return any(r.status in {Status.ERROR, Status.CRASHED} for r in self.results.values())
def to_dict(self):
return {
"context": self.context_id,
"filepath": self.filepath,
"summary": {s.name: n for s, n in self.counts().items() if n},
"results": [r.to_dict() for r in self.results.values()],
}
def to_text(self):
context = contexts.get_context(self.context_id)
lines = [
"WTD Sanity Check report",
f"File: {self.filepath or '<unsaved>'}",
f"Context: {context.label if context else self.context_id}",
"Summary: " + ", ".join(f"{s.value}: {n}" for s, n in self.counts().items() if n),
"",
]
for result in self.results.values():
cls = get_check(result.check_id)
label = cls.label if cls else result.check_id
lines.append(f"[{result.status.value.upper():8}] {label}")
if result.message:
lines.extend(" " + line for line in result.message.splitlines())
for issue in result.issues:
severity = (issue.severity or (cls.severity if cls else Severity.ERROR)).name
lines.append(f" - ({severity.lower()}) {issue.message}")
return "\n".join(lines)
# Last report, read by the UI. Reset on file load.
LAST_REPORT: Report | None = None
def get_result(check_id):
if LAST_REPORT is None:
return None
return LAST_REPORT.results.get(check_id)
def clear():
global LAST_REPORT
LAST_REPORT = None
def disabled_check_ids(scene):
settings = getattr(scene, "wtd_sanity", None)
if settings is None or not settings.disabled_checks:
return set()
return {name for name in settings.disabled_checks.split(";") if name}
def _enter_object_mode(scene):
"""Leave edit/paint modes so mesh data is complete. Returns the mode to restore."""
context = bpy.context
active = context.view_layer.objects.active
if context.scene != scene or active is None or active.mode == "OBJECT":
return None
mode = active.mode
bpy.ops.object.mode_set(mode="OBJECT")
return mode
def _restore_mode(mode):
if mode is None:
return
try:
bpy.ops.object.mode_set(mode=mode)
except (RuntimeError, TypeError):
pass # the active object may have been removed by a fix
def _prepare(scene):
# World matrices of objects changed by scripts are stale until evaluated.
if bpy.context.scene == scene:
bpy.context.view_layer.update()
def run_check(check_cls, ctx):
result = CheckResult(check_cls.id)
start = time.perf_counter()
try:
issues = list(check_cls().run(ctx) or [])
except SkipCheck as exc:
result.status = Status.SKIPPED
result.message = str(exc)
except Exception:
result.status = Status.CRASHED
result.message = traceback.format_exc()
traceback.print_exc()
else:
result.issues = issues
if issues:
worst = max(issue.severity or check_cls.severity for issue in issues)
result.status = _SEVERITY_STATUS[worst]
else:
result.status = Status.PASSED
result.duration = time.perf_counter() - start
return result
def run_checks(scene=None, context_id=None, check_ids=None, include_disabled=False):
"""Run the checks of a context and store the report in ``LAST_REPORT``.
``check_ids`` limits the run to some checks; their results are merged into
the previous report when it was made for the same context.
"""
scene = scene or bpy.context.scene
if context_id is None:
context_id = scene.wtd_sanity.context
previous_mode = _enter_object_mode(scene)
try:
report = _run(scene, context_id, check_ids, include_disabled)
finally:
_restore_mode(previous_mode)
return report
def _run(scene, context_id, check_ids, include_disabled):
global LAST_REPORT
_prepare(scene)
ctx = CheckContext(scene, context_id)
classes = checks_for_context(context_id)
if check_ids is not None:
classes = [cls for cls in classes if cls.id in check_ids]
disabled = set() if include_disabled else disabled_check_ids(scene)
if check_ids is not None and LAST_REPORT is not None and LAST_REPORT.context_id == context_id:
report = LAST_REPORT
report.filepath = bpy.data.filepath
else:
report = Report(context_id, bpy.data.filepath)
# Keep the context ordering stable in the report.
for cls in checks_for_context(context_id):
report.results[cls.id] = CheckResult(cls.id)
for cls in classes:
if cls.id in disabled:
report.results[cls.id] = CheckResult(cls.id, Status.SKIPPED, message="Disabled in this file")
continue
report.results[cls.id] = run_check(cls, ctx)
LAST_REPORT = report
return report
def fix_check(check_id, scene=None):
"""Run the fix of a check on its last issues, then re-run it.
Returns messages for issues that could not be fixed.
"""
scene = scene or bpy.context.scene
cls = get_check(check_id)
result = get_result(check_id)
if cls is None or not cls.can_fix() or result is None or not result.issues:
return []
context_id = LAST_REPORT.context_id
previous_mode = _enter_object_mode(scene)
try:
messages = list(cls().fix(CheckContext(scene, context_id), result.issues) or [])
except Exception as exc:
traceback.print_exc()
messages = [f"{cls.label}: fix failed ({exc.__class__.__name__}: {exc})"]
finally:
_restore_mode(previous_mode)
run_checks(scene, context_id, check_ids={check_id})
return messages