2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,236 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import time
from .transfer_functions import (
attributes,
constraints,
custom_props,
modifiers,
parent,
shape_keys,
vertex_groups,
materials,
)
from typing import List
from ... import constants, logging
from .transfer_util import (
transfer_data_add_entry,
find_ownership_data,
link_objs_to_collection,
isolate_collection,
)
# TODO use logging module here
def copy_transfer_data_ownership(
td_type_key: str, target_obj: bpy.types.Object, transfer_data_dict: dict
) -> None:
"""Copy Transferable Data item to object if non entry exists
Args:
transfer_data_item: Item of bpy.types.CollectionProperty from source object
target_obj (bpy.types.Object): Object to add Transferable Data item to
"""
transfer_data = target_obj.transfer_data_ownership
ownership_data = find_ownership_data(
transfer_data,
transfer_data_dict["name"],
td_type_key,
)
if not ownership_data:
transfer_data_add_entry(
transfer_data,
transfer_data_dict["name"],
td_type_key,
transfer_data_dict["owner"],
transfer_data_dict["surrender"],
)
def transfer_data_clean(obj):
vertex_groups.vertex_groups_clean(obj)
modifiers.modifiers_clean(obj)
constraints.constraints_clean(obj)
custom_props.custom_prop_clean(obj)
shape_keys.shape_keys_clean(obj)
attributes.attribute_clean(obj)
parent.parent_clean(obj)
def transfer_data_is_missing(transfer_data_item) -> bool:
"""Check if Transferable Data item is missing
Args:
transfer_data_item: Item of class ASSET_TRANSFER_DATA
Returns:
bool: bool if item is missing
"""
return bool(
vertex_groups.vertex_group_is_missing(transfer_data_item)
or modifiers.modifier_is_missing(transfer_data_item)
or constraints.constraint_is_missing(transfer_data_item)
or custom_props.custom_prop_is_missing(transfer_data_item)
or shape_keys.shape_key_is_missing(transfer_data_item)
or attributes.attribute_is_missing(transfer_data_item)
)
def init_transfer_data(
scene: bpy.types.Scene,
obj: bpy.types.Object,
):
"""Collect Transferable Data Items on a given object
Args:
obj (bpy.types.Object): Target object for Transferable Data
task_layer_name (str): Name of task layer
temp_transfer_data: Item of class ASSET_TRANSFER_DATA_TEMP
"""
if obj.library:
# Don't create ownership data for object data if the object is linked.
return
constraints.init_constraints(scene, obj)
custom_props.init_custom_prop(scene, obj)
parent.init_parent(scene, obj)
modifiers.init_modifiers(scene, obj)
if not obj.data or obj.data.library:
# Don't create ownership data for mesh data if the mesh is linked, or Empties.
return
vertex_groups.init_vertex_groups(scene, obj)
materials.init_materials(scene, obj)
shape_keys.init_shape_keys(scene, obj)
attributes.init_attributes(scene, obj)
def apply_transfer_data_items(
context,
source_obj: bpy.types.Object,
target_obj: bpy.types.Object,
td_type_key: str,
transfer_data_dicts: List[dict],
):
logger = logging.get_logger()
# Get source/target from first item in list, because all items in list are same object/type
if target_obj is None:
logger.warning(f"Failed to Transfer {td_type_key.title()} from {source_obj.name}")
return
for transfer_data_dict in transfer_data_dicts:
copy_transfer_data_ownership(td_type_key, target_obj, transfer_data_dict)
# if TD Source is Target, restore the ownership data but don't transfer anything
if source_obj == target_obj:
return
if td_type_key == constants.VERTEX_GROUP_KEY:
# Transfer All Vertex Groups in one go
logger.debug(f"Transferring All Vertex Groups from {source_obj.name} to {target_obj.name}.")
vertex_groups.transfer_vertex_groups(
vertex_group_names=[item["name"] for item in transfer_data_dicts],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.MODIFIER_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Modifier {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
modifiers.transfer_modifier(
context,
modifier_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.CONSTRAINT_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Constraint {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
constraints.transfer_constraint(
constraint_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.CUSTOM_PROP_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Custom Property {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
custom_props.transfer_custom_prop(
prop_name=transfer_data_dict["name"],
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.MATERIAL_SLOT_KEY:
logger.debug(f"Transferring Materials from {source_obj.name} to {target_obj.name}.")
for transfer_data_dict in transfer_data_dicts:
materials.transfer_materials(
target_obj=target_obj,
source_obj=source_obj,
)
if td_type_key == constants.SHAPE_KEY_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Shape Key {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
shape_keys.transfer_shape_key(
context=context,
target_obj=target_obj,
source_obj=source_obj,
shape_key_name=transfer_data_dict["name"],
)
if td_type_key == constants.ATTRIBUTE_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Attribute {transfer_data_dict['name']} from {source_obj.name} to {target_obj.name}."
)
attributes.transfer_attribute(
target_obj=target_obj,
source_obj=source_obj,
attribute_name=transfer_data_dict["name"],
)
if td_type_key == constants.PARENT_KEY:
for transfer_data_dict in transfer_data_dicts:
logger.debug(
f"Transferring Parent Relationship from {source_obj.name} to {target_obj.name}."
)
parent.transfer_parent(
target_obj=target_obj,
source_obj=source_obj,
)
def apply_transfer_data(context: bpy.types.Context, transfer_data_map) -> None:
"""Apply all Transferable Data from Transferable Data map onto objects.
Copies any Transferable Data owned by local layer onto objects owned by external layers.
Applies Transferable Data from external layers onto objects owned by local layers
Transfer_data_map is generated by class 'AssetTransferMapping'
Args:
context (bpy.types.Context): context of .blend file
transfer_data_map: Map generated by class AssetTransferMapping
"""
# Create/isolate tmp collection to reduce depsgraph update time
profiler = logging.get_profiler()
td_col = bpy.data.collections.new("ISO_COL_TEMP")
with isolate_collection(context, td_col):
# Loop over objects in Transfer data map
for source_obj in transfer_data_map:
target_obj = transfer_data_map[source_obj]["target_obj"]
td_types = transfer_data_map[source_obj]["td_types"]
with link_objs_to_collection(set([target_obj, source_obj]), td_col):
for td_type_key, td_dicts in td_types.items():
start_time = time.time()
apply_transfer_data_items(
context, source_obj, target_obj, td_type_key, td_dicts
)
profiler.add(time.time() - start_time, td_type_key)
bpy.data.collections.remove(td_col)
@@ -0,0 +1,261 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
from .transfer_function_util.proximity_core import (
tris_per_face,
closest_face_to_point,
closest_tri_on_face,
is_obdata_identical,
transfer_corner_data,
)
from ..transfer_util import find_ownership_data
from ...naming import merge_get_basename
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
def attributes_get_editable(attributes):
return [
attribute
for attribute in attributes
if not (
attribute.is_internal
or attribute.is_required
# Material Index is part of material transfer and should be skipped
or attribute.name == 'material_index'
)
]
def attribute_clean(obj):
logger = logging.get_logger()
if obj.type != "MESH":
return
attributes = attributes_get_editable(obj.data.attributes)
attributes_to_remove = []
for attribute in attributes:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(attribute.name),
constants.ATTRIBUTE_KEY,
)
if not ownership_data:
attributes_to_remove.append(attribute.name)
for attribute_name_to_remove in reversed(attributes_to_remove):
attribute_to_remove = obj.data.attributes.get(attribute_name_to_remove)
logger.debug(f"Cleaning attribute {attribute.name}")
obj.data.attributes.remove(attribute_to_remove)
def attribute_is_missing(transfer_data_item):
obj = transfer_data_item.id_data
if obj.type != "MESH":
return
attributes = attributes_get_editable(obj.data.attributes)
attribute_names = [attribute.name for attribute in attributes]
if (
transfer_data_item.type == constants.ATTRIBUTE_KEY
and not transfer_data_item["name"] in attribute_names
):
return True
def init_attributes(scene, obj):
asset_pipe = scene.asset_pipeline
if obj.type != "MESH":
return
transfer_data = obj.transfer_data_ownership
td_type_key = constants.ATTRIBUTE_KEY
for atttribute in attributes_get_editable(obj.data.attributes):
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, atttribute.name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe, td_type_key, atttribute.name
)
asset_pipe.add_temp_transfer_data(
name=atttribute.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_attribute(
attribute_name: str,
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
source_attributes = source_obj.data.attributes
target_attributes = target_obj.data.attributes
source_attribute = source_attributes.get(attribute_name)
target_attribute = target_attributes.get(attribute_name)
logger = logging.get_logger()
if not source_attribute:
logger.debug(f"Failed to find attribute to transfer: {attribute_name}")
return
if target_attribute:
target_attributes.remove(target_attribute)
target_attribute = target_attributes.new(
name=attribute_name,
type=source_attribute.data_type,
domain=source_attribute.domain,
)
if not target_attribute:
logger.debug(f"Failed to create attribute: {target_obj.name} -> {attribute_name}")
return
if not is_obdata_identical(source_obj, target_obj):
proximity_transfer_single_attribute(
source_obj, target_obj, source_attribute, target_attribute
)
return
for source_data_item in source_attribute.data.items():
index = source_data_item[0]
source_data = source_data_item[1]
keys = set(source_data.bl_rna.properties.keys()) - set(
bpy.types.Attribute.bl_rna.properties.keys()
)
for key in list(keys):
target_data = target_attribute.data[index]
setattr(target_data, key, getattr(source_data, key))
def proximity_transfer_single_attribute(
source_obj: bpy.types.Object,
target_obj: bpy.types.Object,
source_attribute: bpy.types.Attribute,
target_attribute: bpy.types.Attribute,
):
# src_dat = source_obj.data
# tgt_dat = target_obj.data
# if type(src_dat) is not type(tgt_dat) or not (src_dat or tgt_dat):
# return False
# if type(tgt_dat) is not bpy.types.Mesh: # TODO: support more types
# return False
# If target attribute already exists, remove it.
# tgt_attr = tgt_dat.attributes.get(source_attribute.name)
# if tgt_attr is not None:
# try:
# tgt_dat.attributes.remove(tgt_attr)
# except RuntimeError:
# # Built-ins like "position" cannot be removed, and should be skipped.
# return
# Create target attribute.
# target_attribute = tgt_dat.attributes.new(
# source_attribute.name, source_attribute.data_type, source_attribute.domain
# )
logger = logging.get_logger()
data_sfx = {
'INT8': 'value',
'INT': 'value',
'FLOAT': 'value',
'FLOAT2': 'vector',
'BOOLEAN': 'value',
'STRING': 'value',
'BYTE_COLOR': 'color',
'FLOAT_COLOR': 'color',
'FLOAT_VECTOR': 'vector',
}
data_sfx = data_sfx[source_attribute.data_type]
# if topo_match:
# # TODO: optimize using foreach_get/set rather than loop
# for i in range(len(source_attribute.data)):
# setattr(tgt_attr.data[i], data_sfx, getattr(source_attribute.data[i], data_sfx))
# return
# proximity fallback
if source_attribute.data_type == 'STRING':
# TODO: add NEAREST transfer fallback for attributes without interpolation
logger.warning(
f'Proximity based transfer for generic attributes of type STRING not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
)
return
domain = source_attribute.domain
if (
domain == 'POINT'
): # TODO: deduplicate interpolated point domain proximity transfer
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for i, vert in enumerate(target_obj.data.vertices):
p = vert.co
face = closest_face_to_point(bm_source, p, bvh_tree)
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
continue
weights = mathutils.interpolate.poly_3d_calc(
[tri[i].vert.co for i in range(3)], point
)
if data_sfx in ['color']:
vals_weighted = [
weights[i]
* (
np.array(
getattr(source_attribute.data[tri[i].vert.index], data_sfx)
)
)
for i in range(3)
]
else:
vals_weighted = [
weights[i]
* (getattr(source_attribute.data[tri[i].vert.index], data_sfx))
for i in range(3)
]
setattr(target_attribute.data[i], data_sfx, sum(np.array(vals_weighted)))
return
elif domain == 'EDGE':
# TODO support proximity fallback for generic edge attributes
logger.warning(
f'Proximity based transfer of generic edge attributes not supported yet. Skipping attribute {source_attribute.name} on {target_obj}.'
)
return
elif domain == 'FACE':
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
for i, face in enumerate(target_obj.data.polygons):
p_target = face.center
closest_face = closest_face_to_point(bm_source, p_target, bvh_tree)
setattr(
target_attribute.data[i],
data_sfx,
getattr(source_attribute.data[closest_face.index], data_sfx),
)
return
elif domain == 'CORNER':
transfer_corner_data(
source_obj,
target_obj,
source_attribute.data,
target_attribute.data,
data_suffix=data_sfx,
)
return
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
find_ownership_data,
)
from ...naming import task_layer_prefix_name_get
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
def constraints_clean(obj):
cleaned_names = transfer_data_clean(
obj=obj, data_list=obj.constraints, td_type_key=constants.CONSTRAINT_KEY
)
# Remove Drivers that match the cleaned item's name
for name in cleaned_names:
cleanup_drivers(obj, 'constraints', name)
def constraint_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.CONSTRAINT_KEY,
data_list=transfer_data_item.id_data.constraints,
)
def init_constraints(scene, obj):
td_type_key = constants.CONSTRAINT_KEY
transfer_data = obj.transfer_data_ownership
asset_pipe = scene.asset_pipeline
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
for const in obj.constraints:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, const.name, td_type_key)
if not ownership_data:
ownership_data = asset_pipe.add_temp_transfer_data(
name=const.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
const.name = task_layer_prefix_name_get(const.name, ownership_data.owner)
def transfer_constraint(constraint_name, target_obj, source_obj):
logger = logging.get_logger()
context = bpy.context
# Remove old and sync existing constraints.
old_con = target_obj.constraints.get(constraint_name)
if old_con:
target_obj.constraints.remove(old_con)
src_idx = source_obj.constraints.find(constraint_name)
if src_idx == -1:
# This happens if a modifier's transfer data is still around, but the modifier
# itself was removed.
logger.debug(f"Constraint Transfer cancelled, '{constraint_name}' not found on '{source_obj.name}'")
return
src_con = source_obj.constraints[src_idx]
new_con = target_obj.constraints.new(src_con.type)
new_con.name = src_con.name
props = [p.identifier for p in src_con.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(src_con, prop)
setattr(new_con, prop, value)
# Armature constraints have some nested properties we need to copy...
if src_con.type == "ARMATURE":
for target_item in src_con.targets:
new_target = new_con.targets.new()
new_target.target = target_item.target
new_target.subtarget = target_item.subtarget
transfer_drivers(source_obj, target_obj, 'constraints', constraint_name)
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from .... import constants
from .transfer_function_util.properties import (
get_all_runtime_prop_names,
remove_property,
copy_runtime_property,
)
def transfer_custom_prop(prop_name, target_obj, source_obj):
copy_runtime_property(source_obj, target_obj, prop_name)
def custom_prop_clean(obj):
cleaned_item_names = set()
for key in get_valid_runtime_prop_names(obj):
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
key,
constants.CUSTOM_PROP_KEY,
)
if not ownership_data:
cleaned_item_names.add(key)
remove_property(obj, key)
return cleaned_item_names
def custom_prop_is_missing(transfer_data_item):
obj = transfer_data_item.id_data
return transfer_data_item.type == constants.CUSTOM_PROP_KEY and not transfer_data_item["name"] in get_valid_runtime_prop_names(obj)
def init_custom_prop(scene, obj):
asset_pipe = scene.asset_pipeline
transfer_data = obj.transfer_data_ownership
td_type_key = constants.CUSTOM_PROP_KEY
for prop_name in get_valid_runtime_prop_names(obj):
ownership_data = find_ownership_data(transfer_data, prop_name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe, td_type_key, prop_name
)
asset_pipe.add_temp_transfer_data(
name=prop_name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def get_valid_runtime_prop_names(id):
all_props = get_all_runtime_prop_names(id)
return [p for p in all_props if p not in constants.ADDON_OWN_PROPERTIES]
@@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .attributes import transfer_attribute
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from .... import constants
from .transfer_function_util.proximity_core import (
is_obdata_identical,
)
def materials_clean(obj):
# Material slots cannot use generic transfer_data_clean() function
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
constants.MATERIAL_TRANSFER_DATA_ITEM_NAME,
constants.MATERIAL_SLOT_KEY,
)
# Clear Materials if No Transferable Data is Found
if ownership_data:
return
if obj.data and hasattr(obj.data, 'materials'):
obj.data.materials.clear()
def materials_is_missing(transfer_data_item):
if (
transfer_data_item.type == constants.MATERIAL_SLOT_KEY
and len(transfer_data_item.id_data.material_slots) == 0
):
return True
def init_materials(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.MATERIAL_SLOT_KEY
name = constants.MATERIAL_TRANSFER_DATA_ITEM_NAME
transfer_data = obj.transfer_data_ownership
if not (obj.data and hasattr(obj.data, 'materials')):
return
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
# Only add new ownership transfer_data_item if material doesn't have an owner
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_materials(target_obj: bpy.types.Object, source_obj):
# Delete all material slots of target object.
target_obj.data.materials.clear()
# Transfer material slots
for idx in range(len(source_obj.material_slots)):
target_obj.data.materials.append(source_obj.material_slots[idx].material)
target_obj.material_slots[idx].link = source_obj.material_slots[idx].link
# Transfer active material slot index
target_obj.active_material_index = source_obj.active_material_index
# Transfer material slot assignments for curve
if target_obj.type == "CURVE":
for spl_to, spl_from in zip(target_obj.data.splines, source_obj.data.splines):
spl_to.material_index = spl_from.material_index
if source_obj.type == "MESH":
if source_obj.data.attributes.get(constants.MATERIAL_ATTRIBUTE_NAME):
transfer_attribute(constants.MATERIAL_ATTRIBUTE_NAME, target_obj, source_obj)
transfer_uv_seams(source_obj, target_obj)
def transfer_uv_seams(source_obj, target_obj):
if is_obdata_identical(source_obj, target_obj):
for edge_from, edge_to in zip(source_obj.data.edges, target_obj.data.edges):
edge_to.use_seam = edge_from.use_seam
elif len(source_obj.data.edges) > 0 and len(target_obj.data.edges) > 0:
# Create proxy object as transfer source to avoid transferring from evaluated mesh
temp_source_obj = bpy.data.objects.new('TEMP', source_obj.data)
with bpy.context.temp_override(
object=temp_source_obj,
active_object=temp_source_obj,
selected_editable_objects=[temp_source_obj, target_obj],
):
bpy.ops.object.data_transfer(
data_type="SEAM",
edge_mapping="NEAREST",
mix_mode="REPLACE",
use_object_transform=False,
)
bpy.data.objects.remove(temp_source_obj)
@@ -0,0 +1,206 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from .transfer_function_util.visibility import override_obj_visibility
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
find_ownership_data,
activate_shapekey,
disable_modifiers,
)
from ...naming import task_layer_prefix_name_get, task_layer_prefix_basename_get
from ...task_layer import get_transfer_data_owner
from .... import constants, logging
BIND_OPS = {
'SURFACE_DEFORM': bpy.ops.object.surfacedeform_bind,
'MESH_DEFORM': bpy.ops.object.meshdeform_bind,
'CORRECTIVE_SMOOTH': bpy.ops.object.correctivesmooth_bind,
}
def modifiers_clean(obj):
cleaned_names = transfer_data_clean(
obj=obj, data_list=obj.modifiers, td_type_key=constants.MODIFIER_KEY
)
# Remove Drivers that match the cleaned item's name
for name in cleaned_names:
cleanup_drivers(obj, 'modifiers', name)
def modifier_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.MODIFIER_KEY,
data_list=transfer_data_item.id_data.modifiers,
)
def init_modifiers(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.MODIFIER_KEY
transfer_data = obj.transfer_data_ownership
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
for mod in obj.modifiers:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, mod.name, td_type_key)
if not ownership_data:
ownership_data = asset_pipe.add_temp_transfer_data(
name=mod.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
mod.name = task_layer_prefix_name_get(mod.name, ownership_data.owner)
def transfer_modifier(context, modifier_name, target_obj, source_obj):
"""Transfer a single modifier from source_obj to target_obj.
For example, when pulling into rigging and transferring a rigging modifier,
then source_obj will be the local object, and target_obj will be the external object.
"""
logger = logging.get_logger()
source_mod = source_obj.modifiers.get(modifier_name)
target_mod = target_obj.modifiers.get(modifier_name)
if not source_mod:
# This happens if a modifier's transfer data is still around, but the modifier
# itself was removed.
logger.debug(
f"Modifer Transfer cancelled, '{modifier_name}' not found on '{source_obj.name}'"
)
if target_mod:
target_obj.modifiers.remove(target_mod)
return
if not target_mod:
target_mod = target_obj.modifiers.new(source_mod.name, source_mod.type)
place_modifier_in_stack(source_obj, target_obj, modifier_name)
transfer_modifier_props(context, source_mod, target_mod)
transfer_drivers(source_obj, target_obj, 'modifiers', modifier_name)
if is_modifier_bound(source_mod):
bind_modifier(context, target_obj, modifier_name)
def place_modifier_in_stack(source_obj, target_obj, modifier_name):
"""Modifiers will try to be placed below the modifier they were below on the source object.
This is not very foolproof, since re-ordering multiple modifiers or renaming plus re-ordering,
or removing plus re-ordering, all in one step, could make it hard to determine the ideal order.
In such cases, user may need to fix the order and sync a 2nd time.
"""
logger = logging.get_logger()
idx_tgt = target_obj.modifiers.find(modifier_name)
idx_src = source_obj.modifiers.find(modifier_name)
idx_new = 0
name_anchor = ""
# Order modifier based on previous modifier in source obj.
if idx_src > 0:
mod_anchor = source_obj.modifiers[idx_src - 1]
name_anchor = task_layer_prefix_basename_get(mod_anchor.name)
for idx, mod_of_tgt in enumerate(target_obj.modifiers):
if name_anchor == task_layer_prefix_basename_get(mod_of_tgt.name):
idx_new = min(len(target_obj.modifiers)-1, idx+1)
break
if idx_tgt != idx_new:
target_obj.modifiers.move(idx_tgt, idx_new)
msg = f" Moved {modifier_name} to index {idx_new}"
if name_anchor:
msg += f"(after {name_anchor})"
logger.debug(msg)
def transfer_modifier_props(context, source_mod, target_mod):
props = [p.identifier for p in source_mod.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(source_mod, prop)
setattr(target_mod, prop, value)
if source_mod.type == 'NODES':
# NOTE: This matches inputs by their internal name, not their display name.
# That means you can rename sockets, but removing and adding new ones might cause trouble.
# Transfer geo node attributes
for key, value in source_mod.items():
typ = type(getattr(target_mod, f'["{key}"]'))
if typ in (int, float, bool, str):
value = typ(value)
target_mod[key] = value
# Transfer geo node bake settings
target_mod.bake_directory = source_mod.bake_directory
for index, target_bake in enumerate(target_mod.bakes):
source_bake = source_mod.bakes[index]
props = [p.identifier for p in source_bake.bl_rna.properties if not p.is_readonly]
for prop in props:
value = getattr(source_bake, prop)
setattr(target_bake, prop, value)
# refresh node modifier UI
if target_mod.node_group:
target_mod.node_group.interface_update(context)
def bind_modifier(context, obj, modifier_name):
"""Binding data cannot be transferred. Instead, modifiers that require binding will have the bind operator executed.
Sometimes binding is meant to be done in a bind pose other than the default. For this, shape keys can be added
to the deforming and/or the deformed mesh, named "BIND-<name_of_modifier_with_prefix>". Such shape keys will be enabled
during binding. Other deforming modifiers will be disabled during binding.
"""
# NOTE: This could be optimized by not re-binding unnecessarily, but Blender doesn't allow checking
# if the binding is broken or not. https://projects.blender.org/blender/blender/issues/140550
# Another way to get around this is to let the rigging task layer own the object base.
logger = logging.get_logger()
modifier = obj.modifiers.get(modifier_name)
assert modifier
bind_op = BIND_OPS.get(modifier.type)
if (
not bind_op or
(hasattr(modifier, 'target') and not modifier.target) or
not modifier.show_viewport or
(modifier.type=='CORRECTIVE_SMOOTH' and modifier.rest_source=='ORCO')
):
return
objs = [obj]
if hasattr(modifier, 'target') and modifier.target:
objs.append(modifier.target)
with activate_shapekey(objs, "BIND-"+modifier_name):
modifiers_to_disable = ['LATTICE', 'ARMATURE', 'SHRINKWRAP', 'SMOOTH']
if modifier.type != 'CORRECTIVE_SMOOTH':
modifiers_to_disable.append('CORRECTIVE_SMOOTH')
with disable_modifiers(objs, modifiers_to_disable):
for i in range(2):
context.view_layer.update()
with override_obj_visibility(obj=obj, scene=context.scene):
with context.temp_override(object=obj, active_object=obj):
bind_op(modifier=modifier.name)
word = "Bound" if is_modifier_bound(modifier) else "Un-bound"
logger.debug(f"{word} {modifier_name} on {obj.name}")
if is_modifier_bound(modifier):
return
def is_modifier_bound(modifier) -> bool | None:
if modifier.type == 'CORRECTIVE_SMOOTH':
return modifier.is_bind
elif hasattr(modifier, 'is_bound'):
return modifier.is_bound
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..transfer_util import find_ownership_data
from ...task_layer import get_transfer_data_owner
from ...naming import merge_get_basename
from .... import constants, logging
def parent_clean(obj):
logger = logging.get_logger()
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(constants.PARENT_TRANSFER_DATA_ITEM_NAME),
constants.PARENT_KEY,
)
if ownership_data:
return
obj.parent = None
logger.debug("Cleaning Parent Relationship")
def parent_is_missing(transfer_data_item):
if (
transfer_data_item.type == constants.PARENT_KEY
and transfer_data_item.id_data.parent == None
):
return True
def init_parent(scene, obj):
asset_pipe = scene.asset_pipeline
td_type_key = constants.PARENT_KEY
name = constants.PARENT_TRANSFER_DATA_ITEM_NAME
transfer_data = obj.transfer_data_ownership
if obj.parent not in list(asset_pipe.asset_collection.all_objects) and obj.parent is not None:
raise Exception(f"Object parent {obj.parent.name} cannot be outside of asset collection")
ownership_data = find_ownership_data(transfer_data, name, td_type_key)
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
def transfer_parent(target_obj, source_obj):
target_obj.parent = source_obj.parent
target_obj.parent_type = source_obj.parent_type
target_obj.parent_bone = source_obj.parent_bone
target_obj.location = source_obj.location
if source_obj.rotation_mode == 'QUATERNION':
target_obj.rotation_quaternion = source_obj.rotation_quaternion
else:
target_obj.rotation_euler = source_obj.rotation_euler
target_obj.scale = source_obj.scale
target_obj.matrix_parent_inverse = source_obj.matrix_parent_inverse.copy()
@@ -0,0 +1,164 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
from .transfer_function_util.proximity_core import (
tris_per_face,
closest_face_to_point,
closest_tri_on_face,
)
from .transfer_function_util.drivers import transfer_drivers, cleanup_drivers
from ..transfer_util import (
transfer_data_item_is_missing,
transfer_data_item_init,
find_ownership_data,
)
from ...naming import merge_get_basename
from .... import constants, logging
def shape_key_set_active(obj, shape_key_name):
for index, shape_key in enumerate(obj.data.shape_keys.key_blocks):
if shape_key.name == shape_key_name:
obj.active_shape_key_index = index
def shape_keys_clean(obj):
if obj.type != "MESH" or obj.data.shape_keys is None:
return
cleaned_item_names = set()
for shape_key in obj.data.shape_keys.key_blocks:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(shape_key.name),
constants.SHAPE_KEY_KEY,
)
if not ownership_data:
cleaned_item_names.add(shape_key.name)
obj.shape_key_remove(shape_key)
if not obj.data.shape_keys:
# It's possible there are no shape keys anymore.
return
for name in cleaned_item_names:
cleanup_drivers(obj.data.shape_keys, 'key_blocks', name)
def shape_key_is_missing(transfer_data_item):
if not transfer_data_item.type == constants.SHAPE_KEY_KEY:
return
obj = transfer_data_item.id_data
if obj.type != 'MESH':
return
if not obj.data.shape_keys:
return True
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.SHAPE_KEY_KEY,
data_list=obj.data.shape_keys.key_blocks,
)
def init_shape_keys(scene, obj):
if obj.type != "MESH" or obj.data.shape_keys is None:
return
# Check that the order is legal.
# Key Blocks must be ordered after the key they are Relative To.
for i, kb in enumerate(obj.data.shape_keys.key_blocks):
if kb.relative_key:
base_shape_idx = obj.data.shape_keys.key_blocks.find(kb.relative_key.name)
if base_shape_idx > i:
raise Exception(
f'Shape Key "{kb.name}" must be ordered after its base shape "{kb.relative_key.name}" on object "{obj.name}".'
)
transfer_data_item_init(
scene=scene,
obj=obj,
data_list=obj.data.shape_keys.key_blocks,
td_type_key=constants.SHAPE_KEY_KEY,
)
def transfer_shape_key(
context: bpy.types.Context,
shape_key_name: str,
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
logger = logging.get_logger()
if not source_obj.data.shape_keys:
return
sk_source = source_obj.data.shape_keys.key_blocks.get(shape_key_name)
assert sk_source
sk_target = None
if not target_obj.data.shape_keys:
sk_target = target_obj.shape_key_add()
if not sk_target:
sk_target = target_obj.data.shape_keys.key_blocks.get(shape_key_name)
if not sk_target:
sk_target = target_obj.shape_key_add()
sk_target.name = sk_source.name
sk_target.value = 0
sk_target.vertex_group = sk_source.vertex_group
if sk_source.relative_key != sk_source:
relative_key = None
if target_obj.data.shape_keys:
relative_key = target_obj.data.shape_keys.key_blocks.get(sk_source.relative_key.name)
if relative_key:
sk_target.relative_key = relative_key
else:
# If the base shape of one of our shapes was removed by another task layer,
# the result will probably be pretty bad, but it's not a catastrophic failure.
# Proceed with a warning.
logger.warning(
f'Base shape "{sk_source.relative_key.name}" of Key "{sk_source.name}" was removed from "{target_obj.name}"'
)
sk_target.slider_min = sk_source.slider_min
sk_target.slider_max = sk_source.slider_max
sk_target.value = sk_source.value
sk_target.mute = sk_source.mute
bm_source = bmesh.new()
bm_source.from_mesh(source_obj.data)
bm_source.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for i, vert in enumerate(target_obj.data.vertices):
p = vert.co
face = closest_face_to_point(bm_source, p, bvh_tree)
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
continue
weights = mathutils.interpolate.poly_3d_calc([tri[i].vert.co for i in range(3)], point)
vals_weighted = [
weights[i]
* (
sk_source.data[tri[i].vert.index].co
- source_obj.data.vertices[tri[i].vert.index].co
)
for i in range(3)
]
val = mathutils.Vector(sum(np.array(vals_weighted)))
sk_target.data[i].co = vert.co + val
if source_obj.data.shape_keys is None:
return
transfer_drivers(
source_obj.data.shape_keys, target_obj.data.shape_keys, 'key_blocks', shape_key_name
)
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
def transfer_active_color_attribute_index(target_obj, active_color_name):
# active_color_name = source_obj.data.color_attributes.active_color_name
if active_color_name is None or active_color_name == "":
return
for color_attribute in target_obj.data.color_attributes:
if color_attribute.name == active_color_name:
target_obj.data.color_attributes.active_color = color_attribute
def transfer_active_uv_layer_index(target_obj, active_uv_name):
# active_uv = source_obj.data.uv_layers.active
if active_uv_name is None or active_uv_name == "":
return
for uv_layer in target_obj.data.uv_layers:
if uv_layer.name == active_uv_name:
target_obj.data.uv_layers.active = uv_layer
@@ -0,0 +1,95 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
def copy_driver(
from_fcurve: bpy.types.FCurve, target: bpy.types.ID, data_path=None, index=None
) -> bpy.types.FCurve:
"""Copy an existing FCurve containing a driver to a new ID, by creating a copy
of the existing driver on the target ID.
Args:
from_fcurve (bpy.types.FCurve): FCurve containing a driver
target (bpy.types.ID): ID that can have drivers added to it
data_path (_type_, optional): Data Path of existing driver. Defaults to None.
index (_type_, optional): array index of the property drive. Defaults to None.
Returns:
bpy.types.FCurve: Fcurve containing copy of driver on target ID
"""
if not target.animation_data:
target.animation_data_create()
new_fc = target.animation_data.drivers.from_existing(src_driver = from_fcurve)
if data_path:
new_fc.data_path = data_path
if index:
new_fc.array_index = index
return new_fc
def find_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> list[bpy.types.FCurve]:
"""_summary_
Args:
drivers (list[bpy.types.FCurve]): List or Collection Property containing Fcurves with drivers
target_type (str): Name of data type found in driver data path, e.g. "modifiers"
target_name (str): Name of data found in driver path, e.g. modifier's name
Returns:
list[bpy.types.FCurve]: List of FCurves containing drivers that match type & name
"""
if not id.animation_data:
return []
found_drivers = []
if id.animation_data is None or id.animation_data.drivers is None:
return found_drivers
drivers = id.animation_data.drivers
for driver in drivers:
if f'{target_type}["{target_name}"]' in driver.data_path:
found_drivers.append(driver)
return found_drivers
def transfer_drivers(
source_id: bpy.types.ID, target_id: bpy.types.ID, target_type: str, target_name: str
) -> None:
"""Transfers Drivers from one ID to another, will copy and new drivres from source to from
source to target, and will remove any drivers on the target that are not in the source.
Args:
source_id (bpy.types.ID): Source ID, containing drivers to copy
target_id (bpy.types.ID): Target ID, which will recieve the drivers from source
target_type (str): Name of driver target's type, like `modifier` or `constraint`
target_name (str): Name of driver target, e.g. name of a modifier or contraint
"""
source_fcurves = find_drivers(source_id, target_type, target_name)
target_fcurves = find_drivers(target_id, target_type, target_name)
# Clear old drivers
for old_fcurve in list(set(target_fcurves) - set(source_fcurves)):
target_id.animation_data.drivers.remove(old_fcurve)
# Transfer new drivers
for fcurve in source_fcurves:
copy_driver(from_fcurve=fcurve, target=target_id)
def cleanup_drivers(id: bpy.types.ID, target_type: str, target_name: str) -> None:
"""Remove all drivers for transfer data that has been removed.
Args:
object (bpy.types.ID): ID, which has drivers to remove
target_type (str): Name of driver target's type, like `modifier` or `constraint`
target_name (str): Name of driver target, e.g. name of a modifier or contraint
"""
target_fcurves = find_drivers(id, target_type, target_name)
for fcurve in target_fcurves:
id.animation_data.drivers.remove(fcurve)
@@ -0,0 +1,161 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import PropertyGroup, bpy_prop_collection, Object
from rna_prop_ui import IDPropertyGroup
from bpy.utils import flip_name
# Functions to manage runtime properties, which include custom properties and add-on properties.
# These functions aim to abstract away that distinction, and also abstract away whether something is a single value,
# a PropertyGroup, or a CollectionProperty.
# Currently the minimum Blender version for this code is 5.0, but it could probably be made backwards-compatible.
def copy_all_runtime_properties(src_id, tgt_id, x_mirror=False):
"""Copy add-on and custom properties from source to target.
Both should be the same type.
Should support anything that supports custom properties or property registration.
"""
for prop_name in get_all_runtime_prop_names(src_id):
copy_runtime_property(src_id, tgt_id, prop_name, x_mirror)
def get_all_runtime_prop_names(owner):
custom_props = list(owner.keys())
addon_props = get_addon_prop_names(owner)
props = custom_props + addon_props
return props
def get_addon_prop_names(owner):
if bpy.app.version >= (5, 0, 0):
return list(owner.bl_system_properties_get().keys())
else:
return [prop_name for prop_name in owner.keys() if is_addon_prop(owner, prop_name)]
def copy_runtime_property(src_id, tgt_id, prop_name, x_mirror=False):
"""Copy add-on properties or custom properties."""
if is_addon_prop(src_id, prop_name):
if is_registered_addon_prop(src_id, prop_name):
src_prop = getattr(src_id, prop_name)
tgt_prop = getattr(tgt_id, prop_name)
if isinstance(src_prop, bpy_prop_collection):
copy_coll_prop(src_prop, tgt_prop, x_mirror)
elif isinstance(src_prop, PropertyGroup):
copy_property_group(src_prop, tgt_prop, x_mirror)
else:
copy_single_addon_prop(src_id, tgt_id, prop_name, x_mirror)
else:
if bpy.app.version >= (5, 0, 0):
# HACK: If we need to copy add-on properties, but the add-on is not present,
# we have to write to the system properties, which is API abuse that could
# lose support any moment, but there is no other way to do this atm.
try:
tgt_id.bl_system_properties_get()[prop_name] = src_id.bl_system_properties_get()[prop_name]
except TypeError:
# Happens for at least a mysterious "booleans" custom property which seems to be an empty PropGroup. Where is it coming from!?
pass
else:
tgt_id[prop_name] = src_id[prop_name]
else:
copy_custom_property(src_id, tgt_id, prop_name)
def copy_property_group(src_pg: PropertyGroup, tgt_pg: PropertyGroup, x_mirror=False):
"""
Copy the values from one PropertyGroup into another of the same type.
Optionally, X-mirror names (e.g., ".L" <-> ".R") in strings and Object references.
"""
assert isinstance(tgt_pg, PropertyGroup) and isinstance(src_pg, PropertyGroup)
assert tgt_pg.__class__ == src_pg.__class__
for prop_name in src_pg.bl_rna.properties.keys():
if prop_name in ('rna_type', 'bl_rna'):
continue
if not src_pg.is_property_set(prop_name):
tgt_pg.property_unset(prop_name)
continue
value = getattr(src_pg, prop_name)
if isinstance(value, bpy_prop_collection):
tgt_collprop = getattr(tgt_pg, prop_name)
copy_coll_prop(value, tgt_collprop)
elif isinstance(value, PropertyGroup):
copy_property_group(value, getattr(tgt_pg, prop_name), x_mirror)
else:
copy_single_addon_prop(src_pg, tgt_pg, prop_name, x_mirror)
for prop_name in src_pg.keys():
if is_custom_prop(src_pg, prop_name):
# PropertyGroups also support custom properties.
copy_custom_property(src_pg, tgt_pg, prop_name, x_mirror)
def copy_coll_prop(src_cp, tgt_cp, x_mirror=False):
tgt_cp.clear()
for src_pg in src_cp:
assert isinstance(src_pg, PropertyGroup)
tgt_pg = tgt_cp.add()
copy_property_group(src_pg, tgt_pg, x_mirror)
def copy_custom_property(src_owner, tgt_owner, prop_name, x_mirror=False):
"""Copy a custom property (one that was created via the UI or via Python dictionary syntax)."""
prop = src_owner.id_properties_ui(prop_name)
assert prop, f'Property "{prop_name}" not found in {src_owner}.'
value = src_owner[prop_name]
if x_mirror:
value = x_mirror_value(value)
tgt_owner[prop_name] = value
new_prop = tgt_owner.id_properties_ui(prop_name)
new_prop.update_from(prop)
def copy_single_addon_prop(src, tgt, prop_name, x_mirror=False) -> True:
if src.is_property_readonly(prop_name):
# This "early" exit has to come after CollectionProperty & PropertyGroup
# checks, since they are technically read-only.
return False
value = getattr(src, prop_name)
if x_mirror:
value = x_mirror_value(value)
setattr(tgt, prop_name, value)
return True
def x_mirror_value(value):
if isinstance(value, str):
return flip_name(value)
elif isinstance(value, Object):
get_opposite_obj(value)
else:
return value
def get_opposite_obj(obj: Object) -> Object:
"""Return the X-mirrored version of a Blender object by name (and library if linked)."""
flipped_name = flip_name(obj.name)
lib = obj.library
return (
bpy.data.objects.get((lib, flipped_name)) if lib else
bpy.data.objects.get(flipped_name)
) or obj
def is_addon_prop(owner, prop_name):
if bpy.app.version >= (5, 0, 0):
return prop_name in owner.bl_system_properties_get().keys()
else:
# NOTE: I don't think it's possible to detect pre-5.0 non-PropertyGroup/CollectionProperty non-registered add-on properties.
# They just behave completely as custom properties.
return prop_name in owner and (isinstance(owner[prop_name], IDPropertyGroup) or isinstance(owner[prop_name], list))
def is_registered_addon_prop(owner, prop_name):
return is_addon_prop(owner, prop_name) and prop_name in owner.bl_rna.properties
def is_custom_prop(owner, prop_name):
return prop_name in owner.keys() and not is_addon_prop(owner, prop_name)
def remove_property(obj, prop_name):
if is_custom_prop(obj, prop_name):
del obj[prop_name]
if is_registered_addon_prop(obj, prop_name):
obj.property_unset(prop_name)
elif is_addon_prop(obj, prop_name):
disabled_addon_props = obj.bl_system_properties_get()
del disabled_addon_props[prop_name]
else:
raise KeyError(f"{prop_name} not found in {obj.name}")
@@ -0,0 +1,235 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import mathutils
import bmesh
import numpy as np
def closest_face_to_point(bm_source, p_target, bvh_tree=None):
if not bvh_tree:
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
(loc, norm, index, distance) = bvh_tree.find_nearest(p_target)
return bm_source.faces[index]
def tris_per_face(bm_source):
tris_source = bm_source.calc_loop_triangles()
tris_dict = dict()
for face in bm_source.faces:
tris_face = []
for i in range(len(tris_source))[::-1]:
if tris_source[i][0] in face.loops:
tris_face.append(tris_source.pop(i))
tris_dict[face] = tris_face
return tris_dict
def closest_tri_on_face(tris_dict, face, p):
points = []
dist = []
tris = []
for tri in tris_dict[face]:
point = mathutils.geometry.closest_point_on_tri(
p, *[tri[i].vert.co for i in range(3)]
)
tris.append(tri)
points.append(point)
dist.append((point - p).length)
min_idx = np.argmin(np.array(dist))
point = points[min_idx]
tri = tris[min_idx]
return (tri, point)
def closest_edge_on_face_to_line(face, p1, p2, skip_edges=None):
"""Returns edge of a face which is closest to line."""
for edge in face.edges:
if skip_edges:
if edge in skip_edges:
continue
res = mathutils.geometry.intersect_line_line(
p1, p2, *[edge.verts[i].co for i in range(2)]
)
if not res:
continue
(p_traversal, p_edge) = res
frac_1 = (edge.verts[1].co - edge.verts[0].co).dot(
p_edge - edge.verts[0].co
) / (edge.verts[1].co - edge.verts[0].co).length ** 2.0
frac_2 = (p2 - p1).dot(p_traversal - p1) / (p2 - p1).length ** 2.0
if (frac_1 >= 0 and frac_1 <= 1) and (frac_2 >= 0 and frac_2 <= 1):
return edge
return None
def edge_data_split(edge, data_layer, data_suffix: str):
for vert in edge.verts:
vals = []
for loop in vert.link_loops:
loops_edge_vert = set([loop for f in edge.link_faces for loop in f.loops])
if loop not in loops_edge_vert:
continue
dat = data_layer[loop.index]
element = list(getattr(dat, data_suffix))
if not vals:
vals.append(element)
elif not vals[0] == element:
vals.append(element)
if len(vals) > 1:
return True
return False
def interpolate_data_from_face(
bm_source, tris_dict, face, p, data_layer_source, data_suffix=''
):
"""Returns interpolated value of a data layer within a face closest to a point."""
(tri, point) = closest_tri_on_face(tris_dict, face, p)
if not tri:
return None
weights = mathutils.interpolate.poly_3d_calc(
[tri[i].vert.co for i in range(3)], point
)
if not data_suffix:
cols_weighted = [
weights[i] * np.array(data_layer_source[tri[i].index]) for i in range(3)
]
col = sum(np.array(cols_weighted))
else:
cols_weighted = [
weights[i] * np.array(getattr(data_layer_source[tri[i].index], data_suffix))
for i in range(3)
]
col = sum(np.array(cols_weighted))
return col
def transfer_corner_data(
obj_source, obj_target, data_layer_source, data_layer_target, data_suffix=''
):
"""
Transfers interpolated face corner data from data layer of a source object to data layer of a
target object, while approximately preserving data seams (e.g. necessary for UV Maps).
The transfer is face interpolated per target corner within the source face that is closest
to the target corner point and does not have any data seams on the way back to the
source face that is closest to the target face's center.
"""
bm_source = bmesh.new()
bm_source.from_mesh(obj_source.data)
bm_source.faces.ensure_lookup_table()
bm_target = bmesh.new()
bm_target.from_mesh(obj_target.data)
bm_target.faces.ensure_lookup_table()
bvh_tree = mathutils.bvhtree.BVHTree.FromBMesh(bm_source)
tris_dict = tris_per_face(bm_source)
for face_target in bm_target.faces:
face_target_center = face_target.calc_center_median()
face_source = closest_face_to_point(bm_source, face_target_center, bvh_tree)
for corner_target in face_target.loops:
# find nearest face on target compared to face that loop belongs to
p = corner_target.vert.co
face_source_closest = closest_face_to_point(bm_source, p, bvh_tree)
enclosed = face_source_closest is face_source
face_source_int = face_source
if not enclosed:
# traverse faces between point and face center
traversed_faces = set()
traversed_edges = set()
while face_source_int is not face_source_closest:
traversed_faces.add(face_source_int)
edge = closest_edge_on_face_to_line(
face_source_int,
face_target_center,
p,
skip_edges=traversed_edges,
)
if edge == None:
break
if len(edge.link_faces) != 2:
break
traversed_edges.add(edge)
split = edge_data_split(edge, data_layer_source, data_suffix)
if split:
break
# set new source face to other face belonging to edge
face_source_int = (
edge.link_faces[1]
if edge.link_faces[1] is not face_source_int
else edge.link_faces[0]
)
# avoid looping behaviour
if face_source_int in traversed_faces:
face_source_int = face_source
break
# interpolate data from selected face
col = interpolate_data_from_face(
bm_source, tris_dict, face_source_int, p, data_layer_source, data_suffix
)
if col is None:
continue
if not data_suffix:
data_layer_target.data[corner_target.index] = col
else:
setattr(data_layer_target[corner_target.index], data_suffix, list(col))
return
def is_mesh_identical(mesh_a, mesh_b) -> bool:
if len(mesh_a.vertices) != len(mesh_b.vertices):
return False
if len(mesh_a.edges) != len(mesh_b.edges):
return False
if len(mesh_a.polygons) != len(mesh_b.polygons):
return False
for e1, e2 in zip(mesh_a.edges, mesh_b.edges):
for v1, v2 in zip(e1.vertices, e2.vertices):
if v1 != v2:
return False
return True
def is_curve_identical(curve_a: bpy.types.Curve, curve_b: bpy.types.Curve) -> bool:
if len(curve_a.splines) != len(curve_b.splines):
return False
for spline1, spline2 in zip(curve_a.splines, curve_b.splines):
if len(spline1.points) != len(spline2.points):
return False
return True
def is_obdata_identical(
a: bpy.types.Object or bpy.types.Mesh, b: bpy.types.Object or bpy.types.Mesh
) -> bool:
"""Checks if two objects have matching topology (efficiency over exactness)"""
if type(a) == bpy.types.Object:
a = a.data
if type(b) == bpy.types.Object:
b = b.data
if type(a) != type(b):
return False
if type(a) == bpy.types.Mesh:
return is_mesh_identical(a, b)
elif type(a) == bpy.types.Curve:
return is_curve_identical(a, b)
else:
# TODO: Support geometry types other than mesh or curve.
return
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import contextlib
from typing import Optional
def get_visibility_driver(obj) -> Optional[bpy.types.FCurve]:
obj = bpy.data.objects.get(obj.name)
assert obj, "Object was renamed while its visibility was being ensured?"
if hasattr(obj, "animation_data") and obj.animation_data:
return obj.animation_data.drivers.find("hide_viewport")
@contextlib.contextmanager
def override_obj_visibility(obj: bpy.types.Object, scene: bpy.types.Scene):
"""Temporarily Change the visibility of an Object so an bpy.ops or other
function that requires the object to be visible can be called.
Args:
obj (bpy.types.Object): Object to un-hide
scene (bpy.types.Scene): Scene Object is in
"""
hide = obj.hide_get() # eye icon
hide_viewport = obj.hide_viewport # hide viewport
select = obj.hide_select # selectable
driver = get_visibility_driver(obj)
if driver:
driver_mute = driver.mute
try:
obj.hide_set(False)
obj.hide_viewport = False
obj.hide_select = False
if driver:
driver.mute = True
assigned_to_scene_root = False
if obj.name not in scene.collection.objects:
assigned_to_scene_root = True
scene.collection.objects.link(obj)
yield
finally:
obj.hide_set(hide)
obj.hide_viewport = hide_viewport
obj.hide_select = select
if driver:
driver.mute = driver_mute
if assigned_to_scene_root and obj.name in scene.collection.objects:
scene.collection.objects.unlink(obj)
@@ -0,0 +1,221 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from mathutils import Vector, kdtree
from typing import Dict, Tuple, List
from ..transfer_util import (
transfer_data_clean,
transfer_data_item_is_missing,
transfer_data_item_init,
)
from .transfer_function_util.proximity_core import (
is_obdata_identical,
)
from .... import constants, logging
def vertex_groups_clean(obj):
transfer_data_clean(
obj=obj, data_list=obj.vertex_groups, td_type_key=constants.VERTEX_GROUP_KEY
)
def vertex_group_is_missing(transfer_data_item):
return transfer_data_item_is_missing(
transfer_data_item=transfer_data_item,
td_type_key=constants.VERTEX_GROUP_KEY,
data_list=transfer_data_item.id_data.vertex_groups,
)
def init_vertex_groups(scene, obj):
transfer_data_item_init(
scene=scene,
obj=obj,
data_list=obj.vertex_groups,
td_type_key=constants.VERTEX_GROUP_KEY,
)
def transfer_vertex_groups(
vertex_group_names: List[str],
target_obj: bpy.types.Object,
source_obj: bpy.types.Object,
):
logger = logging.get_logger()
for vertex_group_name in vertex_group_names:
if not source_obj.vertex_groups.get(vertex_group_name):
logger.error(f"Vertex Group {vertex_group_name} not found in {source_obj.name}")
return
# If topology matches transfer directly, otherwise use vertex proximity
if is_obdata_identical(source_obj, target_obj):
for vertex_group_name in vertex_group_names:
transfer_single_vgroup_by_topology(source_obj, target_obj, vertex_group_name)
else:
precalc_and_transfer_multiple_groups(source_obj, target_obj, vertex_group_names, expand=2)
def transfer_single_vgroup_by_topology(source_obj, target_obj, vgroup_name):
"""Function to quickly transfer single vertex group between mesh objects in case of matching topology."""
remove_vgroups([target_obj], [vgroup_name])
vgroup_src = source_obj.vertex_groups.get(vgroup_name)
vgroup_tgt = target_obj.vertex_groups.new(name=vgroup_name)
for v in source_obj.data.vertices:
if vgroup_src.index in [g.group for g in v.groups]:
vgroup_tgt.add([v.index], vgroup_src.weight(v.index), 'REPLACE')
def remove_vgroups(objs, vgroup_names):
for obj in objs:
for vgroup_name in vgroup_names:
target_vgroup = obj.vertex_groups.get(vgroup_name)
if target_vgroup:
obj.vertex_groups.remove(target_vgroup)
def precalc_and_transfer_multiple_groups(source_obj, target_obj, vgroup_names, expand=2):
"""Convenience function to transfer multiple groups."""
remove_vgroups([target_obj], vgroup_names)
kd_tree = build_kdtree(source_obj.data)
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
transfer_multiple_vertex_groups(
source_obj,
target_obj,
vert_influence_map,
src_vgroups=[source_obj.vertex_groups[name] for name in vgroup_names],
)
def precalc_and_transfer_single_group(source_obj, target_obj, vgroup_name, expand=2):
"""Convenience function to transfer a single group. For transferring multiple groups,
precalc_and_transfer_multiple_groups should be used as it is more efficient."""
remove_vgroups([target_obj], [vgroup_name])
kd_tree = build_kdtree(source_obj.data)
vert_influence_map = build_vert_influence_map(source_obj, target_obj, kd_tree, expand)
transfer_multiple_vertex_groups(
source_obj,
target_obj,
vert_influence_map,
[source_obj.vertex_groups[vgroup_name]],
)
def build_kdtree(mesh):
kd = kdtree.KDTree(len(mesh.vertices))
for i, v in enumerate(mesh.vertices):
kd.insert(v.co, i)
kd.balance()
return kd
def build_vert_influence_map(obj_from, obj_to, kd_tree, expand=2):
verts_of_edge = {i: (e.vertices[0], e.vertices[1]) for i, e in enumerate(obj_from.data.edges)}
edges_of_vert: Dict[int, List[int]] = {}
for edge_idx, edge in enumerate(obj_from.data.edges):
for vert_idx in edge.vertices:
if vert_idx not in edges_of_vert:
edges_of_vert[vert_idx] = []
edges_of_vert[vert_idx].append(edge_idx)
# A mapping from target vertex index to a list of source vertex indicies and
# their influence.
# This can be pre-calculated once per object pair, to minimize re-calculations
# of subsequent transferring of individual vertex groups.
vert_influence_map: List[int, List[Tuple[int, float]]] = {}
for i, dest_vert in enumerate(obj_to.data.vertices):
vert_influence_map[i] = get_source_vert_influences(
dest_vert, obj_from, kd_tree, expand, edges_of_vert, verts_of_edge
)
return vert_influence_map
def get_source_vert_influences(
target_vert, obj_from, kd_tree, expand=2, edges_of_vert={}, verts_of_edge={}
) -> List[Tuple[int, float]]:
_coord, idx, dist = get_nearest_vert(target_vert.co, kd_tree)
source_vert_indices = [idx]
if dist == 0:
# If the vertex position is a perfect match, just use that one vertex with max influence.
return [(idx, 1)]
for i in range(0, expand):
new_indices = []
for vert_idx in source_vert_indices:
for edge in edges_of_vert[vert_idx]:
vert_other = other_vert_of_edge(edge, vert_idx, verts_of_edge)
if vert_other not in source_vert_indices:
new_indices.append(vert_other)
source_vert_indices.extend(new_indices)
distances: List[Tuple[int, float]] = []
distance_total = 0
for src_vert_idx in source_vert_indices:
distance = (target_vert.co - obj_from.data.vertices[src_vert_idx].co).length
distance_total += distance
distances.append((src_vert_idx, distance))
# Calculate influences such that the total of all influences adds up to 1.0,
# and the influence is inversely correlated with the distance.
parts = [1 / (dist / distance_total) for idx, dist in distances]
parts_sum = sum(parts)
influences = [
(idx, 1 if dist == 0 else part / parts_sum) for part, dist in zip(parts, distances)
]
return influences
def get_nearest_vert(coords: Vector, kd_tree: kdtree.KDTree) -> Tuple[Vector, int, float]:
"""Return coordinate, index, and distance of nearest vert to coords in kd_tree."""
return kd_tree.find(coords)
def other_vert_of_edge(edge: int, vert: int, verts_of_edge: Dict[int, Tuple[int, int]]) -> int:
verts = verts_of_edge[edge]
assert vert in verts, f"Vert {vert} not part of edge {edge}."
return verts[0] if vert == verts[1] else verts[1]
def transfer_multiple_vertex_groups(obj_from, obj_to, vert_influence_map, src_vgroups):
"""Transfer src_vgroups in obj_from to obj_to using a pre-calculated vert_influence_map."""
for src_vg in src_vgroups:
target_vg = obj_to.vertex_groups.get(src_vg.name)
if target_vg == None:
target_vg = obj_to.vertex_groups.new(name=src_vg.name)
for i, dest_vert in enumerate(obj_to.data.vertices):
source_verts = vert_influence_map[i]
# Vertex Group Name : Weight
vgroup_weights = {}
for src_vert_idx, influence in source_verts:
for group in obj_from.data.vertices[src_vert_idx].groups:
group_idx = group.group
vg = obj_from.vertex_groups[group_idx]
if vg not in src_vgroups:
continue
if vg.name not in vgroup_weights:
vgroup_weights[vg.name] = 0
vgroup_weights[vg.name] += vg.weight(src_vert_idx) * influence
# Assign final weights of this vertex in the vertex groups.
for vg_name in vgroup_weights.keys():
target_vg = obj_to.vertex_groups.get(vg_name)
target_vg.add([dest_vert.index], vgroup_weights[vg_name], 'REPLACE')
@@ -0,0 +1,91 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ... import constants
from ..task_layer import draw_task_layer_selection
def draw_transfer_data_type(
context: bpy.types.Context,
layout: bpy.types.UILayout,
transfer_data: bpy.types.CollectionProperty,
) -> None:
"""Draw UI Element for items of a Transferable Data type"""
asset_pipe = bpy.context.scene.asset_pipeline
if transfer_data == []:
return
name, icon = constants.TRANSFER_DATA_TYPES[transfer_data[0].type]
box = layout.box()
header, panel = box.panel(transfer_data[0].obj_name + name, default_closed=True)
header.label(text=name, icon=icon)
if not panel:
return
box = panel.box()
for transfer_data_item in transfer_data:
main_row = box.row()
main_row.label(text=f"{transfer_data_item.name}: ")
if transfer_data_item.surrender:
# Disable entire row if the item is surrendered
main_row.operator(
"assetpipe.update_surrendered_transfer_data"
).transfer_data_item_name = transfer_data_item.name
draw_task_layer_selection(
context,
layout=main_row.row(),
data=transfer_data_item,
)
surrender_icon = "ORPHAN_DATA" if transfer_data_item.surrender else "HEART"
surrender_row = main_row.row()
surrender_row.enabled = transfer_data_item.owner in asset_pipe.local_task_layers
surrender_row.prop(
transfer_data_item, "surrender", text="", icon=surrender_icon
)
def draw_transfer_data(
context: bpy.types.Context,
transfer_data: bpy.types.CollectionProperty,
layout: bpy.types.UILayout,
) -> None:
"""Draw UI List of Transferable Data"""
vertex_groups = []
material_slots = []
modifiers = []
constraints = []
custom_props = []
shape_keys = []
attributes = []
parent = []
for transfer_data_item in transfer_data:
if transfer_data_item.type == constants.VERTEX_GROUP_KEY:
vertex_groups.append(transfer_data_item)
if transfer_data_item.type == constants.MATERIAL_SLOT_KEY:
material_slots.append(transfer_data_item)
if transfer_data_item.type == constants.MODIFIER_KEY:
modifiers.append(transfer_data_item)
if transfer_data_item.type == constants.CONSTRAINT_KEY:
constraints.append(transfer_data_item)
if transfer_data_item.type == constants.CUSTOM_PROP_KEY:
custom_props.append(transfer_data_item)
if transfer_data_item.type == constants.SHAPE_KEY_KEY:
shape_keys.append(transfer_data_item)
if transfer_data_item.type == constants.ATTRIBUTE_KEY:
attributes.append(transfer_data_item)
if transfer_data_item.type == constants.PARENT_KEY:
parent.append(transfer_data_item)
draw_transfer_data_type(context, layout, vertex_groups)
draw_transfer_data_type(context, layout, modifiers)
draw_transfer_data_type(context, layout, material_slots)
draw_transfer_data_type(context, layout, constraints)
draw_transfer_data_type(context, layout, custom_props)
draw_transfer_data_type(context, layout, shape_keys)
draw_transfer_data_type(context, layout, attributes)
draw_transfer_data_type(context, layout, parent)
@@ -0,0 +1,211 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..naming import merge_get_basename, task_layer_prefix_basename_get
from ..task_layer import get_transfer_data_owner
import contextlib
from ...props import AssetTransferData
def find_ownership_data(
transfer_data: bpy.types.CollectionProperty,
key: str,
td_type_key: str,
) -> AssetTransferData | None:
"""Return matching AssetTransferData if it exists."""
existing_items = [
transfer_data_item
for transfer_data_item in transfer_data
if transfer_data_item.type == td_type_key and key == transfer_data_item.name
]
if existing_items:
return existing_items[0]
def transfer_data_add_entry(
transfer_data: bpy.types.CollectionProperty,
name: str,
td_type_key: str,
task_layer_name: str,
surrender: bool,
):
"""Add entry to Transferable Data ownership
Args:
ownership (bpy.types.CollectionProperty): Transferable Data of an object
name (str): Name of new Transferable Data item
td_type_key (str): Type of Transferable Data
task_layer_name (str): Name of current task layer
surrender (bool): Whether this data's ownership should be surrendered to begin with
"""
transfer_data_item = transfer_data.add()
transfer_data_item.name = name
transfer_data_item.owner = task_layer_name
transfer_data_item.type = td_type_key
transfer_data_item.surrender = surrender
return transfer_data_item
def transfer_data_clean(
obj: bpy.types.Object, data_list: bpy.types.CollectionProperty, td_type_key: str
):
"""Removes data if a transfer_data_item doesn't exist but the data does exist
Args:
obj (bpy.types.Object): Object containing Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
"""
cleaned_item_names = set()
for item in data_list:
ownership_data = find_ownership_data(
obj.transfer_data_ownership,
merge_get_basename(item.name),
td_type_key,
)
if not ownership_data:
cleaned_item_names.add(item.name)
data_list.remove(item)
return cleaned_item_names
def transfer_data_item_is_missing(
transfer_data_item, data_list: bpy.types.CollectionProperty, td_type_key: str
) -> bool:
"""Returns true if a transfer_data_item exists the data doesn't exist
Args:
transfer_data_item (_type_): Item of Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
Returns:
bool: Returns True if transfer_data_item is missing
"""
if transfer_data_item.type == td_type_key and not data_list.get(
transfer_data_item["name"]
):
return True
"""Intilize Transferable Data to a temporary collection property, used
to draw a display of new Transferable Data to the user before merge process.
"""
def transfer_data_item_init(
scene: bpy.types.Scene,
obj: bpy.types.Object,
data_list: bpy.types.CollectionProperty,
td_type_key: str,
):
"""_summary_
Args:
scene (bpy.types.Scene): Scene that contains a the file's asset
obj (bpy.types.Object): Object containing possible Transferable Data
data_list (bpy.types.CollectionProperty): Collection Property containing a type of possible Transferable Data e.g. obj.modifiers
td_type_key (str): Key for the Transferable Data type
"""
asset_pipe = scene.asset_pipeline
transfer_data = obj.transfer_data_ownership
for item in data_list:
# Only add new ownership transfer_data_item if vertex group doesn't have an owner
ownership_data = find_ownership_data(transfer_data, item.name, td_type_key)
if not ownership_data:
task_layer_owner, auto_surrender = get_transfer_data_owner(
asset_pipe,
td_type_key,
)
asset_pipe.add_temp_transfer_data(
name=item.name,
owner=task_layer_owner,
type=td_type_key,
obj_name=obj.name,
surrender=auto_surrender,
)
@contextlib.contextmanager
def isolate_collection(context, iso_col: bpy.types.Collection):
col_exclude = {}
view_layer_col = context.view_layer.layer_collection
view_layer_col.collection.children.link(iso_col)
for col in view_layer_col.children:
col_exclude[col.name] = col.exclude
try:
# Exclude all collections that are not iso collection
for col in view_layer_col.children:
col.exclude = col.name != iso_col.name
yield
finally:
for col in view_layer_col.children:
col.exclude = col_exclude[col.name]
view_layer_col.collection.children.unlink(iso_col)
@contextlib.contextmanager
def link_objs_to_collection(objs: set, col: bpy.types.Collection):
try:
for obj in objs:
col.objects.link(obj)
yield
finally:
for obj in objs:
col.objects.unlink(obj)
@contextlib.contextmanager
def activate_shapekey(objs: set, sk_name: str):
old_values = {}
try:
for obj in objs:
if not obj.data.shape_keys:
continue
sk = obj.data.shape_keys.key_blocks.get(sk_name)
if not sk:
continue
old_values[obj] = sk.value
sk.value = 1
yield
finally:
for obj, val in old_values.items():
obj.data.shape_keys.key_blocks[sk_name].value = val
@contextlib.contextmanager
def disable_modifiers(objs: set, mod_types: set[str]):
mods_to_enable = {obj: [] for obj in objs}
try:
for obj in objs:
for mod in obj.modifiers:
if mod.type in mod_types and mod.show_viewport:
mods_to_enable[obj].append(mod.name)
mod.show_viewport = False
yield
finally:
for obj, mod_names in mods_to_enable.items():
for mod_name in mod_names:
obj.modifiers[mod_name].show_viewport = True
@contextlib.contextmanager
def simplify(scene):
"""Disable subdivision surface modifiers globally using the scene's Simplify setting.
Important for binding modifiers, but also probably doesn't hurt for general performance.
"""
orig_simplify = scene.render.use_simplify
levels = scene.render.simplify_subdivision
scene.render.use_simplify = True
scene.render.simplify_subdivision = 0
yield
scene.render.use_simplify = orig_simplify
scene.render.simplify_subdivision = levels