Load Publish (Loaded panel) searches every asset of the project, then loads any of its published .blend files at any version, cast in the shot or not. The file the casting would load and the latest version on this site are preselected; a version not on this site is requested instead. Built on the existing wtd_list_assets / wtd_get_asset_files / wtd_get_revisions commands. New instances are numbered (COL_bob_01, tree_01...), counting on from the highest number in the file (SCENE_MANAGER_INSTANCE_NAME). README: Pick Shot only lists shots that exist in Libreflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""Blender side of the Scene Manager: the assets linked from Libreflow publishes.
|
|
|
|
Every loaded publish is one ``bpy.types.Library``, tagged with custom
|
|
properties (``wtd_asset_oid``, ``wtd_file_oid``, ``wtd_revision``...). The tags
|
|
live on the library only: library overrides lose custom properties when they
|
|
resync. Instances are found back from their library: a collection instance
|
|
(empty) through its instanced collection, a library override through the
|
|
linked collection it overrides.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
|
|
import bpy
|
|
|
|
from .. import config
|
|
|
|
ASSET_OID = "wtd_asset_oid"
|
|
|
|
|
|
class LoadError(Exception):
|
|
"""An asset cannot be loaded or updated. The message is shown to the user."""
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Loaded assets
|
|
# --------------------------------------------------------------------------- #
|
|
def managed_libraries():
|
|
"""Libraries loaded by the Scene Manager, sorted by asset name."""
|
|
libraries = [lib for lib in bpy.data.libraries if lib.get(ASSET_OID)]
|
|
return sorted(libraries, key=lambda lib: (lib.get("wtd_asset_name", ""), lib.name))
|
|
|
|
|
|
def find_library(file_oid):
|
|
return next((lib for lib in managed_libraries() if lib.get("wtd_file_oid") == file_oid), None)
|
|
|
|
|
|
def linked_collection(library):
|
|
"""The asset collection linked from the library."""
|
|
name = library.get("wtd_collection", "")
|
|
return next((c for c in bpy.data.collections if c.library == library and c.name == name), None)
|
|
|
|
|
|
def all_instances():
|
|
"""{library name: [instances]}: collection instances (empties) and override hierarchy roots,
|
|
found in one pass over the file."""
|
|
found = {}
|
|
for ob in bpy.data.objects:
|
|
collection = ob.instance_collection if ob.instance_type == "COLLECTION" else None
|
|
if ob.library is None and collection is not None and collection.library is not None:
|
|
found.setdefault(collection.library.name, []).append(ob)
|
|
for collection in bpy.data.collections:
|
|
override = collection.override_library
|
|
if (
|
|
collection.library is None
|
|
and override is not None
|
|
and override.reference is not None
|
|
and override.reference.library is not None
|
|
and override.hierarchy_root == collection
|
|
):
|
|
found.setdefault(override.reference.library.name, []).append(collection)
|
|
return found
|
|
|
|
|
|
def instances(library):
|
|
return all_instances().get(library.name, [])
|
|
|
|
|
|
def instance_objects(instance):
|
|
return [instance] if isinstance(instance, bpy.types.Object) else list(instance.all_objects)
|
|
|
|
|
|
def selected_instances(view_layer):
|
|
"""(library, instance) pairs with at least one selected object."""
|
|
selected = {ob.as_pointer() for ob in view_layer.objects if ob.select_get(view_layer=view_layer)}
|
|
found = all_instances()
|
|
return [
|
|
(library, instance)
|
|
for library in managed_libraries()
|
|
for instance in found.get(library.name, [])
|
|
if any(ob.as_pointer() in selected for ob in instance_objects(instance))
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Load
|
|
# --------------------------------------------------------------------------- #
|
|
def link_mode(asset_type):
|
|
modes = config.SCENE_MANAGER_LINK_MODES
|
|
return modes.get(asset_type, modes.get("*", "INSTANCE"))
|
|
|
|
|
|
def _type_collection(scene, asset_type):
|
|
"""Collection gathering the instances of an asset type, under the scene collection."""
|
|
name = config.SCENE_MANAGER_TYPE_COLLECTION.format(asset_type=asset_type)
|
|
collection = bpy.data.collections.get(name)
|
|
if collection is None or collection.library is not None:
|
|
collection = bpy.data.collections.new(name)
|
|
if collection.name not in scene.collection.children:
|
|
scene.collection.children.link(collection)
|
|
return collection
|
|
|
|
|
|
def _revision_path(revision, what):
|
|
path = (revision or {}).get("path")
|
|
if not path:
|
|
raise LoadError(f"{what}: Libreflow gives no path for {revision and revision.get('name')}")
|
|
if not os.path.isfile(path):
|
|
raise LoadError(f"{what}: {revision['name']} not found on disk ({path})")
|
|
return path
|
|
|
|
|
|
def link_asset(asset, revision, context_oid):
|
|
"""Link the asset collection of a published revision. Returns its library.
|
|
|
|
The collection is the first of config SCENE_MANAGER_COLLECTION_NAMES found in
|
|
the file, else its single top-level collection.
|
|
"""
|
|
name = asset["name"]
|
|
path = _revision_path(revision, name)
|
|
candidates = [pattern.format(asset=name) for pattern in config.SCENE_MANAGER_COLLECTION_NAMES]
|
|
with bpy.data.libraries.load(path, link=True) as (data_from, data_to):
|
|
chosen = next((c for c in candidates if c in data_from.collections), None)
|
|
data_to.collections = [chosen] if chosen else list(data_from.collections)
|
|
linked = [c for c in data_to.collections if c is not None]
|
|
if not linked:
|
|
raise LoadError(f"{name}: no collection in {os.path.basename(path)}")
|
|
library = linked[0].library
|
|
|
|
if chosen is None:
|
|
roots = [c for c in linked if not any(c in parent.children.values() for parent in linked)]
|
|
if len(roots) != 1:
|
|
message = (
|
|
f"{name}: no collection named {' or '.join(candidates)}, and "
|
|
f"{len(roots)} top-level collections in the file ({', '.join(c.name for c in roots)})"
|
|
)
|
|
bpy.data.libraries.remove(library)
|
|
remove_unused_libraries()
|
|
raise LoadError(message)
|
|
chosen = roots[0].name
|
|
|
|
library.name = name
|
|
library[ASSET_OID] = asset["oid"]
|
|
library["wtd_asset_name"] = name
|
|
library["wtd_asset_type"] = asset.get("asset_type") or ""
|
|
library["wtd_file_oid"] = asset["file"]["file_oid"]
|
|
library["wtd_revision"] = revision["name"]
|
|
library["wtd_collection"] = chosen
|
|
library["wtd_context_oid"] = context_oid
|
|
return library
|
|
|
|
|
|
def add_instance(scene, view_layer, library, parent=None):
|
|
"""Bring the library's asset in the scene: a library override or a collection instance
|
|
(config SCENE_MANAGER_LINK_MODES), in ``parent`` or in the collection of its asset type."""
|
|
collection = linked_collection(library)
|
|
if collection is None:
|
|
raise LoadError(f"{library.name}: collection {library.get('wtd_collection')} missing from the file")
|
|
parent = parent or _type_collection(scene, library.get("wtd_asset_type", ""))
|
|
if link_mode(library.get("wtd_asset_type", "")) == "OVERRIDE":
|
|
instance = collection.override_hierarchy_create(scene, view_layer)
|
|
if scene.collection.children.get(instance.name) == instance and parent != scene.collection:
|
|
scene.collection.children.unlink(instance)
|
|
if parent.children.get(instance.name) != instance:
|
|
parent.children.link(instance)
|
|
instance.name = numbered_name(collection.name, bpy.data.collections)
|
|
else:
|
|
name = numbered_name(library.get("wtd_asset_name", collection.name), bpy.data.objects)
|
|
instance = bpy.data.objects.new(name, None)
|
|
instance.instance_type = "COLLECTION"
|
|
instance.instance_collection = collection
|
|
parent.objects.link(instance)
|
|
apply_default_lod(library, instance)
|
|
return instance
|
|
|
|
|
|
def numbered_name(base, ids):
|
|
"""config SCENE_MANAGER_INSTANCE_NAME for the number after the highest one ``ids`` use
|
|
(bob_01, bob_02...; removing bob_01 does not free its number while bob_02 exists)."""
|
|
template = config.SCENE_MANAGER_INSTANCE_NAME
|
|
before, _, rest = template.partition("{number")
|
|
after = rest.partition("}")[2]
|
|
pattern = re.compile(re.escape(before.format(name=base)) + r"(\d+)" + re.escape(after.format(name=base)) + "$")
|
|
numbers = [int(match.group(1)) for id in ids if (match := pattern.match(id.name))]
|
|
return template.format(name=base, number=max(numbers, default=0) + 1)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Levels of detail
|
|
# --------------------------------------------------------------------------- #
|
|
def _lod_pattern(asset_name):
|
|
before, _, after = config.SCENE_MANAGER_LOD_COLLECTION.partition("{lod}")
|
|
return re.compile(
|
|
re.escape(before.format(asset=asset_name)) + r"(.+?)" + re.escape(after.format(asset=asset_name)) + r"(?:\.\d{3,})?$"
|
|
)
|
|
|
|
|
|
def lod_order(name):
|
|
"""Sort key of a LOD name: config SCENE_MANAGER_LODS order, then numbers, then names A-Z."""
|
|
listed = [str(n) for n in config.SCENE_MANAGER_LODS]
|
|
if name in listed:
|
|
return (0, listed.index(name), "")
|
|
if name.isdigit():
|
|
return (1, int(name), "")
|
|
return (2, 0, name.lower())
|
|
|
|
|
|
def lods(library, instance):
|
|
"""{LOD name: collection} in config order: the LOD child collections of the asset
|
|
collection, linked for a collection instance, their overrides for an override."""
|
|
root = linked_collection(library) if isinstance(instance, bpy.types.Object) else instance
|
|
if root is None:
|
|
return {}
|
|
pattern = _lod_pattern(library.get("wtd_asset_name", library.name))
|
|
found = {}
|
|
for child in root.children:
|
|
# Overrides of a second instance are renamed (COL_bob_LOD0.001): match the linked name.
|
|
name = child.override_library.reference.name if child.override_library else child.name
|
|
match = pattern.match(name)
|
|
if match:
|
|
found[match.group(1)] = child
|
|
return {name: found[name] for name in sorted(found, key=lod_order)}
|
|
|
|
|
|
def current_lod(library, instance):
|
|
"""LOD shown by the instance, None when it shows none or several."""
|
|
found = lods(library, instance)
|
|
if isinstance(instance, bpy.types.Object):
|
|
return next((lod for lod, c in found.items() if c == instance.instance_collection), None)
|
|
shown = [lod for lod, c in found.items() if not c.hide_viewport]
|
|
return shown[0] if len(shown) == 1 else None
|
|
|
|
|
|
def set_lod(library, instance, lod):
|
|
"""Show one LOD: an override hides the other LOD collections (viewport and render, so
|
|
they are not evaluated), a collection instance instances the LOD collection."""
|
|
found = lods(library, instance)
|
|
if lod not in found:
|
|
available = ", ".join(found) or "none"
|
|
raise LoadError(f"{instance.name}: no LOD {lod} (LODs in the file: {available})")
|
|
if isinstance(instance, bpy.types.Object):
|
|
root = linked_collection(library)
|
|
others = [c.name for c in root.children if c not in found.values()]
|
|
if root.objects or others:
|
|
raise LoadError(
|
|
f"{instance.name}: {library.name} has content outside its LOD collections "
|
|
f"({', '.join([ob.name for ob in root.objects] + others)}); load it as an override to switch LODs"
|
|
)
|
|
instance.instance_collection = found[lod]
|
|
return
|
|
for name, collection in found.items():
|
|
collection.hide_viewport = collection.hide_render = name != lod
|
|
|
|
|
|
def apply_default_lod(library, instance):
|
|
"""After a load or an update: show the first LOD in config order when the instance shows
|
|
no single LOD. An instance of a LOD the file no longer has goes back to the whole asset
|
|
collection first."""
|
|
found = lods(library, instance)
|
|
root = linked_collection(library)
|
|
if isinstance(instance, bpy.types.Object) and root and instance.instance_collection not in (root, *found.values()):
|
|
instance.instance_collection = root
|
|
if not found or current_lod(library, instance) is not None:
|
|
return
|
|
try:
|
|
set_lod(library, instance, next(iter(found)))
|
|
except LoadError:
|
|
pass # a collection instance with content outside its LODs keeps the whole collection
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Update / remove
|
|
# --------------------------------------------------------------------------- #
|
|
def update(library_name, revision):
|
|
"""Point a library to another revision of its file and reload it."""
|
|
library = bpy.data.libraries[library_name]
|
|
path = _revision_path(revision, library.get("wtd_asset_name", library_name))
|
|
tags = dict(library)
|
|
library.filepath = path
|
|
library.reload()
|
|
# The reload replaces the library (named after its file, no custom properties):
|
|
# find it by its path, then give it back its name and tags.
|
|
library = next((lib for lib in bpy.data.libraries if _same_path(lib.filepath, path)), None)
|
|
if library is None:
|
|
raise LoadError(f"{library_name}: {os.path.basename(path)} could not be reloaded")
|
|
library.name = library_name
|
|
for key, value in tags.items():
|
|
library[key] = value
|
|
library["wtd_revision"] = revision["name"]
|
|
# The new revision may add, rename or drop LOD collections.
|
|
for instance in instances(library):
|
|
apply_default_lod(library, instance)
|
|
return library
|
|
|
|
|
|
def _same_path(a, b):
|
|
return os.path.normcase(os.path.normpath(bpy.path.abspath(a))) == os.path.normcase(os.path.normpath(b))
|
|
|
|
|
|
def remove_instance(instance):
|
|
if isinstance(instance, bpy.types.Object):
|
|
bpy.data.objects.remove(instance)
|
|
return
|
|
ids = {instance}
|
|
ids.update(c for c in instance.children_recursive if c.override_library and c.library is None)
|
|
for ob in instance.all_objects:
|
|
if ob.override_library and ob.library is None:
|
|
ids.add(ob)
|
|
if ob.data is not None and ob.data.override_library and ob.data.library is None:
|
|
ids.add(ob.data)
|
|
bpy.data.batch_remove(ids)
|
|
|
|
|
|
def remove_library(library):
|
|
"""Remove every instance of the asset, then the library and its linked data."""
|
|
for instance in instances(library):
|
|
remove_instance(instance)
|
|
bpy.data.libraries.remove(library)
|
|
remove_unused_libraries()
|
|
|
|
|
|
def remove_unused_libraries():
|
|
"""Remove the libraries none of whose data is used any more, e.g. the ones a removed
|
|
asset linked indirectly (node group assets...). Managed libraries are kept."""
|
|
while True:
|
|
used = {
|
|
id.library.name
|
|
for id in bpy.data.user_map()
|
|
if id.library is not None and (id.users or id.use_fake_user)
|
|
}
|
|
unused = [lib for lib in bpy.data.libraries if lib.name not in used and not lib.get(ASSET_OID)]
|
|
if not unused:
|
|
return
|
|
for lib in unused:
|
|
bpy.data.libraries.remove(lib)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Assetize
|
|
# --------------------------------------------------------------------------- #
|
|
def _holds_linked_data(collection):
|
|
return any(c.override_library or c.library for c in collection.children_recursive) or any(
|
|
ob.override_library or ob.library or (ob.instance_collection and ob.instance_collection.library)
|
|
for ob in collection.all_objects
|
|
)
|
|
|
|
|
|
def assetize_candidates():
|
|
"""Local collections that can be published: not linked, not an override, holding no loaded asset."""
|
|
return [
|
|
c
|
|
for c in bpy.data.collections
|
|
if c.library is None and c.override_library is None and not c.get(ASSET_OID) and not _holds_linked_data(c)
|
|
]
|
|
|
|
|
|
def asset_name_from(collection_name):
|
|
"""'COL_street lamp.001' -> 'street_lamp': the prefix and duplicate suffix removed, an identifier."""
|
|
name = re.sub(r"\.\d{3,}$", "", collection_name)
|
|
prefix = config.DATABLOCK_PREFIXES.get("collections", "")
|
|
if prefix and name.upper().startswith(prefix.upper()):
|
|
name = name[len(prefix):]
|
|
return re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_")
|
|
|
|
|
|
def write_collection(collection, path):
|
|
"""Write the collection and what it uses to a .blend, for publishing."""
|
|
bpy.data.libraries.write(path, {collection}, path_remap="RELATIVE_ALL", fake_user=True)
|
|
|
|
|
|
def parents_of(collection, scene):
|
|
return [p for p in [scene.collection, *bpy.data.collections] if p.children.get(collection.name) == collection]
|