Files
Nero Frame 13andClaude Opus 5.5 1a1767c984 Added Rename and Shot Viewer tools
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>
2026-10-19 14:30:00 +02:00

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)