Rename panel: batch rename selected objects (or their materials) as prefix_name_side_number_suffix. Numbering continues after existing names, object data follows its object, and a preview shows the result. Shot Viewer (button in the Tools panel): opens a window with its own workspace showing the shots (sequencer scene strips) through their camera, with the edit's sequencer at the bottom. Scrubbing the edit switches shots, playing a shot moves the edit playhead, Loop Shot loops playback on the shot. Shots can be added and deleted, and character / prop rigs selected in pose mode from the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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)
|