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
+34
-5
@@ -1,3 +1,19 @@
|
||||
import sys
|
||||
|
||||
# Reload support: drop our cached submodules so "Reload Scripts" and
|
||||
# disable/enable pick up edits in every file, not only this one.
|
||||
for _name in [name for name in sys.modules if name.startswith(__name__ + ".")]:
|
||||
del sys.modules[_name]
|
||||
|
||||
import bpy # noqa: E402
|
||||
from bpy.app.handlers import persistent # noqa: E402
|
||||
|
||||
from . import checks, core, tools_ops, ui # noqa: E402
|
||||
from .core import runner # noqa: E402
|
||||
|
||||
api = core
|
||||
|
||||
# Old bl_info to be able to develop on live code
|
||||
bl_info = {
|
||||
"name": "Melvile Tools",
|
||||
"author": "Walking The Dog (WTD)",
|
||||
@@ -11,19 +27,32 @@ bl_info = {
|
||||
"category": [],
|
||||
}
|
||||
|
||||
import bpy
|
||||
|
||||
# Import modules
|
||||
from . import ops, ui
|
||||
@persistent
|
||||
def _on_load_post(*_args):
|
||||
runner.clear()
|
||||
|
||||
|
||||
def _load_config_override():
|
||||
prefs = ui.properties.get_prefs()
|
||||
ui.properties.load_config_override(prefs.config_path if prefs else "")
|
||||
|
||||
|
||||
# Register modules
|
||||
def register() -> None:
|
||||
ops.register()
|
||||
tools_ops.register()
|
||||
checks.load_all()
|
||||
ui.register()
|
||||
_load_config_override()
|
||||
bpy.app.handlers.load_post.append(_on_load_post)
|
||||
|
||||
|
||||
# Unregister modules
|
||||
def unregister() -> None:
|
||||
ops.unregister()
|
||||
tools_ops.unregister()
|
||||
if _on_load_post in bpy.app.handlers.load_post:
|
||||
bpy.app.handlers.load_post.remove(_on_load_post)
|
||||
ui.unregister()
|
||||
runner.clear()
|
||||
# The check registry is plain Python and is rebuilt with the modules on
|
||||
# re-import, so it is kept as is (enable/disable without re-import).
|
||||
@@ -7,13 +7,15 @@ tagline = "Tools for the Melvile Pipeline"
|
||||
maintainer = "Walking The DOG (WTD)"
|
||||
type = "add-on"
|
||||
website = "walkingthedog.be"
|
||||
tags = ["tools"]
|
||||
blender_version_min = "5.2.0"
|
||||
tags = ["Pipeline", "Scene", "Tools"]
|
||||
|
||||
license = ["SPDX:GPL-2.0-or-later"]
|
||||
blender_version_min = "4.4.0"
|
||||
|
||||
license = ["SPDX:GPL-3.0-or-later"]
|
||||
copyright = ["2026 WTD"]
|
||||
|
||||
[permissions]
|
||||
files = "Read reference, config and sound files"
|
||||
|
||||
[build]
|
||||
paths_exclude_pattern = [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Check modules.
|
||||
|
||||
Every module in this package (and its sub-packages) is imported at
|
||||
registration, so dropping a ``.py`` file containing ``Check`` subclasses
|
||||
anywhere in here is enough to add checks. Sub-packages are only used for
|
||||
grouping, the ``contexts`` attribute of each check decides where it runs.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
|
||||
def load_all():
|
||||
"""Import every check module so their classes register themselves."""
|
||||
for info in pkgutil.walk_packages(__path__, __name__ + "."):
|
||||
importlib.import_module(info.name)
|
||||
@@ -0,0 +1 @@
|
||||
"""Extra checks for character lineart / shading files."""
|
||||
@@ -0,0 +1,77 @@
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
from ..general.objects import ModifiersCheck
|
||||
|
||||
|
||||
class LineartModifiersCheck(ModifiersCheck):
|
||||
"""Same as the general modifier check, with lineart modifiers allowed."""
|
||||
|
||||
id = "modifiers_lineart"
|
||||
label = "Modifiers (lineart)"
|
||||
description = "No modifiers other than Line Art, Thickness, Subdivision Surface and Multiresolution."
|
||||
contexts = frozenset({contexts.CHAR_LINEART})
|
||||
allowed_config_key = "ALLOWED_MODIFIERS_LINEART"
|
||||
|
||||
|
||||
def _color_settings(scene):
|
||||
"""(key, label, getter, setter) of every color management setting."""
|
||||
view = scene.view_settings
|
||||
return [
|
||||
("display_device", "Display device",
|
||||
lambda: scene.display_settings.display_device,
|
||||
lambda v: setattr(scene.display_settings, "display_device", v)),
|
||||
("view_transform", "View transform",
|
||||
lambda: view.view_transform, lambda v: setattr(view, "view_transform", v)),
|
||||
("look", "Look", lambda: view.look, lambda v: setattr(view, "look", v)),
|
||||
("exposure", "Exposure", lambda: view.exposure, lambda v: setattr(view, "exposure", v)),
|
||||
("gamma", "Gamma", lambda: view.gamma, lambda v: setattr(view, "gamma", v)),
|
||||
("sequencer_colorspace", "Sequencer colorspace",
|
||||
lambda: scene.sequencer_colorspace_settings.name,
|
||||
lambda v: setattr(scene.sequencer_colorspace_settings, "name", v)),
|
||||
("working_space", "Working space",
|
||||
lambda: getattr(getattr(bpy.data, "colorspace", None), "working_space", None),
|
||||
None),
|
||||
]
|
||||
|
||||
|
||||
class ColorManagementCheck(Check):
|
||||
id = "color_management"
|
||||
label = "Color management"
|
||||
description = "Display, view transform, look, exposure, gamma and colorspaces follow the production specs."
|
||||
category = "Lineart"
|
||||
contexts = frozenset({contexts.CHAR_LINEART})
|
||||
fix_description = "Apply the production color management (working space excepted)"
|
||||
|
||||
def run(self, ctx):
|
||||
spec = config.COLOR_MANAGEMENT
|
||||
for key, label, getter, _setter in _color_settings(ctx.scene):
|
||||
expected = spec.get(key)
|
||||
if expected is None:
|
||||
continue
|
||||
value = getter()
|
||||
if isinstance(expected, float):
|
||||
ok = value is not None and math.isclose(value, expected, abs_tol=1e-6)
|
||||
else:
|
||||
ok = value == expected
|
||||
if not ok:
|
||||
yield Issue(f"{label} is '{value}', expected '{expected}'", data={"key": key})
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
spec = config.COLOR_MANAGEMENT
|
||||
keys = {issue.data.get("key") for issue in issues}
|
||||
failed = []
|
||||
for key, label, _getter, setter in _color_settings(ctx.scene):
|
||||
if key not in keys:
|
||||
continue
|
||||
if setter is None:
|
||||
failed.append(f"{label} must be changed by hand (it converts existing colors)")
|
||||
continue
|
||||
try:
|
||||
setter(spec[key])
|
||||
except (TypeError, ValueError) as exc:
|
||||
failed.append(f"{label}: {exc}")
|
||||
return failed
|
||||
@@ -0,0 +1 @@
|
||||
"""Extra checks for character modeling files."""
|
||||
@@ -0,0 +1,133 @@
|
||||
import difflib
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, SkipCheck, contexts
|
||||
from ...core import char_reference as ref
|
||||
from ...core.mesh import polygon_arrays
|
||||
|
||||
|
||||
def _require_asset(ctx):
|
||||
if not ctx.asset_name:
|
||||
raise SkipCheck("Unknown character name: set it in the panel or follow the file naming convention")
|
||||
return ctx.asset_name
|
||||
|
||||
|
||||
def _require_reference(ctx):
|
||||
path = ref.reference_path(ctx.prefs)
|
||||
data = ref.load_reference(path)
|
||||
if data is None:
|
||||
raise SkipCheck(
|
||||
"No character reference: export one from the master character "
|
||||
"and set its path in the add-on preferences"
|
||||
+ (f" (not found: {path})" if path else "")
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _body(ctx, asset):
|
||||
name = ref.body_name(asset)
|
||||
ob = ctx.scene.objects.get(name)
|
||||
if ob is None or ob.type != "MESH":
|
||||
return None, Issue(f"Body mesh '{name}' not found")
|
||||
return ob, None
|
||||
|
||||
|
||||
class CharPartsNamingCheck(Check):
|
||||
id = "char_parts_naming"
|
||||
label = "Inner mouth & eyes naming"
|
||||
description = (
|
||||
"Inner mouth and eye objects carry exactly the names of the reference character "
|
||||
"(with this character's name)."
|
||||
)
|
||||
category = "Character"
|
||||
contexts = frozenset({contexts.CHAR_MOD})
|
||||
editable_names = True
|
||||
|
||||
def run(self, ctx):
|
||||
asset = _require_asset(ctx)
|
||||
reference = _require_reference(ctx)
|
||||
expected = {name.format(asset=asset) for name in reference.get("parts", [])}
|
||||
found = {ob.name for ob in ref.part_objects(ctx.objects())}
|
||||
|
||||
for name in sorted(expected - found):
|
||||
yield Issue(f"Missing '{name}'")
|
||||
for name in sorted(found - expected):
|
||||
close = difflib.get_close_matches(name, sorted(expected - found) or sorted(expected), n=1)
|
||||
hint = f" (did you mean '{close[0]}'?)" if close else ""
|
||||
yield Issue.for_id(f"Unexpected name '{name}'{hint}", ctx.scene.objects[name])
|
||||
|
||||
|
||||
class BodyTopologyCheck(Check):
|
||||
id = "body_topology"
|
||||
label = "Body vertex order"
|
||||
description = "The body mesh has the same vertex count and vertex order as the reference character."
|
||||
category = "Character"
|
||||
contexts = frozenset({contexts.CHAR_MOD})
|
||||
|
||||
def run(self, ctx):
|
||||
asset = _require_asset(ctx)
|
||||
reference = _require_reference(ctx)
|
||||
body, issue = _body(ctx, asset)
|
||||
if issue:
|
||||
yield issue
|
||||
return
|
||||
expected = reference["body"]
|
||||
current = ref.topology_signature(body.data)
|
||||
if current["hash"] == expected["hash"]:
|
||||
return
|
||||
|
||||
counts = [
|
||||
f"{key} {current[key]} vs {expected[key]}"
|
||||
for key in ("vertices", "edges", "faces")
|
||||
if current[key] != expected[key]
|
||||
]
|
||||
if counts:
|
||||
yield Issue.for_id(f"{body.name}: different topology ({', '.join(counts)})", body)
|
||||
return
|
||||
|
||||
# Same counts: find the faces whose vertex indices differ.
|
||||
ref_totals, ref_loops = ref.decode_topology(expected)
|
||||
_, totals, loops = polygon_arrays(body.data)
|
||||
if not np.array_equal(totals, ref_totals):
|
||||
bad = np.flatnonzero(totals != ref_totals)
|
||||
else:
|
||||
poly_of_loop = np.repeat(np.arange(len(totals)), totals)
|
||||
bad = np.unique(poly_of_loop[loops != ref_loops])
|
||||
yield Issue.for_id(
|
||||
f"{body.name}: vertex order differs from '{reference.get('source_asset', 'reference')}' "
|
||||
f"on {bad.size} face(s)",
|
||||
body,
|
||||
elements=bad.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
|
||||
|
||||
class BodyVertexGroupsCheck(Check):
|
||||
id = "body_vertex_groups"
|
||||
label = "Body vertex groups"
|
||||
description = "The body mesh has the vertex groups required by production (config CHAR_BODY_VERTEX_GROUPS)."
|
||||
category = "Character"
|
||||
contexts = frozenset({contexts.CHAR_MOD})
|
||||
fix_description = "Create the missing (empty) vertex groups"
|
||||
|
||||
def run(self, ctx):
|
||||
if not config.CHAR_BODY_VERTEX_GROUPS:
|
||||
raise SkipCheck("No required vertex group defined in config CHAR_BODY_VERTEX_GROUPS")
|
||||
asset = _require_asset(ctx)
|
||||
body, issue = _body(ctx, asset)
|
||||
if issue:
|
||||
yield issue
|
||||
return
|
||||
missing = [name for name in config.CHAR_BODY_VERTEX_GROUPS if name not in body.vertex_groups]
|
||||
if missing:
|
||||
yield Issue.for_id(f"{body.name}: missing {', '.join(missing)}", body, data={"missing": missing})
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
body = issue.get_id()
|
||||
if body is None:
|
||||
continue
|
||||
for name in issue.data.get("missing", []):
|
||||
body.vertex_groups.new(name=name)
|
||||
@@ -0,0 +1 @@
|
||||
"""Checks shared by every asset context (General, Character Mod, Character Lineart)."""
|
||||
@@ -0,0 +1,49 @@
|
||||
import bpy
|
||||
|
||||
from ...core import Check, Issue, contexts, is_local
|
||||
|
||||
_ANIMATABLE = [
|
||||
"objects", "meshes", "curves", "armatures", "grease_pencils", "materials",
|
||||
"node_groups", "shape_keys", "cameras", "lights", "worlds", "scenes",
|
||||
]
|
||||
|
||||
|
||||
def _animated_ids():
|
||||
for collection_name in _ANIMATABLE:
|
||||
for id_block in getattr(bpy.data, collection_name, ()):
|
||||
if not is_local(id_block):
|
||||
continue
|
||||
ad = id_block.animation_data
|
||||
if ad is not None and (ad.action is not None or len(ad.nla_tracks)):
|
||||
yield id_block, ad
|
||||
|
||||
|
||||
class AnimationCheck(Check):
|
||||
id = "animation"
|
||||
label = "No animation"
|
||||
description = "No action or NLA track is assigned (drivers are allowed)."
|
||||
category = "Animation"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Unassign actions and remove NLA tracks (drivers are kept)"
|
||||
|
||||
def run(self, ctx):
|
||||
for id_block, ad in _animated_ids():
|
||||
parts = []
|
||||
if ad.action is not None:
|
||||
parts.append(f"action '{ad.action.name}'")
|
||||
if len(ad.nla_tracks):
|
||||
parts.append(f"{len(ad.nla_tracks)} NLA track(s)")
|
||||
name = id_block.user.name if isinstance(id_block, bpy.types.Key) else id_block.name
|
||||
yield Issue.for_id(f"{name}: {', '.join(parts)}", id_block)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
id_block = issue.get_id()
|
||||
ad = id_block.animation_data if id_block is not None else None
|
||||
if ad is None:
|
||||
continue
|
||||
ad.action = None
|
||||
for track in list(ad.nla_tracks):
|
||||
ad.nla_tracks.remove(track)
|
||||
if not len(ad.drivers):
|
||||
id_block.animation_data_clear()
|
||||
@@ -0,0 +1,109 @@
|
||||
import bmesh
|
||||
import numpy as np
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
from ...core.mesh import loop_polygon_index, polygon_arrays, polygon_normals, vertex_coords
|
||||
|
||||
|
||||
class NgonCheck(Check):
|
||||
id = "ngons"
|
||||
label = "N-gons"
|
||||
description = "Meshes contain only triangles and quads (no face with more than 4 sides)."
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 10
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
_, totals, _ = polygon_arrays(ob.data)
|
||||
bad = np.flatnonzero(totals > config.MAX_FACE_SIDES)
|
||||
if bad.size:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {bad.size} face(s) with more than {config.MAX_FACE_SIDES} sides",
|
||||
ob,
|
||||
elements=bad.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
|
||||
|
||||
def concave_polygons(mesh, tolerance):
|
||||
"""Indices of polygons with at least one reflex corner."""
|
||||
if not len(mesh.polygons):
|
||||
return np.empty(0, dtype=np.int32)
|
||||
starts, totals, loop_verts = polygon_arrays(mesh)
|
||||
co = vertex_coords(mesh)
|
||||
normals = polygon_normals(mesh)
|
||||
|
||||
poly_of_loop = loop_polygon_index(totals)
|
||||
start = starts[poly_of_loop]
|
||||
total = totals[poly_of_loop]
|
||||
local = np.arange(len(loop_verts)) - start
|
||||
prev_loop = start + (local - 1) % total
|
||||
next_loop = start + (local + 1) % total
|
||||
|
||||
point = co[loop_verts]
|
||||
incoming = point - co[loop_verts[prev_loop]]
|
||||
outgoing = co[loop_verts[next_loop]] - point
|
||||
turn = np.einsum("ij,ij->i", np.cross(incoming, outgoing), normals[poly_of_loop])
|
||||
scale = np.linalg.norm(incoming, axis=1) * np.linalg.norm(outgoing, axis=1) + 1e-12
|
||||
reflex = (turn / scale < -tolerance) & (total > 3)
|
||||
return np.unique(poly_of_loop[reflex])
|
||||
|
||||
|
||||
class ConcaveFaceCheck(Check):
|
||||
id = "concave_faces"
|
||||
label = "Concave faces"
|
||||
description = "Quads and n-gons are convex (no corner angle above 180 degrees)."
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 20
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
bad = concave_polygons(ob.data, config.CONCAVE_TOLERANCE)
|
||||
if bad.size:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {bad.size} concave face(s)",
|
||||
ob,
|
||||
elements=bad.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
|
||||
|
||||
class NonManifoldCheck(Check):
|
||||
id = "non_manifold"
|
||||
label = "Non-manifold geometry"
|
||||
description = (
|
||||
"No wire edges, edges shared by more than two faces, inconsistent normals, "
|
||||
"bow-tie vertices or (unless allowed in config) open borders."
|
||||
)
|
||||
category = "Geometry"
|
||||
contexts = contexts.ASSET
|
||||
order = 30
|
||||
|
||||
def run(self, ctx):
|
||||
allow_boundary = config.NON_MANIFOLD_ALLOW_BOUNDARY
|
||||
for ob in ctx.objects("MESH"):
|
||||
bm = bmesh.new()
|
||||
try:
|
||||
bm.from_mesh(ob.data)
|
||||
edges = [
|
||||
e.index for e in bm.edges
|
||||
if not e.is_contiguous and not (allow_boundary and e.is_boundary)
|
||||
]
|
||||
verts = [
|
||||
v.index for v in bm.verts
|
||||
if not v.is_manifold and not (allow_boundary and v.is_boundary)
|
||||
and not any(not e.is_contiguous for e in v.link_edges)
|
||||
]
|
||||
finally:
|
||||
bm.free()
|
||||
if edges:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {len(edges)} non-manifold edge(s)", ob, elements=edges, element_type="EDGE"
|
||||
)
|
||||
if verts:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {len(verts)} non-manifold vertex(es)", ob, elements=verts, element_type="VERT"
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
import re
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
_SUFFIX_RE = re.compile(r"\.\d{3,}$")
|
||||
|
||||
|
||||
def _strip_known_prefix(name, prefixes):
|
||||
for prefix in sorted(prefixes, key=len, reverse=True):
|
||||
if name.upper().startswith(prefix.upper()):
|
||||
return name[len(prefix):]
|
||||
return name
|
||||
|
||||
|
||||
def suggest_name(name, prefix, all_prefixes):
|
||||
"""Best-effort conforming name, or None when a human has to choose."""
|
||||
body = _SUFFIX_RE.sub("", name)
|
||||
body = _strip_known_prefix(body, all_prefixes)
|
||||
body = body.encode("ascii", "ignore").decode()
|
||||
body = re.sub(r"[^A-Za-z0-9]+", "_", body).strip("_")
|
||||
if not body or _is_default(body):
|
||||
return None
|
||||
candidate = prefix + body
|
||||
return candidate if candidate != name else None
|
||||
|
||||
|
||||
def _is_default(body):
|
||||
return re.sub(r"[^a-z]", "", body.lower()) in config.DEFAULT_NAMES
|
||||
|
||||
|
||||
def _validate(name, prefix):
|
||||
"""Return the list of problems of a name for the given prefix."""
|
||||
problems = []
|
||||
if _SUFFIX_RE.search(name):
|
||||
problems.append("duplicate suffix")
|
||||
if not name.isascii():
|
||||
problems.append("non-ASCII characters")
|
||||
if " " in name:
|
||||
problems.append("spaces")
|
||||
if prefix and not name.startswith(prefix):
|
||||
problems.append(f"missing prefix '{prefix}'")
|
||||
body = _SUFFIX_RE.sub("", name)
|
||||
body = body[len(prefix):] if prefix and body.startswith(prefix) else body
|
||||
if _is_default(body):
|
||||
problems.append("Blender default name")
|
||||
elif not re.fullmatch(config.NAME_BODY_PATTERN, body):
|
||||
problems.append("invalid characters or empty tokens")
|
||||
return problems
|
||||
|
||||
|
||||
class NamingCheck(Check):
|
||||
id = "naming"
|
||||
label = "Naming convention"
|
||||
description = (
|
||||
"Objects, mesh data, collections, materials and node groups follow the type prefix "
|
||||
"convention (GEO_, COL_, MAT_...), with no default names, '.001' suffixes, spaces or accents."
|
||||
)
|
||||
category = "Naming"
|
||||
contexts = contexts.ASSET
|
||||
editable_names = True
|
||||
fix_description = "Rename to the suggested name when one can be derived (prefix, suffix and character clean-up)"
|
||||
|
||||
def run(self, ctx):
|
||||
all_prefixes = set(config.OBJECT_PREFIXES.values()) | set(config.DATABLOCK_PREFIXES.values())
|
||||
|
||||
def check(id_block, prefix, kind):
|
||||
problems = _validate(id_block.name, prefix)
|
||||
if problems:
|
||||
suggestion = suggest_name(id_block.name, prefix, all_prefixes)
|
||||
hint = f" -> {suggestion}" if suggestion else ""
|
||||
return Issue.for_id(
|
||||
f"{kind} '{id_block.name}': {', '.join(problems)}{hint}",
|
||||
id_block,
|
||||
data={"suggestion": suggestion},
|
||||
)
|
||||
return None
|
||||
|
||||
objects = ctx.objects()
|
||||
for ob in objects:
|
||||
prefix = config.OBJECT_PREFIXES.get(ob.type)
|
||||
if prefix is None:
|
||||
continue
|
||||
issue = check(ob, prefix, "Object")
|
||||
if issue:
|
||||
yield issue
|
||||
|
||||
if config.MESH_DATA_MATCHES_OBJECT:
|
||||
for ob in objects:
|
||||
me = ob.data
|
||||
if ob.type != "MESH" or me is None or not is_local(me) or me.users > 1:
|
||||
continue
|
||||
if me.name != ob.name:
|
||||
yield Issue.for_id(
|
||||
f"Mesh data '{me.name}' should be named like its object '{ob.name}'",
|
||||
me,
|
||||
severity=Severity.WARNING,
|
||||
data={"match_object": True},
|
||||
)
|
||||
|
||||
for collection_name, prefix in config.DATABLOCK_PREFIXES.items():
|
||||
kind = collection_name.rstrip("s").replace("_", " ").capitalize()
|
||||
for id_block in getattr(bpy.data, collection_name):
|
||||
if not is_local(id_block) or id_block.users == 0:
|
||||
continue
|
||||
if collection_name == "node_groups" and id_block.bl_idname == "CompositorNodeTree":
|
||||
continue
|
||||
issue = check(id_block, prefix, kind)
|
||||
if issue:
|
||||
yield issue
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
skipped = []
|
||||
# Mesh data last: it takes the name of its object once that is fixed.
|
||||
for issue in sorted(issues, key=lambda i: i.data.get("match_object", False)):
|
||||
id_block = issue.get_id()
|
||||
if id_block is None:
|
||||
continue
|
||||
if issue.data.get("match_object"):
|
||||
owners = [ob for ob in bpy.data.objects if ob.data == id_block]
|
||||
suggestion = owners[0].name if len(owners) == 1 else None
|
||||
else:
|
||||
suggestion = issue.data.get("suggestion")
|
||||
if not suggestion:
|
||||
skipped.append(f"'{issue.id_name}': no automatic name, rename it by hand")
|
||||
continue
|
||||
collection = getattr(bpy.data, issue.id_type)
|
||||
existing = collection.get(suggestion)
|
||||
if existing is not None and existing != id_block:
|
||||
skipped.append(f"'{issue.id_name}': '{suggestion}' is already taken")
|
||||
continue
|
||||
id_block.name = suggestion
|
||||
return skipped
|
||||
@@ -0,0 +1,90 @@
|
||||
import bpy
|
||||
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
# Node types that make a tree produce something.
|
||||
_OUTPUT_TYPES = {
|
||||
"OUTPUT_MATERIAL", "OUTPUT_WORLD", "OUTPUT_LIGHT", "OUTPUT_AOV", "OUTPUT_LINESTYLE",
|
||||
"COMPOSITE", "OUTPUT_FILE", "VIEWER",
|
||||
}
|
||||
# Node types never reported as unused.
|
||||
_IGNORED_TYPES = {"FRAME", "GROUP_INPUT"}
|
||||
|
||||
|
||||
def unused_nodes(tree):
|
||||
"""Nodes that do not contribute to any output of ``tree``.
|
||||
|
||||
Returns None when the tree has no output at all (nothing to compare with).
|
||||
"""
|
||||
roots = [
|
||||
node for node in tree.nodes
|
||||
if node.type in _OUTPUT_TYPES
|
||||
or (node.type == "GROUP_OUTPUT" and getattr(node, "is_active_output", True))
|
||||
]
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
upstream = {}
|
||||
for link in tree.links:
|
||||
upstream.setdefault(link.to_node.name, []).append(link.from_node)
|
||||
|
||||
used = set()
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.name in used:
|
||||
continue
|
||||
used.add(node.name)
|
||||
stack.extend(upstream.get(node.name, ()))
|
||||
|
||||
return [node for node in tree.nodes if node.name not in used and node.type not in _IGNORED_TYPES]
|
||||
|
||||
|
||||
def _node_trees():
|
||||
"""(owner datablock, node tree) of every local node tree in use."""
|
||||
for material in bpy.data.materials:
|
||||
if is_local(material) and material.users and material.node_tree:
|
||||
yield material, material.node_tree
|
||||
for world in bpy.data.worlds:
|
||||
if is_local(world) and world.users and world.node_tree:
|
||||
yield world, world.node_tree
|
||||
for group in bpy.data.node_groups:
|
||||
if is_local(group) and group.users:
|
||||
yield group, group
|
||||
|
||||
|
||||
def _tree_of(owner):
|
||||
return owner if isinstance(owner, bpy.types.NodeTree) else owner.node_tree
|
||||
|
||||
|
||||
class UnusedNodesCheck(Check):
|
||||
id = "unused_nodes"
|
||||
label = "Unused nodes"
|
||||
description = "Material, world and node group trees contain no node disconnected from the output."
|
||||
category = "Shading"
|
||||
contexts = contexts.ASSET
|
||||
severity = Severity.WARNING
|
||||
fix_description = "Delete the unused nodes"
|
||||
|
||||
def run(self, ctx):
|
||||
for owner, tree in _node_trees():
|
||||
nodes = unused_nodes(tree)
|
||||
if nodes:
|
||||
names = [node.name for node in nodes]
|
||||
shown = ", ".join(names[:5]) + (" ..." if len(names) > 5 else "")
|
||||
yield Issue.for_id(
|
||||
f"{owner.name}: {len(names)} unused node(s): {shown}",
|
||||
owner,
|
||||
data={"nodes": names},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
owner = issue.get_id()
|
||||
if owner is None:
|
||||
continue
|
||||
tree = _tree_of(owner)
|
||||
for name in issue.data["nodes"]:
|
||||
node = tree.nodes.get(name)
|
||||
if node is not None:
|
||||
tree.nodes.remove(node)
|
||||
@@ -0,0 +1,108 @@
|
||||
import math
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
|
||||
|
||||
class ModifiersCheck(Check):
|
||||
id = "modifiers"
|
||||
label = "Modifiers"
|
||||
description = "No modifiers other than Subdivision Surface and Multiresolution."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET - {contexts.CHAR_LINEART}
|
||||
fix_description = "Apply the modifiers that are not allowed"
|
||||
# Config key holding the allowed modifier types; subclasses change it.
|
||||
allowed_config_key = "ALLOWED_MODIFIERS"
|
||||
|
||||
def run(self, ctx):
|
||||
allowed = getattr(config, self.allowed_config_key)
|
||||
for ob in ctx.objects():
|
||||
names = [m.name for m in ob.modifiers if m.type not in allowed]
|
||||
if names:
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: {', '.join(names)}",
|
||||
ob,
|
||||
data={"modifiers": names},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
failed = []
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is None:
|
||||
continue
|
||||
for name in issue.data["modifiers"]:
|
||||
try:
|
||||
with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob]):
|
||||
bpy.ops.object.modifier_apply(modifier=name)
|
||||
except RuntimeError as exc:
|
||||
failed.append(f"{ob.name} / {name}: {str(exc).strip()}")
|
||||
return failed
|
||||
|
||||
|
||||
class ApplyTransformCheck(Check):
|
||||
id = "apply_transform"
|
||||
label = "Transforms applied"
|
||||
description = "Rotation and scale (and location if set in config) of geometry objects are applied."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Apply the transforms (multi-user data is reported, not changed)"
|
||||
|
||||
def run(self, ctx):
|
||||
tolerance = config.TRANSFORM_TOLERANCE
|
||||
channels = config.APPLY_TRANSFORM
|
||||
for ob in ctx.objects(*config.APPLY_TRANSFORM_TYPES):
|
||||
loc, rot, scale = ob.matrix_basis.decompose()
|
||||
problems = []
|
||||
if channels.get("location") and loc.length > tolerance:
|
||||
problems.append("location")
|
||||
if channels.get("rotation") and min(rot.angle, math.tau - rot.angle) > tolerance:
|
||||
problems.append("rotation")
|
||||
if channels.get("scale") and any(abs(s - 1.0) > tolerance for s in scale):
|
||||
problems.append("scale")
|
||||
if problems:
|
||||
yield Issue.for_id(f"{ob.name}: {', '.join(problems)} not applied", ob)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
failed = []
|
||||
channels = config.APPLY_TRANSFORM
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is None:
|
||||
continue
|
||||
if ob.data is not None and ob.data.users > 1:
|
||||
failed.append(f"{ob.name}: data shared by {ob.data.users} users, apply by hand")
|
||||
continue
|
||||
try:
|
||||
with bpy.context.temp_override(
|
||||
object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob]
|
||||
):
|
||||
bpy.ops.object.transform_apply(
|
||||
location=bool(channels.get("location")),
|
||||
rotation=bool(channels.get("rotation")),
|
||||
scale=bool(channels.get("scale")),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
failed.append(f"{ob.name}: {str(exc).strip()}")
|
||||
return failed
|
||||
|
||||
|
||||
class LightCameraCheck(Check):
|
||||
id = "lights_cameras"
|
||||
label = "Lights & cameras"
|
||||
description = "Asset files contain no lights or cameras."
|
||||
category = "Objects"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Delete the lights and cameras"
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects(*config.FORBIDDEN_ASSET_OBJECT_TYPES):
|
||||
yield Issue.for_id(f"{ob.type.capitalize()} '{ob.name}' must be removed", ob)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is not None:
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Scene-level settings shared by asset and shot files."""
|
||||
|
||||
import math
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
|
||||
|
||||
class UnitsCheck(Check):
|
||||
id = "units"
|
||||
label = "Units"
|
||||
description = "Unit system, unit scale and length unit follow the production specs."
|
||||
category = "Scene"
|
||||
contexts = contexts.ALL
|
||||
fix_description = "Set the production units"
|
||||
|
||||
def run(self, ctx):
|
||||
units = ctx.scene.unit_settings
|
||||
if units.system != config.UNIT_SYSTEM:
|
||||
yield Issue(f"Unit system is {units.system}, expected {config.UNIT_SYSTEM}")
|
||||
if not math.isclose(units.scale_length, config.UNIT_SCALE_LENGTH, rel_tol=1e-6):
|
||||
yield Issue(f"Unit scale is {units.scale_length:g}, expected {config.UNIT_SCALE_LENGTH:g}")
|
||||
if config.UNIT_LENGTH and units.length_unit != config.UNIT_LENGTH:
|
||||
yield Issue(f"Length unit is {units.length_unit}, expected {config.UNIT_LENGTH}")
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
units = ctx.scene.unit_settings
|
||||
units.system = config.UNIT_SYSTEM
|
||||
units.scale_length = config.UNIT_SCALE_LENGTH
|
||||
if config.UNIT_LENGTH:
|
||||
units.length_unit = config.UNIT_LENGTH
|
||||
|
||||
|
||||
class FilenameCheck(Check):
|
||||
id = "filename"
|
||||
label = "File name"
|
||||
description = "The file is saved and its name follows the naming pattern of the context."
|
||||
category = "File"
|
||||
contexts = contexts.ALL
|
||||
|
||||
def run(self, ctx):
|
||||
if not ctx.filepath:
|
||||
yield Issue("The file has never been saved")
|
||||
return
|
||||
pattern = config.FILENAME_PATTERNS.get(ctx.context_id)
|
||||
if pattern and ctx.filename_match is None:
|
||||
yield Issue(f"'{ctx.filename}' does not match {pattern}")
|
||||
@@ -0,0 +1,50 @@
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, Severity, contexts, is_local
|
||||
|
||||
|
||||
class CollectionStructureCheck(Check):
|
||||
"""Placeholder until the production hierarchy is defined: see REQUIRED_COLLECTIONS."""
|
||||
|
||||
id = "collection_structure"
|
||||
label = "Collection structure"
|
||||
description = (
|
||||
"No object directly in the Scene Collection, no empty collections, "
|
||||
"and the required collections (config REQUIRED_COLLECTIONS) exist."
|
||||
)
|
||||
category = "Structure"
|
||||
contexts = contexts.ASSET
|
||||
fix_description = "Remove empty collections"
|
||||
|
||||
def run(self, ctx):
|
||||
scene = ctx.scene
|
||||
for ob in scene.collection.objects:
|
||||
yield Issue.for_id(f"'{ob.name}' is directly in the Scene Collection", ob)
|
||||
|
||||
for name in config.REQUIRED_COLLECTIONS:
|
||||
if "{asset}" in name:
|
||||
if not ctx.asset_name:
|
||||
yield Issue(f"Cannot resolve required collection '{name}': unknown asset name")
|
||||
continue
|
||||
name = name.format(asset=ctx.asset_name)
|
||||
if name not in bpy.data.collections:
|
||||
yield Issue(f"Required collection '{name}' is missing")
|
||||
|
||||
for collection in scene.collection.children_recursive:
|
||||
if is_local(collection) and not collection.all_objects and not collection.children:
|
||||
yield Issue.for_id(
|
||||
f"Collection '{collection.name}' is empty",
|
||||
collection,
|
||||
severity=Severity.WARNING,
|
||||
data={"empty": True},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
if issue.data.get("empty"):
|
||||
collection = issue.get_id()
|
||||
if collection is not None:
|
||||
bpy.data.collections.remove(collection)
|
||||
unfixed = [i for i in issues if not i.data.get("empty")]
|
||||
return [f"{len(unfixed)} issue(s) must be fixed by hand"] if unfixed else []
|
||||
@@ -0,0 +1,37 @@
|
||||
import numpy as np
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, contexts
|
||||
from ...core.mesh import loop_polygon_index, polygon_arrays
|
||||
|
||||
|
||||
class UVCheck(Check):
|
||||
id = "uvs"
|
||||
label = "UVs"
|
||||
description = "Every mesh has a UV map and no UV coordinate is negative."
|
||||
category = "UVs"
|
||||
contexts = contexts.ASSET
|
||||
|
||||
def run(self, ctx):
|
||||
for ob in ctx.objects("MESH"):
|
||||
me = ob.data
|
||||
if not len(me.polygons):
|
||||
continue
|
||||
if not me.uv_layers:
|
||||
yield Issue.for_id(f"{ob.name}: no UV map", ob)
|
||||
continue
|
||||
|
||||
_, totals, _ = polygon_arrays(me)
|
||||
poly_of_loop = loop_polygon_index(totals)
|
||||
for layer in me.uv_layers:
|
||||
uv = np.empty(len(me.loops) * 2, dtype=np.float32)
|
||||
layer.uv.foreach_get("vector", uv)
|
||||
negative = (uv.reshape(-1, 2) < -config.UV_NEGATIVE_TOLERANCE).any(axis=1)
|
||||
if negative.any():
|
||||
faces = np.unique(poly_of_loop[negative])
|
||||
yield Issue.for_id(
|
||||
f"{ob.name}: UV map '{layer.name}' has negative UVs on {faces.size} face(s)",
|
||||
ob,
|
||||
elements=faces.tolist(),
|
||||
element_type="FACE",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Checks for shot layout files (one shot per file)."""
|
||||
@@ -0,0 +1,158 @@
|
||||
import math
|
||||
import re
|
||||
|
||||
import bpy
|
||||
from mathutils import Matrix
|
||||
|
||||
from ... import config, providers
|
||||
from ...core import Check, Issue, SkipCheck, contexts, is_local
|
||||
|
||||
_SUFFIX_RE = re.compile(r"\.\d{3,}$")
|
||||
|
||||
|
||||
class FramerateCheck(Check):
|
||||
id = "framerate"
|
||||
label = "Frame rate"
|
||||
description = "Scene frame rate matches the production frame rate."
|
||||
category = "Shot"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 10
|
||||
fix_description = "Set the production frame rate"
|
||||
|
||||
def run(self, ctx):
|
||||
render = ctx.scene.render
|
||||
fps = render.fps / render.fps_base
|
||||
expected = config.FPS / config.FPS_BASE
|
||||
if not math.isclose(fps, expected, rel_tol=1e-6):
|
||||
yield Issue(f"Frame rate is {fps:g} fps, expected {expected:g}")
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
ctx.scene.render.fps = config.FPS
|
||||
ctx.scene.render.fps_base = config.FPS_BASE
|
||||
|
||||
|
||||
class ResolutionCheck(Check):
|
||||
id = "resolution"
|
||||
label = "Resolution"
|
||||
description = "Render resolution, percentage and pixel aspect match the production format."
|
||||
category = "Shot"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 20
|
||||
fix_description = "Set the production resolution"
|
||||
|
||||
def run(self, ctx):
|
||||
render = ctx.scene.render
|
||||
size = (render.resolution_x, render.resolution_y)
|
||||
if size != tuple(config.RESOLUTION):
|
||||
yield Issue(f"Resolution is {size[0]}x{size[1]}, expected {config.RESOLUTION[0]}x{config.RESOLUTION[1]}")
|
||||
if render.resolution_percentage != config.RESOLUTION_PERCENTAGE:
|
||||
yield Issue(f"Resolution scale is {render.resolution_percentage}%, expected {config.RESOLUTION_PERCENTAGE}%")
|
||||
aspect = (render.pixel_aspect_x, render.pixel_aspect_y)
|
||||
if any(not math.isclose(a, b) for a, b in zip(aspect, config.PIXEL_ASPECT)):
|
||||
yield Issue(f"Pixel aspect is {aspect[0]:g}:{aspect[1]:g}, expected {config.PIXEL_ASPECT[0]:g}:{config.PIXEL_ASPECT[1]:g}")
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
render = ctx.scene.render
|
||||
render.resolution_x, render.resolution_y = config.RESOLUTION
|
||||
render.resolution_percentage = config.RESOLUTION_PERCENTAGE
|
||||
render.pixel_aspect_x, render.pixel_aspect_y = config.PIXEL_ASPECT
|
||||
|
||||
|
||||
class FrameRangeCheck(Check):
|
||||
id = "frame_range"
|
||||
label = "Shot start / end"
|
||||
description = "Scene frame range matches the shot range from production tracking (Libreflow / Kitsu)."
|
||||
category = "Shot"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 30
|
||||
fix_description = "Set the scene frame range from production tracking"
|
||||
|
||||
def run(self, ctx):
|
||||
expected = providers.get_shot_frame_range(ctx)
|
||||
if expected is None:
|
||||
raise SkipCheck("No shot frame range available: providers.get_shot_frame_range is not connected yet")
|
||||
start, end = expected
|
||||
scene = ctx.scene
|
||||
if (scene.frame_start, scene.frame_end) != (start, end):
|
||||
yield Issue(
|
||||
f"Frame range is {scene.frame_start}-{scene.frame_end}, expected {start}-{end}",
|
||||
data={"range": (start, end)},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
ctx.scene.frame_start, ctx.scene.frame_end = issue.data["range"]
|
||||
|
||||
|
||||
class DuplicateNamesCheck(Check):
|
||||
id = "duplicate_names"
|
||||
label = "Duplicated names"
|
||||
description = "No local datablock carries a '.001' style duplicate suffix."
|
||||
category = "Shot"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 40
|
||||
editable_names = True
|
||||
fix_description = "Remove the suffix when the base name is free"
|
||||
|
||||
def run(self, ctx):
|
||||
for collection_name in config.DUPLICATE_NAME_TYPES:
|
||||
collection = getattr(bpy.data, collection_name, None)
|
||||
if collection is None:
|
||||
continue
|
||||
for id_block in collection:
|
||||
if is_local(id_block) and _SUFFIX_RE.search(id_block.name):
|
||||
base = _SUFFIX_RE.sub("", id_block.name)
|
||||
clash = " (base name taken)" if base in collection else ""
|
||||
yield Issue.for_id(f"{id_block.name}{clash}", id_block, data={"base": base})
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
skipped = []
|
||||
for issue in issues:
|
||||
id_block = issue.get_id()
|
||||
if id_block is None:
|
||||
continue
|
||||
base = issue.data["base"]
|
||||
if base in getattr(bpy.data, issue.id_type):
|
||||
skipped.append(f"'{id_block.name}': '{base}' already exists")
|
||||
continue
|
||||
id_block.name = base
|
||||
return skipped
|
||||
|
||||
|
||||
class SetPositionCheck(Check):
|
||||
id = "set_positions"
|
||||
label = "Set positions"
|
||||
description = "Instanced set collections (config SET_COLLECTION_PATTERN) sit at the world origin."
|
||||
category = "Shot"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 50
|
||||
fix_description = "Move the set instances back to the origin"
|
||||
|
||||
def run(self, ctx):
|
||||
pattern = re.compile(config.SET_COLLECTION_PATTERN)
|
||||
found = False
|
||||
for ob in ctx.scene.objects:
|
||||
collection = ob.instance_collection if ob.instance_type == "COLLECTION" else None
|
||||
if collection is None or not pattern.search(collection.name):
|
||||
continue
|
||||
found = True
|
||||
if not _is_identity(ob.matrix_world):
|
||||
loc = ob.matrix_world.translation
|
||||
yield Issue.for_id(
|
||||
f"{ob.name} ({collection.name}) is not at the origin "
|
||||
f"(location {loc.x:.3f}, {loc.y:.3f}, {loc.z:.3f})",
|
||||
ob,
|
||||
)
|
||||
if not found:
|
||||
raise SkipCheck("No set instance found in the scene")
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
ob = issue.get_id()
|
||||
if ob is not None:
|
||||
ob.matrix_world = Matrix.Identity(4)
|
||||
|
||||
|
||||
def _is_identity(matrix, tolerance=1e-5):
|
||||
identity = Matrix.Identity(4)
|
||||
return all(abs(matrix[i][j] - identity[i][j]) <= tolerance for i in range(4) for j in range(4))
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
import bpy
|
||||
|
||||
from ... import config
|
||||
from ...core import Check, Issue, Severity, contexts
|
||||
|
||||
|
||||
def _sound_strips(scene):
|
||||
editor = scene.sequence_editor
|
||||
if editor is None:
|
||||
return []
|
||||
return [strip for strip in editor.strips_all if strip.type == "SOUND"]
|
||||
|
||||
|
||||
def latest_version(path):
|
||||
"""Return (current, latest, latest_path) for a versioned path, or None.
|
||||
|
||||
The version token (config VERSION_PATTERN) can be in the file name
|
||||
(``dialog_v003.wav``) and/or in a folder (``.../v003/dialog.wav``). Every
|
||||
token is tried; when a folder is bumped, the same token is updated in the
|
||||
rest of the path.
|
||||
"""
|
||||
version_re = re.compile(config.VERSION_PATTERN)
|
||||
parts = os.path.normpath(path).split(os.sep)
|
||||
current = None
|
||||
best, best_path = None, path
|
||||
for index, part in enumerate(parts):
|
||||
match = version_re.search(part)
|
||||
if not match:
|
||||
continue
|
||||
current_text = match.group(1)
|
||||
if current is None:
|
||||
current = best = int(current_text)
|
||||
parent = os.sep.join(parts[:index]) or os.sep
|
||||
prefix, suffix = part[: match.start(1)], part[match.end(1):]
|
||||
sibling_re = re.compile(re.escape(prefix) + r"(\d+)" + re.escape(suffix) + "$")
|
||||
try:
|
||||
siblings = os.listdir(parent)
|
||||
except OSError:
|
||||
continue
|
||||
for name in siblings:
|
||||
sibling = sibling_re.match(name)
|
||||
if not sibling or int(sibling.group(1)) <= best:
|
||||
continue
|
||||
new_text = sibling.group(1)
|
||||
rest = [p.replace(f"v{current_text}", f"v{new_text}") for p in parts[index + 1:]]
|
||||
candidate = os.sep.join([parent, name, *rest])
|
||||
if os.path.exists(candidate):
|
||||
best, best_path = int(new_text), candidate
|
||||
if current is None:
|
||||
return None
|
||||
return current, best, best_path
|
||||
|
||||
|
||||
class SoundVersionCheck(Check):
|
||||
id = "sound_versions"
|
||||
label = "Sound versions"
|
||||
description = "Sound strips exist on disk and use the latest published version."
|
||||
category = "Sound"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 10
|
||||
fix_description = "Point the sounds to their latest version"
|
||||
|
||||
def run(self, ctx):
|
||||
for strip in _sound_strips(ctx.scene):
|
||||
sound = strip.sound
|
||||
if sound is None:
|
||||
yield Issue(f"Strip '{strip.name}' has no sound")
|
||||
continue
|
||||
if sound.packed_file is not None:
|
||||
yield Issue.for_id(f"'{strip.name}': sound is packed, cannot check its version", sound)
|
||||
continue
|
||||
path = bpy.path.abspath(sound.filepath, library=sound.library)
|
||||
if not os.path.isfile(path):
|
||||
yield Issue.for_id(f"'{strip.name}': file not found {path}", sound)
|
||||
continue
|
||||
versions = latest_version(path)
|
||||
if versions is None:
|
||||
yield Issue.for_id(
|
||||
f"'{strip.name}': no version number in {os.path.basename(path)}",
|
||||
sound,
|
||||
severity=Severity.WARNING,
|
||||
)
|
||||
continue
|
||||
current, latest, latest_path = versions
|
||||
if latest > current:
|
||||
yield Issue.for_id(
|
||||
f"'{strip.name}': v{current:03d} used, v{latest:03d} available",
|
||||
sound,
|
||||
data={"path": latest_path},
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
for issue in issues:
|
||||
sound = issue.get_id()
|
||||
path = issue.data.get("path")
|
||||
if sound is None or not path:
|
||||
continue
|
||||
relative = sound.filepath.startswith("//")
|
||||
# Setting the path reloads the sound.
|
||||
sound.filepath = bpy.path.relpath(path) if relative and bpy.data.filepath else path
|
||||
|
||||
|
||||
class SoundTrackActiveCheck(Check):
|
||||
id = "sound_active"
|
||||
label = "Sound track active"
|
||||
description = "The scene has sound, audio is not muted, and no sound strip or channel is muted."
|
||||
category = "Sound"
|
||||
contexts = frozenset({contexts.LAYOUT})
|
||||
order = 20
|
||||
fix_description = "Unmute the scene audio, sound strips and their channels"
|
||||
|
||||
def run(self, ctx):
|
||||
scene = ctx.scene
|
||||
strips = _sound_strips(scene)
|
||||
if not strips:
|
||||
yield Issue("No sound strip in the sequencer")
|
||||
return
|
||||
if scene.use_audio:
|
||||
yield Issue("Scene audio is muted (Scene > Audio > Mute)", data={"scene": True})
|
||||
channels = scene.sequence_editor.channels
|
||||
for strip in strips:
|
||||
if strip.mute:
|
||||
yield Issue(f"Sound strip '{strip.name}' is muted", data={"strip": strip.name})
|
||||
if channels[strip.channel].mute:
|
||||
yield Issue(
|
||||
f"Channel {strip.channel} of '{strip.name}' is muted", data={"channel": strip.channel}
|
||||
)
|
||||
|
||||
def fix(self, ctx, issues):
|
||||
scene = ctx.scene
|
||||
editor = scene.sequence_editor
|
||||
for issue in issues:
|
||||
if issue.data.get("scene"):
|
||||
scene.use_audio = False
|
||||
if "strip" in issue.data:
|
||||
strip = editor.strips_all.get(issue.data["strip"])
|
||||
if strip is not None:
|
||||
strip.mute = False
|
||||
if "channel" in issue.data:
|
||||
editor.channels[issue.data["channel"]].mute = False
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Production specs used by the checks.
|
||||
|
||||
Every UPPERCASE value can be overridden by a studio JSON file, see
|
||||
``apply_overrides`` (path set in the add-on preferences or through the
|
||||
``WTD_SANITY_CONFIG`` environment variable). Checks must read values at run
|
||||
time (``config.FPS``), never copy them at import time, so overrides apply.
|
||||
|
||||
Values marked TODO are placeholders waiting for the real production specs.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scene / render
|
||||
# --------------------------------------------------------------------------- #
|
||||
FPS = 24
|
||||
FPS_BASE = 1.0
|
||||
RESOLUTION = (2048, 858)
|
||||
RESOLUTION_PERCENTAGE = 100
|
||||
PIXEL_ASPECT = (1.0, 1.0)
|
||||
|
||||
# Units. TODO: confirm with production (metric/meters assumed).
|
||||
UNIT_SYSTEM = "METRIC"
|
||||
UNIT_SCALE_LENGTH = 1.0
|
||||
UNIT_LENGTH = "METERS"
|
||||
|
||||
# Color management. TODO: colorspace still to be determined by production.
|
||||
# A value of None means "not checked".
|
||||
COLOR_MANAGEMENT = {
|
||||
"display_device": "sRGB",
|
||||
"view_transform": "Standard",
|
||||
"look": "None",
|
||||
"exposure": 0.0,
|
||||
"gamma": 1.0,
|
||||
"sequencer_colorspace": "sRGB",
|
||||
"working_space": None, # e.g. "Linear Rec.709" (Blender 5+). Check only, never auto-fixed.
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# File names. TODO: replace with the real Libreflow/Kitsu naming.
|
||||
# Named group "asset" is used to guess the asset/character name.
|
||||
# --------------------------------------------------------------------------- #
|
||||
FILENAME_PATTERNS = {
|
||||
"GENERAL": r"^(?:prp|set|chr)_(?P<asset>[A-Za-z0-9]+)_(?:mod|lineart|shading)(?:_v\d{3})?\.blend$",
|
||||
"CHAR_MOD": r"^chr_(?P<asset>[A-Za-z0-9]+)_mod(?:_v\d{3})?\.blend$",
|
||||
"CHAR_LINEART": r"^chr_(?P<asset>[A-Za-z0-9]+)_(?:lineart|shading)(?:_v\d{3})?\.blend$",
|
||||
"LAYOUT": r"^(?P<sequence>sq\d{3})_(?P<shot>sh\d{4})_layout(?:_v\d{3})?\.blend$",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Naming
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Body of a name after its prefix: snake_case or PascalCase tokens joined by "_".
|
||||
NAME_BODY_PATTERN = r"[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*"
|
||||
|
||||
# Object prefix per object type. Types not listed are not checked.
|
||||
OBJECT_PREFIXES = {
|
||||
"MESH": "GEO_",
|
||||
"CURVE": "CRV_",
|
||||
"SURFACE": "SRF_",
|
||||
"FONT": "TXT_",
|
||||
"EMPTY": "NUL_",
|
||||
"ARMATURE": "RIG_",
|
||||
"LATTICE": "LAT_",
|
||||
"GREASEPENCIL": "LA_",
|
||||
"CAMERA": "CAM_",
|
||||
"LIGHT": "LGT_",
|
||||
}
|
||||
|
||||
# Prefix per bpy.data collection.
|
||||
DATABLOCK_PREFIXES = {
|
||||
"collections": "COL_",
|
||||
"materials": "MAT_",
|
||||
"node_groups": "NG_",
|
||||
}
|
||||
|
||||
# Mesh datablocks must carry the same name as their (single) object.
|
||||
MESH_DATA_MATCHES_OBJECT = True
|
||||
|
||||
# Blender default names that must never survive in a published file.
|
||||
DEFAULT_NAMES = {
|
||||
"cube", "plane", "sphere", "uvsphere", "icosphere", "cylinder", "cone",
|
||||
"torus", "circle", "grid", "suzanne", "monkey", "empty", "camera",
|
||||
"light", "lamp", "point", "sun", "spot", "area", "material", "collection",
|
||||
"beziercurve", "beziercircle", "nurbscurve", "nurbspath", "text",
|
||||
"armature", "lattice", "nodegroup", "geometrynodes", "stroke", "gpencil",
|
||||
"lineart", "scene", "world", "object", "mesh",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Structure (placeholder, real hierarchy to be defined)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Collections that must exist. "{asset}" is replaced by the asset name.
|
||||
REQUIRED_COLLECTIONS = []
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Geometry
|
||||
# --------------------------------------------------------------------------- #
|
||||
MAX_FACE_SIDES = 4
|
||||
# sin() of the reflex angle above which a corner counts as concave.
|
||||
CONCAVE_TOLERANCE = 1e-4
|
||||
# Open borders are reported as non-manifold unless this is True.
|
||||
NON_MANIFOLD_ALLOW_BOUNDARY = False
|
||||
UV_NEGATIVE_TOLERANCE = 1e-6
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Objects
|
||||
# --------------------------------------------------------------------------- #
|
||||
ALLOWED_MODIFIERS = {"SUBSURF", "MULTIRES"}
|
||||
ALLOWED_MODIFIERS_LINEART = {
|
||||
"SUBSURF", "MULTIRES", "LINEART", "GREASE_PENCIL_THICKNESS",
|
||||
}
|
||||
|
||||
# Which transform channels must be applied.
|
||||
APPLY_TRANSFORM = {"location": False, "rotation": True, "scale": True}
|
||||
APPLY_TRANSFORM_TYPES = {"MESH", "CURVE", "SURFACE", "FONT", "META"}
|
||||
TRANSFORM_TOLERANCE = 1e-5
|
||||
|
||||
# Object types that must not be present in asset files.
|
||||
FORBIDDEN_ASSET_OBJECT_TYPES = {"LIGHT", "CAMERA"}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Characters
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reference JSON exported from the master character (see panel > Export
|
||||
# Character Reference). Overridden by the add-on preferences or by the
|
||||
# WTD_CHAR_REFERENCE environment variable.
|
||||
CHAR_REFERENCE_PATH = ""
|
||||
|
||||
# Name of the character body object. "{asset}" is the character name.
|
||||
CHAR_BODY_NAME = "GEO_{asset}_Body"
|
||||
|
||||
# Name keywords that identify inner mouth / eye parts (case-insensitive).
|
||||
CHAR_PART_KEYWORDS = [
|
||||
"eye", "iris", "pupil", "cornea", "sclera", "lid", "lash",
|
||||
"mouth", "teeth", "tooth", "tongue", "gum", "palate",
|
||||
]
|
||||
|
||||
# Vertex groups the body mesh must have. TODO: real list.
|
||||
CHAR_BODY_VERTEX_GROUPS = []
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Layout
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Instanced collections matching this pattern are sets that must sit at the origin.
|
||||
SET_COLLECTION_PATTERN = r"^(?:SET|ENV|set|env)[_-]"
|
||||
|
||||
# Version token in a file or folder name (Libreflow stores v001/ folders).
|
||||
VERSION_PATTERN = r"(?<![A-Za-z0-9])v(\d{3,4})(?![0-9])"
|
||||
|
||||
# Datablock types scanned for ".001" duplicated names.
|
||||
DUPLICATE_NAME_TYPES = ["objects", "collections", "cameras", "materials", "actions", "node_groups"]
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UI
|
||||
# --------------------------------------------------------------------------- #
|
||||
CATEGORY_ORDER = [
|
||||
"Scene", "File", "Naming", "Structure", "Geometry", "UVs", "Objects",
|
||||
"Shading", "Animation", "Character", "Lineart", "Shot", "Sound",
|
||||
]
|
||||
MAX_ISSUES_DRAWN = 30
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Overrides
|
||||
# --------------------------------------------------------------------------- #
|
||||
_DEFAULTS = None
|
||||
|
||||
|
||||
def _snapshot():
|
||||
global _DEFAULTS
|
||||
if _DEFAULTS is None:
|
||||
module = sys.modules[__name__]
|
||||
_DEFAULTS = {
|
||||
k: copy.deepcopy(v) for k, v in vars(module).items() if k.isupper() and not k.startswith("_")
|
||||
}
|
||||
return _DEFAULTS
|
||||
|
||||
|
||||
def apply_overrides(path=""):
|
||||
"""Reset to defaults, then apply a JSON file of {"NAME": value} overrides.
|
||||
|
||||
``path`` falls back to the WTD_SANITY_CONFIG environment variable.
|
||||
Returns the list of keys that were overridden.
|
||||
"""
|
||||
module = sys.modules[__name__]
|
||||
defaults = _snapshot()
|
||||
for key, value in defaults.items():
|
||||
setattr(module, key, copy.deepcopy(value))
|
||||
|
||||
path = path or os.environ.get("WTD_SANITY_CONFIG", "")
|
||||
if not path:
|
||||
return []
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
applied = []
|
||||
for key, value in data.items():
|
||||
if key not in defaults:
|
||||
print(f"[WTD Sanity] Unknown config key ignored: {key}")
|
||||
continue
|
||||
default = defaults[key]
|
||||
if isinstance(default, (set, frozenset)):
|
||||
value = set(value)
|
||||
elif isinstance(default, tuple):
|
||||
value = tuple(value)
|
||||
elif isinstance(default, dict) and isinstance(value, dict):
|
||||
value = {**default, **value}
|
||||
setattr(module, key, value)
|
||||
applied.append(key)
|
||||
return applied
|
||||
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Hooks to production tracking (Libreflow / Kitsu).
|
||||
|
||||
These functions are the single place where checks ask the outside world for
|
||||
expected values. Return None when the information is unavailable: the
|
||||
related checks are then skipped instead of failing.
|
||||
"""
|
||||
|
||||
|
||||
def get_shot_frame_range(ctx):
|
||||
"""Return the expected (frame_start, frame_end) of the shot, or None.
|
||||
|
||||
``ctx`` is a ``core.CheckContext``: ``ctx.filepath``, ``ctx.filename_match``
|
||||
(groups "sequence" and "shot" of config.FILENAME_PATTERNS["LAYOUT"]) and
|
||||
``ctx.scene`` help identify the shot.
|
||||
|
||||
TODO: query Libreflow or Kitsu (gazu), e.g.::
|
||||
|
||||
import gazu
|
||||
shot = gazu.shot.get_shot_by_name(sequence, shot_name)
|
||||
start = shot["data"]["frame_in"]; end = shot["data"]["frame_out"]
|
||||
"""
|
||||
return None
|
||||
File renamed without changes.
@@ -1,44 +0,0 @@
|
||||
"""Sidebar UI."""
|
||||
|
||||
import bpy
|
||||
from . import ops
|
||||
|
||||
|
||||
# Panel like kabaret ipc
|
||||
# class WTDToolsPanel(bpy.types.Panel):
|
||||
#
|
||||
# bl_idname = "wtd_tools.ui.panel"
|
||||
# bl_label = "WTD TOOLS"
|
||||
# bl_space_type = "VIEW_3D"
|
||||
# bl_region_type = "UI"
|
||||
# bl_category = "WTD"
|
||||
#
|
||||
# def draw(self, context) -> None:
|
||||
# #wm = context.window_manager
|
||||
# layout = self.layout
|
||||
#
|
||||
# build_op_row = layout.row()
|
||||
# build_op_row.operator("wtdtools.build", text = "Build scene")
|
||||
|
||||
|
||||
class WTDToolsMenu(bpy.types.Menu):
|
||||
bl_label = "WTDTools"
|
||||
bl_idname = "wtd_tools.ui.menu"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.operator(ops.WTDGetOidForPath.bl_idname, text=ops.WTDGetOidForPath.bl_label)
|
||||
|
||||
def draw_item(self, context):
|
||||
self.layout.menu(WTDToolsMenu.bl_idname)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
#bpy.utils.register_class(WTDToolsPanel)
|
||||
bpy.utils.register_class(WTDToolsMenu)
|
||||
bpy.types.TOPBAR_MT_editor_menus.append(draw_item)
|
||||
|
||||
def unregister() -> None:
|
||||
# bpy.utils.unregister_class(WTDToolsPanel)
|
||||
bpy.utils.unregister_class(WTDToolsMenu)
|
||||
bpy.types.TOPBAR_MT_editor_menus.remove(draw_item)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user