diff --git a/__init__.py b/__init__.py index ec4c826..c76e7d2 100644 --- a/__init__.py +++ b/__init__.py @@ -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). diff --git a/blender_manifest.toml b/blender_manifest.toml index 8acbe5e..fe8058b 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -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 = [ diff --git a/checks/__init__.py b/checks/__init__.py new file mode 100644 index 0000000..ff258df --- /dev/null +++ b/checks/__init__.py @@ -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) diff --git a/checks/char_lineart/__init__.py b/checks/char_lineart/__init__.py new file mode 100644 index 0000000..bda310e --- /dev/null +++ b/checks/char_lineart/__init__.py @@ -0,0 +1 @@ +"""Extra checks for character lineart / shading files.""" diff --git a/checks/char_lineart/lineart.py b/checks/char_lineart/lineart.py new file mode 100644 index 0000000..f151533 --- /dev/null +++ b/checks/char_lineart/lineart.py @@ -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 diff --git a/checks/char_mod/__init__.py b/checks/char_mod/__init__.py new file mode 100644 index 0000000..ea3eb0b --- /dev/null +++ b/checks/char_mod/__init__.py @@ -0,0 +1 @@ +"""Extra checks for character modeling files.""" diff --git a/checks/char_mod/character.py b/checks/char_mod/character.py new file mode 100644 index 0000000..d7390fa --- /dev/null +++ b/checks/char_mod/character.py @@ -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) diff --git a/checks/general/__init__.py b/checks/general/__init__.py new file mode 100644 index 0000000..4af4d2e --- /dev/null +++ b/checks/general/__init__.py @@ -0,0 +1 @@ +"""Checks shared by every asset context (General, Character Mod, Character Lineart).""" diff --git a/checks/general/animation.py b/checks/general/animation.py new file mode 100644 index 0000000..6b48179 --- /dev/null +++ b/checks/general/animation.py @@ -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() diff --git a/checks/general/geometry.py b/checks/general/geometry.py new file mode 100644 index 0000000..6d9243d --- /dev/null +++ b/checks/general/geometry.py @@ -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" + ) diff --git a/checks/general/naming.py b/checks/general/naming.py new file mode 100644 index 0000000..9056689 --- /dev/null +++ b/checks/general/naming.py @@ -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 diff --git a/checks/general/nodes.py b/checks/general/nodes.py new file mode 100644 index 0000000..358462b --- /dev/null +++ b/checks/general/nodes.py @@ -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) diff --git a/checks/general/objects.py b/checks/general/objects.py new file mode 100644 index 0000000..8466e64 --- /dev/null +++ b/checks/general/objects.py @@ -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) diff --git a/checks/general/scene.py b/checks/general/scene.py new file mode 100644 index 0000000..8cde37a --- /dev/null +++ b/checks/general/scene.py @@ -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}") diff --git a/checks/general/structure.py b/checks/general/structure.py new file mode 100644 index 0000000..867e5c5 --- /dev/null +++ b/checks/general/structure.py @@ -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 [] diff --git a/checks/general/uvs.py b/checks/general/uvs.py new file mode 100644 index 0000000..c652cf7 --- /dev/null +++ b/checks/general/uvs.py @@ -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", + ) diff --git a/checks/layout/__init__.py b/checks/layout/__init__.py new file mode 100644 index 0000000..1b82e13 --- /dev/null +++ b/checks/layout/__init__.py @@ -0,0 +1 @@ +"""Checks for shot layout files (one shot per file).""" diff --git a/checks/layout/shot.py b/checks/layout/shot.py new file mode 100644 index 0000000..2eb8a05 --- /dev/null +++ b/checks/layout/shot.py @@ -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)) diff --git a/checks/layout/sound.py b/checks/layout/sound.py new file mode 100644 index 0000000..65301ea --- /dev/null +++ b/checks/layout/sound.py @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..9fc2a82 --- /dev/null +++ b/config.py @@ -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[A-Za-z0-9]+)_(?:mod|lineart|shading)(?:_v\d{3})?\.blend$", + "CHAR_MOD": r"^chr_(?P[A-Za-z0-9]+)_mod(?:_v\d{3})?\.blend$", + "CHAR_LINEART": r"^chr_(?P[A-Za-z0-9]+)_(?:lineart|shading)(?:_v\d{3})?\.blend$", + "LAYOUT": r"^(?Psq\d{3})_(?Psh\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"(? 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() diff --git a/core/contexts.py b/core/contexts.py new file mode 100644 index 0000000..be683b3 --- /dev/null +++ b/core/contexts.py @@ -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)] diff --git a/core/mesh.py b/core/mesh.py new file mode 100644 index 0000000..16d0fb8 --- /dev/null +++ b/core/mesh.py @@ -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) diff --git a/core/runner.py b/core/runner.py new file mode 100644 index 0000000..606a684 --- /dev/null +++ b/core/runner.py @@ -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 ''}", + 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 diff --git a/providers.py b/providers.py new file mode 100644 index 0000000..14dbac0 --- /dev/null +++ b/providers.py @@ -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 diff --git a/ops.py b/tools_ops.py similarity index 100% rename from ops.py rename to tools_ops.py diff --git a/ui.py b/ui.py deleted file mode 100644 index 66a80a6..0000000 --- a/ui.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..6ade9b7 --- /dev/null +++ b/ui/__init__.py @@ -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() diff --git a/ui/operators.py b/ui/operators.py new file mode 100644 index 0000000..1c66038 --- /dev/null +++ b/ui/operators.py @@ -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) diff --git a/ui/panel.py b/ui/panel.py new file mode 100644 index 0000000..02dc0ac --- /dev/null +++ b/ui/panel.py @@ -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) diff --git a/ui/properties.py b/ui/properties.py new file mode 100644 index 0000000..536e1b4 --- /dev/null +++ b/ui/properties.py @@ -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)