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>
This commit is contained in:
1 parent
b800e5a68b
commit
1a1767c984
5 files changed
+1173
-3
No files matched your search
@@ -0,0 +1,281 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user