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
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user