"""Batch rename tool. Names are built as ``{prefix}_{name}_{side}_{number}_{suffix}`` (empty parts are dropped). Numbering continues after the highest number already used by a datablock with the same pattern: renaming more 'pillar' objects later gives pillar_004, pillar_005... """ import re import bpy from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty from . import config from .core import is_local _DUPLICATE_RE = re.compile(r"\.\d{3,}$") _TMP_PREFIX = "__wtd_rename_" AFFIX_MODES = [ ("NONE", "None", "No affix"), ("TYPE", "By Type", "Affix of the production config for the type of each item"), ("CUSTOM", "Custom", "Custom text"), ] # --------------------------------------------------------------------------- # # Name building # --------------------------------------------------------------------------- # def clean(text): """ASCII only, every run of other characters replaced by the separator.""" sep = config.RENAME_SEPARATOR text = text.encode("ascii", "ignore").decode() return re.sub(r"[^A-Za-z0-9]+", lambda _m: sep, text).strip(sep) def _natural_key(name): return [int(token) if token.isdigit() else token.lower() for token in re.split(r"(\d+)", name)] def _type_key(id_block): return id_block.type if isinstance(id_block, bpy.types.Object) else "MATERIAL" def _prefix(id_block, settings): if settings.prefix_mode == "CUSTOM": return clean(settings.prefix_custom) if settings.prefix_mode == "TYPE": if isinstance(id_block, bpy.types.Object): return clean(config.OBJECT_PREFIXES.get(id_block.type, "")) return clean(config.DATABLOCK_PREFIXES.get("materials", "")) return "" def _suffix(id_block, settings): if settings.suffix_mode == "CUSTOM": return clean(settings.suffix_custom) if settings.suffix_mode == "TYPE": return clean(config.RENAME_TYPE_SUFFIXES.get(_type_key(id_block), "")) return "" def current_base(name, settings): """Base name of an existing name: 'GEO_box_center_002_mesh' -> 'box', 'box2' -> 'box'.""" body = _DUPLICATE_RE.sub("", name) prefixes = {*config.OBJECT_PREFIXES.values(), *config.DATABLOCK_PREFIXES.values()} if settings.prefix_custom: prefixes.add(clean(settings.prefix_custom) + config.RENAME_SEPARATOR) for prefix in sorted(prefixes, key=len, reverse=True): if prefix and body.upper().startswith(prefix.upper()): body = body[len(prefix):] break suffixes = {suffix.lower() for suffix in config.RENAME_TYPE_SUFFIXES.values()} if settings.suffix_custom: suffixes.add(clean(settings.suffix_custom).lower()) sides = {side.lower() for side in config.RENAME_SIDES} tokens = [token for token in re.split(r"[^A-Za-z0-9]+", body) if token] # Peel the parts this tool adds, from the end: suffix, number, side. if len(tokens) > 1 and tokens[-1].lower() in suffixes: tokens.pop() if len(tokens) > 1 and tokens[-1].isdigit(): tokens.pop() if len(tokens) > 1 and tokens[-1].lower() in sides: tokens.pop() if tokens: tokens[-1] = tokens[-1].rstrip("0123456789") return clean(config.RENAME_SEPARATOR.join(tokens)) def targets(context, settings): """Local datablocks to rename, from the selected objects.""" objects = [ob for ob in context.selected_objects if is_local(ob)] if settings.target == "OBJECTS": return objects materials = [] for ob in objects: for slot in ob.material_slots: material = slot.material if material is not None and is_local(material) and material not in materials: materials.append(material) return materials def _sort_key(id_block, order): if order != "NAME" and isinstance(id_block, bpy.types.Object): return (id_block.matrix_world.translation["XYZ".index(order)], _natural_key(id_block.name)) return (0.0, _natural_key(id_block.name)) def plan_renames(context, settings): """Return ([(id_block, new_name)], [names skipped because no base name could be found]).""" sep = config.RENAME_SEPARATOR custom_base = clean(settings.base_name) side = "" if settings.side == "NONE" else clean(settings.side) groups = {} # (prefix, base, suffix) -> [id_block] skipped = [] for id_block in sorted(targets(context, settings), key=lambda i: _sort_key(i, settings.sort)): base = custom_base or current_base(id_block.name, settings) if not base: skipped.append(id_block.name) continue key = (_prefix(id_block, settings), base, _suffix(id_block, settings)) groups.setdefault(key, []).append(id_block) collection = bpy.data.objects if settings.target == "OBJECTS" else bpy.data.materials renamed = {id_block.as_pointer() for members in groups.values() for id_block in members} plan = [] for (prefix, base, suffix), members in groups.items(): head = sep.join(part for part in (prefix, base, side) if part) + sep tail = sep + suffix if suffix else "" pattern = re.compile(re.escape(head) + r"(\d+)" + re.escape(tail)) # Linked datablocks count too, so local names never repeat a library's numbers. used = [ int(match.group(1)) for other in collection if other.as_pointer() not in renamed and (match := pattern.fullmatch(other.name)) ] # Numbers the selection already holds are reused first, so renaming # again is stable, then numbering continues after the highest one. owned = {int(match.group(1)) for id_block in members if (match := pattern.fullmatch(id_block.name))} if settings.sort == "NAME": # Conforming names first, in their number order, so they keep their number. members.sort(key=lambda i: (0, int(m.group(1))) if (m := pattern.fullmatch(i.name)) else (1, 0)) numbers = sorted(owned - set(used)) number = max([*used, *owned], default=0) for id_block in members: if numbers: current = numbers.pop(0) else: number += 1 current = number plan.append((id_block, f"{head}{current:0{config.RENAME_PADDING}d}{tail}")) return plan, skipped def apply_renames(plan, rename_data): """Rename the datablocks of the plan. Returns the messages of failed renames.""" datas = [] if rename_data: owners = {} # data pointer -> (data, [objects of the plan using it]) for id_block, _name in plan: data = getattr(id_block, "data", None) if data is not None and is_local(data): owners.setdefault(data.as_pointer(), (data, []))[1].append(id_block) # Shared data (instances) is renamed only when all its users are renamed, # after the first of them in numbering order. datas = [(data, objects[0]) for data, objects in owners.values() if data.users == len(objects)] # Temporary names first, so items of the selection can swap numbers # without Blender adding '.001' to the ones not renamed yet. for index, id_block in enumerate([id_block for id_block, _ in plan] + [data for data, _ in datas]): id_block.name = f"{_TMP_PREFIX}{index}" failed = [] for id_block, name in plan: id_block.name = name if id_block.name != name: failed.append(f"'{name}' is taken, got '{id_block.name}'") for data, owner in datas: data.name = owner.name return failed # --------------------------------------------------------------------------- # # Settings / operator # --------------------------------------------------------------------------- # _SIDE_ITEMS = {} def _side_items(self, context): # Cached: Blender needs the enum strings to stay alive. key = tuple(config.RENAME_SIDES) if key not in _SIDE_ITEMS: _SIDE_ITEMS[key] = [("NONE", "None", "No orientation")] + [ (side, side.capitalize(), f"Add '{side}' after the name") for side in key ] return _SIDE_ITEMS[key] class WTD_RenameSettings(bpy.types.PropertyGroup): """Per-file settings of the rename tool, saved in the .blend.""" target: EnumProperty( name="Target", items=[ ("OBJECTS", "Objects", "Rename the selected objects"), ("MATERIALS", "Materials", "Rename the materials of the selected objects"), ], ) base_name: StringProperty( name="Name", description="New name. Leave empty to keep the current base name of each item (box2 -> box)", ) prefix_mode: EnumProperty(name="Prefix", items=AFFIX_MODES, default="TYPE") prefix_custom: StringProperty(name="Custom Prefix") side: EnumProperty(name="Side", description="Orientation token", items=_side_items) suffix_mode: EnumProperty(name="Suffix", items=AFFIX_MODES, default="NONE") suffix_custom: StringProperty(name="Custom Suffix") sort: EnumProperty( name="Order", description="Order in which the items are numbered", items=[ ("NAME", "Name", "Current name"), ("X", "X", "World X location"), ("Y", "Y", "World Y location"), ("Z", "Z", "World Z location"), ], ) rename_data: BoolProperty( name="Rename Data", description=( "Give the object data (mesh, curve, light..., Maya's shape node) the name of its object. " "Data shared by several objects takes the name of the first one, when they are all renamed" ), default=True, ) class WTD_OT_batch_rename(bpy.types.Operator): bl_idname = "wtd_tools.batch_rename" bl_label = "Rename" bl_description = "Rename the selected objects (or their materials), numbering after the existing ones" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return bool(context.selected_objects) def execute(self, context): settings = context.scene.wtd_rename plan, skipped = plan_renames(context, settings) if not plan: self.report({"WARNING"}, "Nothing to rename (set a name, or select local items)") return {"CANCELLED"} failed = apply_renames(plan, settings.target == "OBJECTS" and settings.rename_data) messages = failed + [f"'{name}': no base name, set a name" for name in skipped] for message in messages: print(f"[WTD Rename] {message}") if messages: self.report({"WARNING"}, f"Renamed {len(plan) - len(failed)}, {len(messages)} issue(s): {messages[0]}") else: self.report({"INFO"}, f"Renamed {len(plan)} item(s)") return {"FINISHED"} classes = (WTD_RenameSettings, WTD_OT_batch_rename) def register(): for cls in classes: bpy.utils.register_class(cls) bpy.types.Scene.wtd_rename = PointerProperty(type=WTD_RenameSettings) def unregister(): del bpy.types.Scene.wtd_rename for cls in reversed(classes): bpy.utils.unregister_class(cls)