Merge pull request #18 from pypeclub/feature/PYPE-616_blender-rig

Pype Blender Integration
This commit is contained in:
Milan Kolar 2020-03-24 17:33:20 +01:00 committed by GitHub
commit dd113ff178
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1476 additions and 183 deletions

View file

@ -10,14 +10,43 @@ from avalon import api
VALID_EXTENSIONS = [".blend"]
def model_name(asset: str, subset: str, namespace: Optional[str] = None) -> str:
"""Return a consistent name for a model asset."""
def asset_name(
asset: str, subset: str, namespace: Optional[str] = None
) -> str:
"""Return a consistent name for an asset."""
name = f"{asset}_{subset}"
if namespace:
name = f"{namespace}:{name}"
return name
def create_blender_context(active: Optional[bpy.types.Object] = None,
selected: Optional[bpy.types.Object] = None,):
"""Create a new Blender context. If an object is passed as
parameter, it is set as selected and active.
"""
if not isinstance(selected, list):
selected = [selected]
for win in bpy.context.window_manager.windows:
for area in win.screen.areas:
if area.type == 'VIEW_3D':
for region in area.regions:
if region.type == 'WINDOW':
override_context = {
'window': win,
'screen': win.screen,
'area': area,
'region': region,
'scene': bpy.context.scene,
'active_object': active,
'selected_objects': selected
}
return override_context
raise Exception("Could not create a custom Blender context.")
class AssetLoader(api.Loader):
"""A basic AssetLoader for Blender
@ -67,7 +96,8 @@ class AssetLoader(api.Loader):
assert obj.library, f"'{obj.name}' is not linked."
libraries.add(obj.library)
assert len(libraries) == 1, "'{container.name}' contains objects from more then 1 library."
assert len(
libraries) == 1, "'{container.name}' contains objects from more then 1 library."
return list(libraries)[0]
@ -122,7 +152,7 @@ class AssetLoader(api.Loader):
asset = context["asset"]["name"]
subset = context["subset"]["name"]
instance_name = model_name(asset, subset, namespace)
instance_name = asset_name(asset, subset, namespace)
return self._get_instance_collection(instance_name, nodes)

View file

@ -0,0 +1,40 @@
"""Create an animation asset."""
import bpy
from avalon import api
from avalon.blender import Creator, lib
import pype.blender.plugin
class CreateAction(Creator):
"""Action output for character rigs"""
name = "actionMain"
label = "Action"
family = "action"
icon = "male"
def process(self):
asset = self.data["asset"]
subset = self.data["subset"]
name = pype.blender.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
self.data['task'] = api.Session.get('AVALON_TASK')
lib.imprint(collection, self.data)
if (self.options or {}).get("useSelection"):
for obj in lib.get_selection():
if (obj.animation_data is not None
and obj.animation_data.action is not None):
empty_obj = bpy.data.objects.new(name=name,
object_data=None)
empty_obj.animation_data_create()
empty_obj.animation_data.action = obj.animation_data.action
empty_obj.animation_data.action.name = name
collection.objects.link(empty_obj)
return collection

View file

@ -0,0 +1,52 @@
"""Create an animation asset."""
import bpy
from avalon import api
from avalon.blender import Creator, lib
import pype.blender.plugin
class CreateAnimation(Creator):
"""Animation output for character rigs"""
name = "animationMain"
label = "Animation"
family = "animation"
icon = "male"
def process(self):
asset = self.data["asset"]
subset = self.data["subset"]
name = pype.blender.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
self.data['task'] = api.Session.get('AVALON_TASK')
lib.imprint(collection, self.data)
# Add the rig object and all the children meshes to
# a set and link them all at the end to avoid duplicates.
# Blender crashes if trying to link an object that is already linked.
# This links automatically the children meshes if they were not
# selected, and doesn't link them twice if they, insted,
# were manually selected by the user.
objects_to_link = set()
if (self.options or {}).get("useSelection"):
for obj in lib.get_selection():
objects_to_link.add(obj)
if obj.type == 'ARMATURE':
for subobj in obj.children:
objects_to_link.add(subobj)
for obj in objects_to_link:
collection.objects.link(obj)
return collection

View file

@ -4,6 +4,7 @@ import bpy
from avalon import api
from avalon.blender import Creator, lib
import pype.blender.plugin
class CreateModel(Creator):
@ -15,11 +16,10 @@ class CreateModel(Creator):
icon = "cube"
def process(self):
import pype.blender
asset = self.data["asset"]
subset = self.data["subset"]
name = pype.blender.plugin.model_name(asset, subset)
name = pype.blender.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
self.data['task'] = api.Session.get('AVALON_TASK')

View file

@ -0,0 +1,52 @@
"""Create a rig asset."""
import bpy
from avalon import api
from avalon.blender import Creator, lib
import pype.blender.plugin
class CreateRig(Creator):
"""Artist-friendly rig with controls to direct motion"""
name = "rigMain"
label = "Rig"
family = "rig"
icon = "wheelchair"
def process(self):
asset = self.data["asset"]
subset = self.data["subset"]
name = pype.blender.plugin.asset_name(asset, subset)
collection = bpy.data.collections.new(name=name)
bpy.context.scene.collection.children.link(collection)
self.data['task'] = api.Session.get('AVALON_TASK')
lib.imprint(collection, self.data)
# Add the rig object and all the children meshes to
# a set and link them all at the end to avoid duplicates.
# Blender crashes if trying to link an object that is already linked.
# This links automatically the children meshes if they were not
# selected, and doesn't link them twice if they, insted,
# were manually selected by the user.
objects_to_link = set()
if (self.options or {}).get("useSelection"):
for obj in lib.get_selection():
objects_to_link.add(obj)
if obj.type == 'ARMATURE':
for subobj in obj.children:
objects_to_link.add(subobj)
for obj in objects_to_link:
collection.objects.link(obj)
return collection

View file

@ -0,0 +1,304 @@
"""Load an action in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
from avalon import api, blender
import bpy
import pype.blender.plugin
logger = logging.getLogger("pype").getChild("blender").getChild("load_action")
class BlendActionLoader(pype.blender.plugin.AssetLoader):
"""Load action from a .blend file.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["action"]
representations = ["blend"]
label = "Link Action"
icon = "code-fork"
color = "orange"
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = pype.blender.plugin.asset_name(asset, subset)
container_name = pype.blender.plugin.asset_name(
asset, subset, namespace
)
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
container,
name,
namespace,
context,
self.__class__.__name__,
)
container_metadata = container.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
collection = bpy.context.scene.collection
collection.children.link(bpy.data.collections[lib_container])
animation_container = collection.children[lib_container].make_local()
objects_list = []
# Link meshes first, then armatures.
# The armature is unparented for all the non-local meshes,
# when it is made local.
for obj in animation_container.objects:
obj = obj.make_local()
anim_data = obj.animation_data
if anim_data is not None and anim_data.action is not None:
anim_data.action.make_local()
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
objects_list.append(obj)
animation_container.pop(blender.pipeline.AVALON_PROPERTY)
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
bpy.ops.object.select_all(action='DESELECT')
nodes = list(container.objects)
nodes.append(container)
self[:] = nodes
return nodes
def update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
logger.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert collection, (
f"The asset is not loaded: {container['objectName']}"
)
assert not (collection.children), (
"Nested collections are not supported."
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in pype.blender.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
logger.debug(
"normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_collection_libpath,
normalized_libpath,
)
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
strips = []
for obj in collection_metadata["objects"]:
# Get all the strips that use the action
arm_objs = [
arm for arm in bpy.data.objects if arm.type == 'ARMATURE']
for armature_obj in arm_objs:
if armature_obj.animation_data is not None:
for track in armature_obj.animation_data.nla_tracks:
for strip in track.strips:
if strip.action == obj.animation_data.action:
strips.append(strip)
bpy.data.actions.remove(obj.animation_data.action)
bpy.data.objects.remove(obj)
lib_container = collection_metadata["lib_container"]
bpy.data.collections.remove(bpy.data.collections[lib_container])
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
str(libpath), link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
anim_container = scene.collection.children[lib_container].make_local()
objects_list = []
# Link meshes first, then armatures.
# The armature is unparented for all the non-local meshes,
# when it is made local.
for obj in anim_container.objects:
obj = obj.make_local()
anim_data = obj.animation_data
if anim_data is not None and anim_data.action is not None:
anim_data.action.make_local()
for strip in strips:
strip.action = anim_data.action
strip.action_frame_end = anim_data.action.frame_range[1]
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": collection.name})
objects_list.append(obj)
anim_container.pop(blender.pipeline.AVALON_PROPERTY)
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
bpy.ops.object.select_all(action='DESELECT')
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (avalon-core:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
if not collection:
return False
assert not (collection.children), (
"Nested collections are not supported."
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
for obj in objects:
# Get all the strips that use the action
arm_objs = [
arm for arm in bpy.data.objects if arm.type == 'ARMATURE']
for armature_obj in arm_objs:
if armature_obj.animation_data is not None:
for track in armature_obj.animation_data.nla_tracks:
for strip in track.strips:
if strip.action == obj.animation_data.action:
track.strips.remove(strip)
bpy.data.actions.remove(obj.animation_data.action)
bpy.data.objects.remove(obj)
bpy.data.collections.remove(bpy.data.collections[lib_container])
bpy.data.collections.remove(collection)
return True

View file

@ -0,0 +1,256 @@
"""Load an animation in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
from avalon import api, blender
import bpy
import pype.blender.plugin
logger = logging.getLogger("pype").getChild(
"blender").getChild("load_animation")
class BlendAnimationLoader(pype.blender.plugin.AssetLoader):
"""Load animations from a .blend file.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["animation"]
representations = ["blend"]
label = "Link Animation"
icon = "code-fork"
color = "orange"
@staticmethod
def _remove(self, objects, lib_container):
for obj in objects:
if obj.type == 'ARMATURE':
bpy.data.armatures.remove(obj.data)
elif obj.type == 'MESH':
bpy.data.meshes.remove(obj.data)
bpy.data.collections.remove(bpy.data.collections[lib_container])
@staticmethod
def _process(self, libpath, lib_container, container_name):
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
anim_container = scene.collection.children[lib_container].make_local()
meshes = [obj for obj in anim_container.objects if obj.type == 'MESH']
armatures = [
obj for obj in anim_container.objects if obj.type == 'ARMATURE']
# Should check if there is only an armature?
objects_list = []
# Link meshes first, then armatures.
# The armature is unparented for all the non-local meshes,
# when it is made local.
for obj in meshes + armatures:
obj = obj.make_local()
obj.data.make_local()
anim_data = obj.animation_data
if anim_data is not None and anim_data.action is not None:
anim_data.action.make_local()
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
objects_list.append(obj)
anim_container.pop(blender.pipeline.AVALON_PROPERTY)
bpy.ops.object.select_all(action='DESELECT')
return objects_list
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = pype.blender.plugin.asset_name(asset, subset)
container_name = pype.blender.plugin.asset_name(
asset, subset, namespace
)
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
container,
name,
namespace,
context,
self.__class__.__name__,
)
container_metadata = container.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
objects_list = self._process(
self, libpath, lib_container, container_name)
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
nodes = list(container.objects)
nodes.append(container)
self[:] = nodes
return nodes
def update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
logger.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert collection, (
f"The asset is not loaded: {container['objectName']}"
)
assert not (collection.children), (
"Nested collections are not supported."
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in pype.blender.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
logger.debug(
"normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_collection_libpath,
normalized_libpath,
)
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
# Get the armature of the rig
armatures = [obj for obj in objects if obj.type == 'ARMATURE']
assert(len(armatures) == 1)
self._remove(self, objects, lib_container)
objects_list = self._process(
self, str(libpath), lib_container, collection.name)
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
bpy.ops.object.select_all(action='DESELECT')
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (avalon-core:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
if not collection:
return False
assert not (collection.children), (
"Nested collections are not supported."
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
self._remove(self, objects, lib_container)
bpy.data.collections.remove(collection)
return True

View file

@ -5,15 +5,14 @@ from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
import avalon.blender.pipeline
from avalon import api, blender
import bpy
import pype.blender
from avalon import api
import pype.blender.plugin
logger = logging.getLogger("pype").getChild("blender").getChild("load_model")
class BlendModelLoader(pype.blender.AssetLoader):
class BlendModelLoader(pype.blender.plugin.AssetLoader):
"""Load models from a .blend file.
Because they come from a .blend file we can simply link the collection that
@ -32,34 +31,55 @@ class BlendModelLoader(pype.blender.AssetLoader):
color = "orange"
@staticmethod
def _get_lib_collection(name: str, libpath: Path) -> Optional[bpy.types.Collection]:
"""Find the collection(s) with name, loaded from libpath.
def _remove(self, objects, lib_container):
Note:
It is assumed that only 1 matching collection is found.
"""
for collection in bpy.data.collections:
if collection.name != name:
continue
if collection.library is None:
continue
if not collection.library.filepath:
continue
collection_lib_path = str(Path(bpy.path.abspath(collection.library.filepath)).resolve())
normalized_libpath = str(Path(bpy.path.abspath(str(libpath))).resolve())
if collection_lib_path == normalized_libpath:
return collection
return None
for obj in objects:
bpy.data.meshes.remove(obj.data)
bpy.data.collections.remove(bpy.data.collections[lib_container])
@staticmethod
def _collection_contains_object(
collection: bpy.types.Collection, object: bpy.types.Object
) -> bool:
"""Check if the collection contains the object."""
for obj in collection.objects:
if obj == object:
return True
return False
def _process(self, libpath, lib_container, container_name):
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
model_container = scene.collection.children[lib_container].make_local()
objects_list = []
for obj in model_container.objects:
obj = obj.make_local()
obj.data.make_local()
for material_slot in obj.material_slots:
material_slot.material.make_local()
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
objects_list.append(obj)
model_container.pop(blender.pipeline.AVALON_PROPERTY)
bpy.ops.object.select_all(action='DESELECT')
return objects_list
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
@ -76,42 +96,35 @@ class BlendModelLoader(pype.blender.AssetLoader):
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = pype.blender.plugin.model_name(asset, subset)
container_name = pype.blender.plugin.model_name(
lib_container = pype.blender.plugin.asset_name(asset, subset)
container_name = pype.blender.plugin.asset_name(
asset, subset, namespace
)
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
instance_empty = bpy.data.objects.new(
container_name, None
)
if not instance_empty.get("avalon"):
instance_empty["avalon"] = dict()
avalon_info = instance_empty["avalon"]
avalon_info.update({"container_name": container_name})
scene.collection.objects.link(instance_empty)
instance_empty.instance_type = 'COLLECTION'
container = bpy.data.collections[lib_container]
container.name = container_name
instance_empty.instance_collection = container
container.make_local()
avalon.blender.pipeline.containerise_existing(
container,
collection = bpy.data.collections.new(lib_container)
collection.name = container_name
blender.pipeline.containerise_existing(
collection,
name,
namespace,
context,
self.__class__.__name__,
)
nodes = list(container.objects)
nodes.append(container)
nodes.append(instance_empty)
container_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
objects_list = self._process(
self, libpath, lib_container, container_name)
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
nodes = list(collection.objects)
nodes.append(collection)
self[:] = nodes
return nodes
@ -154,9 +167,13 @@ class BlendModelLoader(pype.blender.AssetLoader):
assert extension in pype.blender.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_libpath = (
self._get_library_from_container(collection).filepath
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
@ -171,58 +188,16 @@ class BlendModelLoader(pype.blender.AssetLoader):
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
# Let Blender's garbage collection take care of removing the library
# itself after removing the objects.
objects_to_remove = set()
collection_objects = list()
collection_objects[:] = collection.objects
for obj in collection_objects:
# Unlink every object
collection.objects.unlink(obj)
remove_obj = True
for coll in [
coll for coll in bpy.data.collections
if coll != collection
]:
if (
coll.objects and
self._collection_contains_object(coll, obj)
):
remove_obj = False
if remove_obj:
objects_to_remove.add(obj)
for obj in objects_to_remove:
# Only delete objects that are not used elsewhere
bpy.data.objects.remove(obj)
self._remove(self, objects, lib_container)
instance_empties = [
obj for obj in collection.users_dupli_group
if obj.name in collection.name
]
if instance_empties:
instance_empty = instance_empties[0]
container_name = instance_empty["avalon"]["container_name"]
objects_list = self._process(
self, str(libpath), lib_container, collection.name)
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
str(libpath), link=True, relative=relative
) as (_, data_to):
data_to.collections = [container_name]
new_collection = self._get_lib_collection(container_name, libpath)
if new_collection is None:
raise ValueError(
"A matching collection '{container_name}' "
"should have been found in: {libpath}"
)
for obj in new_collection.objects:
collection.objects.link(obj)
bpy.data.collections.remove(new_collection)
# Update the representation on the collection
avalon_prop = collection[avalon.blender.pipeline.AVALON_PROPERTY]
avalon_prop["representation"] = str(representation["_id"])
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
@ -245,16 +220,20 @@ class BlendModelLoader(pype.blender.AssetLoader):
assert not (collection.children), (
"Nested collections are not supported."
)
instance_parents = list(collection.users_dupli_group)
instance_objects = list(collection.objects)
for obj in instance_objects + instance_parents:
bpy.data.objects.remove(obj)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
self._remove(self, objects, lib_container)
bpy.data.collections.remove(collection)
return True
class CacheModelLoader(pype.blender.AssetLoader):
class CacheModelLoader(pype.blender.plugin.AssetLoader):
"""Load cache models.
Stores the imported asset in a collection named after the asset.
@ -281,7 +260,8 @@ class CacheModelLoader(pype.blender.AssetLoader):
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
raise NotImplementedError("Loading of Alembic files is not yet implemented.")
raise NotImplementedError(
"Loading of Alembic files is not yet implemented.")
# TODO (jasper): implement Alembic import.
libpath = self.fname
@ -289,7 +269,7 @@ class CacheModelLoader(pype.blender.AssetLoader):
subset = context["subset"]["name"]
# TODO (jasper): evaluate use of namespace which is 'alien' to Blender.
lib_container = container_name = (
pype.blender.plugin.model_name(asset, subset, namespace)
pype.blender.plugin.asset_name(asset, subset, namespace)
)
relative = bpy.context.preferences.filepaths.use_relative_paths

View file

@ -0,0 +1,256 @@
"""Load a rig asset in Blender."""
import logging
from pathlib import Path
from pprint import pformat
from typing import Dict, List, Optional
from avalon import api, blender
import bpy
import pype.blender.plugin
logger = logging.getLogger("pype").getChild("blender").getChild("load_model")
class BlendRigLoader(pype.blender.plugin.AssetLoader):
"""Load rigs from a .blend file.
Because they come from a .blend file we can simply link the collection that
contains the model. There is no further need to 'containerise' it.
Warning:
Loading the same asset more then once is not properly supported at the
moment.
"""
families = ["rig"]
representations = ["blend"]
label = "Link Rig"
icon = "code-fork"
color = "orange"
@staticmethod
def _remove(self, objects, lib_container):
for obj in objects:
if obj.type == 'ARMATURE':
bpy.data.armatures.remove(obj.data)
elif obj.type == 'MESH':
bpy.data.meshes.remove(obj.data)
bpy.data.collections.remove(bpy.data.collections[lib_container])
@staticmethod
def _process(self, libpath, lib_container, container_name, action):
relative = bpy.context.preferences.filepaths.use_relative_paths
with bpy.data.libraries.load(
libpath, link=True, relative=relative
) as (_, data_to):
data_to.collections = [lib_container]
scene = bpy.context.scene
scene.collection.children.link(bpy.data.collections[lib_container])
rig_container = scene.collection.children[lib_container].make_local()
meshes = [obj for obj in rig_container.objects if obj.type == 'MESH']
armatures = [
obj for obj in rig_container.objects if obj.type == 'ARMATURE']
objects_list = []
assert(len(armatures) == 1)
# Link meshes first, then armatures.
# The armature is unparented for all the non-local meshes,
# when it is made local.
for obj in meshes + armatures:
obj = obj.make_local()
obj.data.make_local()
if not obj.get(blender.pipeline.AVALON_PROPERTY):
obj[blender.pipeline.AVALON_PROPERTY] = dict()
avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
avalon_info.update({"container_name": container_name})
if obj.type == 'ARMATURE' and action is not None:
obj.animation_data.action = action
objects_list.append(obj)
rig_container.pop(blender.pipeline.AVALON_PROPERTY)
bpy.ops.object.select_all(action='DESELECT')
return objects_list
def process_asset(
self, context: dict, name: str, namespace: Optional[str] = None,
options: Optional[Dict] = None
) -> Optional[List]:
"""
Arguments:
name: Use pre-defined name
namespace: Use pre-defined namespace
context: Full parenthood of representation to load
options: Additional settings dictionary
"""
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
lib_container = pype.blender.plugin.asset_name(asset, subset)
container_name = pype.blender.plugin.asset_name(
asset, subset, namespace
)
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
container,
name,
namespace,
context,
self.__class__.__name__,
)
container_metadata = container.get(
blender.pipeline.AVALON_PROPERTY)
container_metadata["libpath"] = libpath
container_metadata["lib_container"] = lib_container
objects_list = self._process(
self, libpath, lib_container, container_name, None)
# Save the list of objects in the metadata container
container_metadata["objects"] = objects_list
nodes = list(container.objects)
nodes.append(container)
self[:] = nodes
return nodes
def update(self, container: Dict, representation: Dict):
"""Update the loaded asset.
This will remove all objects of the current collection, load the new
ones and add them to the collection.
If the objects of the collection are used in another collection they
will not be removed, only unlinked. Normally this should not be the
case though.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
libpath = Path(api.get_representation_path(representation))
extension = libpath.suffix.lower()
logger.info(
"Container: %s\nRepresentation: %s",
pformat(container, indent=2),
pformat(representation, indent=2),
)
assert collection, (
f"The asset is not loaded: {container['objectName']}"
)
assert not (collection.children), (
"Nested collections are not supported."
)
assert libpath, (
"No existing library file found for {container['objectName']}"
)
assert libpath.is_file(), (
f"The file doesn't exist: {libpath}"
)
assert extension in pype.blender.plugin.VALID_EXTENSIONS, (
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
normalized_collection_libpath = (
str(Path(bpy.path.abspath(collection_libpath)).resolve())
)
normalized_libpath = (
str(Path(bpy.path.abspath(str(libpath))).resolve())
)
logger.debug(
"normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
normalized_collection_libpath,
normalized_libpath,
)
if normalized_collection_libpath == normalized_libpath:
logger.info("Library already loaded, not updating...")
return
# Get the armature of the rig
armatures = [obj for obj in objects if obj.type == 'ARMATURE']
assert(len(armatures) == 1)
action = armatures[0].animation_data.action
self._remove(self, objects, lib_container)
objects_list = self._process(
self, str(libpath), lib_container, collection.name, action)
# Save the list of objects in the metadata container
collection_metadata["objects"] = objects_list
collection_metadata["libpath"] = str(libpath)
collection_metadata["representation"] = str(representation["_id"])
bpy.ops.object.select_all(action='DESELECT')
def remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.
Arguments:
container (avalon-core:container-1.0): Container to remove,
from `host.ls()`.
Returns:
bool: Whether the container was deleted.
Warning:
No nested collections are supported at the moment!
"""
collection = bpy.data.collections.get(
container["objectName"]
)
if not collection:
return False
assert not (collection.children), (
"Nested collections are not supported."
)
collection_metadata = collection.get(
blender.pipeline.AVALON_PROPERTY)
objects = collection_metadata["objects"]
lib_container = collection_metadata["lib_container"]
self._remove(self, objects, lib_container)
bpy.data.collections.remove(collection)
return True

View file

@ -14,3 +14,6 @@ class CollectBlenderCurrentFile(pyblish.api.ContextPlugin):
"""Inject the current working file"""
current_file = bpy.data.filepath
context.data['currentFile'] = current_file
assert current_file != '', "Current file is empty. " \
"Save the file before continuing."

View file

@ -1,22 +1,21 @@
import typing
from typing import Generator
import bpy
import json
import avalon.api
import pyblish.api
from avalon.blender.pipeline import AVALON_PROPERTY
class CollectModel(pyblish.api.ContextPlugin):
class CollectInstances(pyblish.api.ContextPlugin):
"""Collect the data of a model."""
hosts = ["blender"]
label = "Collect Model"
label = "Collect Instances"
order = pyblish.api.CollectorOrder
@staticmethod
def get_model_collections() -> Generator:
def get_collections() -> Generator:
"""Return all 'model' collections.
Check if the family is 'model' and if it doesn't have the
@ -25,13 +24,13 @@ class CollectModel(pyblish.api.ContextPlugin):
"""
for collection in bpy.data.collections:
avalon_prop = collection.get(AVALON_PROPERTY) or dict()
if (avalon_prop.get('family') == 'model'
and not avalon_prop.get('representation')):
if avalon_prop.get('id') == 'pyblish.avalon.instance':
yield collection
def process(self, context):
"""Collect the models from the current Blender scene."""
collections = self.get_model_collections()
collections = self.get_collections()
for collection in collections:
avalon_prop = collection[AVALON_PROPERTY]
asset = avalon_prop['asset']
@ -50,4 +49,6 @@ class CollectModel(pyblish.api.ContextPlugin):
members = list(collection.objects)
members.append(collection)
instance[:] = members
self.log.debug(instance.data)
self.log.debug(json.dumps(instance.data, indent=4))
for obj in instance:
self.log.debug(obj)

View file

@ -0,0 +1,95 @@
import os
import pype.api
import pype.blender.plugin
import bpy
class ExtractABC(pype.api.Extractor):
"""Extract as ABC."""
label = "Extract ABC"
hosts = ["blender"]
families = ["model"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.fbx"
filepath = os.path.join(stagingdir, filename)
context = bpy.context
scene = context.scene
view_layer = context.view_layer
# Perform extraction
self.log.info("Performing extraction..")
collections = [
obj for obj in instance if type(obj) is bpy.types.Collection]
assert len(collections) == 1, "There should be one and only one " \
"collection collected for this asset"
old_active_layer_collection = view_layer.active_layer_collection
layers = view_layer.layer_collection.children
# Get the layer collection from the collection we need to export.
# This is needed because in Blender you can only set the active
# collection with the layer collection, and there is no way to get
# the layer collection from the collection
# (but there is the vice versa).
layer_collections = [
layer for layer in layers if layer.collection == collections[0]]
assert len(layer_collections) == 1
view_layer.active_layer_collection = layer_collections[0]
old_scale = scene.unit_settings.scale_length
selected = list()
for obj in instance:
try:
obj.select_set(True)
selected.append(obj)
except:
continue
new_context = pype.blender.plugin.create_blender_context(active=selected[0], selected=selected)
# We set the scale of the scene for the export
scene.unit_settings.scale_length = 0.01
self.log.info(new_context)
# We export the abc
bpy.ops.wm.alembic_export(
new_context,
filepath=filepath,
start=1,
end=1
)
view_layer.active_layer_collection = old_active_layer_collection
scene.unit_settings.scale_length = old_scale
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'abc',
'ext': 'abc',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)

View file

@ -1,47 +1,47 @@
import os
import avalon.blender.workio
import pype.api
class ExtractModel(pype.api.Extractor):
"""Extract as model."""
label = "Model"
hosts = ["blender"]
families = ["model"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.blend"
filepath = os.path.join(stagingdir, filename)
# Perform extraction
self.log.info("Performing extraction..")
# Just save the file to a temporary location. At least for now it's no
# problem to have (possibly) extra stuff in the file.
avalon.blender.workio.save_file(filepath, copy=True)
#
# # Store reference for integration
# if "files" not in instance.data:
# instance.data["files"] = list()
#
# # instance.data["files"].append(filename)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'blend',
'ext': 'blend',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s", instance.name, representation)
import os
import avalon.blender.workio
import pype.api
class ExtractBlend(pype.api.Extractor):
"""Extract a blend file."""
label = "Extract Blend"
hosts = ["blender"]
families = ["animation", "model", "rig", "action"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.blend"
filepath = os.path.join(stagingdir, filename)
# Perform extraction
self.log.info("Performing extraction..")
# Just save the file to a temporary location. At least for now it's no
# problem to have (possibly) extra stuff in the file.
avalon.blender.workio.save_file(filepath, copy=True)
#
# # Store reference for integration
# if "files" not in instance.data:
# instance.data["files"] = list()
#
# # instance.data["files"].append(filename)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'blend',
'ext': 'blend',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)

View file

@ -0,0 +1,81 @@
import os
import pype.api
import bpy
class ExtractFBX(pype.api.Extractor):
"""Extract as FBX."""
label = "Extract FBX"
hosts = ["blender"]
families = ["model", "rig"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.fbx"
filepath = os.path.join(stagingdir, filename)
context = bpy.context
scene = context.scene
view_layer = context.view_layer
# Perform extraction
self.log.info("Performing extraction..")
collections = [
obj for obj in instance if type(obj) is bpy.types.Collection]
assert len(collections) == 1, "There should be one and only one " \
"collection collected for this asset"
old_active_layer_collection = view_layer.active_layer_collection
layers = view_layer.layer_collection.children
# Get the layer collection from the collection we need to export.
# This is needed because in Blender you can only set the active
# collection with the layer collection, and there is no way to get
# the layer collection from the collection
# (but there is the vice versa).
layer_collections = [
layer for layer in layers if layer.collection == collections[0]]
assert len(layer_collections) == 1
view_layer.active_layer_collection = layer_collections[0]
old_scale = scene.unit_settings.scale_length
# We set the scale of the scene for the export
scene.unit_settings.scale_length = 0.01
# We export the fbx
bpy.ops.export_scene.fbx(
filepath=filepath,
use_active_collection=True,
mesh_smooth_type='FACE',
add_leaf_bones=False
)
view_layer.active_layer_collection = old_active_layer_collection
scene.unit_settings.scale_length = old_scale
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)

View file

@ -0,0 +1,139 @@
import os
import pype.api
import bpy
import bpy_extras
import bpy_extras.anim_utils
class ExtractAnimationFBX(pype.api.Extractor):
"""Extract as animation."""
label = "Extract FBX"
hosts = ["blender"]
families = ["animation"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
filename = f"{instance.name}.fbx"
filepath = os.path.join(stagingdir, filename)
context = bpy.context
scene = context.scene
view_layer = context.view_layer
# Perform extraction
self.log.info("Performing extraction..")
collections = [
obj for obj in instance if type(obj) is bpy.types.Collection]
assert len(collections) == 1, "There should be one and only one " \
"collection collected for this asset"
old_active_layer_collection = view_layer.active_layer_collection
layers = view_layer.layer_collection.children
# Get the layer collection from the collection we need to export.
# This is needed because in Blender you can only set the active
# collection with the layer collection, and there is no way to get
# the layer collection from the collection
# (but there is the vice versa).
layer_collections = [
layer for layer in layers if layer.collection == collections[0]]
assert len(layer_collections) == 1
view_layer.active_layer_collection = layer_collections[0]
old_scale = scene.unit_settings.scale_length
# We set the scale of the scene for the export
scene.unit_settings.scale_length = 0.01
armatures = [
obj for obj in collections[0].objects if obj.type == 'ARMATURE']
object_action_pairs = []
original_actions = []
starting_frames = []
ending_frames = []
# For each armature, we make a copy of the current action
for obj in armatures:
curr_action = None
copy_action = None
if obj.animation_data and obj.animation_data.action:
curr_action = obj.animation_data.action
copy_action = curr_action.copy()
curr_frame_range = curr_action.frame_range
starting_frames.append(curr_frame_range[0])
ending_frames.append(curr_frame_range[1])
object_action_pairs.append((obj, copy_action))
original_actions.append(curr_action)
# We compute the starting and ending frames
max_frame = min(starting_frames)
min_frame = max(ending_frames)
# We bake the copy of the current action for each object
bpy_extras.anim_utils.bake_action_objects(
object_action_pairs,
frames=range(int(min_frame), int(max_frame)),
do_object=False,
do_clean=False
)
# We export the fbx
bpy.ops.export_scene.fbx(
filepath=filepath,
use_active_collection=True,
bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=False,
add_leaf_bones=False
)
view_layer.active_layer_collection = old_active_layer_collection
scene.unit_settings.scale_length = old_scale
# We delete the baked action and set the original one back
for i in range(0, len(object_action_pairs)):
pair = object_action_pairs[i]
action = original_actions[i]
if action:
pair[0].animation_data.action = action
if pair[1]:
pair[1].user_clear()
bpy.data.actions.remove(pair[1])
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
'name': 'fbx',
'ext': 'fbx',
'files': filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)

View file

@ -35,12 +35,15 @@ class ValidateMeshHasUvs(pyblish.api.InstancePlugin):
invalid = []
# TODO (jasper): only check objects in the collection that will be published?
for obj in [
obj for obj in bpy.data.objects if obj.type == 'MESH'
]:
# Make sure we are in object mode.
bpy.ops.object.mode_set(mode='OBJECT')
if not cls.has_uvs(obj):
invalid.append(obj)
obj for obj in instance]:
try:
if obj.type == 'MESH':
# Make sure we are in object mode.
bpy.ops.object.mode_set(mode='OBJECT')
if not cls.has_uvs(obj):
invalid.append(obj)
except:
continue
return invalid
def process(self, instance):

View file

@ -82,6 +82,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"source",
"assembly",
"textures"
"action"
]
exclude_families = ["clip"]
db_representation_context_keys = [