Added sanity check baseline made by claude.

Also changed the location to a panel in VIEW_3D instead of menu.
This commit is contained in:
Nero Frame 13 committed 2026-10-13 16:33:03 +02:00
1 parent 71b1a61b5c
commit 4617c47ad0
33 files changed
+2771 -52

No files matched your search

+16
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Extra checks for character lineart / shading files."""
+77
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Extra checks for character modeling files."""
+133
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Checks shared by every asset context (General, Character Mod, Character Lineart)."""
+49
View File
@@ -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()
+109
View File
@@ -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"
)
+135
View File
@@ -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
+90
View File
@@ -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)
+108
View File
@@ -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)
+47
View File
@@ -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}")
+50
View File
@@ -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 []
+37
View File
@@ -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",
)
+1
View File
@@ -0,0 +1 @@
"""Checks for shot layout files (one shot per file)."""
+158
View 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))
+143
View File
@@ -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