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)