Files
wtd-libreflow-addon/scene_manager/library.py
T
NDemaerschalkandClaude Opus 5.5 9ee7710479 Scene Manager: fixes from the first run against a real Libreflow
- Update / version switch: Blender 5.2 replaces the library on reload
  (renamed after its file, custom properties gone); find it by path and
  restore its name and tags.
- Remove the libraries an asset linked indirectly when it is removed or
  fails to load (remove_unused_libraries), never a managed one.
- Config: real Kitsu asset types for link modes and Shot Viewer groups,
  setdress_low files for bg_location, "environments" collection fallback.
- Dev fake server and test use the real asset types; rock links a node
  group from the essentials to cover the indirect library cleanup.
- README and CLAUDE.md updated with the live findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:30:46 +01:00

273 lines
11 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":
override = collection.override_hierarchy_create(scene, view_layer)
if scene.collection.children.get(override.name) == override and parent != scene.collection:
scene.collection.children.unlink(override)
if parent.children.get(override.name) != override:
parent.children.link(override)
return override
empty = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
parent.objects.link(empty)
return empty
# --------------------------------------------------------------------------- #
# 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"]
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]