2025-12-01
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
+20
@@ -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
|
||||
+95
@@ -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)
|
||||
+161
@@ -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}")
|
||||
+235
@@ -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
|
||||
+57
@@ -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)
|
||||
+221
@@ -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
|
||||
Reference in New Issue
Block a user