2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,329 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from typing import Dict, Set
from .naming import (
merge_get_target_name,
task_layer_prefix_basename_get,
)
from .util import get_storage_of_id
from .shared_ids import get_shared_ids
from .. import constants, logging
class AssetTransferMapping:
"""
The AssetTranfserMapping class represents a mapping between a source and a target.
It contains an object mapping which connects each source object with a target
object as well as a collection mapping.
The mapping process relies heavily on suffixes, which is why we use
MergeCollections as input that store a suffix.
Instances of this class will be pased TaskLayer data transfer function so Users
can easily write their merge instructions.
"""
def __init__(
self,
local_coll: bpy.types.Collection,
external_coll: bpy.types.Collection,
local_tls: Set[str],
):
self._local_top_col = local_coll
self._external_col = external_coll
self._local_tls = local_tls
self.external_col_to_remove: Set[bpy.types.Object] = set()
self.external_col_to_add: Set[bpy.types.Object] = set()
self.external_obj_to_add: Set[bpy.types.Object] = set()
self.surrendered_obj_to_remove: Set[bpy.types.Object] = set()
self._no_match_source_objs: Set[bpy.types.Object] = set()
self._no_match_source_colls: Set[bpy.types.Object] = set()
self._no_match_target_colls: Set[bpy.types.Object] = set()
self.conflict_ids: list[bpy.types.ID] = []
self.conflict_transfer_data = [] # Item of bpy.types.CollectionProperty
self.transfer_data_map: Dict[bpy.types.Collection, bpy.types.Collection] = {}
self.logger = logging.get_logger()
self.generate_mapping()
def generate_mapping(self) -> None:
self.object_map = self._gen_object_map()
self.collection_map = self._gen_collection_map()
self.shared_id_map = self._gen_shared_id_map()
self._gen_transfer_data_map()
self.index_map = self._gen_active_index_map()
def _get_external_object(self, local_obj):
external_obj_name = merge_get_target_name(
local_obj.name,
)
external_obj = self._external_col.all_objects.get(external_obj_name)
if not external_obj:
self.logger.debug(f"Failed to find match obj {external_obj_name} for {local_obj.name}")
self._no_match_source_objs.add(local_obj)
return
return external_obj
def _check_id_conflict(self, external_id, local_id):
if local_id.asset_id_owner not in self._local_tls:
# If the local ID was not owned by any task layer of the current file
# in the first place, there cannot be a conflict.
return
if external_id.asset_id_owner != local_id.asset_id_owner and (
local_id.asset_id_surrender == external_id.asset_id_surrender
):
self.conflict_ids.append(local_id)
def _gen_object_map(self) -> Dict[bpy.types.Object, bpy.types.Object]:
"""
Tries to link all objects in source collection to an object in
target collection. Uses suffixes to match them up.
"""
object_map: Dict[bpy.types.Object, bpy.types.Object] = {}
for local_obj in self._local_top_col.all_objects:
# Skip items with no owner
if local_obj.asset_id_owner == "NONE":
continue
if local_obj.library:
continue
external_obj = self._get_external_object(local_obj)
if not external_obj:
self.logger.debug(f"Couldn't find external obj for {local_obj}")
continue
self._check_id_conflict(external_obj, local_obj)
# IF ITEM IS OWNED BY LOCAL TASK LAYERS
if (
external_obj.asset_id_surrender
and not local_obj.asset_id_surrender
and local_obj.asset_id_owner != external_obj.asset_id_owner
):
self.logger.debug(f"Skipping {external_obj} is surrendered")
object_map[external_obj] = local_obj
continue
if (
local_obj.asset_id_surrender
and not external_obj.asset_id_surrender
and local_obj.asset_id_owner != external_obj.asset_id_owner
):
self.logger.debug(f"Skipping {local_obj} is surrendered")
object_map[local_obj] = external_obj
continue
if local_obj.asset_id_owner in self._local_tls:
object_map[external_obj] = local_obj
# IF ITEM IS NOT OWNED BY LOCAL TASK LAYERS
else:
object_map[local_obj] = external_obj
# Find new objects to add to local_col
for external_obj in self._external_col.all_objects:
if external_obj.library:
continue
local_col_objs = self._local_top_col.all_objects
obj = local_col_objs.get(merge_get_target_name(external_obj.name))
if not obj and external_obj.asset_id_owner not in self._local_tls:
self.external_obj_to_add.add(external_obj)
return object_map
def _gen_collection_map(self) -> Dict[bpy.types.Collection, bpy.types.Collection]:
"""
Tries to link all source collections to a target collection.
Uses suffixes to match them up.
"""
coll_map: Dict[bpy.types.Collection, bpy.types.Collection] = {}
for local_task_layer_col in self._local_top_col.children:
if local_task_layer_col.asset_id_owner not in self._local_tls:
# Replace source object suffix with target suffix to get target object.
external_col_name = merge_get_target_name(local_task_layer_col.name)
local_col = bpy.data.collections.get(external_col_name)
if local_col:
coll_map[local_task_layer_col] = local_col
else:
self.logger.debug(
f"Failed to find match collection {local_task_layer_col.name} for {external_col_name}"
)
self._no_match_source_colls.add(local_task_layer_col)
external_top_col_name = merge_get_target_name(self._local_top_col.name)
external_top_col = bpy.data.collections.get(external_top_col_name)
# TODO Refactor
for external_col in external_top_col.children:
local_col_name = merge_get_target_name(external_col.name)
local_col = bpy.data.collections.get(local_col_name)
if not local_col and external_col.asset_id_owner not in self._local_tls:
self.external_col_to_add.add(external_col)
for local_col in self._local_top_col.children:
external_col_name = merge_get_target_name(local_col.name)
external_col = bpy.data.collections.get(external_col_name)
if not external_col and local_col.asset_id_owner not in self._local_tls:
self.external_col_to_remove.add(local_col)
all_tgt_colls = set(self._external_col.children_recursive)
all_tgt_colls.add(self._external_col)
match_target_colls = set([coll for coll in coll_map.values()])
self._no_match_target_colls = all_tgt_colls - match_target_colls
return coll_map
def _get_transfer_data_dict(self, transfer_data_item):
return {
'name': transfer_data_item.name,
"owner": transfer_data_item.owner,
"surrender": transfer_data_item.surrender,
}
def _transfer_data_pair_not_local(self, td_1, td_2):
# Returns true if neither owners are local to current file
if td_1.owner not in self._local_tls and td_2.owner not in self._local_tls:
return True
def _transfer_data_pair_local(self, td_1, td_2):
# Returns true both owners are local to current file
if td_1.owner in self._local_tls and td_2.owner in self._local_tls:
return True
def _transfer_data_check_conflict(self, obj, transfer_data_item):
matching_transfer_data_item = self._transfer_data_get_matching(transfer_data_item)
if matching_transfer_data_item is None:
return
if self._transfer_data_pair_not_local(matching_transfer_data_item, transfer_data_item):
return
if matching_transfer_data_item.owner != transfer_data_item.owner and not (
matching_transfer_data_item.surrender or transfer_data_item.surrender
):
# Skip conflict checker if both owners are local to current file
if self._transfer_data_pair_local(matching_transfer_data_item, transfer_data_item):
return
self.conflict_transfer_data.append(transfer_data_item)
self.logger.critical(f"Transfer Data Conflict for {transfer_data_item.name}")
return True
def _transfer_data_get_matching(self, transfer_data_item):
obj = transfer_data_item.id_data
other_obj = bpy.data.objects.get(merge_get_target_name(obj.name))
# Find Related Transferable Data Item on Target/Source Object
for other_obj_transfer_data_item in other_obj.transfer_data_ownership:
if other_obj_transfer_data_item.type == transfer_data_item.type and (
task_layer_prefix_basename_get(other_obj_transfer_data_item.name)
== task_layer_prefix_basename_get(transfer_data_item.name)
):
return other_obj_transfer_data_item
return None
def _transfer_data_is_surrendered(self, transfer_data_item):
matching_td = self._transfer_data_get_matching(transfer_data_item)
if matching_td:
if (
transfer_data_item.surrender
and not matching_td.surrender
and transfer_data_item.owner != matching_td.owner
):
return True
return False
def _transfer_data_map_item_add(self, source_obj, target_obj, transfer_data_item):
"""Adds item to Transfer Data Map"""
if self._transfer_data_is_surrendered(transfer_data_item):
return
td_type_key = transfer_data_item.type
transfer_data_dict = self._get_transfer_data_dict(transfer_data_item)
if not source_obj in self.transfer_data_map:
self.transfer_data_map[source_obj] = {
"target_obj": target_obj,
"td_types": {td_type_key: [transfer_data_dict]},
}
return
if not td_type_key in self.transfer_data_map[source_obj]["td_types"]:
self.transfer_data_map[source_obj]["td_types"][td_type_key] = [transfer_data_dict]
return
else:
self.transfer_data_map[source_obj]["td_types"][td_type_key].append(transfer_data_dict)
def _transfer_data_map_item(self, source_obj, target_obj, transfer_data_item):
"""Verifies if Transfer Data Item is valid/can be mapped"""
# If item is locally owned and is part of local file
if transfer_data_item.owner in self._local_tls and source_obj.name.endswith(
constants.LOCAL_SUFFIX
):
self._transfer_data_map_item_add(source_obj, target_obj, transfer_data_item)
# If item is externally owned and is not part of local file
if (
transfer_data_item.owner not in self._local_tls
and transfer_data_item.owner != "NONE"
and source_obj.name.endswith(constants.EXTERNAL_SUFFIX)
):
self._transfer_data_map_item_add(source_obj, target_obj, transfer_data_item)
def _gen_transfer_data_map(self):
# Generate Mapping for Transfer Data Items
for objs in self.object_map.items():
_, target_obj = objs
for obj in objs:
# Must execute for both objs in map (so we map external and local TD)
# Must include maps even if obj==target_obj to preserve exisiting local TD entry
for transfer_data_item in obj.transfer_data_ownership:
if self._transfer_data_check_conflict(obj, transfer_data_item):
continue
self._transfer_data_map_item(obj, target_obj, transfer_data_item)
return self.transfer_data_map
def _gen_active_index_map(self):
# Generate a Map of Indexes that need to be set post merge
# Stores active_uv & active_color_attribute
index_map = {}
for source_obj in self.transfer_data_map:
target_obj = self.transfer_data_map[source_obj]["target_obj"]
td_types = self.transfer_data_map[source_obj]["td_types"]
for td_type_key, _ in td_types.items():
if td_type_key != constants.MATERIAL_SLOT_KEY:
continue
if source_obj.type != 'MESH':
continue
active_uv_name = (
source_obj.data.uv_layers.active.name
if source_obj.data.uv_layers.active
else ''
)
active_color_attribute_name = source_obj.data.color_attributes.active_color_name
index_map[source_obj] = {
'active_uv_name': active_uv_name,
'active_color_attribute_name': active_color_attribute_name,
'target_obj': target_obj,
}
return index_map
def _gen_shared_id_map(self):
shared_id_map: Dict[bpy.types.ID, bpy.types.ID] = {}
for local_id in get_shared_ids(self._local_top_col):
external_id_name = merge_get_target_name(local_id.name)
id_storage = get_storage_of_id(local_id)
external_id = id_storage.get(external_id_name)
if not external_id:
continue
self._check_id_conflict(external_id, local_id)
if local_id.asset_id_owner in self._local_tls and local_id.asset_id_owner != "NONE":
if external_id:
shared_id_map[external_id] = local_id
else:
if external_id:
shared_id_map[local_id] = external_id
return shared_id_map
+327
View File
@@ -0,0 +1,327 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..merge.naming import task_layer_prefix_transfer_data_update
from .asset_mapping import AssetTransferMapping
from .transfer_data.transfer_core import (
init_transfer_data,
transfer_data_is_missing,
apply_transfer_data,
transfer_data_clean,
)
from .transfer_data.transfer_util import (
transfer_data_add_entry,
simplify,
)
from .naming import (
merge_add_suffix_to_hierarchy,
merge_remove_suffix_from_hierarchy,
get_id_type_name,
)
from .transfer_data.transfer_functions.transfer_function_util.active_indexes import (
transfer_active_uv_layer_index,
transfer_active_color_attribute_index,
)
from pathlib import Path
from .. import constants, logging
import time
def ownership_transfer_data_cleanup(
asset_pipe: 'bpy.types.AssetPipeline',
obj: bpy.types.Object,
) -> None:
"""Remove Transferable Data ownership items if the corresponding data is missing
Args:
obj (bpy.types.Object): Object that contains the Transferable Data
"""
local_task_layer_keys = asset_pipe.get_local_task_layers()
transfer_data = obj.transfer_data_ownership
to_remove = []
for transfer_data_item in transfer_data:
if transfer_data_item.owner in local_task_layer_keys:
if transfer_data_is_missing(transfer_data_item):
to_remove.append(transfer_data_item.name)
for name in to_remove:
transfer_data.remove(transfer_data.keys().index(name))
def ownership_get(
local_col: bpy.types.Collection,
scene: bpy.types.Scene,
) -> None:
"""Find new Transferable Data owned by the local task layer.
Marks items as owned by the local task layer if they are in the
corresponding task layer collection and have no owner.
Args:
local_col (bpy.types.Collection): The top level asset collection that is local to the file
task_layer_name (str): Name of the current task layer that will be the owner of the data
temp_transfer_data (bpy.types.CollectionProperty): Collection property containing newly found
data and the object that contains this data.
Returns:
list[bpy.types.Object]: Returns a list of objects that have no owner and will not be included
in the merge process
"""
asset_pipe = scene.asset_pipeline
asset_pipe.temp_transfer_data.clear()
default_task_layer = asset_pipe.get_local_task_layers()[0]
for col in asset_pipe.asset_collection.children:
if col.asset_id_owner == "NONE":
col.asset_id_owner = default_task_layer
task_layer_objs = get_task_layer_objects(asset_pipe)
for obj in local_col.all_objects:
# TODO REPLACE This is expensive to loop over everything again
for transfer_data_item in obj.transfer_data_ownership:
task_layer_prefix_transfer_data_update(transfer_data_item)
# Mark Asset ID Owner for objects in the current task layers collection
if obj.asset_id_owner == "NONE" and obj in task_layer_objs:
obj.asset_id_owner = default_task_layer
# obj.name = asset_prefix_name_get(obj.name)
# Skip items that have no owner
if obj.asset_id_owner == "NONE":
continue
ownership_transfer_data_cleanup(asset_pipe, obj)
init_transfer_data(scene, obj)
def ownership_set(temp_transfer_data: bpy.types.CollectionProperty) -> None:
"""Add new Transferable Data items on each object found in the
temp Transferable Data collection property
Args:
temp_transfer_data (bpy.types.CollectionProperty): Collection property containing newly found
data and the object that contains this data.
"""
for transfer_data_item in temp_transfer_data:
obj = bpy.data.objects.get(transfer_data_item.obj_name)
transfer_data = obj.transfer_data_ownership
transfer_data_add_entry(
transfer_data,
transfer_data_item.name,
transfer_data_item.type,
transfer_data_item.owner,
transfer_data_item.surrender,
)
def get_invalid_objects(
asset_pipe: 'bpy.types.AssetPipeline',
local_col: bpy.types.Collection,
) -> list[bpy.types.Object]:
"""Returns a list of objects not used in the merge processing,
which are considered invalid. The objects will be excluded from
the merge process.
Args:
local_col (bpy.types.Collection): The top level asset collection that is local to the file
scene (bpy.types.Scene): Scene that contains a the file's asset
Returns:
list[bpy.types.Object]: List of Invalid Objects
"""
local_task_layer_keys = asset_pipe.get_local_task_layers()
task_layer_objs = get_task_layer_objects(asset_pipe)
invalid_obj = []
for obj in local_col.all_objects:
if obj.library:
# Linked objects don't have or need ownership data, so they don't count
# as invalid.
continue
if obj.asset_id_owner == "NONE":
invalid_obj.append(obj)
if obj not in task_layer_objs and obj.asset_id_owner in local_task_layer_keys:
invalid_obj.append(obj)
return invalid_obj
def remap_user(source_datablock: bpy.types.ID, target_datablock: bpy.types.ID) -> None:
"""Remap datablock and append name to datablock that has been remapped
Args:
source_datablock (bpy.types.ID): datablock that will be replaced by the target
target_datablock (bpy.types.ID): datablock that will replace the source
"""
logger = logging.get_logger()
logger.debug(
f"Remapping {source_datablock.rna_type.name}: {source_datablock.name} to {target_datablock.name}"
)
source_datablock.user_remap(target_datablock)
source_datablock.name += "_Users_Remapped"
def merge_task_layer(
context: bpy.types.Context,
local_tls: list[str],
external_file: Path,
) -> None:
"""Combines data from an external task layer collection in the local
task layer collection. By finding the owner of each collection,
object and Transferable Data item and keeping each layer of data via a copy
from its respective owners.
This ensures that objects owned by an external task layer will always be kept
linked into the scene, and any local Transferable Data like a modifier will be applied
ontop of that external object of vice versa. Ownership is stored in an objects properties,
and map is created to match each object to its respective owner.
Args:
context: (bpy.types.Context): context of current .blend
local_tls: (list[str]): list of task layers that are local to the current file
external_file (Path): external file to pull data into the current file from
"""
logger = logging.get_logger()
profiles = logging.get_profiler()
start_time = time.time()
local_col = context.scene.asset_pipeline.asset_collection
if not local_col:
return "Unable to find Asset Collection"
col_base_name = local_col.name
local_suffix = constants.LOCAL_SUFFIX
external_suffix = constants.EXTERNAL_SUFFIX
merge_add_suffix_to_hierarchy(local_col, local_suffix)
external_col = import_data_from_lib(external_file, "collections", col_base_name)
assert external_col, f"Failed to append collection {col_base_name} from {external_file}"
merge_add_suffix_to_hierarchy(external_col, external_suffix)
imported_time = time.time()
profiles.add((imported_time - start_time), "IMPORT")
local_col = bpy.data.collections[f"{col_base_name}.{local_suffix}"]
# External col may come from publish, ensure it is not marked as asset so it purges correctly
external_col.asset_clear()
map = AssetTransferMapping(local_col, external_col, local_tls)
error_msg = ''
if len(map.conflict_transfer_data) != 0:
for conflict in map.conflict_transfer_data:
error_msg += f"Transferable Data conflict found for '{conflict.name}' on obj '{conflict.id_data.name}'\n"
return error_msg
if len(map.conflict_ids) != 0:
for conflict_obj in map.conflict_ids:
type_name = get_id_type_name(type(conflict_obj))
error_msg += f"Ownership conflict found for {type_name}: '{conflict_obj.name}'\n"
return error_msg
mapped_time = time.time()
profiles.add((mapped_time - imported_time), "MAPPING")
# Remove all Transferable Data from target objects
for source_obj in map.object_map:
target_obj = map.object_map[source_obj]
target_obj.transfer_data_ownership.clear()
with simplify(context.scene):
apply_transfer_data(context, map.transfer_data_map)
apply_td_time = time.time()
profiles.add((apply_td_time - mapped_time), "TRANSFER_DATA")
for source_obj, target_obj in map.object_map.items():
remap_user(source_obj, target_obj)
transfer_data_clean(target_obj)
obj_remap_time = time.time()
profiles.add((obj_remap_time - apply_td_time), "OBJECTS")
# Restore Active UV Layer and Active Color Attributes
for _, index_map_item in map.index_map.items():
target_obj = index_map_item.get('target_obj')
transfer_active_uv_layer_index(target_obj, index_map_item.get('active_uv_name'))
transfer_active_color_attribute_index(
target_obj, index_map_item.get('active_color_attribute_name')
)
index_time = time.time()
profiles.add((index_time - obj_remap_time), "INDEXES")
for source_col, target_col in map.collection_map.items():
remap_user(source_col, target_col)
for col in map.external_col_to_add:
local_col.children.link(col)
for col in map.external_col_to_remove:
local_col.children.unlink(col)
col_remap_time = time.time()
profiles.add((col_remap_time - index_time), "COLLECTIONS")
for source_id, target_id in map.shared_id_map.items():
remap_user(source_id, target_id)
shared_id_remap_time = time.time()
profiles.add((shared_id_remap_time - col_remap_time), "SHARED_IDS")
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=False, do_recursive=True)
merge_remove_suffix_from_hierarchy(local_col)
profiles.add((time.time() - start_time), "MERGE")
def import_data_from_lib(
libpath: Path,
data_category: str,
data_name: str,
link: bool = False,
) -> bpy.types.ID:
"""Appends/Links data from an external file into the current file.
Args:
libpath (Path): path to .blend file that contains library
data_category (str): bpy.types, like object or collection
data_name (str): name of datablock to link/append
link (bool, optional): Set to link library otherwise append. Defaults to False.
Returns:
bpy.types.ID: returns datablock that was linked/appended
"""
noun = "Appended"
if link:
noun = "Linked"
logger = logging.get_logger()
data_local_collprop = getattr(bpy.data, data_category)
with bpy.data.libraries.load(libpath.as_posix(), relative=True, link=link) as (
data_from,
data_to,
):
data_from_collprop = getattr(data_from, data_category)
data_to_collprop = getattr(data_to, data_category)
if data_name not in data_from_collprop:
logger.critical(
f"Failed to import {data_category} {data_name} from {libpath.as_posix()}. Doesn't exist in file.",
)
# Check if datablock with same name already exists in blend file.
existing_datablock = data_local_collprop.get(data_name)
if existing_datablock:
logger.critical(
f"{data_name} already in bpy.data.{data_category} of this blendfile.",
)
# Append data block.
data_to_collprop.append(data_name)
logger.info(f"{noun}:{data_name} from library: {libpath.as_posix()}")
if link:
return data_local_collprop.get((data_name, bpy.path.relpath(libpath.as_posix())))
return data_local_collprop.get(data_name)
def get_task_layer_objects(asset_pipe):
local_task_layer_keys = asset_pipe.get_local_task_layers()
local_col = asset_pipe.asset_collection
task_layer_objs = []
for col in local_col.children:
if col.asset_id_owner in local_task_layer_keys:
task_layer_objs = task_layer_objs + list(col.all_objects)
return task_layer_objs
@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy_extras.id_map_utils import get_id_reference_map, get_all_referenced_ids
from .util import get_storage_of_id
from .. import constants, config
from .util import data_type_from_transfer_data_key
def merge_get_target_suffix(suffix: str) -> str:
"""Get the corresponding suffix for a given suffix
Args:
suffix (str): Suffix for External or Local Datablock
Returns:
str: Returns External Suffix if given Local suffix for vice-versa
"""
if suffix.endswith(constants.EXTERNAL_SUFFIX):
return constants.LOCAL_SUFFIX
if suffix.endswith(constants.LOCAL_SUFFIX):
return constants.EXTERNAL_SUFFIX
def merge_get_target_name(name: str) -> str:
"""Get the corresponding target name for a given datablock's suffix.
Suffixes are set by the add_suffix_to_hierarchy() function prior to
calling this function.
Args:
name (str): Name of a given datablock including its suffix
Returns:
str: Returns datablock name with the opposite suffix
"""
old = name.split(constants.MERGE_DELIMITER)[-1]
new = merge_get_target_suffix(old)
assert new, "Failed to flip the LOC/EXT suffix of this name, this should never happen : " + name
li = name.rsplit(old, 1)
return new.join(li)
def merge_get_basename(name: str) -> str:
"""Returns the name of an asset without its suffix"""
if name.endswith(constants.LOCAL_SUFFIX) or name.endswith(
constants.EXTERNAL_SUFFIX
):
return constants.MERGE_DELIMITER.join(
name.split(constants.MERGE_DELIMITER)[:-1]
)
return name
def merge_remove_suffix_from_hierarchy(collection: bpy.types.Collection) -> None:
"""Removes the suffix after a set delimiter from all datablocks
referenced by a collection, itself included
Args:
collection (bpy.types.Collection): Collection that as been suffixed
"""
ref_map = get_id_reference_map()
datablocks = get_all_referenced_ids(collection, ref_map)
datablocks.add(collection)
for action in bpy.data.actions:
datablocks.add(action)
for db in datablocks:
if db == None:
# Not sure why this would happen.
raise Exception(
f"None value in datablock list"
)
if db.library:
# Don't rename linked datablocks.
continue
try:
db.name = merge_get_basename(db.name)
except:
pass
def merge_add_suffix_to_hierarchy(
collection: bpy.types.Collection, suffix_base: str
) -> None:
"""Add a suffix to the names of all datablocks referenced by a collection,
itself included.
Args:
collection (bpy.types.Collection): Collection that needs to be suffixed
suffix_base (str): Suffix to append to collection and items linked to collection
"""
suffix = f"{constants.MERGE_DELIMITER}{suffix_base}"
ref_map = get_id_reference_map()
datablocks = get_all_referenced_ids(collection, ref_map)
datablocks.add(collection)
for db in datablocks:
if db == None:
# Not sure why this would happen.
continue
if len(db.name) > 59:
raise Exception(
f"Datablock name too long, must be max 59 characters: {db.name}"
)
if db.library:
# Don't rename linked datablocks.
continue
collision_db = get_storage_of_id(db).get(db.name + suffix)
if collision_db:
collision_db.name += f'{constants.MERGE_DELIMITER}OLD'
try:
new_name = db.name + suffix
db.name = new_name
assert (
db.name == new_name
), "This should never happen here, unless some add-on suffix is >3 characters. Avoid!"
except:
pass
def asset_prefix_name_get(name: str) -> str:
"""Returns a string with the asset prefix if it is not already set.
Users can specify a prefix to live on all objects during the
asset creation process. This prefix is stored in the scene.
Args:
name (str): Name to add prefix to
Returns:
str: Returns name with prefix
"""
asset_pipe = bpy.context.scene.asset_pipeline
if name.startswith(asset_pipe.prefix + constants.NAME_DELIMITER):
return name
prefix = (
asset_pipe.prefix + constants.NAME_DELIMITER if asset_pipe.prefix != "" else ""
)
return prefix + name
def task_layer_prefix_name_get(name: str, task_layer_owner: str) -> str:
"""Returns a string with the task layer prefix if one is not already set.
Prefix for assets is defined task_layer.json file within TASK_LAYER_TYPES
Will return early if any prefix is found, cannot replace existing prefixes.
Args:
name (str): Name to add prefix to
task_layer_owner (str):
Returns:
str: Returns name with prefix
"""
prefix = config.TASK_LAYER_TYPES[task_layer_owner]
return prefix + constants.NAME_DELIMITER + task_layer_prefix_basename_get(name)
def task_layer_prefix_basename_get(name: str) -> str:
"""Get the base of a name if it contains a task layer prefix.
This prefix is set on some Transferable Data items, this functions
removes the prefixes and returns the basename
Args:
name (str): Original name including prefix
Returns:
str: Returns name without task layer prefix
"""
for task_layer_key in config.TASK_LAYER_TYPES:
prefix = config.TASK_LAYER_TYPES[task_layer_key] + constants.NAME_DELIMITER
if name.startswith(prefix):
return name[len(prefix):]
return name
def task_layer_prefix_legacy_basename(name) -> str:
# TODO Remove this is legacy code (coordinate with team)
if "." in name:
legacy_name = name.replace(".", "")
for task_layer_key in config.TASK_LAYER_TYPES:
if legacy_name.startswith(config.TASK_LAYER_TYPES[task_layer_key]):
return legacy_name.replace(config.TASK_LAYER_TYPES[task_layer_key], "")
def task_layer_prefix_transfer_data_update(
transfer_data_item: bpy.types.CollectionProperty,
) -> bool:
"""Task Layer Prefix can become out of date with the actual owner of the task layer.
This will update the existing prefixes on transfer_data_item so it can match the
owner of that transfer_data_item. Will update both the transfer_data_item.name and the
name of the actual data the transfer_data_item is referring to.
Args:
transfer_data_item (bpy.types.CollectionProperty): Transferable Data Item that is named with prefix
Returns:
bool: Returns True if a change to the name was completed
"""
prefix_types = [constants.MODIFIER_KEY, constants.CONSTRAINT_KEY]
if transfer_data_item.type not in prefix_types:
return
obj = transfer_data_item.id_data
td_data = data_type_from_transfer_data_key(obj, transfer_data_item.type)
# TODO Remove this
# Legacy Prefix Name was used during add-on testing stage but not production
legacy_name = task_layer_prefix_legacy_basename(transfer_data_item.name)
if legacy_name:
base_name = legacy_name
else:
base_name = task_layer_prefix_basename_get(transfer_data_item.name)
prefix = config.TASK_LAYER_TYPES[transfer_data_item.owner]
new_name = prefix + constants.NAME_DELIMITER + base_name
if new_name == transfer_data_item.name or not td_data.get(transfer_data_item.name):
return
# Ensure no period in name
# TODO Remove this is legacy code (coordinate with team)
new_name = new_name.replace(".", "")
td_data[transfer_data_item.name].name = new_name
transfer_data_item.name = new_name
return True
def get_id_type_name(id_type: bpy.types) -> str:
"""Return the cosmetic name of a given ID type
Args:
id_type (bpy.types): An ID type e.g. bpy.types.Object
Returns:
str: Name of an ID type e.g. bpy.types.Object will return 'Object'
"""
return str(id_type).split("'bpy_types.")[1].replace("'>", "")
@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Collection
from .transfer_data.transfer_functions.shape_keys import shape_key_set_active
from .. import prefs
class Perserve:
def __init__(self, local_col: Collection) -> None:
self._local_col = local_col
self._action_dict: dict = {}
self.generate_preserve_maps()
def generate_preserve_maps(self) -> None:
addon_prefs = prefs.get_addon_prefs()
if addon_prefs.preserve_action:
self._action_dict = self._get_action_map()
if addon_prefs.preserve_indexes:
self._active_index_dict = self._get_active_index_map()
def _get_action_map(self):
action_dict = {}
for obj in self._local_col.all_objects:
# Set action map for armatures only
if obj.type != "ARMATURE":
continue
# Only set action map if obj has action assignment
if not obj.animation_data or not obj.animation_data.action:
continue
# Store obj name as obj may removed during merge
action = obj.animation_data.action
action_dict[obj.name] = (action, obj.animation_data.action_slot, action.use_fake_user)
action.use_fake_user = True
return action_dict
def set_action_map(self):
for obj_name, action_info in self._action_dict.items():
action, slot, fake_user = action_info
obj = bpy.data.objects.get(obj_name)
if not obj:
continue
if not obj.animation_data:
obj.animation_data_create()
obj.animation_data.action = action
obj.animation_data.action_slot = slot
action.use_fake_user = fake_user
def unassign_actions(self):
for obj_name, action_info in self._action_dict.items():
action, slot, fake_user = action_info
obj = bpy.data.objects.get(obj_name)
if not obj:
continue
if obj.animation_data:
obj.animation_data.action = None
action.use_fake_user = fake_user
def _get_active_index_map(self):
active_index = {}
for obj in self._local_col.all_objects:
indexes = {}
if getattr(obj.data, "uv_layers", None) and getattr(obj.data.uv_layers, "active", None):
indexes['uv_layer'] = obj.data.uv_layers.active.name
if getattr(obj.vertex_groups, "active", None):
indexes['vertex_group'] = obj.vertex_groups.active.name
if (
getattr(obj.data, "color_attributes", None) and
getattr(obj.data.color_attributes, "active_color", None) and
len(obj.data.color_attributes) > 0
):
indexes['color_attribute'] = obj.data.color_attributes.active_color_name
if getattr(obj.data, "attributes", None) and getattr(
obj.data.attributes, "active", None
):
indexes['attribute'] = obj.data.attributes.active.name
if getattr(obj.data, "shape_keys", None) and getattr(obj, "active_shape_key", None):
indexes['shape_key'] = obj.active_shape_key.name
active_index[obj.name] = indexes
return active_index
def set_active_index_map(self):
for obj_name, indexes in self._active_index_dict.items():
obj = bpy.data.objects.get(obj_name)
if not obj:
continue
if indexes.get('uv_layer'):
uv_layer = obj.data.uv_layers[indexes.get('uv_layer')]
if uv_layer:
obj.data.uv_layers.active = uv_layer
if indexes.get('vertex_group'):
vertex_group = obj.vertex_groups.get(indexes.get('vertex_group'))
if vertex_group:
obj.vertex_groups.active = vertex_group
# Setting color_attribute active also sets attribute active, so attribute must always follow color_attribute
if indexes.get('color_attribute'):
color_attribute = obj.data.color_attributes.get(indexes.get('color_attribute'))
for index, color_attribute in enumerate(obj.data.color_attributes):
if color_attribute.name == indexes.get('color_attribute'):
obj.data.color_attributes.active_color_index = index
if indexes.get('attribute'):
attribute = obj.data.attributes.get(indexes.get('attribute'))
if attribute:
obj.data.attributes.active = attribute
if indexes.get('shape_key'):
shape_key = obj.data.shape_keys.key_blocks.get(indexes.get('shape_key'))
if shape_key:
shape_key_set_active(obj, indexes.get('shape_key'))
@@ -0,0 +1,163 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from .. import constants
import bpy
def find_file_version(published_file: Path) -> int:
"""Returns the version number from a published file's name
Args:
file (Path): Path to a publish file, naming convention is
asset_name-v{3-digit_version}.blend`
Returns:
int: returns current version in filename as integer
"""
name_without_ext = published_file.name.strip(".blend")
# Support Legacy Delimiter
# TODO Remove this is legacy code (coordinate with team)
if "." in name_without_ext:
return int(name_without_ext.split(".")[1].replace("v", ""))
return int(name_without_ext.split(constants.FILE_DELIMITER)[1].replace("v", ""))
def get_next_published_file(
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY
) -> Path:
"""Returns the path where the next published file version should be saved to
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
Returns:
Path: Path where the next published file should be saved to, path doesn't exist yet
""" """"""
last_publish = find_latest_publish(current_file, publish_type)
base_name = bpy.context.scene.asset_pipeline.name
publish_dir = current_file.parent.joinpath(publish_type)
publish_dir.mkdir(parents=True, exist_ok=True) # Create Directory if it doesn't exist
if not last_publish:
new_version_number = 1
else:
new_version_number = find_file_version(last_publish) + 1
new_version = "{0:0=3d}".format(new_version_number)
return publish_dir.joinpath(
base_name + constants.FILE_DELIMITER + "v" + new_version + ".blend"
)
def get_asset_catalogues():
folder = Path(bpy.data.filepath).parent
target_catalog = "Catalog"
with (folder / "blender_assets.cats.txt").open() as f:
for line in f.readlines():
if line.startswith(("#", "VERSION", "\n")):
continue
# Each line contains : 'uuid:catalog_tree:catalog_name' + eol ('\n')
name = line.split(":")[2].split("\n")[0]
if name == target_catalog:
uuid = line.split(":")[0]
obj = bpy.data.objects["Suzanne"] # Object name, case-sensitive !
asset_data = obj.asset_data
asset_data.catalog_id = uuid
def create_next_published_file(
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY, catalog_id: str = ''
) -> str:
"""Creates new Published version of a given Publish Type
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
"""
# TODO Set Catalogue here
new_file_path = get_next_published_file(current_file, publish_type)
asset_col = bpy.context.scene.asset_pipeline.asset_collection
if publish_type == constants.ACTIVE_PUBLISH_KEY:
asset_col.asset_mark()
if catalog_id != '' or catalog_id != 'NONE':
asset_col.asset_data.catalog_id = catalog_id
bpy.ops.wm.save_as_mainfile(filepath=str(new_file_path), copy=True)
asset_col.asset_clear()
return str(new_file_path)
def find_all_published(current_file: Path, publish_type: str) -> list[Path]:
"""Retuns a list of published files of a given type,
each publish type is seperated into its own folder at the
root of the asset's directory
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
Returns:
list[Path]: list of published files of a given publish type
"""
publish_dir = current_file.parent.joinpath(publish_type)
if not publish_dir.exists():
return
published_files = list(publish_dir.glob('*.blend'))
published_files.sort(key=find_file_version)
return published_files
def find_latest_publish(
current_file: Path, publish_type=constants.ACTIVE_PUBLISH_KEY
) -> Path:
"""Returns the path to the latest published file in a given folder
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
publish_type (_type_, optional): Publish type, 'publish', 'staged', 'sandbox'. Defaults to 'publish'.
Returns:
Path: Path to latest publish file of a given publish type
"""
published_files = find_all_published(current_file, publish_type)
if published_files:
return published_files[-1]
def find_sync_target(current_file: Path) -> Path:
"""Returns the latest published file to use as push/pull a.k.a sync target
this will either be the latest active publish, or the latest staged asset if
any asset is staged
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
Returns:
Path: Path to latest active or staged publish file
""" """"""
latest_staged = find_latest_publish(
current_file, publish_type=constants.STAGED_PUBLISH_KEY
)
if latest_staged:
return latest_staged
return find_latest_publish(current_file, publish_type=constants.ACTIVE_PUBLISH_KEY)
def is_staged_publish(current_file: Path) -> bool:
"""Checks if there is a staged publish file, which
will be used as the push/pull target.
Args:
current_file (Path): Current file, which must be a task file at root of asset directory
Returns:
bool: True if staged file exists
"""
return bool(
find_latest_publish(current_file, publish_type=constants.STAGED_PUBLISH_KEY)
)
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy_extras.id_map_utils import get_id_reference_map, get_all_referenced_ids
from .util import get_fundamental_id_type
from .. import constants
def get_shared_ids(collection: bpy.types.Collection) -> list[bpy.types.ID]:
"""Returns a list of any ID that is not covered by the merge process
Args:
collection (bpy.types.Collection): Collection that contains data that references 'shared_ids'
Returns:
list[bpy.types.ID]: List of 'shared_ids'
"""
ref_map = get_id_reference_map()
all_ids_of_coll = get_all_referenced_ids(collection, ref_map)
return [
id
for id in all_ids_of_coll
if (isinstance(id, bpy.types.NodeTree) or isinstance(id, bpy.types.Image))
and id.library is None
]
def init_shared_ids(scene: bpy.types.Scene) -> list[bpy.types.ID]:
"""Intilizes any ID not covered by the transfer process as an 'shared_id'
and marks all 'shared_ids' without an owner to the current task layer
Args:
scene (bpy.types.Scene): Scene that contains a the file's asset
Returns:
list[bpy.types.ID]: A list of new 'shared_ids' owned by the file's task layer
"""
asset_pipe = scene.asset_pipeline
task_layer_key = asset_pipe.get_local_task_layers()[0]
shared_ids = []
local_col = asset_pipe.asset_collection
for id in get_shared_ids(local_col):
if id.asset_id_owner == 'NONE':
id.asset_id_owner = task_layer_key
shared_ids.append(id)
return shared_ids
def get_shared_id_icon(id: bpy.types.ID) -> str:
if bpy.types.NodeTree == get_fundamental_id_type(id):
return constants.GEO_NODE
if bpy.types.Image == get_fundamental_id_type(id):
return constants.IMAGE
return constants.BLANK
@@ -0,0 +1,105 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import constants
from .. import config
def get_default_task_layer_owner(td_type: str, name="") -> [str, bool]:
if td_type == constants.ATTRIBUTE_KEY:
if name in config.ATTRIBUTE_DEFAULTS:
return (
config.ATTRIBUTE_DEFAULTS[name]['default_owner'],
config.ATTRIBUTE_DEFAULTS[name]['auto_surrender'],
)
try:
return (
config.TRANSFER_DATA_DEFAULTS[td_type]['default_owner'],
config.TRANSFER_DATA_DEFAULTS[td_type]['auto_surrender'],
)
except KeyError:
from .. import logging
logger = logging.get_logger()
logger.fatal(f"Task Layer File missing key {td_type}")
# TODO stop execution of operator at this point if this fails
def get_transfer_data_owner(
asset_pipe: bpy.types.PropertyGroup,
td_type_key: str,
name="",
) -> [str, bool]:
default_tl, auto_surrender = get_default_task_layer_owner(td_type_key, name)
if default_tl in asset_pipe.get_local_task_layers():
# If the default owner is local to the file, don't use auto_surrender
return default_tl, False
else:
# If the default owner is not local, pass auto surrender value
return asset_pipe.get_local_task_layers()[0], auto_surrender
def draw_task_layer_selection(
context: bpy.types.Context,
layout: bpy.types.UILayout,
data: bpy.types.CollectionProperty or bpy.types.ID,
show_all_task_layers=False,
text="",
data_owner_name="",
current_data_owner=None,
) -> None:
"""Draw an prop search UI for ownership of either OBJ/COL or Task Layer.
It has three modes, 'Show All Task Layers" "Show All Task Layers Greyed Out" and
"Only Show Local Task Layers"
- When the property is already set to a local task layer show: "Only Show Local Task Layers"
- When a property is owned by an external task layer: "Show All Task Layers Greyed Out" so they user cannot edit it
- When a user is overriding or the object is new (using default ownership): "Show All Task Layers"
Args:
layout (bpy.types.UILayout): Any UI Layout element like self.layout or row
data (bpy.types.CollectionProperty or bpy.types.ID or bpy.types.Operator): Python object that owns the ownership data.
show_all_task_layers (bool, optional): Used when we want to list all task layers in the production as options.
text (str, optional): Title of prop search.
data_owner_name(str, optional): Name of Data if it needs to be specified
current_data_owner(str, optional): Property that is named by data_owner_name so it can be checked, property should return a string
"""
# Set data_owner_name based on type of it hasn't been passed
if data_owner_name == "":
# These rna_type.names are defined by class names in props.py
if data.rna_type.name in ["AssetTransferData", 'AssetTransferDataTemp']:
data_owner_name = "owner"
else:
data_owner_name = "asset_id_owner"
# Get the current data owner from OBJ/COL or Transferable Data Item if it hasn't been passed
if current_data_owner is None:
current_data_owner = getattr(data, data_owner_name)
asset_pipe = context.scene.asset_pipeline
row = layout.row()
if current_data_owner not in asset_pipe.local_task_layers:
show_all_task_layers = True
if not isinstance(data, bpy.types.Operator):
row.enabled = False
if show_all_task_layers:
row.prop_search(
data,
data_owner_name,
asset_pipe,
'all_task_layers',
text=text,
)
else:
row.prop_search(
data,
data_owner_name,
asset_pipe,
'local_task_layers',
text=text,
)
@@ -0,0 +1,236 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import time
from .transfer_functions import (
attributes,
constraints,
custom_props,
modifiers,
parent,
shape_keys,
vertex_groups,
materials,
)
from typing import List
from ... import constants, logging
from .transfer_util import (
transfer_data_add_entry,
find_ownership_data,
link_objs_to_collection,
isolate_collection,
)
# TODO use logging module here
def copy_transfer_data_ownership(
td_type_key: str, target_obj: bpy.types.Object, transfer_data_dict: dict
) -> None:
"""Copy Transferable Data item to object if non entry exists
Args:
transfer_data_item: Item of bpy.types.CollectionProperty from source object
target_obj (bpy.types.Object): Object to add Transferable Data item to
"""
transfer_data = target_obj.transfer_data_ownership
ownership_data = find_ownership_data(
transfer_data,
transfer_data_dict["name"],
td_type_key,
)
if not ownership_data:
transfer_data_add_entry(
transfer_data,
transfer_data_dict["name"],
td_type_key,
transfer_data_dict["owner"],
transfer_data_dict["surrender"],
)
def transfer_data_clean(obj):
vertex_groups.vertex_groups_clean(obj)
modifiers.modifiers_clean(obj)
constraints.constraints_clean(obj)
custom_props.custom_prop_clean(obj)
shape_keys.shape_keys_clean(obj)
attributes.attribute_clean(obj)
parent.parent_clean(obj)
def transfer_data_is_missing(transfer_data_item) -> bool:
"""Check if Transferable Data item is missing
Args:
transfer_data_item: Item of class ASSET_TRANSFER_DATA
Returns:
bool: bool if item is missing
"""
return bool(
vertex_groups.vertex_group_is_missing(transfer_data_item)
or modifiers.modifier_is_missing(transfer_data_item)
or constraints.constraint_is_missing(transfer_data_item)
or custom_props.custom_prop_is_missing(transfer_data_item)
or shape_keys.shape_key_is_missing(transfer_data_item)
or attributes.attribute_is_missing(transfer_data_item)
)
def init_transfer_data(
scene: bpy.types.Scene,
obj: bpy.types.Object,
):
"""Collect Transferable Data Items on a given object
Args:
obj (bpy.types.Object): Target object for Transferable Data
task_layer_name (str): Name of task layer
temp_transfer_data: Item of class ASSET_TRANSFER_DATA_TEMP
"""
if obj.library:
# Don't create ownership data for object data if the object is linked.
return
constraints.init_constraints(scene, obj)
custom_props.init_custom_prop(scene, obj)
parent.init_parent(scene, obj)
modifiers.init_modifiers(scene, obj)
if not obj.data or obj.data.library:
# Don't create ownership data for mesh data if the mesh is linked, or Empties.
return
vertex_groups.init_vertex_groups(scene, obj)
materials.init_materials(scene, obj)
shape_keys.init_shape_keys(scene, obj)
attributes.init_attributes(scene, obj)
def apply_transfer_data_items(
context,
source_obj: bpy.types.Object,
target_obj: bpy.types.Object,
td_type_key: str,
transfer_data_dicts: List[dict],
):
logger = logging.get_logger()
# Get source/target from first item in list, because all items in list are same object/type
if target_obj is None:
logger.warning(f"Failed to Transfer {td_type_key.title()} from {source_obj.name}")
return
for transfer_data_dict in transfer_data_dicts:
copy_transfer_data_ownership(td_type_key, target_obj, transfer_data_dict)
# if TD Source is Target, restore the ownership data but don't transfer anything
if source_obj == target_obj:
return
if td_type_key == constants.VERTEX_GROUP_KEY:
# Transfer All Vertex Groups in one go
logger.debug(f"Transferring All Vertex Groups from {source_obj.name} to {target_obj.name}.")
vertex_groups.transfer_vertex_groups(
vertex_group_names=[item["name"] for item in transfer_data_dicts],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.MODIFIER_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Modifier {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
modifiers.transfer_modifier(
context,
modifier_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.CONSTRAINT_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Constraint {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
constraints.transfer_constraint(
constraint_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.CUSTOM_PROP_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Custom Property {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
custom_props.transfer_custom_prop(
prop_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.MATERIAL_SLOT_KEY:
logger.debug(f"Transferring Materials from {source_obj.name} to {target_obj.name}.")
for transfer_data_dict in transfer_data_dicts:
materials.transfer_materials(
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.SHAPE_KEY_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Shape Key {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
shape_keys.transfer_shape_key(
context=context,
target_obj=target_obj,
source_obj=source_obj,
shape_key_name=transfer_data_dict["name"],
)
if td_type_key == constants.ATTRIBUTE_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Attribute {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
attributes.transfer_attribute(
target_obj=target_obj,
source_obj=source_obj,
attribute_name=transfer_data_dict["name"],
)
if td_type_key == constants.PARENT_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Parent Relationship from {source_obj.name} to {target_obj.name}."
)
parent.transfer_parent(
target_obj=target_obj,
source_obj=source_obj,
)
def apply_transfer_data(context: bpy.types.Context, transfer_data_map) -> None:
"""Apply all Transferable Data from Transferable Data map onto objects.
Copies any Transferable Data owned by local layer onto objects owned by external layers.
Applies Transferable Data from external layers onto objects owned by local layers
Transfer_data_map is generated by class 'AssetTransferMapping'
Args:
context (bpy.types.Context): context of .blend file
transfer_data_map: Map generated by class AssetTransferMapping
"""
# Create/isolate tmp collection to reduce depsgraph update time
profiler = logging.get_profiler()
td_col = bpy.data.collections.new("ISO_COL_TEMP")
with isolate_collection(context, td_col):
# Loop over objects in Transfer data map
for source_obj in transfer_data_map:
target_obj = transfer_data_map[source_obj]["target_obj"]
td_types = transfer_data_map[source_obj]["td_types"]
with link_objs_to_collection(set([target_obj, source_obj]), td_col):
for td_type_key, td_dicts in td_types.items():
start_time = time.time()
apply_transfer_data_items(
context, source_obj, target_obj, td_type_key, td_dicts
)
profiler.add(time.time() - start_time, td_type_key)
bpy.data.collections.remove(td_col)
@@ -0,0 +1,261 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
from .transfer_function_util.proximity_core import (
tris_per_face,
closest_face_to_point,
closest_tri_on_face,
is_obdata_identical,
transfer_corner_data,
)
from ..transfer_util import find_ownership_data
from ...naming import merge_get_basename
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
def attributes_get_editable(attributes):
return [
attribute
for attribute in attributes
if not (
attribute.is_internal
or attribute.is_required
# Material Index is part of material transfer and should be skipped
or attribute.name == 'material_index'
)
]
def attribute_clean(obj):
logger = logging.get_logger()
if obj.type != "MESH":
return
attributes = attributes_get_editable(obj.data.attributes)
attributes_to_remove = []
for attribute in attributes:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(attribute.name),
constants.ATTRIBUTE_KEY,
)
if not ownership_data:
attributes_to_remove.append(attribute.name)
for attribute_name_to_remove in reversed(attributes_to_remove):
attribute_to_remove = obj.data.attributes.get(attribute_name_to_remove)
logger.debug(f"Cleaning attribute {attribute.name}")
obj.data.attributes.remove(attribute_to_remove)
def attribute_is_missing(transfer_data_item):
obj = transfer_data_item.id_data
if obj.type != "MESH":
return
attributes = attributes_get_editable(obj.data.attributes)
attribute_names = [attribute.name for attribute in attributes]
if (
transfer_data_item.type == constants.ATTRIBUTE_KEY
and not transfer_data_item["name"] in attribute_names
):
return True
def init_attributes(scene, obj):
asset_pipe = scene.asset_pipeline
if obj.type != "MESH":
return
transfer_data = obj.transfer_data_ownership
td_type_key = constants.ATTRIBUTE_KEY
for atttribute in attributes_get_editable(obj.data.attributes):
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, atttribute.name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe, td_type_key, atttribute.name
)
asset_pipe.add_temp_transfer_data(
name=atttribute.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_attribute(
attribute_name: str,
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
source_attributes = source_obj.data.attributes
target_attributes = target_obj.data.attributes
source_attribute = source_attributes.get(attribute_name)
target_attribute = target_attributes.get(attribute_name)
logger = logging.get_logger()
if not source_attribute:
logger.debug(f"Failed to find attribute to transfer: {attribute_name}")
return
if target_attribute:
target_attributes.remove(target_attribute)
target_attribute = target_attributes.new(
name=attribute_name,
type=source_attribute.data_type,
domain=source_attribute.domain,
)
if not target_attribute:
logger.debug(f"Failed to create attribute: {target_obj.name} -> {attribute_name}")
return
if not is_obdata_identical(source_obj, target_obj):
proximity_transfer_single_attribute(
source_obj, target_obj, source_attribute, target_attribute
)
return
for source_data_item in source_attribute.data.items():
index = source_data_item[0]
source_data = source_data_item[1]
keys = set(source_data.bl_rna.properties.keys()) - set(
bpy.types.Attribute.bl_rna.properties.keys()
)
for key in list(keys):
target_data = target_attribute.data[index]
setattr(target_data, key, getattr(source_data, key))
def proximity_transfer_single_attribute(
source_obj: bpy.types.Object,
target_obj: bpy.types.Object,
source_attribute: bpy.types.Attribute,
target_attribute: bpy.types.Attribute,
):
# src_dat = source_obj.data
# tgt_dat = target_obj.data
# if type(src_dat) is not type(tgt_dat) or not (src_dat or tgt_dat):
# return False
# if type(tgt_dat) is not bpy.types.Mesh: # TODO: support more types
# return False
# If target attribute already exists, remove it.
# tgt_attr = tgt_dat.attributes.get(source_attribute.name)
# if tgt_attr is not None:
# try:
# tgt_dat.attributes.remove(tgt_attr)
# except RuntimeError:
# # Built-ins like "position" cannot be removed, and should be skipped.
# return
# Create target attribute.
# target_attribute = tgt_dat.attributes.new(
# source_attribute.name, source_attribute.data_type, source_attribute.domain
# )
logger = logging.get_logger()
data_sfx = {
'INT8': 'value',
'INT': 'value',
'FLOAT': 'value',
'FLOAT2': 'vector',
'BOOLEAN': 'value',
'STRING': 'value',
'BYTE_COLOR': 'color',
'FLOAT_COLOR': 'color',
'FLOAT_VECTOR': 'vector',
}
data_sfx = data_sfx[source_attribute.data_type]
# if topo_match:
# # TODO: optimize using foreach_get/set rather than loop
# for i in range(len(source_attribute.data)):
# setattr(tgt_attr.data[i], data_sfx, getattr(source_attribute.data[i], data_sfx))
# return
# proximity fallback
if source_attribute.data_type == 'STRING':
# TODO: add NEAREST transfer fallback for attributes without interpolation
logger.warning(
f'Proximity based transfer for generic attributes of type STRING not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
)
return
domain = source_attribute.domain
if (
domain == 'POINT'
): # TODO: deduplicate interpolated point domain proximity transfer
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for i, vert in enumerate(target_obj.data.vertices):
p = vert.co
face = closest_face_to_point(bm_source, p, bvh_tree)
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
continue
weights = mathutils.interpolate.poly_3d_calc(
[tri[i].vert.co for i in range(3)], point
)
if data_sfx in ['color']:
vals_weighted = [
weights[i]
* (
np.array(
getattr(source_attribute.data[tri[i].vert.index], data_sfx)
)
)
for i in range(3)
]
else:
vals_weighted = [
weights[i]
* (getattr(source_attribute.data[tri[i].vert.index], data_sfx))
for i in range(3)
]
setattr(target_attribute.data[i], data_sfx, sum(np.array(vals_weighted)))
return
elif domain == 'EDGE':
# TODO support proximity fallback for generic edge attributes
logger.warning(
f'Proximity based transfer of generic edge attributes not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
)
return
elif domain == 'FACE':
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
for i, face in enumerate(target_obj.data.polygons):
p_target = face.center
closest_face = closest_face_to_point(bm_source, p_target, bvh_tree)
setattr(
target_attribute.data[i],
data_sfx,
getattr(source_attribute.data[closest_face.index], data_sfx),
)
return
elif domain == 'CORNER':
transfer_corner_data(
source_obj,
target_obj,
source_attribute.data,
target_attribute.data,
data_suffix=data_sfx,
)
return
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
find_ownership_data,
)
from ...naming import task_layer_prefix_name_get
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
def constraints_clean(obj):
cleaned_names = transfer_data_clean(
obj=obj, data_list=obj.constraints, td_type_key=constants.CONSTRAINT_KEY
)
# Remove Drivers that match the cleaned item's name
for name in cleaned_names:
cleanup_drivers(obj, 'constraints', name)
def constraint_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.CONSTRAINT_KEY,
data_list=transfer_data_item.id_data.constraints,
)
def init_constraints(scene, obj):
td_type_key = constants.CONSTRAINT_KEY
transfer_data = obj.transfer_data_ownership
asset_pipe = scene.asset_pipeline
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
for const in obj.constraints:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, const.name, td_type_key)
if not ownership_data:
ownership_data = asset_pipe.add_temp_transfer_data(
name=const.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
const.name = task_layer_prefix_name_get(const.name, ownership_data.owner)
def transfer_constraint(constraint_name, target_obj, source_obj):
logger = logging.get_logger()
context = bpy.context
# Remove old and sync existing constraints.
old_con = target_obj.constraints.get(constraint_name)
if old_con:
target_obj.constraints.remove(old_con)
src_idx = source_obj.constraints.find(constraint_name)
if src_idx == -1:
# This happens if a modifier's transfer data is still around, but the modifier
# itself was removed.
logger.debug(f"Constraint Transfer cancelled, '{constraint_name}' not found on '{source_obj.name}'")
return
src_con = source_obj.constraints[src_idx]
new_con = target_obj.constraints.new(src_con.type)
new_con.name = src_con.name
props = [p.identifier for p in src_con.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(src_con, prop)
setattr(new_con, prop, value)
# Armature constraints have some nested properties we need to copy...
if src_con.type == "ARMATURE":
for target_item in src_con.targets:
new_target = new_con.targets.new()
new_target.target = target_item.target
new_target.subtarget = target_item.subtarget
transfer_drivers(source_obj, target_obj, 'constraints', constraint_name)
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from .... import constants
from .transfer_function_util.properties import (
get_all_runtime_prop_names,
remove_property,
copy_runtime_property,
)
def transfer_custom_prop(prop_name, target_obj, source_obj):
copy_runtime_property(source_obj, target_obj, prop_name)
def custom_prop_clean(obj):
cleaned_item_names = set()
for key in get_valid_runtime_prop_names(obj):
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
key,
constants.CUSTOM_PROP_KEY,
)
if not ownership_data:
cleaned_item_names.add(key)
remove_property(obj, key)
return cleaned_item_names
def custom_prop_is_missing(transfer_data_item):
obj = transfer_data_item.id_data
return transfer_data_item.type == constants.CUSTOM_PROP_KEY and not transfer_data_item["name"] in get_valid_runtime_prop_names(obj)
def init_custom_prop(scene, obj):
asset_pipe = scene.asset_pipeline
transfer_data = obj.transfer_data_ownership
td_type_key = constants.CUSTOM_PROP_KEY
for prop_name in get_valid_runtime_prop_names(obj):
ownership_data = find_ownership_data(transfer_data, prop_name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe, td_type_key, prop_name
)
asset_pipe.add_temp_transfer_data(
name=prop_name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def get_valid_runtime_prop_names(id):
all_props = get_all_runtime_prop_names(id)
return [p for p in all_props if p not in constants.ADDON_OWN_PROPERTIES]
@@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .attributes import transfer_attribute
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from .... import constants
from .transfer_function_util.proximity_core import (
is_obdata_identical,
)
def materials_clean(obj):
# Material slots cannot use generic transfer_data_clean() function
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
constants.MATERIAL_TRANSFER_DATA_ITEM_NAME,
constants.MATERIAL_SLOT_KEY,
)
# Clear Materials if No Transferable Data is Found
if ownership_data:
return
if obj.data and hasattr(obj.data, 'materials'):
obj.data.materials.clear()
def materials_is_missing(transfer_data_item):
if (
transfer_data_item.type == constants.MATERIAL_SLOT_KEY
and len(transfer_data_item.id_data.material_slots) == 0
):
return True
def init_materials(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.MATERIAL_SLOT_KEY
name = constants.MATERIAL_TRANSFER_DATA_ITEM_NAME
transfer_data = obj.transfer_data_ownership
if not (obj.data and hasattr(obj.data, 'materials')):
return
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
# Only add new ownership transfer_data_item if material doesn't have an owner
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_materials(target_obj: bpy.types.Object, source_obj):
# Delete all material slots of target object.
target_obj.data.materials.clear()
# Transfer material slots
for idx in range(len(source_obj.material_slots)):
target_obj.data.materials.append(source_obj.material_slots[idx].material)
target_obj.material_slots[idx].link = source_obj.material_slots[idx].link
# Transfer active material slot index
target_obj.active_material_index = source_obj.active_material_index
# Transfer material slot assignments for curve
if target_obj.type == "CURVE":
for spl_to, spl_from in zip(target_obj.data.splines, source_obj.data.splines):
spl_to.material_index = spl_from.material_index
if source_obj.type == "MESH":
if source_obj.data.attributes.get(constants.MATERIAL_ATTRIBUTE_NAME):
transfer_attribute(constants.MATERIAL_ATTRIBUTE_NAME, target_obj, source_obj)
transfer_uv_seams(source_obj, target_obj)
def transfer_uv_seams(source_obj, target_obj):
if is_obdata_identical(source_obj, target_obj):
for edge_from, edge_to in zip(source_obj.data.edges, target_obj.data.edges):
edge_to.use_seam = edge_from.use_seam
elif len(source_obj.data.edges) > 0 and len(target_obj.data.edges) > 0:
# Create proxy object as transfer source to avoid transferring from evaluated mesh
temp_source_obj = bpy.data.objects.new('TEMP', source_obj.data)
with bpy.context.temp_override(
object=temp_source_obj,
active_object=temp_source_obj,
selected_editable_objects=[temp_source_obj, target_obj],
):
bpy.ops.object.data_transfer(
data_type="SEAM",
edge_mapping="NEAREST",
mix_mode="REPLACE",
use_object_transform=False,
)
bpy.data.objects.remove(temp_source_obj)
@@ -0,0 +1,206 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from .transfer_function_util.visibility import override_obj_visibility
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
find_ownership_data,
activate_shapekey,
disable_modifiers,
)
from ...naming import task_layer_prefix_name_get, task_layer_prefix_basename_get
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
BIND_OPS = {
'SURFACE_DEFORM': bpy.ops.object.surfacedeform_bind,
'MESH_DEFORM': bpy.ops.object.meshdeform_bind,
'CORRECTIVE_SMOOTH': bpy.ops.object.correctivesmooth_bind,
}
def modifiers_clean(obj):
cleaned_names = transfer_data_clean(
obj=obj, data_list=obj.modifiers, td_type_key=constants.MODIFIER_KEY
)
# Remove Drivers that match the cleaned item's name
for name in cleaned_names:
cleanup_drivers(obj, 'modifiers', name)
def modifier_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.MODIFIER_KEY,
data_list=transfer_data_item.id_data.modifiers,
)
def init_modifiers(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.MODIFIER_KEY
transfer_data = obj.transfer_data_ownership
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
for mod in obj.modifiers:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, mod.name, td_type_key)
if not ownership_data:
ownership_data = asset_pipe.add_temp_transfer_data(
name=mod.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
mod.name = task_layer_prefix_name_get(mod.name, ownership_data.owner)
def transfer_modifier(context, modifier_name, target_obj, source_obj):
"""Transfer a single modifier from source_obj to target_obj.
For example, when pulling into rigging and transferring a rigging modifier,
then source_obj will be the local object, and target_obj will be the external object.
"""
logger = logging.get_logger()
source_mod = source_obj.modifiers.get(modifier_name)
target_mod = target_obj.modifiers.get(modifier_name)
if not source_mod:
# This happens if a modifier's transfer data is still around, but the modifier
# itself was removed.
logger.debug(
f"Modifer Transfer cancelled, '{modifier_name}' not found on '{source_obj.name}'"
)
if target_mod:
target_obj.modifiers.remove(target_mod)
return
if not target_mod:
target_mod = target_obj.modifiers.new(source_mod.name, source_mod.type)
place_modifier_in_stack(source_obj, target_obj, modifier_name)
transfer_modifier_props(context, source_mod, target_mod)
transfer_drivers(source_obj, target_obj, 'modifiers', modifier_name)
if is_modifier_bound(source_mod):
bind_modifier(context, target_obj, modifier_name)
def place_modifier_in_stack(source_obj, target_obj, modifier_name):
"""Modifiers will try to be placed below the modifier they were below on the source object.
This is not very foolproof, since re-ordering multiple modifiers or renaming plus re-ordering,
or removing plus re-ordering, all in one step, could make it hard to determine the ideal order.
In such cases, user may need to fix the order and sync a 2nd time.
"""
logger = logging.get_logger()
idx_tgt = target_obj.modifiers.find(modifier_name)
idx_src = source_obj.modifiers.find(modifier_name)
idx_new = 0
name_anchor = ""
# Order modifier based on previous modifier in source obj.
if idx_src > 0:
mod_anchor = source_obj.modifiers[idx_src - 1]
name_anchor = task_layer_prefix_basename_get(mod_anchor.name)
for idx, mod_of_tgt in enumerate(target_obj.modifiers):
if name_anchor == task_layer_prefix_basename_get(mod_of_tgt.name):
idx_new = min(len(target_obj.modifiers)-1, idx+1)
break
if idx_tgt != idx_new:
target_obj.modifiers.move(idx_tgt, idx_new)
msg = f" Moved {modifier_name} to index {idx_new}"
if name_anchor:
msg += f"(after {name_anchor})"
logger.debug(msg)
def transfer_modifier_props(context, source_mod, target_mod):
props = [p.identifier for p in source_mod.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(source_mod, prop)
setattr(target_mod, prop, value)
if source_mod.type == 'NODES':
# NOTE: This matches inputs by their internal name, not their display name.
# That means you can rename sockets, but removing and adding new ones might cause trouble.
# Transfer geo node attributes
for key, value in source_mod.items():
typ = type(getattr(target_mod, f'["{key}"]'))
if typ in (int, float, bool, str):
value = typ(value)
target_mod[key] = value
# Transfer geo node bake settings
target_mod.bake_directory = source_mod.bake_directory
for index, target_bake in enumerate(target_mod.bakes):
source_bake = source_mod.bakes[index]
props = [p.identifier for p in source_bake.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(source_bake, prop)
setattr(target_bake, prop, value)
# refresh node modifier UI
if target_mod.node_group:
target_mod.node_group.interface_update(context)
def bind_modifier(context, obj, modifier_name):
"""Binding data cannot be transferred. Instead, modifiers that require binding will have the bind operator executed.
Sometimes binding is meant to be done in a bind pose other than the default. For this, shape keys can be added
to the deforming and/or the deformed mesh, named "BIND-<name_of_modifier_with_prefix>". Such shape keys will be enabled
during binding. Other deforming modifiers will be disabled during binding.
"""
# NOTE: This could be optimized by not re-binding unnecessarily, but Blender doesn't allow checking
# if the binding is broken or not. https://projects.blender.org/blender/blender/issues/140550
# Another way to get around this is to let the rigging task layer own the object base.
logger = logging.get_logger()
modifier = obj.modifiers.get(modifier_name)
assert modifier
bind_op = BIND_OPS.get(modifier.type)
if (
not bind_op or
(hasattr(modifier, 'target') and not modifier.target) or
not modifier.show_viewport or
(modifier.type=='CORRECTIVE_SMOOTH' and modifier.rest_source=='ORCO')
):
return
objs = [obj]
if hasattr(modifier, 'target') and modifier.target:
objs.append(modifier.target)
with activate_shapekey(objs, "BIND-"+modifier_name):
modifiers_to_disable = ['LATTICE', 'ARMATURE', 'SHRINKWRAP', 'SMOOTH']
if modifier.type != 'CORRECTIVE_SMOOTH':
modifiers_to_disable.append('CORRECTIVE_SMOOTH')
with disable_modifiers(objs, modifiers_to_disable):
for i in range(2):
context.view_layer.update()
with override_obj_visibility(obj=obj, scene=context.scene):
with context.temp_override(object=obj, active_object=obj):
bind_op(modifier=modifier.name)
word = "Bound" if is_modifier_bound(modifier) else "Un-bound"
logger.debug(f"{word} {modifier_name} on {obj.name}")
if is_modifier_bound(modifier):
return
def is_modifier_bound(modifier) -> bool | None:
if modifier.type == 'CORRECTIVE_SMOOTH':
return modifier.is_bind
elif hasattr(modifier, 'is_bound'):
return modifier.is_bound
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from ...naming import merge_get_basename
from .... import constants, logging
def parent_clean(obj):
logger = logging.get_logger()
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(constants.PARENT_TRANSFER_DATA_ITEM_NAME),
constants.PARENT_KEY,
)
if ownership_data:
return
obj.parent = None
logger.debug("Cleaning Parent Relationship")
def parent_is_missing(transfer_data_item):
if (
transfer_data_item.type == constants.PARENT_KEY
and transfer_data_item.id_data.parent == None
):
return True
def init_parent(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.PARENT_KEY
name = constants.PARENT_TRANSFER_DATA_ITEM_NAME
transfer_data = obj.transfer_data_ownership
if obj.parent not in list(asset_pipe.asset_collection.all_objects) and obj.parent is not None:
raise Exception(f"Object parent {obj.parent.name} cannot be outside of asset collection")
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_parent(target_obj, source_obj):
target_obj.parent = source_obj.parent
target_obj.parent_type = source_obj.parent_type
target_obj.parent_bone = source_obj.parent_bone
target_obj.location = source_obj.location
if source_obj.rotation_mode == 'QUATERNION':
target_obj.rotation_quaternion = source_obj.rotation_quaternion
else:
target_obj.rotation_euler = source_obj.rotation_euler
target_obj.scale = source_obj.scale
target_obj.matrix_parent_inverse = source_obj.matrix_parent_inverse.copy()
@@ -0,0 +1,164 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
from .transfer_function_util.proximity_core import (
tris_per_face,
closest_face_to_point,
closest_tri_on_face,
)
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from ..transfer_util import (
transfer_data_item_is_missing,
transfer_data_item_init,
find_ownership_data,
)
from ...naming import merge_get_basename
from .... import constants, logging
def shape_key_set_active(obj, shape_key_name):
for index, shape_key in enumerate(obj.data.shape_keys.key_blocks):
if shape_key.name == shape_key_name:
obj.active_shape_key_index = index
def shape_keys_clean(obj):
if obj.type != "MESH" or obj.data.shape_keys is None:
return
cleaned_item_names = set()
for shape_key in obj.data.shape_keys.key_blocks:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(shape_key.name),
constants.SHAPE_KEY_KEY,
)
if not ownership_data:
cleaned_item_names.add(shape_key.name)
obj.shape_key_remove(shape_key)
if not obj.data.shape_keys:
# It's possible there are no shape keys anymore.
return
for name in cleaned_item_names:
cleanup_drivers(obj.data.shape_keys, 'key_blocks', name)
def shape_key_is_missing(transfer_data_item):
if not transfer_data_item.type == constants.SHAPE_KEY_KEY:
return
obj = transfer_data_item.id_data
if obj.type != 'MESH':
return
if not obj.data.shape_keys:
return True
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.SHAPE_KEY_KEY,
data_list=obj.data.shape_keys.key_blocks,
)
def init_shape_keys(scene, obj):
if obj.type != "MESH" or obj.data.shape_keys is None:
return
# Check that the order is legal.
# Key Blocks must be ordered after the key they are Relative To.
for i, kb in enumerate(obj.data.shape_keys.key_blocks):
if kb.relative_key:
base_shape_idx = obj.data.shape_keys.key_blocks.find(kb.relative_key.name)
if base_shape_idx > i:
raise Exception(
f'Shape Key "{kb.name}" must be ordered after its base shape "{kb.relative_key.name}" on object "{obj.name}".'
)
transfer_data_item_init(
scene=scene,
obj=obj,
data_list=obj.data.shape_keys.key_blocks,
td_type_key=constants.SHAPE_KEY_KEY,
)
def transfer_shape_key(
context: bpy.types.Context,
shape_key_name: str,
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
logger = logging.get_logger()
if not source_obj.data.shape_keys:
return
sk_source = source_obj.data.shape_keys.key_blocks.get(shape_key_name)
assert sk_source
sk_target = None
if not target_obj.data.shape_keys:
sk_target = target_obj.shape_key_add()
if not sk_target:
sk_target = target_obj.data.shape_keys.key_blocks.get(shape_key_name)
if not sk_target:
sk_target = target_obj.shape_key_add()
sk_target.name = sk_source.name
sk_target.value = 0
sk_target.vertex_group = sk_source.vertex_group
if sk_source.relative_key != sk_source:
relative_key = None
if target_obj.data.shape_keys:
relative_key = target_obj.data.shape_keys.key_blocks.get(sk_source.relative_key.name)
if relative_key:
sk_target.relative_key = relative_key
else:
# If the base shape of one of our shapes was removed by another task layer,
# the result will probably be pretty bad, but it's not a catastrophic failure.
# Proceed with a warning.
logger.warning(
f'Base shape "{sk_source.relative_key.name}" of Key "{sk_source.name}" was removed from "{target_obj.name}"'
)
sk_target.slider_min = sk_source.slider_min
sk_target.slider_max = sk_source.slider_max
sk_target.value = sk_source.value
sk_target.mute = sk_source.mute
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for i, vert in enumerate(target_obj.data.vertices):
p = vert.co
face = closest_face_to_point(bm_source, p, bvh_tree)
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
continue
weights = mathutils.interpolate.poly_3d_calc([tri[i].vert.co for i in range(3)], point)
vals_weighted = [
weights[i]
* (
sk_source.data[tri[i].vert.index].co
- source_obj.data.vertices[tri[i].vert.index].co
)
for i in range(3)
]
val = mathutils.Vector(sum(np.array(vals_weighted)))
sk_target.data[i].co = vert.co + val
if source_obj.data.shape_keys is None:
return
transfer_drivers(
source_obj.data.shape_keys, target_obj.data.shape_keys, 'key_blocks', shape_key_name
)
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
def transfer_active_color_attribute_index(target_obj, active_color_name):
# active_color_name = source_obj.data.color_attributes.active_color_name
if active_color_name is None or active_color_name == "":
return
for color_attribute in target_obj.data.color_attributes:
if color_attribute.name == active_color_name:
target_obj.data.color_attributes.active_color = color_attribute
def transfer_active_uv_layer_index(target_obj, active_uv_name):
# active_uv = source_obj.data.uv_layers.active
if active_uv_name is None or active_uv_name == "":
return
for uv_layer in target_obj.data.uv_layers:
if uv_layer.name == active_uv_name:
target_obj.data.uv_layers.active = uv_layer
@@ -0,0 +1,95 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
def copy_driver(
from_fcurve: bpy.types.FCurve, target: bpy.types.ID, data_path=None, index=None
) -> bpy.types.FCurve:
"""Copy an existing FCurve containing a driver to a new ID, by creating a copy
of the existing driver on the target ID.
Args:
from_fcurve (bpy.types.FCurve): FCurve containing a driver
target (bpy.types.ID): ID that can have drivers added to it
data_path (_type_, optional): Data Path of existing driver. Defaults to None.
index (_type_, optional): array index of the property drive. Defaults to None.
Returns:
bpy.types.FCurve: Fcurve containing copy of driver on target ID
"""
if not target.animation_data:
target.animation_data_create()
new_fc = target.animation_data.drivers.from_existing(src_driver = from_fcurve)
if data_path:
new_fc.data_path = data_path
if index:
new_fc.array_index = index
return new_fc
def find_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> list[bpy.types.FCurve]:
"""_summary_
Args:
drivers (list[bpy.types.FCurve]): List or Collection Property containing Fcurves with drivers
target_type (str): Name of data type found in driver data path, e.g. "modifiers"
target_name (str): Name of data found in driver path, e.g. modifier's name
Returns:
list[bpy.types.FCurve]: List of FCurves containing drivers that match type & name
"""
if not id.animation_data:
return []
found_drivers = []
if id.animation_data is None or id.animation_data.drivers is None:
return found_drivers
drivers = id.animation_data.drivers
for driver in drivers:
if f'{target_type}["{target_name}"]' in driver.data_path:
found_drivers.append(driver)
return found_drivers
def transfer_drivers(
source_id: bpy.types.ID, target_id: bpy.types.ID, target_type: str, target_name: str
) -> None:
"""Transfers Drivers from one ID to another, will copy and new drivres from source to from
source to target, and will remove any drivers on the target that are not in the source.
Args:
source_id (bpy.types.ID): Source ID, containing drivers to copy
target_id (bpy.types.ID): Target ID, which will recieve the drivers from source
target_type (str): Name of driver target's type, like `modifier` or `constraint`
target_name (str): Name of driver target, e.g. name of a modifier or contraint
"""
source_fcurves = find_drivers(source_id, target_type, target_name)
target_fcurves = find_drivers(target_id, target_type, target_name)
# Clear old drivers
for old_fcurve in list(set(target_fcurves) - set(source_fcurves)):
target_id.animation_data.drivers.remove(old_fcurve)
# Transfer new drivers
for fcurve in source_fcurves:
copy_driver(from_fcurve=fcurve, target=target_id)
def cleanup_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> None:
"""Remove all drivers for transfer data that has been removed.
Args:
object (bpy.types.ID): ID, which has drivers to remove
target_type (str): Name of driver target's type, like `modifier` or `constraint`
target_name (str): Name of driver target, e.g. name of a modifier or contraint
"""
target_fcurves = find_drivers(id, target_type, target_name)
for fcurve in target_fcurves:
id.animation_data.drivers.remove(fcurve)
@@ -0,0 +1,161 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import PropertyGroup, bpy_prop_collection, Object
from rna_prop_ui import IDPropertyGroup
from bpy.utils import flip_name
# Functions to manage runtime properties, which include custom properties and add-on properties.
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
# a PropertyGroup, or a CollectionProperty.
# Currently the minimum Blender version for this code is 5.0, but it could probably be made backwards-compatible.
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
"""Copy add-on and custom properties from source to target.
Both should be the same type.
Should support anything that supports custom properties or property registration.
"""
for prop_name in get_all_runtime_prop_names(src_id):
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
def get_all_runtime_prop_names(owner):
custom_props = list(owner.keys())
addon_props = get_addon_prop_names(owner)
props = custom_props + addon_props
return props
def get_addon_prop_names(owner):
if bpy.app.version >= (5, 0, 0):
return list(owner.bl_system_properties_get().keys())
else:
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
"""Copy add-on properties or custom properties."""
if is_addon_prop(src_id, prop_name):
if is_registered_addon_prop(src_id, prop_name):
src_prop = getattr(src_id, prop_name)
tgt_prop = getattr(tgt_id, prop_name)
if isinstance(src_prop, bpy_prop_collection):
copy_coll_prop(src_prop, tgt_prop, x_mirror)
elif isinstance(src_prop, PropertyGroup):
copy_property_group(src_prop, tgt_prop, x_mirror)
else:
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
else:
if bpy.app.version >= (5, 0, 0):
# HACK: If we need to copy add-on properties, but the add-on is not present,
# we have to write to the system properties, which is API abuse that could
# lose support any moment, but there is no other way to do this atm.
try:
tgt_id.bl_system_properties_get()[prop_name] = src_id.bl_system_properties_get()[prop_name]
except TypeError:
# Happens for at least a mysterious "booleans" custom property which seems to be an empty PropGroup. Where is it coming from!?
pass
else:
tgt_id[prop_name] = src_id[prop_name]
else:
copy_custom_property(src_id, tgt_id, prop_name)
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
"""
Copy the values from one PropertyGroup into another of the same type.
Optionally, X-mirror names (e.g., ".L" <-> ".R") in strings and Object references.
"""
assert isinstance(tgt_pg, PropertyGroup) and isinstance(src_pg, PropertyGroup)
assert tgt_pg.__class__ == src_pg.__class__
for prop_name in src_pg.bl_rna.properties.keys():
if prop_name in ('rna_type', 'bl_rna'):
continue
if not src_pg.is_property_set(prop_name):
tgt_pg.property_unset(prop_name)
continue
value = getattr(src_pg, prop_name)
if isinstance(value, bpy_prop_collection):
tgt_collprop = getattr(tgt_pg, prop_name)
copy_coll_prop(value, tgt_collprop)
elif isinstance(value, PropertyGroup):
copy_property_group(value, getattr(tgt_pg, prop_name), x_mirror)
else:
copy_single_addon_prop(src_pg, tgt_pg, prop_name, x_mirror)
for prop_name in src_pg.keys():
if is_custom_prop(src_pg, prop_name):
# PropertyGroups also support custom properties.
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_cp.clear()
for src_pg in src_cp:
assert isinstance(src_pg, PropertyGroup)
tgt_pg = tgt_cp.add()
copy_property_group(src_pg, tgt_pg, x_mirror)
def copy_custom_property(src_owner, tgt_owner, prop_name, x_mirror=False):
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
prop = src_owner.id_properties_ui(prop_name)
assert prop, f'Property "{prop_name}" not found in {src_owner}.'
value = src_owner[prop_name]
if x_mirror:
value = x_mirror_value(value)
tgt_owner[prop_name] = value
new_prop = tgt_owner.id_properties_ui(prop_name)
new_prop.update_from(prop)
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
if src.is_property_readonly(prop_name):
# This "early" exit has to come after CollectionProperty & PropertyGroup
# checks, since they are technically read-only.
return False
value = getattr(src, prop_name)
if x_mirror:
value = x_mirror_value(value)
setattr(tgt, prop_name, value)
return True
def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
elif isinstance(value, Object):
get_opposite_obj(value)
else:
return value
def get_opposite_obj(obj: Object) -> Object:
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
flipped_name = flip_name(obj.name)
lib = obj.library
return (
bpy.data.objects.get((lib, flipped_name)) if lib else
bpy.data.objects.get(flipped_name)
) or obj
def is_addon_prop(owner, prop_name):
if bpy.app.version >= (5, 0, 0):
return prop_name in owner.bl_system_properties_get().keys()
else:
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
# They just behave completely as custom properties.
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
def is_registered_addon_prop(owner, prop_name):
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
def is_custom_prop(owner, prop_name):
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
def remove_property(obj, prop_name):
if is_custom_prop(obj, prop_name):
del obj[prop_name]
if is_registered_addon_prop(obj, prop_name):
obj.property_unset(prop_name)
elif is_addon_prop(obj, prop_name):
disabled_addon_props = obj.bl_system_properties_get()
del disabled_addon_props[prop_name]
else:
raise KeyError(f"{prop_name} not found in {obj.name}")
@@ -0,0 +1,235 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
def closest_face_to_point(bm_source, p_target, bvh_tree=None):
if not bvh_tree:
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
(loc, norm, index, distance) = bvh_tree.find_nearest(p_target)
return bm_source.faces[index]
def tris_per_face(bm_source):
tris_source = bm_source.calc_loop_triangles()
tris_dict = dict()
for face in bm_source.faces:
tris_face = []
for i in range(len(tris_source))[::-1]:
if tris_source[i][0] in face.loops:
tris_face.append(tris_source.pop(i))
tris_dict[face] = tris_face
return tris_dict
def closest_tri_on_face(tris_dict, face, p):
points = []
dist = []
tris = []
for tri in tris_dict[face]:
point = mathutils.geometry.closest_point_on_tri(
p, *[tri[i].vert.co for i in range(3)]
)
tris.append(tri)
points.append(point)
dist.append((point - p).length)
min_idx = np.argmin(np.array(dist))
point = points[min_idx]
tri = tris[min_idx]
return (tri, point)
def closest_edge_on_face_to_line(face, p1, p2, skip_edges=None):
"""Returns edge of a face which is closest to line."""
for edge in face.edges:
if skip_edges:
if edge in skip_edges:
continue
res = mathutils.geometry.intersect_line_line(
p1, p2, *[edge.verts[i].co for i in range(2)]
)
if not res:
continue
(p_traversal, p_edge) = res
frac_1 = (edge.verts[1].co - edge.verts[0].co).dot(
p_edge - edge.verts[0].co
) / (edge.verts[1].co - edge.verts[0].co).length ** 2.0
frac_2 = (p2 - p1).dot(p_traversal - p1) / (p2 - p1).length ** 2.0
if (frac_1 >= 0 and frac_1 <= 1) and (frac_2 >= 0 and frac_2 <= 1):
return edge
return None
def edge_data_split(edge, data_layer, data_suffix: str):
for vert in edge.verts:
vals = []
for loop in vert.link_loops:
loops_edge_vert = set([loop for f in edge.link_faces for loop in f.loops])
if loop not in loops_edge_vert:
continue
dat = data_layer[loop.index]
element = list(getattr(dat, data_suffix))
if not vals:
vals.append(element)
elif not vals[0] == element:
vals.append(element)
if len(vals) > 1:
return True
return False
def interpolate_data_from_face(
bm_source, tris_dict, face, p, data_layer_source, data_suffix=''
):
"""Returns interpolated value of a data layer within a face closest to a point."""
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
return None
weights = mathutils.interpolate.poly_3d_calc(
[tri[i].vert.co for i in range(3)], point
)
if not data_suffix:
cols_weighted = [
weights[i] * np.array(data_layer_source[tri[i].index]) for i in range(3)
]
col = sum(np.array(cols_weighted))
else:
cols_weighted = [
weights[i] * np.array(getattr(data_layer_source[tri[i].index], data_suffix))
for i in range(3)
]
col = sum(np.array(cols_weighted))
return col
def transfer_corner_data(
obj_source, obj_target, data_layer_source, data_layer_target, data_suffix=''
):
"""
Transfers interpolated face corner data from data layer of a source object to data layer of a
target object, while approximately preserving data seams (e.g. necessary for UV Maps).
The transfer is face interpolated per target corner within the source face that is closest
to the target corner point and does not have any data seams on the way back to the
source face that is closest to the target face's center.
"""
bm_source = bmesh.new()
bm_source.from_mesh(obj_source.data)
bm_source.faces.ensure_lookup_table()
bm_target = bmesh.new()
bm_target.from_mesh(obj_target.data)
bm_target.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for face_target in bm_target.faces:
face_target_center = face_target.calc_center_median()
face_source = closest_face_to_point(bm_source, face_target_center, bvh_tree)
for corner_target in face_target.loops:
# find nearest face on target compared to face that loop belongs to
p = corner_target.vert.co
face_source_closest = closest_face_to_point(bm_source, p, bvh_tree)
enclosed = face_source_closest is face_source
face_source_int = face_source
if not enclosed:
# traverse faces between point and face center
traversed_faces = set()
traversed_edges = set()
while face_source_int is not face_source_closest:
traversed_faces.add(face_source_int)
edge = closest_edge_on_face_to_line(
face_source_int,
face_target_center,
p,
skip_edges=traversed_edges,
)
if edge == None:
break
if len(edge.link_faces) != 2:
break
traversed_edges.add(edge)
split = edge_data_split(edge, data_layer_source, data_suffix)
if split:
break
# set new source face to other face belonging to edge
face_source_int = (
edge.link_faces[1]
if edge.link_faces[1] is not face_source_int
else edge.link_faces[0]
)
# avoid looping behaviour
if face_source_int in traversed_faces:
face_source_int = face_source
break
# interpolate data from selected face
col = interpolate_data_from_face(
bm_source, tris_dict, face_source_int, p, data_layer_source, data_suffix
)
if col is None:
continue
if not data_suffix:
data_layer_target.data[corner_target.index] = col
else:
setattr(data_layer_target[corner_target.index], data_suffix, list(col))
return
def is_mesh_identical(mesh_a, mesh_b) -> bool:
if len(mesh_a.vertices) != len(mesh_b.vertices):
return False
if len(mesh_a.edges) != len(mesh_b.edges):
return False
if len(mesh_a.polygons) != len(mesh_b.polygons):
return False
for e1, e2 in zip(mesh_a.edges, mesh_b.edges):
for v1, v2 in zip(e1.vertices, e2.vertices):
if v1 != v2:
return False
return True
def is_curve_identical(curve_a: bpy.types.Curve, curve_b: bpy.types.Curve) -> bool:
if len(curve_a.splines) != len(curve_b.splines):
return False
for spline1, spline2 in zip(curve_a.splines, curve_b.splines):
if len(spline1.points) != len(spline2.points):
return False
return True
def is_obdata_identical(
a: bpy.types.Object or bpy.types.Mesh, b: bpy.types.Object or bpy.types.Mesh
) -> bool:
"""Checks if two objects have matching topology (efficiency over exactness)"""
if type(a) == bpy.types.Object:
a = a.data
if type(b) == bpy.types.Object:
b = b.data
if type(a) != type(b):
return False
if type(a) == bpy.types.Mesh:
return is_mesh_identical(a, b)
elif type(a) == bpy.types.Curve:
return is_curve_identical(a, b)
else:
# TODO: Support geometry types other than mesh or curve.
return
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import contextlib
from typing import Optional
def get_visibility_driver(obj) -> Optional[bpy.types.FCurve]:
obj = bpy.data.objects.get(obj.name)
assert obj, "Object was renamed while its visibility was being ensured?"
if hasattr(obj, "animation_data") and obj.animation_data:
return obj.animation_data.drivers.find("hide_viewport")
@contextlib.contextmanager
def override_obj_visibility(obj: bpy.types.Object, scene: bpy.types.Scene):
"""Temporarily Change the visibility of an Object so an bpy.ops or other
function that requires the object to be visible can be called.
Args:
obj (bpy.types.Object): Object to un-hide
scene (bpy.types.Scene): Scene Object is in
"""
hide = obj.hide_get() # eye icon
hide_viewport = obj.hide_viewport # hide viewport
select = obj.hide_select # selectable
driver = get_visibility_driver(obj)
if driver:
driver_mute = driver.mute
try:
obj.hide_set(False)
obj.hide_viewport = False
obj.hide_select = False
if driver:
driver.mute = True
assigned_to_scene_root = False
if obj.name not in scene.collection.objects:
assigned_to_scene_root = True
scene.collection.objects.link(obj)
yield
finally:
obj.hide_set(hide)
obj.hide_viewport = hide_viewport
obj.hide_select = select
if driver:
driver.mute = driver_mute
if assigned_to_scene_root and obj.name in scene.collection.objects:
scene.collection.objects.unlink(obj)
@@ -0,0 +1,221 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from mathutils import Vector, kdtree
from typing import Dict, Tuple, List
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
transfer_data_item_init,
)
from .transfer_function_util.proximity_core import (
is_obdata_identical,
)
from .... import constants, logging
def vertex_groups_clean(obj):
transfer_data_clean(
obj=obj, data_list=obj.vertex_groups, td_type_key=constants.VERTEX_GROUP_KEY
)
def vertex_group_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.VERTEX_GROUP_KEY,
data_list=transfer_data_item.id_data.vertex_groups,
)
def init_vertex_groups(scene, obj):
transfer_data_item_init(
scene=scene,
obj=obj,
data_list=obj.vertex_groups,
td_type_key=constants.VERTEX_GROUP_KEY,
)
def transfer_vertex_groups(
vertex_group_names: List[str],
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
logger = logging.get_logger()
for vertex_group_name in vertex_group_names:
if not source_obj.vertex_groups.get(vertex_group_name):
logger.error(f"Vertex Group {vertex_group_name} not found in {source_obj.name}")
return
# If topology matches transfer directly, otherwise use vertex proximity
if is_obdata_identical(source_obj, target_obj):
for vertex_group_name in vertex_group_names:
transfer_single_vgroup_by_topology(source_obj, target_obj, vertex_group_name)
else:
precalc_and_transfer_multiple_groups(source_obj, target_obj, vertex_group_names, expand=2)
def transfer_single_vgroup_by_topology(source_obj, target_obj, vgroup_name):
"""Function to quickly transfer single vertex group between mesh objects in case of matching topology."""
remove_vgroups([target_obj], [vgroup_name])
vgroup_src = source_obj.vertex_groups.get(vgroup_name)
vgroup_tgt = target_obj.vertex_groups.new(name=vgroup_name)
for v in source_obj.data.vertices:
if vgroup_src.index in [g.group for g in v.groups]:
vgroup_tgt.add([v.index], vgroup_src.weight(v.index), 'REPLACE')
def remove_vgroups(objs, vgroup_names):
for obj in objs:
for vgroup_name in vgroup_names:
target_vgroup = obj.vertex_groups.get(vgroup_name)
if target_vgroup:
obj.vertex_groups.remove(target_vgroup)
def precalc_and_transfer_multiple_groups(source_obj, target_obj, vgroup_names, expand=2):
"""Convenience function to transfer multiple groups."""
remove_vgroups([target_obj], vgroup_names)
kd_tree = build_kdtree(source_obj.data)
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
transfer_multiple_vertex_groups(
source_obj,
target_obj,
vert_influence_map,
src_vgroups=[source_obj.vertex_groups[name] for name in vgroup_names],
)
def precalc_and_transfer_single_group(source_obj, target_obj, vgroup_name, expand=2):
"""Convenience function to transfer a single group. For transferring multiple groups,
precalc_and_transfer_multiple_groups should be used as it is more efficient."""
remove_vgroups([target_obj], [vgroup_name])
kd_tree = build_kdtree(source_obj.data)
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
transfer_multiple_vertex_groups(
source_obj,
target_obj,
vert_influence_map,
[source_obj.vertex_groups[vgroup_name]],
)
def build_kdtree(mesh):
kd = kdtree.KDTree(len(mesh.vertices))
for i, v in enumerate(mesh.vertices):
kd.insert(v.co, i)
kd.balance()
return kd
def build_vert_influence_map(obj_from, obj_to, kd_tree, expand=2):
verts_of_edge = {i: (e.vertices[0], e.vertices[1]) for i, e in enumerate(obj_from.data.edges)}
edges_of_vert: Dict[int, List[int]] = {}
for edge_idx, edge in enumerate(obj_from.data.edges):
for vert_idx in edge.vertices:
if vert_idx not in edges_of_vert:
edges_of_vert[vert_idx] = []
edges_of_vert[vert_idx].append(edge_idx)
# A mapping from target vertex index to a list of source vertex indicies and
# their influence.
# This can be pre-calculated once per object pair, to minimize re-calculations
# of subsequent transferring of individual vertex groups.
vert_influence_map: List[int, List[Tuple[int, float]]] = {}
for i, dest_vert in enumerate(obj_to.data.vertices):
vert_influence_map[i] = get_source_vert_influences(
dest_vert, obj_from, kd_tree, expand, edges_of_vert, verts_of_edge
)
return vert_influence_map
def get_source_vert_influences(
target_vert, obj_from, kd_tree, expand=2, edges_of_vert={}, verts_of_edge={}
) -> List[Tuple[int, float]]:
_coord, idx, dist = get_nearest_vert(target_vert.co, kd_tree)
source_vert_indices = [idx]
if dist == 0:
# If the vertex position is a perfect match, just use that one vertex with max influence.
return [(idx, 1)]
for i in range(0, expand):
new_indices = []
for vert_idx in source_vert_indices:
for edge in edges_of_vert[vert_idx]:
vert_other = other_vert_of_edge(edge, vert_idx, verts_of_edge)
if vert_other not in source_vert_indices:
new_indices.append(vert_other)
source_vert_indices.extend(new_indices)
distances: List[Tuple[int, float]] = []
distance_total = 0
for src_vert_idx in source_vert_indices:
distance = (target_vert.co - obj_from.data.vertices[src_vert_idx].co).length
distance_total += distance
distances.append((src_vert_idx, distance))
# Calculate influences such that the total of all influences adds up to 1.0,
# and the influence is inversely correlated with the distance.
parts = [1 / (dist / distance_total) for idx, dist in distances]
parts_sum = sum(parts)
influences = [
(idx, 1 if dist == 0 else part / parts_sum) for part, dist in zip(parts, distances)
]
return influences
def get_nearest_vert(coords: Vector, kd_tree: kdtree.KDTree) -> Tuple[Vector, int, float]:
"""Return coordinate, index, and distance of nearest vert to coords in kd_tree."""
return kd_tree.find(coords)
def other_vert_of_edge(edge: int, vert: int, verts_of_edge: Dict[int, Tuple[int, int]]) -> int:
verts = verts_of_edge[edge]
assert vert in verts, f"Vert {vert} not part of edge {edge}."
return verts[0] if vert == verts[1] else verts[1]
def transfer_multiple_vertex_groups(obj_from, obj_to, vert_influence_map, src_vgroups):
"""Transfer src_vgroups in obj_from to obj_to using a pre-calculated vert_influence_map."""
for src_vg in src_vgroups:
target_vg = obj_to.vertex_groups.get(src_vg.name)
if target_vg == None:
target_vg = obj_to.vertex_groups.new(name=src_vg.name)
for i, dest_vert in enumerate(obj_to.data.vertices):
source_verts = vert_influence_map[i]
# Vertex Group Name : Weight
vgroup_weights = {}
for src_vert_idx, influence in source_verts:
for group in obj_from.data.vertices[src_vert_idx].groups:
group_idx = group.group
vg = obj_from.vertex_groups[group_idx]
if vg not in src_vgroups:
continue
if vg.name not in vgroup_weights:
vgroup_weights[vg.name] = 0
vgroup_weights[vg.name] += vg.weight(src_vert_idx) * influence
# Assign final weights of this vertex in the vertex groups.
for vg_name in vgroup_weights.keys():
target_vg = obj_to.vertex_groups.get(vg_name)
target_vg.add([dest_vert.index], vgroup_weights[vg_name], 'REPLACE')
@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ... import constants
from ..task_layer import draw_task_layer_selection
def draw_transfer_data_type(
context: bpy.types.Context,
layout: bpy.types.UILayout,
transfer_data: bpy.types.CollectionProperty,
) -> None:
"""Draw UI Element for items of a Transferable Data type"""
asset_pipe = bpy.context.scene.asset_pipeline
if transfer_data == []:
return
name, icon = constants.TRANSFER_DATA_TYPES[transfer_data[0].type]
box = layout.box()
header, panel = box.panel(transfer_data[0].obj_name + name, default_closed=True)
header.label(text=name, icon=icon)
if not panel:
return
box = panel.box()
for transfer_data_item in transfer_data:
main_row = box.row()
main_row.label(text=f"{transfer_data_item.name}: ")
if transfer_data_item.surrender:
# Disable entire row if the item is surrendered
main_row.operator(
"assetpipe.update_surrendered_transfer_data"
).transfer_data_item_name = transfer_data_item.name
draw_task_layer_selection(
context,
layout=main_row.row(),
data=transfer_data_item,
)
surrender_icon = "ORPHAN_DATA" if transfer_data_item.surrender else "HEART"
surrender_row = main_row.row()
surrender_row.enabled = transfer_data_item.owner in asset_pipe.local_task_layers
surrender_row.prop(
transfer_data_item, "surrender", text="", icon=surrender_icon
)
def draw_transfer_data(
context: bpy.types.Context,
transfer_data: bpy.types.CollectionProperty,
layout: bpy.types.UILayout,
) -> None:
"""Draw UI List of Transferable Data"""
vertex_groups = []
material_slots = []
modifiers = []
constraints = []
custom_props = []
shape_keys = []
attributes = []
parent = []
for transfer_data_item in transfer_data:
if transfer_data_item.type == constants.VERTEX_GROUP_KEY:
vertex_groups.append(transfer_data_item)
if transfer_data_item.type == constants.MATERIAL_SLOT_KEY:
material_slots.append(transfer_data_item)
if transfer_data_item.type == constants.MODIFIER_KEY:
modifiers.append(transfer_data_item)
if transfer_data_item.type == constants.CONSTRAINT_KEY:
constraints.append(transfer_data_item)
if transfer_data_item.type == constants.CUSTOM_PROP_KEY:
custom_props.append(transfer_data_item)
if transfer_data_item.type == constants.SHAPE_KEY_KEY:
shape_keys.append(transfer_data_item)
if transfer_data_item.type == constants.ATTRIBUTE_KEY:
attributes.append(transfer_data_item)
if transfer_data_item.type == constants.PARENT_KEY:
parent.append(transfer_data_item)
draw_transfer_data_type(context, layout, vertex_groups)
draw_transfer_data_type(context, layout, modifiers)
draw_transfer_data_type(context, layout, material_slots)
draw_transfer_data_type(context, layout, constraints)
draw_transfer_data_type(context, layout, custom_props)
draw_transfer_data_type(context, layout, shape_keys)
draw_transfer_data_type(context, layout, attributes)
draw_transfer_data_type(context, layout, parent)
@@ -0,0 +1,211 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..naming import merge_get_basename, task_layer_prefix_basename_get
from ..task_layer import get_transfer_data_owner
import contextlib
from ...props import AssetTransferData
def find_ownership_data(
transfer_data: bpy.types.CollectionProperty,
key: str,
td_type_key: str,
) -> AssetTransferData | None:
"""Return matching AssetTransferData if it exists."""
existing_items = [
transfer_data_item
for transfer_data_item in transfer_data
if transfer_data_item.type == td_type_key and key == transfer_data_item.name
]
if existing_items:
return existing_items[0]
def transfer_data_add_entry(
transfer_data: bpy.types.CollectionProperty,
name: str,
td_type_key: str,
task_layer_name: str,
surrender: bool,
):
"""Add entry to Transferable Data ownership
Args:
ownership (bpy.types.CollectionProperty): Transferable Data of an object
name (str): Name of new Transferable Data item
td_type_key (str): Type of Transferable Data
task_layer_name (str): Name of current task layer
surrender (bool): Whether this data's ownership should be surrendered to begin with
"""
transfer_data_item = transfer_data.add()
transfer_data_item.name = name
transfer_data_item.owner = task_layer_name
transfer_data_item.type = td_type_key
transfer_data_item.surrender = surrender
return transfer_data_item
def transfer_data_clean(
obj: bpy.types.Object, data_list: bpy.types.CollectionProperty, td_type_key: str
):
"""Removes data if a transfer_data_item doesn't exist but the data does exist
Args:
obj (bpy.types.Object): Object containing Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
"""
cleaned_item_names = set()
for item in data_list:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(item.name),
td_type_key,
)
if not ownership_data:
cleaned_item_names.add(item.name)
data_list.remove(item)
return cleaned_item_names
def transfer_data_item_is_missing(
transfer_data_item, data_list: bpy.types.CollectionProperty, td_type_key: str
) -> bool:
"""Returns true if a transfer_data_item exists the data doesn't exist
Args:
transfer_data_item (_type_): Item of Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
Returns:
bool: Returns True if transfer_data_item is missing
"""
if transfer_data_item.type == td_type_key and not data_list.get(
transfer_data_item["name"]
):
return True
"""Intilize Transferable Data to a temporary collection property, used
to draw a display of new Transferable Data to the user before merge process.
"""
def transfer_data_item_init(
scene: bpy.types.Scene,
obj: bpy.types.Object,
data_list: bpy.types.CollectionProperty,
td_type_key: str,
):
"""_summary_
Args:
scene (bpy.types.Scene): Scene that contains a the file's asset
obj (bpy.types.Object): Object containing possible Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
"""
asset_pipe = scene.asset_pipeline
transfer_data = obj.transfer_data_ownership
for item in data_list:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, item.name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=item.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
@contextlib.contextmanager
def isolate_collection(context, iso_col: bpy.types.Collection):
col_exclude = {}
view_layer_col = context.view_layer.layer_collection
view_layer_col.collection.children.link(iso_col)
for col in view_layer_col.children:
col_exclude[col.name] = col.exclude
try:
# Exclude all collections that are not iso collection
for col in view_layer_col.children:
col.exclude = col.name != iso_col.name
yield
finally:
for col in view_layer_col.children:
col.exclude = col_exclude[col.name]
view_layer_col.collection.children.unlink(iso_col)
@contextlib.contextmanager
def link_objs_to_collection(objs: set, col: bpy.types.Collection):
try:
for obj in objs:
col.objects.link(obj)
yield
finally:
for obj in objs:
col.objects.unlink(obj)
@contextlib.contextmanager
def activate_shapekey(objs: set, sk_name: str):
old_values = {}
try:
for obj in objs:
if not obj.data.shape_keys:
continue
sk = obj.data.shape_keys.key_blocks.get(sk_name)
if not sk:
continue
old_values[obj] = sk.value
sk.value = 1
yield
finally:
for obj, val in old_values.items():
obj.data.shape_keys.key_blocks[sk_name].value = val
@contextlib.contextmanager
def disable_modifiers(objs: set, mod_types: set[str]):
mods_to_enable = {obj: [] for obj in objs}
try:
for obj in objs:
for mod in obj.modifiers:
if mod.type in mod_types and mod.show_viewport:
mods_to_enable[obj].append(mod.name)
mod.show_viewport = False
yield
finally:
for obj, mod_names in mods_to_enable.items():
for mod_name in mod_names:
obj.modifiers[mod_name].show_viewport = True
@contextlib.contextmanager
def simplify(scene):
"""Disable subdivision surface modifiers globally using the scene's Simplify setting.
Important for binding modifiers, but also probably doesn't hurt for general performance.
"""
orig_simplify = scene.render.use_simplify
levels = scene.render.simplify_subdivision
scene.render.use_simplify = True
scene.render.simplify_subdivision = 0
yield
scene.render.use_simplify = orig_simplify
scene.render.simplify_subdivision = levels
@@ -0,0 +1,97 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Tuple, Generator, List
from .. import constants
import bpy
####################################
############# ID Stuff #############
####################################
def get_id_info() -> List[Tuple[type, str, str]]:
"""Return a list of tuples with the python class, type identifier string, and bpy.data container name
of each ID type present in the blend file.
Eg. when called in a file containing meshes and objects, it will return at least:
[
(bpy.types.Object, 'OBJECT', "objects"),
(bpy.types.Mesh, 'MESH', "meshes"),
]
"""
bpy_prop_collection = type(bpy.data.objects)
id_info = []
for prop_name in dir(bpy.data):
coll_prop = getattr(bpy.data, prop_name)
if type(coll_prop) == bpy_prop_collection:
if len(coll_prop) == 0:
# We can't get full info about the ID type if there isn't at least one entry of it.
# But we shouldn't need it, since we don't have any entries of it!
continue
id_info.append((get_fundamental_id_type(coll_prop[0]), coll_prop[0].id_type, prop_name))
return id_info
def get_id_identifier_from_class(id_type: type):
"""Return the string name of an ID type class.
eg. bpy.types.Object -> 'OBJECT'
"""
id_info = get_id_info()
for typ, typ_str, container_str in id_info:
if id_type == typ:
return typ_str
def get_fundamental_id_type(datablock: bpy.types.ID) -> Any:
"""Certain datablocks have very specific types, such as
bpy.types.GeometryNodeTree instead of bpy.types.NodeTree.
This function should return their fundamental type, ie. parent class,
by reaching into the python Method Resolution Order (MRO) to find its
python class inheritance chain and then step back 4 steps:
object->bpy_struct->bpy.types.ID->bpy.types.WhatWeNeed"""
return type(datablock).mro()[-4]
def get_storage_of_id(datablock: bpy.types.ID) -> 'bpy_prop_collection':
"""Return the storage collection property of the datablock.
Eg. for an object, returns bpy.data.objects.
"""
fundamental_type = get_fundamental_id_type(datablock)
id_info = get_id_info()
for typ, _typ_str, container_str in id_info:
if fundamental_type == typ:
return getattr(bpy.data, container_str)
assert False, "Failed to find the type of this ID: " + str(datablock) + "with fundamental type: " + str(fundamental_type)
def traverse_collection_tree(
collection: bpy.types.Collection,
) -> Generator[bpy.types.Collection, None, None]:
yield collection
for child in collection.children:
yield from traverse_collection_tree(child)
def data_type_from_transfer_data_key(obj: bpy.types.Object, td_type: str):
"""Returns the data on an object that is referred to by the Transferable Data type"""
if td_type == constants.VERTEX_GROUP_KEY:
return obj.vertex_groups
if td_type == constants.MODIFIER_KEY:
return obj.modifiers
if td_type == constants.CONSTRAINT_KEY:
return obj.constraints
if td_type == constants.MATERIAL_SLOT_KEY:
return obj.material_slots
if td_type == constants.SHAPE_KEY_KEY:
return obj.data.shape_keys.key_blocks
if td_type == constants.ATTRIBUTE_KEY:
return obj.data.attributes
if td_type == constants.PARENT_KEY:
return obj.parent