Files
wtd-libreflow-addon/scene_manager/library.py
T
NDemaerschalkandClaude Opus 5.5 bac7a6f5da Scene Manager: LOD switching; Shot Viewer renamed Shot Manager
LODs are child collections of the asset collection (COL_{asset}_LOD{n}).
Overrides hide the other LOD collections (viewport and render), keeping
the rig, pose and animation; collection instances instance the LOD
collection. Each instance has its own LOD, set per instance or for the
selection in the Loaded panel, kept through save, reload and updates.
Loading shows SCENE_MANAGER_DEFAULT_LOD (0).

The Shot Viewer panel is now "Shot Manager" and starts closed. Code
names and SHOT_VIEWER_* config keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:42:24 +01:00

349 lines
15 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)
else:
instance = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None)
instance.instance_type = "COLLECTION"
instance.instance_collection = collection
parent.objects.link(instance)
apply_default_lod(library, instance)
return instance
# --------------------------------------------------------------------------- #
# 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"(\d+)" + re.escape(after.format(asset=asset_name)) + "$")
def lods(library, instance):
"""{LOD number: collection}: 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[int(match.group(1))] = child
return dict(sorted(found.items()))
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(str(n) for n in 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 number, collection in found.items():
collection.hide_viewport = collection.hide_render = number != lod
def apply_default_lod(library, instance):
"""After a load or an update: show config SCENE_MANAGER_DEFAULT_LOD (else the lowest LOD)
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
default = config.SCENE_MANAGER_DEFAULT_LOD
if not found or default is None or current_lod(library, instance) is not None:
return
try:
set_lod(library, instance, default if default in found else min(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]