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