work: restore shift+spacebar for media play/pause

maybe put in maya config? idk what funiman's preference is
This commit is contained in:
Nathan
2026-05-29 14:58:59 -06:00
parent 2f8e5f472f
commit 6c3b78075b
130 changed files with 10461 additions and 19696 deletions
+23 -2
View File
@@ -20,7 +20,7 @@
bl_info = {
"name": "Animation Layers",
"author": "Tal Hershkovich",
"version" : (2, 4, 0),
"version" : (2, 4, 1),
"blender" : (3, 2, 0),
"location": "View3D - Properties - Animation Panel",
"description": "Simplifying the NLA editor into an animation layers UI and workflow",
@@ -73,7 +73,10 @@ class AnimLayersSceneSettings(bpy.types.PropertyGroup):
class AnimLayersSettings(bpy.types.PropertyGroup):
turn_on: bpy.props.BoolProperty(name="Turn Animation Layers On", description="Turn on and start Animation Layers", default=False, options={'HIDDEN'}, update = anim_layers.turn_animlayers_on, override = {'LIBRARY_OVERRIDABLE'})
# Active row in obj.Anim_Layers. Post-migration, 1:1 with NLA-track index.
layer_index: bpy.props.IntProperty(update = anim_layers.update_layer_index, options={'LIBRARY_EDITABLE'}, default = 0, override = {'LIBRARY_OVERRIDABLE'})
# Schema version. 0 = pre-hierarchical (legacy GROUP rows); 1 = hierarchical (parent_layer refs).
schema_version: bpy.props.IntProperty(name="Schema Version", default=0, options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
linked: bpy.props.BoolProperty(name="Linked", description="Duplicate a layer with a linked action", default=False, options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
#Bake settings
@@ -125,13 +128,31 @@ class AnimLayersItems(bpy.types.PropertyGroup):
action_range: bpy.props.FloatVectorProperty(name='action range', description="used to check if layer needs to update frame range", override = {'LIBRARY_OVERRIDABLE'}, size = 2)
custom_frame_range: bpy.props.BoolProperty(name="Custom Frame Range", description="Use a custom frame range per layer instead of the scene frame range", default=False, options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_frame_range)
frame_start: bpy.props.FloatProperty(name='Action Start Frame', description="First frame of the layer's action",min = 0, default=0, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_frame_start)
frame_end: bpy.props.FloatProperty(name='Action End Frame', description="End frame of the layer's action", default=0, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_frame_end)
speed: bpy.props.FloatProperty(name='Speed of the action', description="Speed of the action strip", default = 1, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_speed)
offset: bpy.props.FloatProperty(name='Offset when the action starts', description="Offseting the whole layer animation", default = 0, precision = 2, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_offset)
repeat: bpy.props.FloatProperty(name="Repeat", description="Repeat the action", min = 0.1, default = 1, options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'}, update = anim_layers.layer_repeat)
# Hierarchical layer fields. Every layer is NLA-backed; a layer is a
# "group" iff something else points to it via parent_layer.
expanded: bpy.props.BoolProperty(name="Expanded", description="Show this layer's children in the UI list", default=True, options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
parent_layer: bpy.props.StringProperty(name="Parent Layer", description="Name of this layer's parent (empty = root)", default="", options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
group_color: bpy.props.FloatVectorProperty(name="Group Color", subtype='COLOR', size=4, min=0.0, max=1.0, default=(0.3, 0.5, 0.8, 1.0), options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
# Dynamic-enum dropdown for picking parent_layer in the UI. The setter
# refuses cycles (self + descendants are excluded from the options).
assigned_group: bpy.props.EnumProperty(name='Parent', description='Set this layer\'s parent (None = root)',
items=anim_layers.layer_group_enum_items, get=anim_layers.layer_group_get, set=anim_layers.layer_group_set,
options={'HIDDEN'})
# Legacy field for pre-migration data. Read by migrate_object_to_hierarchical
# and ignored thereafter. Schema retained so old .blend files load cleanly.
type: bpy.props.EnumProperty(name="Item Type (legacy)", default='LAYER',
items=[('LAYER', 'Layer', 'NLA-backed layer'),
('GROUP', 'Group', 'Legacy phantom group row')],
options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
parent_group: bpy.props.StringProperty(name="Parent Group (legacy)", description="Pre-migration field. Read once by the schema-v1 migration, then unused.", default="", options={'HIDDEN'}, override = {'LIBRARY_OVERRIDABLE'})
class AnimLayersObjects(bpy.types.PropertyGroup):
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"last_check": "2026-04-21 12:47:39.126086",
"backup_date": "April-21-2026",
"last_check": "2026-05-27 14:41:11.263249",
"backup_date": "May-27-2026",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -20,7 +20,7 @@
bl_info = {
"name": "Animation Layers",
"author": "Tal Hershkovich",
"version" : (2, 3, 8),
"version" : (2, 4, 0),
"blender" : (3, 2, 0),
"location": "View3D - Properties - Animation Panel",
"description": "Simplifying the NLA editor into an animation layers UI and workflow",
@@ -153,7 +153,6 @@ def update_panel(self, context):
bpy.utils.unregister_class(panel)
for panel in panels:
#print (panel.bl_category)
panel.bl_category = context.preferences.addons[__name__].preferences.category
bpy.utils.register_class(panel)
@@ -176,8 +175,9 @@ class AnimLayersAddonPreferences(bpy.types.AddonPreferences):
items = [('ANIMLAYERS', 'Anim Layers Settings', 'Use Anim Layers properties to adjust custom frame range'),
('NLA', 'NLA Settings', 'Use the nla properties to adjust custom frame range')])
lock_nlatracks: bpy.props.BoolProperty(name="Automatically lock the nla tracks for safety measures", description="Automatically lock nla tracks when creating layers for safety", default = True)
lock_nlatracks: bpy.props.BoolProperty(name="Automatically lock the NLA tracks", description="Automatically lock nla tracks when creating layers for safety", default = True)
auto_custom_range: bpy.props.BoolProperty(name="Switch automatically to custom frame range when editing NLA Strips", description="Automatically use custom frame range when adjusting NLA Strips manually", default = False)
#Property for ClearActiveAction
proceed: bpy.props.EnumProperty(name="Choose how to proceed", description="Select an option how to proceed with Anim Layers", override = {'LIBRARY_OVERRIDABLE'},
items = [
@@ -260,8 +260,9 @@ class AnimLayersAddonPreferences(bpy.types.AddonPreferences):
row.label(text = "Custom Frame Range Settings")
row.prop(self, "frame_range_settings", text = '')
col.prop(self, "lock_nlatracks")
row = col.row()
row.prop(self, "auto_custom_range")
row.prop(self, "lock_nlatracks")
classes = (AnimLayersSettings, AnimLayersSceneSettings, AnimLayersItems, AnimLayersObjects)
@@ -399,7 +399,7 @@ def get_fcu_layer_keyframes(obj, context, track):
keyframes = []
# fcurves = get_fcurves(track.strips[0].action)
# fcurves = track.strips[0].action.fcurves
fcurves = get_fcurves(obj, track.strips[0].action)
fcurves = get_fcurves(obj, track.strips[0].action, obj.als.data_type)
#store all the keyframe locations from the fcurves of the layer
for fcu in fcurves:
if fcu.group is not None:
@@ -1546,6 +1546,8 @@ def strip_action_recalc(self, strip):
###################################################### HELPER FUNCTIONS ################################################
def redraw_areas(areas):
if not len(bpy.context.window_manager.windows):
return
for area in bpy.context.window_manager.windows[0].screen.areas:
if area.type in areas:
area.tag_redraw()
@@ -1621,7 +1623,7 @@ def select_layer_bones(self, context):
###################################################### CLASSES ###########################################################
class SelectBonesInLayer(bpy.types.Operator):
"""Select bones with keyframes in the current layer"""
"""Select bones with keyframes in the current layer, use shift to add to the current selection"""
bl_idname = "anim.bones_in_layer"
bl_label = "Select layer bones"
bl_icon = "BONE_DATA"
@@ -1864,18 +1866,9 @@ class AutoCustomFrameRange(bpy.types.Operator):
# return {'CANCELLED'}
def restore(self, context):
if hasattr(subscriptions, 'frame_range'):
frame_start, frame_end = subscriptions.frame_range
else:
frame_start, frame_end = subscriptions.get_frame_range(context.scene)
print('restore')
subscriptions.frameend_update_callback()
self.strip.repeat = 1 #change strip repeat but keep self.repeat value stored
self.strip.use_reverse = False
self.strip.frame_start = frame_start
self.strip.scale = self.layer.speed
self.strip.frame_end = frame_end
# update_action_frame_range(frame_start, frame_end, layer, strip)
subscriptions.subscriptions_add(context.scene)
def update_action_list(scene):
@@ -2596,9 +2589,7 @@ class RemoveFcurves(bpy.types.Operator):
if mod.type == 'CYCLES':
fcu.modifiers.remove(mod)
fcu.update()
for area in context.window_manager.windows[0].screen.areas:
if area.type == 'GRAPH_EDITOR' or area.type == 'VIEW_3D':
area.tag_redraw()
redraw_areas(['GRAPH_EDITOR', 'VIEW_3D'])
break
return {'FINISHED'}
@@ -3301,25 +3292,36 @@ def copy_action(action):
return new_action
def get_obj_slot(obj, action, data_type = 'OBJECT'):
def get_obj_slot(obj, action, data_type = None):
'''Get the slot in the action that this object is using either it's object, or shapekeys'''
if data_type is None:
data_type = obj.als.data_type
if not hasattr(action, 'slots'):
return None
if not len(action.slots):
# If no slots exist, create one for the object and return it
slot = add_action_slot(obj, action)
return slot
# data_type = obj.als.data_type
for slot in action.slots:
if slot.target_id_type != data_type:
continue
# if obj.als.data_type == 'OBJECT' and obj in slot.users():
# return slot
if data_type == 'KEY' and obj.data.shape_keys in slot.users():
return slot
elif obj in slot.users():
return slot
return None
return add_action_slot(obj, action)
def get_fcurves(obj: bpy.types.Object, action: bpy.types.Action, data_type = 'OBJECT'):
def get_fcurves(obj: bpy.types.Object, action: bpy.types.Action, data_type = None):
if data_type is None:
data_type = obj.als.data_type
if hasattr(action, 'layers'):
slot = get_obj_slot(obj, action, data_type)
@@ -3335,10 +3337,13 @@ def get_fcurves(obj: bpy.types.Object, action: bpy.types.Action, data_type = 'OB
return action.fcurves
return []
def get_channelbag(obj: bpy.types.Object, action: bpy.types.Action, data_type = 'OBJECT'):
def get_channelbag(obj: bpy.types.Object, action: bpy.types.Action, data_type = None):
'''Getting the container of the fcurves, either the action or channelbag
Using this when adding a new group to the action'''
if data_type is None:
data_type = obj.als.data_type
if hasattr(action, 'layers'):
slot = get_obj_slot(obj, action, data_type)
channelbag = None
@@ -1,16 +1,16 @@
{
"last_check": "2026-04-21 12:47:39.126086",
"backup_date": "March-27-2026",
"last_check": "2026-05-27 14:41:11.263249",
"backup_date": "April-21-2026",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://gitlab.com/api/v4/projects/22294607/repository/archive.zip?sha=dddd6932039b8a3e5fae3ce2de957f21a5942c84",
"link": "https://gitlab.com/api/v4/projects/22294607/repository/archive.zip?sha=321d411a449bc9acee2a759e30cd3d0f36bbd2ab",
"version": [
2,
4,
0
1
]
}
}
@@ -645,6 +645,7 @@ def AL_bake(frame_start, frame_end, nla_tracks, fcu_keys, additive, step, action
baked_action = track.strips[0].action
clean_no_user_slots(baked_action)
#create the baked fcurve
# baked_channelbag = anim_layers.get_channelbag(obj, baked_action, obj.als.data_type)
baked_channelbag = anim_layers.get_channelbag(obj, baked_action)
baked_fcurves = baked_channelbag.fcurves
@@ -73,7 +73,6 @@ def animlayers_frame(scene, context):
scene['framerange_preview'] = scene.use_preview_range
frameend_update_callback()
return
frame_start, frame_end = bake_ops.frame_start_end(scene)
# frame_start, frame_end = get_frame_range(scene)
reset_subscription = False
@@ -106,7 +105,6 @@ def animlayers_frame(scene, context):
for i, track in enumerate(nla_tracks):
if len(track.strips) != 1:
continue
#checks if the layer has a custom frame range
layer = obj.Anim_Layers[i]
if layer.custom_frame_range:
@@ -127,6 +125,7 @@ def animlayers_frame(scene, context):
if strip.frame_start < 0:
strip.frame_start = 0
anim_layers.update_action_frame_range(0, frame_end, layer, strip)
return
anim_layers.update_action_frame_range(strip.frame_start, current + 10.0, layer, strip)
strip.frame_end = current + 10.0
@@ -231,6 +230,9 @@ def track_layer_synchronization(obj, nla_tracks):
if obj.als.layer_index > len(obj.Anim_Layers)-1:
obj.als.layer_index = len(obj.Anim_Layers)-1
if not bpy.context.preferences.addons[__package__].preferences.auto_custom_range:
return
#update new layer with strip settings
frame_start, frame_end = get_frame_range(bpy.context.scene)
@@ -243,7 +245,6 @@ def track_layer_synchronization(obj, nla_tracks):
continue
if (strip.frame_start, strip.frame_end) != (frame_start, frame_end):
subscriptions_remove()
# print(f'strip.frame_start {strip.frame_start} strip.frame_end {strip.frame_end} frame_start {frame_start} frame_end {frame_end}')
bpy.ops.anim.custom_frame_range_warning('INVOKE_DEFAULT')
return
@@ -311,11 +312,16 @@ def sync_frame_range(scene, track, layer):
return
#Turn on custom frame range if the current strip is not following the scene frame range
# Should be activated when nla strips are edited manually in the nla editor, only when auto custom range is turned on, otherwise just update the strip frame range to the scene frame range
if (round(strip.frame_start, 2), round(strip.frame_end, 2)) != (round(frame_start, 2), round(frame_end, 2)):
subscriptions_remove()
# print('315 custom frame range')
bpy.ops.anim.custom_frame_range_warning('INVOKE_DEFAULT')
return
if bpy.context.preferences.addons[__package__].preferences.auto_custom_range:
subscriptions_remove()
# print('321 custom frame range')
bpy.ops.anim.custom_frame_range_warning('INVOKE_DEFAULT')
return
else:
frameend_update_callback()
return
def sync_strip_range(scene):
'''Checking all the strips if a value was changed in the nla (not including UI changes)
@@ -356,7 +362,6 @@ def sync_strip_range(scene):
if (strip_frame_start, round(strip_frame_end, 2)) != (frame_start, float(frame_end)):
subscriptions_remove()
# print('357 custom_frame_range_warning ')
# print(f'strip_frame_start {strip_frame_start} strip_frame_end {round(strip_frame_end, 2)} frame_start {frame_start} frame_end {float(frame_end)}')
bpy.ops.anim.custom_frame_range_warning('INVOKE_DEFAULT')
return
@@ -567,7 +572,6 @@ def subscribe_to_preview_frame_end(scene):
# Subscribing to preview frame end since it's not registering in the depsgraph
subscribe_preview_end = scene.path_resolve("frame_preview_end", False)
subscribe_use_preview = scene.path_resolve("use_preview_range", False)
# print('subscribe_to_preview_frame_end')
for subscribe in [subscribe_preview_end, subscribe_use_preview]:
bpy.msgbus.subscribe_rna(
+44 -19
View File
@@ -180,10 +180,11 @@ def smart_bake(context):
fcurves = anim_layers.get_fcurves(obj, track.strips[0].action)
total_iterations += len(fcurves)
wm.progress_begin(0, total_iterations)
wm.progress_begin(0, total_iterations)
processed = 0
for layer, track in zip(obj.Anim_Layers, anim_data.nla_tracks):
layer_items = [l for l in obj.Anim_Layers if l.type == 'LAYER']
for layer, track in zip(layer_items, anim_data.nla_tracks):
if track.mute:
continue
if len(track.strips) != 1 or track.strips[0].action is None:
@@ -242,9 +243,9 @@ def smart_bake(context):
smartkeys = smart_start_end(smartkeys, strip.frame_start, strip.frame_end)
smartkeys = remove_outofrange_keys(smartkeys, strip.frame_start, strip.frame_end)
#if the strip is cutting with a different strip, then add keyframes in the cut
#if the strip is cutting with a different strip, then add keyframes in the cut
for layercut in obj.Anim_Layers:
if layercut.mute or not layercut.custom_frame_range or layercut == layer:
if layercut.type == 'GROUP' or layercut.mute or not layercut.custom_frame_range or layercut == layer:
continue
if strip_start < layercut.frame_start < strip_end:
smartkeys = smart_start_end(smartkeys, (layercut.frame_start-1), strip.frame_end-1)
@@ -407,7 +408,7 @@ def unmute_modifiers(obj, nla_tracks, modifier_rec):
for mod in fcu.modifiers:
if mod in modifier_rec:
mod.mute = False
elif obj.als.mergefcurves and track == nla_tracks[obj.als.layer_index]:
elif obj.als.mergefcurves and track == nla_tracks[anim_layers.nla_idx(obj)]:
mod.mute = True
def invisible_layers(b_layers):
@@ -426,7 +427,14 @@ def select_keyframed_bones(self, context, obj):
if obj.mode != 'POSE':
bpy.ops.object.posemode_toggle()
bpy.ops.pose.select_all(action='DESELECT')
for i in range(0, obj.als.layer_index+1):
# Iterate over LAYER rows up to (and including) the active row, skipping
# group headers (they have no NLA track and no bones to select).
current_row = obj.als.layer_index
for i, it in enumerate(obj.Anim_Layers):
if i > current_row:
break
if it.type != 'LAYER':
continue
obj.als['layer_index'] = i
anim_layers.select_layer_bones(self, context)
@@ -446,7 +454,7 @@ def smartbake_apply(obj, nla_tracks, fcu_keys, extrapolations):
#apply smartbake for blenders bake
#smart bake - delete unnecessery keyframes:
# transform_types = ['location', 'rotation_euler', 'rotation_quaternion', 'scale']
strip = nla_tracks[obj.als.layer_index].strips[0]
strip = nla_tracks[anim_layers.nla_idx(obj)].strips[0]
# if strip.action is None:
# return
@@ -641,7 +649,7 @@ def AL_bake(frame_start, frame_end, nla_tracks, fcu_keys, additive, step, action
return
anim_data = anim_layers.anim_data_type(obj)
# baked_action = anim_data.action
track = nla_tracks[obj.als.layer_index]
track = nla_tracks[anim_layers.nla_idx(obj)]
baked_action = track.strips[0].action
clean_no_user_slots(baked_action)
#create the baked fcurve
@@ -1065,10 +1073,18 @@ class MergeAnimLayerDown(bpy.types.Operator):
bl_idname = "anim.layers_merge_down"
bl_label = "Merge_Layers_Down"
bl_options = {'REGISTER', 'UNDO'}
step: bpy.props.IntProperty(name='Step', description='Bake every number of frame steps', default=1)
actioncopy: bpy.props.BoolProperty(name='Copy original merged action', description='Create a copy of the original action that is being overwritten', default = False)
@classmethod
def poll(cls, context):
obj = context.object
if obj is None:
return False
# Disable when active row is a group header — merge only applies to NLA-backed layers.
return anim_layers.is_layer_row_active(obj)
def invoke(self, context, event):
obj = context.object
bake_range_type(context.scene.als, context)
@@ -1143,7 +1159,8 @@ class MergeAnimLayerDown(bpy.types.Operator):
# Incase the strips are shorter then the keyframe range (because scene is shorter)
# Then updating the strips length
for layer, track in zip(obj.Anim_Layers, anim_data.nla_tracks):
layer_items = [l for l in obj.Anim_Layers if l.type == 'LAYER']
for layer, track in zip(layer_items, anim_data.nla_tracks):
if layer.custom_frame_range:
continue
if len(track.strips) != 1:
@@ -1181,7 +1198,7 @@ class MergeAnimLayerDown(bpy.types.Operator):
if obj.als.direction == 'DOWN':
obj.als.layer_index = 0
baked_layer = None
strip = anim_data.nla_tracks[obj.als.layer_index].strips[0]
strip = anim_data.nla_tracks[anim_layers.nla_idx(obj)].strips[0]
action = strip.action
if hasattr(strip, 'action_slot'):
action_slot = strip.action_slot
@@ -1190,21 +1207,29 @@ class MergeAnimLayerDown(bpy.types.Operator):
#if baking to a new layer then setup the new index and layer
elif obj.als.operator == 'NEW':
self.actioncopy = False
# `add_at_nla` is the NLA-track index passed to add_animlayer.
if obj.als.direction == 'UP' and additive and 'REPLACE' in blendings:
obj.als.layer_index = layer_index + blendings.index('REPLACE') - 1
add_at_nla = layer_index + blendings.index('REPLACE') - 1
elif obj.als.direction == 'UP' or obj.als.direction == 'ALL':
obj.als.layer_index = len(obj.Anim_Layers)-1
add_at_nla = anim_layers.nla_layer_count(obj) - 1
else:
add_at_nla = anim_layers.nla_idx(obj)
layer_names = [layer.name for layer in obj.Anim_Layers]
baked_layer = anim_layers.add_animlayer(layer_name = anim_layers.unique_name(layer_names, 'Baked_Layer') , duplicate = False, index = obj.als.layer_index, blend_type = blend)
layer_names = [layer.name for layer in obj.Anim_Layers if layer.type == 'LAYER']
baked_layer = anim_layers.add_animlayer(layer_name = anim_layers.unique_name(layer_names, 'Baked_Layer') , duplicate = False, index = add_at_nla, blend_type = blend)
anim_layers.register_layers(obj, nla_tracks)
obj.als.layer_index += 1
# Point layer_index at the newly-added baked layer's collection row.
if baked_layer is not None:
for ridx, it in enumerate(obj.Anim_Layers):
if it.type == 'LAYER' and it.name == baked_layer.name:
obj.als.layer_index = ridx
break
#remove subsciption again after adding a layer there was new subsciption applied
subscriptions.subscriptions_remove()
track = nla_tracks[obj.als.layer_index]
track = nla_tracks[anim_layers.nla_idx(obj)]
#use internal bake
if obj.als.baketype =='NLA':
modifier_rec, extrapolations = mute_modifiers(obj, nla_tracks)
@@ -1282,7 +1307,7 @@ class MergeAnimLayerDown(bpy.types.Operator):
strip.action_slot = anim_layers.get_obj_slot(obj, action)
#reset layer settings
baked_layer = obj.Anim_Layers[obj.als.layer_index]
baked_layer = obj.Anim_Layers[obj.als.layer_index]
baked_layer.repeat, baked_layer.speed, baked_layer.offset = 1, 1, 0
strip.use_sync_length = False
if baked_layer.custom_frame_range:
@@ -106,7 +106,10 @@ def animlayers_frame(scene, context):
if len(track.strips) != 1:
continue
#checks if the layer has a custom frame range
layer = obj.Anim_Layers[i]
row_idx = anim_layers.layer_to_row_index(obj, i)
if row_idx < 0 or row_idx >= len(obj.Anim_Layers):
continue
layer = obj.Anim_Layers[row_idx]
if layer.custom_frame_range:
continue
if not reset_subscription:
@@ -169,6 +172,10 @@ def check_handler(scene):
return
anim_layers.add_obj_to_animlayers(obj, [item.object for item in scene.AL_objects])
nla_tracks = anim_data.nla_tracks
# When the active UIList row is a group header (no NLA track of its own),
# skip the LAYER-specific syncs below — they assume a real layer.
if not anim_layers.is_layer_row_active(obj):
return
layer = obj.Anim_Layers[obj.als.layer_index]
active_action_update(obj, anim_data, nla_tracks)
#check if a keyframe was removed
@@ -189,7 +196,7 @@ def check_handler(scene):
if track_layer_synchronization(obj, nla_tracks):
return
track = nla_tracks[obj.als.layer_index]
track = nla_tracks[anim_layers.nla_idx(obj)]
sync_frame_range(scene, track, layer)
# sync_strip_range(scene)
@@ -217,18 +224,20 @@ def check_handler(scene):
anim_layers.hide_view_all_keyframes(obj, anim_data)
check_selected_bones(obj)
influence_check(nla_tracks[obj.als.layer_index])
influence_check(nla_tracks[anim_layers.nla_idx(obj)])
def track_layer_synchronization(obj, nla_tracks):
'''check if track and layers are synchronized, running only when adding/removing tracks via the nla'''
if len(nla_tracks) == len(obj.Anim_Layers):
if len(nla_tracks) == anim_layers.nla_layer_count(obj):
return False
new_layers_names = set(track.name for track in nla_tracks).difference(set(layer.name for layer in obj.Anim_Layers))
layer_items = [layer for layer in obj.Anim_Layers if layer.type == 'LAYER']
new_layers_names = set(track.name for track in nla_tracks).difference(set(layer.name for layer in layer_items))
anim_layers.visible_layers(obj, nla_tracks)
if obj.als.layer_index > len(obj.Anim_Layers)-1:
obj.als.layer_index = len(obj.Anim_Layers)-1
row_count = len(obj.Anim_Layers)
if row_count and obj.als.layer_index > row_count - 1:
obj.als.layer_index = row_count - 1
if not bpy.context.preferences.addons[__package__].preferences.auto_custom_range:
return
@@ -261,9 +270,9 @@ def active_action_update(obj, anim_data, nla_tracks):
anim_data.action = None
subscriptions_add(bpy.context.scene)
return
if anim_data.action == nla_tracks[obj.als.layer_index].strips[0].action:
if anim_data.action == nla_tracks[anim_layers.nla_idx(obj)].strips[0].action:
return
if not len(nla_tracks[obj.als.layer_index].strips):
if not len(nla_tracks[anim_layers.nla_idx(obj)].strips):
return
if not anim_data.action or anim_data.is_property_readonly('action'):
return
@@ -434,7 +443,7 @@ def influence_sync(scene, obj, nla_tracks):
if action.name == scene.name + 'Action' and not len(scene.animation_data.nla_tracks) and not len(fcurves):
bpy.data.actions.remove(action)
strip = nla_tracks[obj.als.layer_index].strips[0]
strip = nla_tracks[anim_layers.nla_idx(obj)].strips[0]
if strip.fcurves[0].mute:
return
strip.fcurves[0].lock = False
@@ -546,9 +555,10 @@ def frameend_update_callback():
for anim_data in anim_datas:
if anim_data is None:
continue
if len(anim_data.nla_tracks) != len(obj.Anim_Layers):
if len(anim_data.nla_tracks) != anim_layers.nla_layer_count(obj):
continue
for layer, track in zip(obj.Anim_Layers, anim_data.nla_tracks):
layer_items = [l for l in obj.Anim_Layers if l.type == 'LAYER']
for layer, track in zip(layer_items, anim_data.nla_tracks):
if layer.custom_frame_range:
continue
if len(track.strips) != 1:
@@ -653,10 +663,12 @@ def action_name_callback():
nla_tracks = anim_data.nla_tracks
if not len(nla_tracks):
return
layer = obj.Anim_Layers[obj.als.layer_index]
if not len(nla_tracks[obj.als.layer_index].strips):
if not anim_layers.is_layer_row_active(obj):
return
action = nla_tracks[obj.als.layer_index].strips[0].action
layer = obj.Anim_Layers[obj.als.layer_index]
if not len(nla_tracks[anim_layers.nla_idx(obj)].strips):
return
action = nla_tracks[anim_layers.nla_idx(obj)].strips[0].action
if action is None:
return
if not obj.als.auto_rename or layer.name == action.name:
@@ -774,10 +786,10 @@ def slot_update_callback():
if not len(obj.Anim_Layers):
return
if not len(anim_data.nla_tracks[obj.als.layer_index].strips):
if not len(anim_data.nla_tracks[anim_layers.nla_idx(obj)].strips):
return
strip = anim_data.nla_tracks[obj.als.layer_index].strips[0]
strip = anim_data.nla_tracks[anim_layers.nla_idx(obj)].strips[0]
anim_data.action_slot = strip.action_slot
@@ -848,9 +860,11 @@ def strip_settings_callback():
return
# sync_strip_range()
if not len(anim_data.nla_tracks[obj.als.layer_index].strips):
if not anim_layers.is_layer_row_active(obj):
return
strip = anim_data.nla_tracks[obj.als.layer_index].strips[0]
if not len(anim_data.nla_tracks[anim_layers.nla_idx(obj)].strips):
return
strip = anim_data.nla_tracks[anim_layers.nla_idx(obj)].strips[0]
layer = obj.Anim_Layers[obj.als.layer_index]
update_strip_layer_settings(strip, layer)
+51 -76
View File
@@ -71,6 +71,7 @@ def draw_wgt(scale, bone, shape = 'sphere'):
shape = shape.lower()
mesh.from_pydata(np.array(shapes[shape]['vertices'])*scale , shapes[shape]['edges'], shapes[shape]['faces'])
# print('draw shape scale ', scale)
shape_obj['WGT_TempCtrl'] = True
return shape_obj
@@ -204,28 +205,27 @@ def tempctrl_shape_type(self, context):
ctrl.custom_shape_translation = (0, 0, 0)
ctrl.custom_shape_rotation_euler = (0, 0, 0)
continue
if org_bone.custom_shape_transform is not None:# and ctrl.custom_shape_transform is None:
if org_bone.custom_shape_transform is not None and ctrl.custom_shape_transform is None:
#check if the transform bone shape already exists
transform_bonename = org_bone.custom_shape_transform.name
if transform_bonename in ctrl.id_data.pose.bones:
# If it already exists then find it
ctrl.custom_shape_transform = ctrl.id_data.pose.bones[transform_bonename]
else:
#add an extra transform bone, if it doesnt exist
bpy.ops.object.mode_set(mode = 'EDIT')
boneshape = add_ctrl_bone(rig, org_bone.custom_shape_transform, rig.data.edit_bones[ctrl.name].parent, '')
transform_bonename = boneshape.name
add_bone_to_collection(rig, boneshape)
bpy.ops.object.mode_set(mode = 'POSE')
continue
#add an extra transform bone, if it doesnt exist
rig = ctrl.id_data
posebone = rig.pose.bones[transform_bonename]
bpy.ops.object.mode_set(mode = 'EDIT')
boneshape = add_ctrl_bone(rig, org_bone.custom_shape_transform, rig.data.edit_bones[ctrl.name].parent, '')
bone_name = boneshape.name
add_bone_to_collection(rig, boneshape)
bpy.ops.object.mode_set(mode = 'POSE')
posebone = rig.pose.bones[bone_name]
constraint_add(posebone, org_bone.custom_shape_transform, 'COPY_TRANSFORMS')
ctrl.custom_shape_transform = posebone
else:
# scale = (length_avg / ctrl.length)*0.4 + 0.4
scale = 1
# print('drawing shape in scale of ', scale)
ctrl.custom_shape = draw_wgt(scale, ctrl, shape = self.shape_type)
ctrl.custom_shape_scale_xyz = tuple([self.shape_size]*3)
ctrl.custom_shape_transform = None
@@ -1466,11 +1466,6 @@ def smartbake_to_ik(context, posebones, chain, sb):
#create the fcurves and add them to smartfcurves
for ctrl in ctrls:
if ctrl == chain.ik_tip and ctrl != chain.ik_ctrl:
continue
# if chain.pole_bone and ctrl == chain.base_bone:
# continue
ctrl_action = ctrl.id_data.animation_data.action
path = ctrl.path_from_id()
rot = Tools.rot_mode_to_channel(ctrl.rotation_mode)
@@ -1909,7 +1904,7 @@ def smartbake_write_keyframes(scene, smartframes, smartfcus, chain = None, bake_
#Store the influence and set them to 0 before calculating the matrix without the constraint (to get the offset)
con_influence = get_con_influence(constrained)
if chain and chain.pole_bone is not None:
if chain:
# Adding the pole bone to the end of the list
bones_matrices.update({chain.pole_bone : None})
bones_matrices[chain.pole_bone] = bones_matrices.pop(chain.pole_bone)
@@ -1929,23 +1924,11 @@ def smartbake_write_keyframes(scene, smartframes, smartfcus, chain = None, bake_
else:
# Get the rest of the bones to check if they are children and if we need viewlayer update
bones = set(bones_matrices.keys()).difference(pasted_bones)
# Calculating pole target matrix after other bones were calcluated
if chain and bone == chain.pole_bone:
base_bone = chain.base_bone
ik_tip = chain.ik_tip
angle = chain.base_bone.vector.angle(chain.ik_tip.vector)
if not round(angle):
# continue
# If the angle is flat then use the position of the pole relative to the rest position base bone
relative_matrix = base_bone.bone.matrix_local.inverted() @ chain.pole_bone.bone.matrix_local
matrix = base_bone.matrix @ relative_matrix
# print(frame, 'angle is 0 using base bone matrix')
else:
bpy.context.view_layer.update()
matrix = find_pole_vector(base_bone, ik_tip, chain.pole_bone, chain.step)
bpy.context.view_layer.update()
matrix = find_pole_vector(chain.base_bone, chain.ik_tip, chain.pole_bone, chain.step)
Tools.paste_bone_matrix(bone, matrix, constrained, bones, x_filter = False)
pasted_bones.add(bone)
@@ -4151,7 +4134,7 @@ def assign_setup_ids(self, context, ctrls, selected, child_ctrls, setup, setup_i
#Assign group, color, matrix and setup ids in pose mode
for ctrl, bone in zip(ctrls, selected):
ctrl.matrix = get_relative_ws_matrix(bone, ctrl)
#assign the controller to a group
if bpy.app.version < (4, 0, 0):
ctrl.bone_group = ctrl_group
@@ -4193,7 +4176,6 @@ def assign_setup_ids(self, context, ctrls, selected, child_ctrls, setup, setup_i
#get custom shape and assign the matrix
if bone.custom_shape_transform and self.shape_type == 'ORIGINAL':
obj_mat = bone.id_data.matrix_world
ot_bone = bone.custom_shape_transform.name
#Get the original override transform bone
target = bone.id_data.pose.bones[ot_bone]
@@ -5328,16 +5310,14 @@ def pole_prop_edit(self, context):
else:
add_bone_to_collection(rig, rig.data.edit_bones[chain.base_bone.name], col_name = 'IK Ctrls', visible = True)
#remove the pole bone and switch to base bone
if chain.pole_bone is None:
if not hasattr(chain, 'pole_bone'):
continue
rig.data.edit_bones.remove(rig.data.edit_bones[chain.pole_bone_name])
#remove from ctrls and append base bone instead
if chain.pole_bone in ctrls:
ctrls.remove(chain.pole_bone)
ctrls.append(chain.base_bone)
chain.pole_bone = chain.pole_bone_name = None
# del chain.pole_bone, chain.pole_bone_name
del chain.pole_bone, chain.pole_bone_name
#Remove child ctrls
if btc.child:
@@ -5350,17 +5330,13 @@ def pole_prop_pose(self, context):
if not btc.pole_target:
chain.base_bone.btc.org = 'CTRL'
# if not hasattr(chain, 'pole_bone'):
# continue
if not chain.pole_bone:
continue
if not chain.pole_bone_name:
continue
chain.pole_bone = chain.pole_bone_name = None
# del chain.pole_bone
# if hasattr(chain,'pole_bone_name'):
# chain.pole_bone_name
# continue
if not hasattr(chain, 'pole_bone'):
continue
del chain.pole_bone
if hasattr(chain,'pole_bone_name'):
chain.pole_bone_name
continue
pole_posebone_update(chain, ctrls)
if chain.base_bone in ctrls:
@@ -5437,9 +5413,7 @@ def ik_ctrl_orientation(self, context):
def pole_posebone_update(chain, ctrls):
'''update pole bone properties in the pole prop and add ik prop'''
# if not hasattr(chain, 'pole_bone_name'):
# return
if not chain.pole_bone_name:
if not hasattr(chain, 'pole_bone_name'):
return
chain.pole_bone = rig.pose.bones[chain.pole_bone_name]
@@ -5473,8 +5447,6 @@ def update_axis_prop(self, context):
if not hasattr(chain, 'pole_bone_name'):
continue
if not chain.pole_bone_name:
continue
pole_matrix = find_pole_vector(base_bone, ik_tip, ik_tip, chain.step)
#pole matrix orientation to world by default
@@ -5579,6 +5551,15 @@ def add_pole_bone(self, context, rig, chain):
new_rot = mathutils.Euler((0, 0, 0), 'XYZ')
pole_matrix = Matrix.LocRotScale(loc, new_rot, scale)
#In case of a flat IK skip adding a pole bone and use base bone instead
# if pole_matrix.translation == chain.ik_tip.matrix.translation:
# print('no pole bone')
# self.report({'ERROR'}, "Can't add Pole Ctrl because of a flat IK chain")
# chain.pole_bone = None
# add_bone_to_collection(rig, chain.base_bone, col_name = 'IK Ctrls', visible = True)
# btc['pole_target'] = False
# return
pole_bone_name = chain.org_bones[-2].name + '_Pole' if len(chain.org_bones) > 1 else chain.org_bones[0].name + '_Pole'
chain.pole_bone = rig.data.edit_bones.new(pole_bone_name)
@@ -5601,9 +5582,7 @@ def pole_offset(self, context):
global ik_chains, ctrls, child_names
for chain in ik_chains:
# if not hasattr(chain, 'pole_bone_name'):
# continue
if not chain.pole_bone_name:
if not hasattr(chain, 'pole_bone_name'):
continue
chain.pole_bone = rig.pose.bones[chain.pole_bone_name]
@@ -5642,7 +5621,7 @@ def add_ik_prop_edit(self, context):
chain.ik_tip_name = chain.ik_tip.name
#remove the pole bone
if btc.pole_target and chain.pole_bone_name:
if btc.pole_target and hasattr(chain, 'pole_bone_name'):
#removing and adding the pole_target without calling the whole pole prop update
ctrls.remove(chain.pole_bone)
rig.data.edit_bones.remove(rig.data.edit_bones[chain.pole_bone_name])
@@ -5675,7 +5654,7 @@ def add_ik_prop_pose(self, context):
chain.ik_ctrl.btc.org = 'CTRL'
chain.ik_ctrl.color.palette = context.scene.btc.color_set
if btc.pole_target and chain.pole_bone_name: #hasattr(chain, 'pole_bone_name'):
if btc.pole_target and hasattr(chain, 'pole_bone_name'):
pole_posebone_update(chain, ctrls)
add_bone_to_collection(rig, chain.ik_ctrl, col_name = 'IK Ctrls', visible = True)
@@ -5803,7 +5782,7 @@ class TempIK(bpy.types.Operator):
# for ctrl in ctrls:
# add_bone_to_collection(rig, ctrl, col_name = 'ORG', visible = True)
chain = IK_Chain(hierarchy, ctrls, root_ctrl)
#if only one bone is selected in the chain then add an extra control automatically
if len(hierarchy) == 1:
add_ik_ctrl = True
@@ -5812,7 +5791,7 @@ class TempIK(bpy.types.Operator):
for i, ctrl in enumerate(ctrls[:-1]):
ctrls[i+1].tail = ctrl.head
context.view_layer.update()
# return {'FINISHED'}
switch_layers(rig, chain.ik_tip, [31])
add_bone_to_collection(rig, chain.ik_tip)
add_bone_to_collection(rig, chain.ik_ctrl, col_name = 'IK Ctrls', visible = True)
@@ -5821,7 +5800,7 @@ class TempIK(bpy.types.Operator):
# rig.data.collections['ORG'].is_visible = False
ik_chains.append(chain)
btc['add_ik_ctrl'] = add_ik_ctrl
if add_ik_ctrl:
add_ik_prop_edit(self, context)
@@ -5845,7 +5824,8 @@ class TempIK(bpy.types.Operator):
####################################################################
bpy.ops.object.mode_set(mode = 'POSE')
setup_ids = set()
if btc.root:
@@ -5878,22 +5858,20 @@ class TempIK(bpy.types.Operator):
# #assign the matrix
distance = (bone.id_data.matrix_world @ bone.bone.matrix_local).inverted() @ ctrl.bone.matrix_local.copy()
ctrl.matrix = bone.id_data.matrix_world @ bone.matrix @ distance
# context.view_layer.update()
ctrl.color.palette = btc.color_set
add_bone_setup_id(chain.ik_ctrl, False, setup, 'CTRL', setup_id, org_id = chain.ik_ctrl.btc.org_id)
if add_ik_ctrl:
add_ik_prop_pose(self, context)
context.view_layer.update()
pole_prop_pose(self, context)
# child_prop_pose(context)
if context.scene.btc.child:
child_prop(context.scene.btc, context)
# return {'FINISHED'}
self.ik_chains = ik_chains
tempctrl_shape_type(self, context)
@@ -5907,7 +5885,7 @@ class TempIK(bpy.types.Operator):
if 'ctrls' not in globals():
self.report({'INFO'},'Not available. Please update the Temp Ctrls from the addon panel')
return {'CANCELLED'}
btc = context.scene.btc
controlled_objs = {item.controlled for item in context.scene.btc.ctrl_items if item.controlled}
hidden_objs = {obj for obj in context.view_layer.objects if obj and obj.hide_get()}
@@ -5974,7 +5952,7 @@ class TempIK(bpy.types.Operator):
rig.data.edit_bones[ctrl.name].parent = rig.data.edit_bones[chain.ctrls[i+1].name]
#updating the polebone position with the offset
if btc.pole_target and chain.pole_bone_name: #hasattr(chain, 'pole_bone_name'):
if btc.pole_target and hasattr(chain, 'pole_bone_name'):
pole_bone = rig.data.edit_bones[chain.pole_bone_name]
base_bone = rig.data.edit_bones[chain.base_bone_name]
ik_tip = rig.data.edit_bones[chain.ik_tip_name]
@@ -6103,8 +6081,7 @@ class TempIK(bpy.types.Operator):
#add the ik constraint to the ctrls
ik_con = constraint_add(chain.ik_tip, chain.ik_ctrl if not btc.child else chain.ik_child_ctrl, 'IK')
if chain.pole_bone:
chain.pole_angle = get_pole_angle(chain.base_bone, chain.ik_tip, chain.pole_bone.matrix.translation)
chain.pole_angle = get_pole_angle(chain.base_bone, chain.ik_tip, chain.pole_bone.matrix.translation)
ik_con.chain_count = chain.length(btc.add_ik_ctrl)
#setup the ik constraint
@@ -6339,8 +6316,6 @@ class IK_Chain:
self.ik_ctrl_name = self.ik_ctrl.name
self.base_bone_name = self.base_bone.name
self.ik_tip_name = self.ik_tip.name
self.pole_bone = None
self.pole_bone_name = None
self.step = get_step(hierarchy[0].id_data)
@@ -6362,7 +6337,7 @@ class IK_Chain:
self.parent = rig.pose.bones[self.parent_name]
if hasattr(self, 'root_name'):
self.root_ctrl = rig.pose.bones[self.root_name]
if self.pole_bone_name:
if hasattr(self, 'pole_bone_name'):
self.pole_bone = rig.pose.bones[self.pole_bone_name]
# if hasattr(self, 'pole_bone_ref_name'):
# self.pole_bone_ref = rig.pose.bones[self.pole_bone_ref_name]
+1 -1
View File
@@ -20,7 +20,7 @@
bl_info = {
"name": "AnimToolBox",
"author": "Tal Hershkovich",
"version" : (0, 2, 3),
"version" : (0, 2, 0),
"blender" : (3, 2, 0),
"location": "View3D - Properties - Animation Panel",
"description": "A set of animation tools",
@@ -1,6 +1,6 @@
{
"last_check": "2026-02-09 15:54:54.419194",
"backup_date": "February-9-2026",
"last_check": "",
"backup_date": "",
"update_ready": false,
"ignore": false,
"just_restored": false,
@@ -1,189 +0,0 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
import bpy
class GizmoSizeUp(bpy.types.Operator):
"""Share keyframes between all the selected objects and bones"""
bl_idname = "view3d.gizmo_size_up"
bl_label = "Gizmo_Size_Up"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
context.preferences.view.gizmo_size += context.scene.animtoolbox.gizmo_size
return {'PASS_THROUGH'}
class GizmoSizeDown(bpy.types.Operator):
"""Share keyframes between all the selected objects and bones"""
bl_idname = "view3d.gizmo_size_down"
bl_label = "Gizmo_Size_Down"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
context.preferences.view.gizmo_size -= context.scene.animtoolbox.gizmo_size
return {'PASS_THROUGH'}
########################################################################################################################
def clear_isolate_pose_mode(scene):
if not len(scene.animtoolbox.isolated):
return
for obj in scene.animtoolbox.isolated:
if not obj.hidden:
continue
obj.hidden.hide_set(False)
scene.animtoolbox.isolated.clear()
scene.animtoolbox.active_obj = None
def isolate_pose_mode(scene):
context = bpy.context
#return when going out of isolate pose or when active object is not in pose mode
if not scene.animtoolbox.isolate_pose_mode or context.active_object.mode != 'POSE':
clear_isolate_pose_mode(scene)
return
#handler continue only if the active object is None otherwise it collects all armature objects
if scene.animtoolbox.active_obj is None:
scene.animtoolbox.active_obj = context.active_object
else:
return
isolated = scene.animtoolbox.isolated
for obj in context.view_layer.objects:
if obj.type != 'ARMATURE':
continue
if obj.hide_get():
continue
rig = isolated.add()
if obj.mode == 'POSE':
rig.selected = obj
else:
rig.hidden = obj
obj.hide_set(True)
class IsolatePoseMode(bpy.types.Operator):
"""Isolates armatures during pose mode"""
bl_idname = "anim.isolate_pose_mode"
bl_label = "Isolate Pose Mode"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
# If the modal is already running, then don't run it the second time
scene = context.scene
if scene.animtoolbox.isolate_pose_mode:
if isolate_pose_mode in bpy.app.handlers.depsgraph_update_pre:
clear_isolate_pose_mode(scene)
bpy.app.handlers.depsgraph_update_pre.remove(isolate_pose_mode)
scene.animtoolbox.isolate_pose_mode = False
return {'FINISHED'}
scene.animtoolbox.isolate_pose_mode = True
isolate_pose_mode(scene)
if isolate_pose_mode not in bpy.app.handlers.depsgraph_update_pre:
bpy.app.handlers.depsgraph_update_pre.append(isolate_pose_mode)
return {'FINISHED'}
class SwitchBoneCollectionsVisibility(bpy.types.Operator):
"""Turn all bone collections visible and then press again to switch back"""
bl_idname = "anim.switch_collections_visibility"
bl_label = "Bone Collections Visibility"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return bpy.app.version >= (4, 0, 0)
def execute(self, context):
obj = context.object
if obj.type != 'ARMATURE':
return {'CANCELLED'}
if not obj.animation_data:
self.report({'INFO'}, 'No animation is available')
return {'CANCELLED'}
if not obj.animation_data.action:
self.report({'INFO'}, 'No animation is available')
return {'CANCELLED'}
collections = obj.data.collections
if not len(collections):
self.report({'INFO'}, 'No collections are available')
return {'CANCELLED'}
#check if there are collections that are marked with
tagged_col = ['atb' in col.keys() for col in collections]
atb_ui = context.scene.animtoolbox
if any(tagged_col) and atb_ui.col_vis:
#collections are already marked so return to previous collection visibilty
for col in collections:
if 'atb' in col.keys():
col.is_visible = col['atb']
del col['atb']
atb_ui.col_vis = False
else:
#Mark visible collections and turn collections with animated bones on
animated_bones = set()
start = 'pose.bones["'
end = '"]'
#get all the animated bones from the fcurves
for fcu in obj.animation_data.action.fcurves:
start_index = fcu.data_path.find(start)
end_index = fcu.data_path.find(end)
#if it's not a posebone fcurve then skip
if start_index == -1 or end_index == -1:
continue
animated_bones.add(fcu.data_path[start_index + len(start):end_index])
#check if the collecetion that is turned off has animated bones
find_anim = []
for col in collections:
for bone in col.bones:
if bone.name in animated_bones and not col.is_visible:
# print(bone.name, 'in ', col.name)
find_anim.append(col)
break
if not find_anim:
self.report({'INFO'}, 'No collections with animated bones and no visibility are found')
return {'CANCELLED'}
#Turn on collections without visiblity
for col in collections:
if col in find_anim:
#tag visibility
col['atb'] = col.is_visible
col.is_visible = True
atb_ui.col_vis = True
return {'FINISHED'}
classes = (GizmoSizeUp, GizmoSizeDown, IsolatePoseMode, SwitchBoneCollectionsVisibility)
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
def unregister():
from bpy.utils import unregister_class
for cls in classes:
unregister_class(cls)
@@ -1,549 +0,0 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
import bpy
from mathutils import Matrix, Vector
from math import radians
import numpy
def draw_wgt(boneLength, bone):
suffix = bone.id_data.name + '_' + bone.name
if 'WGTB_object' + suffix in bpy.data.objects:
obj = bpy.data.objects['WGTB_object'] + suffix
if 'WGTB_shape' + suffix in obj.data.name:
return obj
mesh = bpy.data.meshes.new('WGTB_shape_' + suffix)
obj = bpy.data.objects.new('WGTB_object_' + suffix, mesh)
#coordinates of the sphere widget shape
sphere = {"edges": [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 9], [9, 10], [10, 11], [11, 12], [12, 13], [13, 14], [14, 15], [15, 16], [16, 17], [17, 18], [18, 19], [19, 20], [20, 21], [21, 22], [22, 23], [0, 23], [24, 25], [25, 26], [26, 27], [27, 28], [28, 29], [29, 30], [30, 31], [31, 32], [32, 33], [33, 34], [34, 35], [35, 36], [36, 37], [37, 38], [38, 39], [39, 40], [40, 41], [41, 42], [42, 43], [43, 44], [44, 45], [45, 46], [46, 47], [24, 47], [48, 49], [49, 50], [50, 51], [51, 52], [52, 53], [53, 54], [54, 55], [55, 56], [56, 57], [57, 58], [58, 59], [59, 60], [60, 61], [61, 62], [62, 63], [63, 64], [64, 65], [65, 66], [66, 67], [67, 68], [68, 69], [69, 70], [70, 71], [48, 71]],
"vertices": [[0.0, 0.10000002384185791, 0.0], [-0.025881901383399963, 0.09659260511398315, 0.0], [-0.050000011920928955, 0.08660250902175903, 0.0], [-0.07071065902709961, 0.07071065902709961, 0.0], [-0.08660256862640381, 0.04999998211860657, 0.0], [-0.09659260511398315, 0.025881901383399963, 0.0], [-0.10000002384185791, 7.549793679118011e-09, 0.0], [-0.09659260511398315, -0.02588188648223877, 0.0], [-0.08660256862640381, -0.04999998211860657, 0.0], [-0.07071071863174438, -0.07071065902709961, 0.0], [-0.050000011920928955, -0.08660250902175903, 0.0], [-0.02588193118572235, -0.09659260511398315, 0.0], [-3.894143674187944e-08, -0.10000002384185791, 0.0], [0.025881856679916382, -0.09659260511398315, 0.0], [0.04999995231628418, -0.08660256862640381, 0.0], [0.07071065902709961, -0.07071071863174438, 0.0], [0.08660250902175903, -0.05000004172325134, 0.0], [0.09659254550933838, -0.025881946086883545, 0.0], [0.10000002384185791, -4.649123752642481e-08, 0.0], [0.09659260511398315, 0.025881856679916382, 0.0], [0.08660256862640381, 0.04999995231628418, 0.0], [0.07071071863174438, 0.07071065902709961, 0.0], [0.05000007152557373, 0.08660250902175903, 0.0], [0.025881975889205933, 0.09659254550933838, 0.0], [0.0, 7.450580596923828e-09, 0.10000002384185791], [-0.025881901383399963, 7.450580596923828e-09, 0.09659260511398315], [-0.050000011920928955, 7.450580596923828e-09, 0.08660250902175903], [-0.07071065902709961, 7.450580596923828e-09, 0.07071065902709961], [-0.08660256862640381, 3.725290298461914e-09, 0.04999998211860657], [-0.09659260511398315, 1.862645149230957e-09, 0.025881901383399963], [-0.10000002384185791, 8.881784197001252e-16, 7.549793679118011e-09], [-0.09659260511398315, -1.862645149230957e-09, -0.02588188648223877], [-0.08660256862640381, -3.725290298461914e-09, -0.04999998211860657], [-0.07071071863174438, -7.450580596923828e-09, -0.07071065902709961], [-0.050000011920928955, -7.450580596923828e-09, -0.08660250902175903], [-0.02588193118572235, -7.450580596923828e-09, -0.09659260511398315], [-3.894143674187944e-08, -7.450580596923828e-09, -0.10000002384185791], [0.025881856679916382, -7.450580596923828e-09, -0.09659260511398315], [0.04999995231628418, -7.450580596923828e-09, -0.08660256862640381], [0.07071065902709961, -7.450580596923828e-09, -0.07071071863174438], [0.08660250902175903, -3.725290298461914e-09, -0.05000004172325134], [0.09659254550933838, -1.862645149230957e-09, -0.025881946086883545], [0.10000002384185791, -3.552713678800501e-15, -4.649123752642481e-08], [0.09659260511398315, 1.862645149230957e-09, 0.025881856679916382], [0.08660256862640381, 3.725290298461914e-09, 0.04999995231628418], [0.07071071863174438, 7.450580596923828e-09, 0.07071065902709961], [0.05000007152557373, 7.450580596923828e-09, 0.08660250902175903], [0.025881975889205933, 7.450580596923828e-09, 0.09659254550933838], [-7.450580596923828e-09, 4.440892098500626e-16, 0.10000002384185791], [-9.313225746154785e-09, -0.025881901383399963, 0.09659260511398315], [-1.1175870895385742e-08, -0.050000011920928955, 0.08660250902175903], [-1.4901161193847656e-08, -0.07071065902709961, 0.07071065902709961], [-7.450580596923828e-09, -0.08660256862640381, 0.04999998211860657], [-7.450580596923828e-09, -0.09659260511398315, 0.025881901383399963], [-7.450580596923828e-09, -0.10000002384185791, 7.549793679118011e-09], [-7.450580596923828e-09, -0.09659260511398315, -0.02588188648223877], [0.0, -0.08660256862640381, -0.04999998211860657], [0.0, -0.07071071863174438, -0.07071065902709961], [3.725290298461914e-09, -0.050000011920928955, -0.08660250902175903], [5.587935447692871e-09, -0.02588193118572235, -0.09659260511398315], [7.450577044210149e-09, -3.894143674187944e-08, -0.10000002384185791], [9.313225746154785e-09, 0.025881856679916382, -0.09659260511398315], [1.1175870895385742e-08, 0.04999995231628418, -0.08660256862640381], [1.4901161193847656e-08, 0.07071065902709961, -0.07071071863174438], [7.450580596923828e-09, 0.08660250902175903, -0.05000004172325134], [7.450580596923828e-09, 0.09659254550933838, -0.025881946086883545], [7.450580596923828e-09, 0.10000002384185791, -4.649123752642481e-08], [7.450580596923828e-09, 0.09659260511398315, 0.025881856679916382], [0.0, 0.08660256862640381, 0.04999995231628418], [0.0, 0.07071071863174438, 0.07071065902709961], [-3.725290298461914e-09, 0.05000007152557373, 0.08660250902175903], [-5.587935447692871e-09, 0.025881975889205933, 0.09659254550933838]], "faces": []}
mesh.from_pydata(numpy.array(sphere['vertices'])*[boneLength, boneLength, boneLength] , sphere['edges'], sphere['faces'])
return obj
def add_driver(obj, posebone, control, target, path, multiply = ''):
if isinstance(target, tuple):
attr = posebone.driver_add(target[0], target[1])
else:
attr = posebone.driver_add(target)
var = attr.driver.variables.new()
var.targets[0].id = obj
var.targets[0].data_path = 'pose.bones["' + control +'"].'+ path
attr.driver.expression = var.name + multiply
def dup_values(source, target):
if hasattr(source, 'parent'):
target.parent = source.parent
for prop in dir(source):
if not hasattr(target, prop):
continue
value = getattr(source, prop)
if type(value) not in {int, float, bool, str, Vector, Matrix, bpy.types.Object}:
continue
if '__' in prop[:2] and '__' in prop[-2:]:
continue
if target.is_property_readonly(prop):
continue
setattr(target, prop, value)
return target
def dup_constraints(source, target):
if not source.constraints.items():
return
for source_con in source.constraints:
target_con = target.constraints.new(source_con.type)
dup_values(source_con, target_con)
def add_vis_bone_con(obj, bone_vis_name, bone_wgt_name):
bone_vis = obj.pose.bones[bone_vis_name]
con = bone_vis.constraints.new('STRETCH_TO')
con.target = obj
con.subtarget = bone_wgt_name
return bone_vis
class target:
def __init__(self, bone):
self.name = bone.name
self.point = tuple(bone.tail)
self.ctrl = 'TRGT_' + bone.name
if bone.parent:
self.parent = bone.parent.name
#print('assign parent to target ', self.name, self.ctrl, self.parent)
def __lt__(self, other):
return self.point < other.point
def __hash__(self):
return hash(self.point)
def __eq__(self, other):
#if not isinstance(other, type(self)):
# return NotImplemented
return self.point == other.point
class parent:
def __init__(self, bone):
self.name = bone.name
self.point = tuple(bone.head)
self.ctrl = 'CTRL_' + bone.name
if bone.parent:
self.parent = bone.parent.name
def __lt__(self, other):
return self.point < other.point
def __hash__(self):
return hash(self.point)
def __eq__(self, other):
#if not isinstance(other, type(self)):
# return NotImplemented
return self.point == other.point
class constraint_dup:
def __init__(self, bone, con):
self.name = con.name
self.target = con.target
self.subtarget = con.subtarget
self.bone = bone.name
def __hash__(self):
return hash(self.bone)
def __eq__(self, other):
return self.bone == other.bone
def bone_orientation(source, target, value):
source.align_orientation(target)
x, y, z = source.matrix.to_3x3().col
R = (Matrix.Translation(source.head) @ Matrix.Rotation(radians(value), 4, x) @ Matrix.Translation(-source.head))
source.transform(R, roll = False)
source.align_roll(target.vector)
def find_ctrl(bone, controls):
i = list(controls).index(bone)
bone.ctrl = list(controls)[i].ctrl
return bone.ctrl
def add_controlers(self, obj, parents, targets):
#controls = set(parents).union(targets)
controls = set(parents + targets)
#create hierarchy
for bone in controls:
editbone = obj.data.edit_bones[bone.name]
if editbone.parent is None:
continue
parentnames = [bone.name for bone in parents]
#if a target and its parent are part of the hierarchy then linked to its own bone parent
if bone in targets and bone not in parents and editbone.parent.name in parentnames:
parentbone = parent(editbone)
else:
parentbone = parent(editbone.parent)
if parentbone in controls and parentbone != bone:
bone.parent = find_ctrl(parentbone, controls)
else:
bone.parent = editbone.parent.name
edit_bones = obj.data.edit_bones
for bone in controls:
editbone = edit_bones[bone.name]
ctrl = obj.data.edit_bones.new(bone.ctrl)
ctrl.head = bone.point
ctrl.tail = bone.point
ctrl.tail[2] = bone.point[2] + (editbone.length / 3)
ctrl.bbone_x = editbone.bbone_x
ctrl.bbone_z = editbone.bbone_z
ctrl.use_deform = False
if self.bone_align:
angle = 90 if self.align_90 else 0
bone_orientation(ctrl, editbone, angle)
if angle == 90:
ctrl.align_roll(editbone.vector)
else:
ctrl.roll = editbone.roll
#apply hierarchy
for bone in parents:
editbone = edit_bones[bone.name]
ctrl_name = find_ctrl(bone, controls)
ctrl = edit_bones[ctrl_name]
editbone.parent = ctrl
for bone in controls:
ctrl = edit_bones[bone.ctrl]
if hasattr(bone, 'parent'):
ctrl.parent = edit_bones[bone.parent]
return controls
def pose_bbone_setup(bone, posebone, bbone_group = None):
#add the custom shape to the widget bones
custom_shape = draw_wgt(bone['length'], posebone)
posebone.custom_shape = custom_shape
posebone.use_custom_shape_bone_size = False
if bbone_group:
posebone.bone_group = bbone_group
posebone.rotation_mode = 'XZY'
posebone.lock_rotation[0] = True
posebone.lock_rotation[2] = True
#####MAIN####
class BboneWidgets(bpy.types.Operator):
"""Add Bbone widget controls to the selected bones"""
bl_idname = "armature.add_bbone_widgets"
bl_label = "Add_Bbone_widgets"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return bpy.context.object.type == 'ARMATURE'
def execute(self, context):
obj = context.object
obj.data.display_type = 'BBONE'
bones = []
parentlayers = [False if i != 24 else True for i in range(32)]
wgtlayers = [True if i == 0 else False for i in range(32)]
bpy.ops.object.mode_set(mode = 'EDIT')
obj.data.use_mirror_x = False
for bone in obj.data.edit_bones:
if not bone.select:
continue
if bone.bbone_segments == 1:
bone.bbone_segments = 10
bone.bbone_handle_type_start = 'TANGENT'
bone.bbone_handle_type_end = 'TANGENT'
bone_name = bone.name
#add parent bone to the Bbone widgets
parent = obj.data.edit_bones.new('WGTB_parent_'+ bone_name)
parent_name = parent.name
dup_values(bone, parent)
parent.name = parent_name
parent.select = False
parent.select_head = False
parent.select_tail = False
#change layer of the parent bone
if bpy.app.version < (4, 0, 0):
parent.layers = parentlayers
#add bbone widget bones
head_widget = obj.data.edit_bones.new('Bhead_'+ bone.name)
head_widget.parent = parent
#head_widget.head = bone.head
head_widget.head = bone.head + (bone.tail - bone.head) * 0.25
#head_widget.tail = (bone.tail - bone.head)/10
head_widget.length = bone.length * 0.1
head_widget.bbone_x = bone.bbone_x
head_widget.bbone_z = bone.bbone_z
head_widget.align_orientation(bone)
head_widget.inherit_scale = 'NONE'
head_name = head_widget.name
tail_widget = obj.data.edit_bones.new('Btail_'+ bone.name)
tail_widget.parent = parent
#tail_widget.head = bone.tail
tail_widget.head = bone.head + (bone.tail - bone.head) * 0.75
#tail_widget.tail = bone.tail - (bone.tail - bone.head)/10
tail_widget.length = bone.length * 0.1
tail_widget.bbone_x = bone.bbone_x
tail_widget.bbone_z = bone.bbone_z
tail_widget.align_orientation(bone)
tail_widget.inherit_scale = 'NONE'
tail_name = tail_widget.name
#add vis bones
head_vis = obj.data.edit_bones.new('Bhead_vis_'+ bone.name)
head_vis.parent = parent
head_vis.head = bone.head
head_vis.tail = head_widget.head
head_vis.bbone_x = bone.bbone_x*0.1
head_vis.bbone_z = bone.bbone_z*0.1
#head_vis_name = head_vis.name
head_vis.hide_select = True
head_vis.use_deform = False
tail_vis = obj.data.edit_bones.new('Btail_vis_'+ bone.name)
tail_vis.parent = parent
tail_vis.head = bone.tail
tail_vis.tail = tail_widget.head
tail_vis.bbone_x = bone.bbone_x*0.1
tail_vis.bbone_z = bone.bbone_z*0.1
#tail_vis_name = tail_vis.name
tail_vis.hide_select = True
tail_vis.use_deform = False
if bpy.app.version < (4, 0, 0):
tail_widget.layers = wgtlayers
head_widget.layers = wgtlayers
head_vis.layers = wgtlayers
tail_vis.layers = wgtlayers
bones.append({'name': bone_name, 'parent': parent_name, 'head': head_name, 'tail': tail_name, 'head_vis': head_vis.name, 'tail_vis': tail_vis.name, 'length': bone.length})
#####POSE MODE#######
bpy.ops.object.mode_set(mode = 'POSE')
if bpy.app.version < (4, 0, 0):
bone_groups = obj.pose.bone_groups
if 'BBone Widgets' not in bone_groups:
bbone_group = bone_groups.new(name = 'BBone Widgets')
bbone_group.color_set = 'THEME09'
else:
bbone_group = bone_groups['BBone Widgets']
else:
bbone_group = None
for bone in bones:
posebone = obj.pose.bones[bone['name']]
# Prepare parent bone in pose mode
poseparent = obj.pose.bones[bone['parent']]
#disable use deform
obj.data.bones[bone['parent']].use_deform = False
obj.data.bones[bone['head']].use_deform = False
obj.data.bones[bone['tail']].use_deform = False
pose_bbone_setup(bone, obj.pose.bones[bone['head']], bbone_group)
pose_bbone_setup(bone, obj.pose.bones[bone['tail']], bbone_group)
dup_constraints(posebone, poseparent)
#add all the drivers
add_driver(obj, posebone, bone['head'], 'bbone_curveinx', 'location.x')
add_driver(obj, posebone, bone['head'], 'bbone_curveinz', 'location.z')
add_driver(obj, posebone, bone['head'], 'bbone_easein', 'location.y', '*5/'+ str(bone['length']))
add_driver(obj, posebone, bone['head'], 'bbone_rollin', 'rotation_euler.y')
add_driver(obj, posebone, bone['head'], ('bbone_scalein', 0), 'scale.x')
add_driver(obj, posebone, bone['head'], ('bbone_scalein', 1), 'scale.y')
add_driver(obj, posebone, bone['head'], ('bbone_scalein', 2), 'scale.z')
add_driver(obj, posebone, bone['tail'], 'bbone_curveoutx', 'location.x')
add_driver(obj, posebone, bone['tail'], 'bbone_curveoutz', 'location.z')
add_driver(obj, posebone, bone['tail'], 'bbone_easeout', 'location.y', '*-5/'+ str(bone['length']))
add_driver(obj, posebone, bone['tail'], 'bbone_rollout', 'rotation_euler.y')
add_driver(obj, posebone, bone['tail'], ('bbone_scaleout', 0), 'scale.x')
add_driver(obj, posebone, bone['tail'], ('bbone_scaleout', 1), 'scale.y')
add_driver(obj, posebone, bone['tail'], ('bbone_scaleout', 2), 'scale.z')
#add constraints to visual bones
head_vis = add_vis_bone_con(obj, bone['head_vis'], bone['head'])
tail_vis = add_vis_bone_con(obj, bone['tail_vis'], bone['tail'])
if bpy.app.version < (4, 0, 0):
head_vis.bone_group = bbone_group
tail_vis.bone_group = bbone_group
return {"FINISHED"}
class ChainControls(bpy.types.Operator):
"""Add parent and target controls to the selected bones to create a chain control"""
bl_idname = "armature.add_chain_ctrls"
bl_label = "Add_Chain_Controls"
bl_options = {'REGISTER', 'UNDO'}
parents: bpy.props.BoolProperty(name = 'Add Parents', description = "Align the controls 90 degrees to the original bones", default = True)
targets: bpy.props.BoolProperty(name = 'Add Targets', description = "Align the controls 90 degrees to the original bones", default = True)
keep_hierarchy: bpy.props.BoolProperty(name = 'Keep Hierarchy', description = "Keep the controls in the hierarchy of the original bones", default = True)
bone_align: bpy.props.BoolProperty(name = 'Align to Bones', description = "Align the controls to the original bones", default = True)
align_90: bpy.props.BoolProperty(name = '+90°', description = "Align the controls 90 degrees to the original bones", default = True)
@classmethod
def poll(cls, context):
return bpy.context.object.type == 'ARMATURE'
def invoke(self, context, event):
#obj = context.object
wm = context.window_manager
return wm.invoke_props_dialog(self, width = 200)
def draw(self, context):
layout = self.layout
row = layout.row()
row.label(text = 'Add Control Bones')
row = layout.row()
row.prop(self, 'parents') #text = 'Size'
row.prop(self, 'targets')
layout.separator()
col = layout.column()
col.prop(self, 'keep_hierarchy')
row = layout.row()
row.prop(self, 'bone_align')
if self.bone_align:
row.prop(self, 'align_90', toggle=True)
def execute(self, context):
obj = context.object
targets = []
parents = []
bpy.ops.object.mode_set(mode = 'EDIT')
edit_bones = bpy.context.selected_editable_bones
#create list of parent and target objects
for bone in edit_bones:
bone.use_connect = False
if self.targets:
targets.append(target(bone))
if self.parents:
parents.append(parent(bone))
controls = add_controlers(self, obj, parents, targets)
bpy.ops.object.mode_set(mode = 'POSE')
#Add the bone group for the ctrls if doesn't exist
if bpy.app.version < (4, 0, 0):
bone_groups = obj.pose.bone_groups
if 'Ctrl Bones' not in bone_groups:
ctrl_group = bone_groups.new(name = 'Ctrl Bones')
ctrl_group.color_set = 'THEME01'
else:
ctrl_group = bone_groups['Ctrl Bones']
for bone in controls:
posebone = obj.pose.bones[bone.ctrl]
if bpy.app.version < (4, 0, 0):
posebone.bone_group = ctrl_group
else:
posebone.color.palette = 'THEME01'
if self.targets:
for bone in targets:
#update from the controls set
ctrl = find_ctrl(bone, controls)
posebone = obj.pose.bones[bone.name]
con = posebone.constraints.new('STRETCH_TO')
con.target = obj
con.subtarget = ctrl
return {"FINISHED"}
class MergeRigs(bpy.types.Operator):
"""Merge selected rigs to active and keep hierarchy and constraints for shared bones"""
bl_idname = "armature.merge"
bl_label = "Merge_Rigs"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return bpy.context.object.type == 'ARMATURE'
def execute(self, context):
target_obj = context.object
if target_obj.type != 'ARMATURE':
return {"CANCELLED"}
target_bones = set([bone.name for bone in target_obj.data.bones])
constraints = []
childrens = {}
#Store children and constraints
bpy.ops.object.mode_set(mode = 'POSE')
for obj in bpy.context.selected_objects:
if obj.type != 'ARMATURE':
continue
if obj == target_obj:
continue
#create a set of all the similiar bones in all the rigs
obj_bones = set([bone.name for bone in obj.data.bones])
shared_bones = target_bones.intersection(obj_bones)
#find all the constraints and children
for bone in obj.pose.bones:
#store all the constraints
for con in bone.constraints:
if not hasattr(con, 'subtarget'):
continue
if con.target == obj and con.subtarget in shared_bones:
constraints.append(constraint_dup(bone, con))
if bone.name in shared_bones:
for child in bone.children:
if child.name in childrens:
continue
childrens.update({child.name : bone.name})
#remove shared bones
bpy.ops.object.mode_set(mode = 'EDIT')
for obj in bpy.context.selected_objects:
if obj.type != 'ARMATURE':
continue
if obj == target_obj:
continue
for bone in shared_bones:
if bone not in obj.data.edit_bones:
continue
obj.data.edit_bones.remove(obj.data.edit_bones[bone])
bpy.ops.object.mode_set(mode = 'POSE')
bpy.ops.object.join()
#restore constraints
for con_dup in constraints:
if con_dup.bone in target_bones:
continue
if con_dup.bone not in target_obj.pose.bones:
continue
#print('constraint on ',con_dup.bone, con_dup.name)
posebone = target_obj.pose.bones[con_dup.bone]
if con_dup.name not in posebone.constraints:
continue
con = posebone.constraints[con_dup.name]
con.target = target_obj
con.subtarget = con_dup.subtarget
#reparent all child bones
bpy.ops.object.mode_set(mode = 'EDIT')
for child, parent in childrens.items():
target_obj.data.edit_bones[child].parent = target_obj.data.edit_bones[parent]
return {"FINISHED"}
classes = (MergeRigs,BboneWidgets, ChainControls)
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
# bpy.utils.register_class(BboneWidgets)
# bpy.utils.register_class(ChainControls)
# bpy.utils.register_class(RiggerToolBox_PT_Panel)
def unregister():
from bpy.utils import unregister_class
for cls in classes:
unregister_class(cls)
# bpy.utils.unregister_class(BboneWidgets)
# bpy.utils.unregister_class(ChainControls)
# bpy.utils.unregister_class(RiggerToolBox_PT_Panel)
if __name__ == "__main__":
register()
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,534 +0,0 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
bl_info = {
"name": "AnimToolBox",
"author": "Tal Hershkovich",
"version" : (0, 0, 8),
"blender" : (3, 2, 0),
"location": "View3D - Properties - Animation Panel",
"description": "A set of animation tools",
"wiki_url": "",
"category": "Animation"}
if "bpy" in locals():
import importlib
if "Rigger_Toolbox" in locals():
importlib.reload(Rigger_Toolbox)
if "TempCtrls" in locals():
importlib.reload(TempCtrls)
if "Tools" in locals():
importlib.reload(Tools)
if "Display" in locals():
importlib.reload(Display)
if "emp" in locals():
importlib.reload(emp)
if "multikey" in locals():
importlib.reload(multikey)
if "Rigger_Toolbox" in locals():
importlib.reload(Rigger_Toolbox)
if "ui" in locals():
importlib.reload(ui)
if "addon_updater_ops" in locals():
importlib.reload(addon_updater_ops)
import bpy
from . import addon_updater_ops
from . import TempCtrls
from . import Rigger_Toolbox
from . import Tools
from . import Display
from . import emp
from . import ui
from . import multikey
from . import Rigger_Toolbox
from pathlib import Path
from bpy.utils import register_class
from bpy.utils import unregister_class
from bpy.app.handlers import persistent
import os
class TempCtrlsItems(bpy.types.PropertyGroup):
#located at context.scene.btc.ctrl_items
controlled: bpy.props.PointerProperty(name = "controlled object", description = "rigs and objects that are being controlled", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
controller: bpy.props.PointerProperty(name = "controller object", description = "rigs and objects that are controling", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
class TempCtrlsSceneSettings(bpy.types.PropertyGroup):
#located at context.scene.btc
root: bpy.props.BoolProperty(name = "Root Empty", description = "Add a root to the empties ", default = False, override = {'LIBRARY_OVERRIDABLE'}, update = TempCtrls.root_prop)
root_bone: bpy.props.StringProperty(name = "Root bone", description = "Root empty as a root bone ", override = {'LIBRARY_OVERRIDABLE'}, update = TempCtrls.root_update)
root_object: bpy.props.PointerProperty(name = "Root object", description = "Root empty as a root object ", update = TempCtrls.root_update, type = bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
ctrl_type: bpy.props.EnumProperty(name = 'Controllers', description="Select empties or a bone with a new rig to bake to", items = [('BONE', 'Bone','Bake to bones','BONE_DATA', 0), ('EMPTY', 'Empty', 'Bake to empties', 'EMPTY_ARROWS', 1)])
ctrl_items: bpy.props.CollectionProperty(type = TempCtrlsItems, override = {'LIBRARY_OVERRIDABLE', 'USE_INSERTION'})
bake_range_type: bpy.props.EnumProperty(name = 'Bake Range', description="Use either scene, actions length or custom frame range", default = 'KEYFRAMES', update= TempCtrls.update_range_type,
items = [('SCENE', 'Scene Range', 'Bake to the scene range'), ('KEYFRAMES', 'Keyframes Range', 'Bake all the keyframes in the layers'), ('CUSTOM', 'Custom', 'Enter a custom frame range')], override = {'LIBRARY_OVERRIDABLE'})
bake_range: bpy.props.IntVectorProperty(name='Frame Range', description='Bake to a custom frame range', size = 2, update= TempCtrls.update_bake_range)
bake_layers: bpy.props.BoolProperty(name = "Bake Layers", description = "Use keyframes from all the layers to include in the bake", default = False)
target: bpy.props.EnumProperty(name = 'Affect', description="Cleanup created constraints and empties", default = 1,
items = [('ALL', 'All Ctrl Rigs','Bake to all Ctrl Rigs', 0),
('SELECTED', 'Selected Chains','Bake to only selected chain controlls', 1),
('RELATIVE', 'Relative Ctrls Rig','Bake to the Relative Control rigs', 2)])
# ('CONSTRAINTS', 'Constraints', 'Clean all the bone constraints', 3),
# ('CONTROLLERS', 'Controllers', 'Remove all the baked empties', 4)])
selection: bpy.props.EnumProperty(name = 'Select', description="Select all controls, original bones or their relative", default = 'CONTROLLERS',
items = [('RELATIVE_CTRLS', 'Relative Ctrls','Select the Relative controller to your current selection', 0),
('RELATIVE_CONSTRAINED', 'Relative Constrained','Select the Relative original constrained bone to your current selection', 1),
('CONTROLLERS', 'All Ctrls', 'Select all the controller bones or empties', 2), ('CONSTRAINED', 'All Constrained', 'Select all the original constrained bones', 3)])
#smartbake setting
linksettings: bpy.props.BoolProperty(name = "Link Settings", description = "Link Settings", default = False, override = {'LIBRARY_OVERRIDABLE'})
bakesettings: bpy.props.BoolProperty(name = "bake settings", description = "bake settings", default = False, override = {'LIBRARY_OVERRIDABLE'})
cleansettings: bpy.props.BoolProperty(name = "clean settings", description = "clean settings", default = False, override = {'LIBRARY_OVERRIDABLE'})
smartbake: bpy.props.BoolProperty(name = "Smart Bake", description = "Keep Original Frame count", default = True, override = {'LIBRARY_OVERRIDABLE'})
inbetween_keyframes: bpy.props.IntProperty(name = "Inbetween Keyframes", description = "Add inbetween keyframes", default = 0, override = {'LIBRARY_OVERRIDABLE'})
from_origin: bpy.props.BoolProperty(name = "From Origin", description = "Use Keyframes from Original Bone", default = True, override = {'LIBRARY_OVERRIDABLE'})
from_ctrl: bpy.props.BoolProperty(name = "From Controller", description = "Use Keyframes from Controller Bone", default = False, override = {'LIBRARY_OVERRIDABLE'})
clean_ctrls: bpy.props.BoolProperty(name = "Remove Ctrls", description = "Remove Controls", default = True, override = {'LIBRARY_OVERRIDABLE'})
clean_constraints: bpy.props.BoolProperty(name = "Remove Constraints", description = "Remove Constraints", default = True, override = {'LIBRARY_OVERRIDABLE'})
rebake_to_org: bpy.props.BoolProperty(name = "ReBake connections to original bones", description = "ReBake ctrls from connected chains current anim to the original bones", default = False, override = {'LIBRARY_OVERRIDABLE'})
link_to: bpy.props.EnumProperty(name = 'Link to Chain', description="Link to begining of an active chain or the tip of the chain", default = 1,
items = [('BASE', 'Base','Link to the base of the active chain', 0), ('TIP', 'Tip', 'Link to the tip of the chain', 1)])
# link_from: bpy.props.EnumProperty(name = 'Link to Chain', description="Link to begining of an active chain or the tip of the chain", default = 0,
# items = [('BASE', 'Base','Link to the base of the active chain', 0), ('TIP', 'Tip', 'Link to the tip of the chain', 1)])
shape_size: bpy.props.FloatProperty(name='Size', description="Multiple factor for the shape size of the temp controls", update = TempCtrls.tempctrl_shapesize, min = 0.001, default = 1.5, override = {'LIBRARY_OVERRIDABLE'})#
shape_type: bpy.props.EnumProperty(name = 'Shape Type', description="Display type for the controls", items = TempCtrls.ctrl_shape_items, update = TempCtrls.tempctrl_shape_type)
color_set: bpy.props.EnumProperty(name="Bone Color Set", description="Choose a bone color set", items = TempCtrls.get_bone_color_sets, update = TempCtrls.update_bone_color, default = 9)
add_ik_ctrl: bpy.props.BoolProperty(name = 'Add an Extra IK Ctrl Bone', description = "Adds an extra bone ctrl as the ik ctrl", default = False, update = TempCtrls.add_ik_prop)
pole_target: bpy.props.BoolProperty(name = 'Add Pole Target', description = "Adding Pole Target to the IK Chain", default = True, update = TempCtrls.pole_prop)
pole_offset: bpy.props.FloatProperty(name="Offset", description="Offset the bone in the axis direction", default=1.0, update = TempCtrls.pole_offset)
child: bpy.props.BoolProperty(name = 'Add extra child Ctrls', description = "Add an child control for an overlay control", default = False, update = TempCtrls.child_prop)
orientation: bpy.props.BoolProperty(name = 'Use World Space Orientation', description = "Orient the bones to world space instead of to the original bones", default = True)
# enabled: bpy.props.BoolProperty(name = 'Switch On / Off', description = "Enabling and Disabling Temp Ctrls influence", default = True)
class TempCtrlsBoneSettings(bpy.types.PropertyGroup):
#located at obj.pose.bones[##].btc
root: bpy.props.BoolProperty(name = "Root Bone", description = "Bone is marked as the root bone", default = False, override = {'LIBRARY_OVERRIDABLE'})
child: bpy.props.BoolProperty(name = "Child Bone", description = "Bone is marked as a child bone inside the setup", default = False, override = {'LIBRARY_OVERRIDABLE'})
org_id: bpy.props.IntProperty(name = "Originate ID", description = "ID number of the bone the ctrl originates from", override = {'LIBRARY_OVERRIDABLE'})
setup_id: bpy.props.IntProperty(name = "Setup ID", description = "ID number of the current chain setup", override = {'LIBRARY_OVERRIDABLE'})
setup: bpy.props.EnumProperty(name = 'Setup Type', description="Describes what kind of setup the bone is part of", override = {'LIBRARY_OVERRIDABLE'},
items = [('NONE', 'No Setup','No Setup Applied', 0),
('WORLDSPACE', 'World Space Ctrl','World Space Ctrl setup', 1),
('WORLDSPACE_CURSOR', 'World Space Cursor Ctrl','World Space Cursor pivot', 2),
('TEMPFK', 'Temporary FK setup','Temporary FK chain setup', 3),
('TEMPFK_FLIP', 'Temporary flipped FK setup','Temporary flipped FK chain setup', 4),
('TEMPIK', 'Temporary IK setup','Temporary IK setup', 5),
('POLE', 'Temporary IK Pole setup','Temporary IK Pole', 6),
('PARENTCTRL', 'Parent Ctrl from cursor setup','Parent Ctrl from cursor setup', 7),
('ROOT', 'Root', 'Root Ctrl for all the setups', 8),
('EMPTY', 'Root', 'Root Ctrl for all the setups', 9),
('TRACK_TO', 'Track To','World Space Track to Ctrl setup', 10),
('TRACK_TO_EMPTY', 'Track To Empty','World Space Track to Empty Ctrl setup', 11)])
#using org mostly to decide if it needs a custom shape
org: bpy.props.EnumProperty(name = 'Org Type', description="Describes what if the function of the bone", override = {'LIBRARY_OVERRIDABLE'},
items = [('CTRL', 'Controller Bone','Controller Bone', 0),
('ORG', 'Original Bone','Original Bone', 1),
('MCH', 'Mechanical Bone','Mechanical Bone', 2),
('NONE', 'Nothing applied','Nothing applied', 3)])
shape: bpy.props.BoolProperty(name = "Apply shape", description = "Mark if the bone needs a shape", default = False, override = {'LIBRARY_OVERRIDABLE'})
class TempCtrlsOrgIds(bpy.types.PropertyGroup):
# A collection of the org ids used in each setup. Org ID is the direct connection
# between original bones and ctrls
pass
class TempCtrlsObjectSetups(bpy.types.PropertyGroup):
#located at obj.animtoolbox.ctrl_setups
#name: using string of the id
setup: bpy.props.EnumProperty(name = 'Setup Type', description="Describes what kind of setup the bone is part of",
items = [('NONE', 'No Setup','No Setup Applied', 0),
('WORLDSPACE', 'World Space Ctrl','World Space Ctrl setup', 1),
('WORLDSPACE_CURSOR', 'World Space Cursor Ctrl','World Space Cursor pivot', 2),
('TEMPFK', 'Temporary FK setup','Temporary FK chain setup', 3),
('TEMPFK_FLIP', 'Temporary flipped FK setup','Temporary flipped FK chain setup', 4),
('TEMPIK', 'Temporary IK setup','Temporary IK setup', 5),
('POLE', 'Temporary IK Pole setup','Temporary IK Pole', 6),
('PARENTCTRL', 'Parent Ctrl from cursor setup','Parent Ctrl from cursor setup', 7),
('ROOT', 'Root', 'Root Ctrl for all the setups', 8),
('EMPTY', 'Root', 'Root Ctrl for all the setups', 9),
('TRACK_TO', 'Track To','World Space Track to Ctrl setup', 10),
('TRACK_TO_EMPTY', 'Track To Empty','World Space Track to Empty Ctrl setup', 11)])
org_ids: bpy.props.CollectionProperty(type = TempCtrlsOrgIds)
class MultikeyProperties(bpy.types.PropertyGroup):
selectedbones: bpy.props.BoolProperty(name="Selected Bones", description="Affect only selected bones", default=True, options={'HIDDEN'})
handletype: bpy.props.BoolProperty(name="Keep handle types", description="Keep handle types", default=False, options={'HIDDEN'})
scale: bpy.props.FloatProperty(name="Scale Factor", description="Scale percentage of the average value", default=1.0, update = multikey.scale_value)
randomness: bpy.props.FloatProperty(name="Randomness", description="Random Threshold of keyframes", default=0.1, min=0.0, max = 1.0, update = multikey.random_value)
class AnimToolBoxObjectSettings(bpy.types.PropertyGroup):
controlled: bpy.props.PointerProperty(name = 'Controlled Rig', description="Adding the rig object that is being controlled by the current object", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
controller: bpy.props.PointerProperty(name = 'Controller Rig', description="Adding the rig object that is used as the temp control object", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
ctrl_setups: bpy.props.CollectionProperty(type = TempCtrlsObjectSetups, override = {'LIBRARY_OVERRIDABLE', 'USE_INSERTION'})
ctrls_enabled: bpy.props.BoolProperty(name = 'Temp Ctrls Switch', description = "Enabling and Disabling Temp Ctrls influence", default = True)
# influence: bpy.props.FloatProperty(name = "Influence Slider for the Temp Ctrls", description = "Switching the influence slider for the temp ctrls", default = 1, min = 0.0, max = 1.0)
#Used for Bake to Empties
# org_id: bpy.props.IntProperty(name = "Originate ID", description = "ID number of the bone the ctrl originates from", override = {'LIBRARY_OVERRIDABLE'})
setup_id: bpy.props.IntProperty(name = "Setup ID", description = "ID number of the current chain setup", override = {'LIBRARY_OVERRIDABLE'})
root: bpy.props.BoolProperty(name = "Root Empty", description = "Empty is marked as the root ctrl", default = False, override = {'LIBRARY_OVERRIDABLE'})
child: bpy.props.BoolProperty(name = "Child Empty", description = "Empty is marked as a child bone inside the setup", default = False, override = {'LIBRARY_OVERRIDABLE'})
keyframes_offset: bpy.props.FloatProperty(name = "Keyframes Offset", description = "Interactive slider to offset keyframes back and forth ", default = 0)
class IsolatedRigs(bpy.types.PropertyGroup):
hidden: bpy.props.PointerProperty(name = "Hidden Rigs", description = "List of Rigs that are hidden during pose mode isolation", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
selected: bpy.props.PointerProperty(name = "Selected Rigs", description = "List of Rigs that are hidden during pose mode isolation", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
class AnimToolBoxUILayout(bpy.types.PropertyGroup):
'''Layout properties for the UI'''
quick_menu: bpy.props.BoolProperty(name = "Use Quick Menu", description = "Opens header menu with only icon", default = False)
copy_paste_matrix: bpy.props.BoolProperty(name = "Copy Matrix Menu", description = "Opens the menu for copy paste matrices", default = True)
copy_paste_world: bpy.props.BoolProperty(name = "Copy Paste World Matrix", description = "Copy and Paste the World Matrix", default = False, update = Tools.copy_paste_world_update)
copy_paste_relative: bpy.props.BoolProperty(name = "Copy Paste Relative Matrix", description = "Copy and Paste the Matrix relative to the active bone", default = False, update = Tools.copy_paste_relative_update)
Inbetweens: bpy.props.BoolProperty(name = "Blendings/Inbetweens", description = "Opens the menu for Inbetweens", default = True)
gizmo_size: bpy.props.BoolProperty(name = "Gizmo size", description = "Change the Gizmo size using alt +/- hotkeys", default = False)
# temp_ctrls: bpy.props.BoolProperty(name = "Temp Ctrls", description = "Open Temp Ctrls", default = False)
temp_ctrls_switch: bpy.props.BoolProperty(name = "Temp Ctrls Switch", description = "Temp Ctrls Switch", default = True)
temp_ctrls_shapes: bpy.props.BoolProperty(name = "Temp Ctrls Shapes", description = "Temp Ctrls Shapes", default = True)
markers_retimer: bpy.props.BoolProperty(name = "Marker Retimer", description = "Flag when marker retimer turned on", default = False, override = {'LIBRARY_OVERRIDABLE'})
relative_cursor: bpy.props.BoolProperty(name = "Relative Cursor Mode", description = "Cursor moves relative to the selection", default = False)
is_dragging: bpy.props.BoolProperty(default = False)
#using Blending sliders in the window manager to avoid undo issues with modal operators
inbetween_worldmatrix: bpy.props.FloatProperty(name='Inbetween World Matrix', description="Adds an inbetween of the World Matrix to the Layer's neighbor keyframes", soft_min = -1, soft_max = 1, default=0.0, update = Tools.add_inbetween_worldmatrix, override = {'LIBRARY_OVERRIDABLE'})
blend_mirror: bpy.props.FloatProperty(name='Blend Mirror', description="Blend into the mirrored pose", soft_min = 0, soft_max = 1, default=0, step = 1, update = Tools.blend_to_mirror, override = {'LIBRARY_OVERRIDABLE'})
multikey: bpy.props.PointerProperty(type = MultikeyProperties, options={'LIBRARY_EDITABLE'}, override = {'LIBRARY_OVERRIDABLE'})
class AnimToolBoxGlobalSettings(bpy.types.PropertyGroup):
#context.scene.animtoolbox
marker_frame_range: bpy.props.BoolProperty(name = "Marker Frame Range", description = "Flag when marker frame range turned on", default = False, override = {'LIBRARY_OVERRIDABLE'})
bake_frame_range: bpy.props.BoolProperty(name = "Bake Frame Range", description = "Flag when marker bake range turned on", default = False, override = {'LIBRARY_OVERRIDABLE'})
# markers_retimer: bpy.props.BoolProperty(name = "Marker Retimer", description = "Flag when marker retimer turned on", default = False, override = {'LIBRARY_OVERRIDABLE'})
keyframes_offset: bpy.props.FloatProperty(name = "Keyframes Offset", description = "Interactive slider to offset keyframes back and forth ", soft_max = 2, soft_min = -2, default = 0, update = Tools.keyframes_offset_slider)
rotation_mode: bpy.props.EnumProperty(name = 'Rotation Mode', description="Describes what kind of setup the bone is part of", override = {'LIBRARY_OVERRIDABLE'},
items = [('QUATERNION', 'Quaternion','Quaternion Rotation Order - No Gimbal Lock', 0),
('XYZ', 'XYZ', 'XYZ Rotation Order', 1), ('XZY', 'XZY','XZY Rotation Order', 2),
('YXZ', 'YXZ','YXZ Rotation Order', 3), ('YZX', 'YZX', 'YZX Rotation Order', 4),
('ZXY', 'ZXY', 'ZXY Rotation Order', 5), ('ZYX', 'ZYX', 'ZYX Rotation Order', 6),
('AXIS_ANGLE', 'AXIS_ANGLE', 'Axis Angle Rotation Order', 7)])
isolate_pose_mode: bpy.props.BoolProperty(name = "Isolate rig in pose mode", description = "Isolates the rig during pose mode, rigs in object mode are hidden", default = False)
active_obj: bpy.props.PointerProperty(name = "Active Object", description = "Current Active Object", type=bpy.types.Object, override = {'LIBRARY_OVERRIDABLE'})
isolated: bpy.props.CollectionProperty(type = IsolatedRigs, override = {'LIBRARY_OVERRIDABLE', 'USE_INSERTION'})
#Blendings
inbetweener : bpy.props.FloatProperty(name='Inbetween Keyframe', description="Adds an inbetween Keyframe between the Layer's neighbor keyframes", soft_min = -1, soft_max = 1, default=0.0, options = set(), update = Tools.add_inbetween_key, override = {'LIBRARY_OVERRIDABLE'})
gizmo_size: bpy.props.IntProperty(name = "Add to Gizmo Size", description = "Addition to Gizmo Size", max = 100, min = -100, default = 10)
#Copy/Pase Matrix
range_type: bpy.props.EnumProperty(name = 'Paste to Frames', description="Paste to current frame or a range of frames.", update = Tools.bake_range_type,
items = [('CURRENT', 'Current Frame','Paste Matrix to only current frame', 0),
('SELECTED', 'Selected Keyframe','Paste Matrix to only selected keyframes', 1),
('RANGE', 'Frame Range','Paste Matrix to a Frame Range', 2)])
bake_frame_start: bpy.props.IntProperty(name = "Bake Frame Start", description = "Define the start frame to paste the matrix", min = 0, update = Tools.bake_frame_start_limit)
bake_frame_end: bpy.props.IntProperty(name = "Bake Frame End", description = "Define the end frame to paste the matrix", min = 0, update = Tools.bake_frame_end_limit)
filter_location: bpy.props.BoolVectorProperty(name="Location", description="Filter Location properties", default=(False, False, False), size = 3, options={'HIDDEN'}, update = Tools.filter_name_update)
filter_rotation: bpy.props.BoolVectorProperty(name="Rotation", description="Filter Rotation properties", default=(False, False, False, False), size = 4, options={'HIDDEN'}, update = Tools.filter_name_update)
filter_scale: bpy.props.BoolVectorProperty(name="Scale", description="Filter Scale properties", default=(False, False, False), size = 3, options={'HIDDEN'}, update = Tools.filter_name_update)
#The name displayed on the filter button
filter_name: bpy.props.StringProperty(name="Filter Name", description="Change the name of the button while chaging the filter options", default= "", options={'HIDDEN'})
filter_custom_props: bpy.props.BoolProperty(name = "Filter Custom Properties", description = "Filter custom properties", default = False)
filter_keyframes: bpy.props.BoolProperty(name = "Filter Aelected Keyframes", description = "Filter selected keyframes for specific tools", default = False)
col_vis: bpy.props.BoolProperty(name = "Animated collections visibility", description = "Display if animated collections are turned on or off", default = False)
@addon_updater_ops.make_annotations
class AnimToolBoxPreferences(bpy.types.AddonPreferences):
# this must match the addon name, use '__package__'
# when defining this in a submodule of a python package.
bl_idname = __package__
category: bpy.props.StringProperty(
name="Tab Category",
description="Choose a name for the category of the panel",
default="Animation",
update=ui.update_panel
)
quick_menu: bpy.props.BoolProperty(name = "Use Quick Menu", description = "Opens header menu with only icon", default = False)
riggertoolbox: bpy.props.BoolProperty(name = "RiggerToolBox", description = "Include RiggerToolbox (experimental)", default = False, update = ui.add_riggertoolbox)
multikey: bpy.props.BoolProperty(name = "Multikey", description = "Include Multikey for adju\sting multiply keyframes", default = False, update = ui.add_multikey)
#Temp Ctrls properties
in_front: bpy.props.BoolProperty(name = "Always In Front", description = "Set Temp Ctrls to be always in front", default = True)
clear_setup : bpy.props.BoolProperty(name = "Clear Selection Before Creating New Temp Ctrls", description = "Clear old setup when adding Temp ctrls to an existing chain", default = False)
#Editable motion path
keyframes_range: bpy.props.IntProperty(name = "Keyframe Range", description = "The range of distance from the keyframes while hovering over them", min = 5, max = 100, default = 15)
mp_pref: bpy.props.BoolProperty(name = "Editable Motion Path Colors Theme", description = "Set the Color them of editable motion path visualization", default = False)
mp_keyframe_color: bpy.props.FloatVectorProperty(name="Keyframes", subtype='COLOR', default=(1.0, 1.0, 0.0, 1.0), size=4, min=0.0, max=1.0, description="Handles selection color")
mp_handle_color: bpy.props.FloatVectorProperty(name="Handles", subtype='COLOR', default=(1.0, 0.8, 0.2, 1.0), size=4, min=0.0, max=1.0, description="Handles selection color")
mp_remove_color: bpy.props.FloatVectorProperty(name="Remove Keyframes", subtype='COLOR', default=(0.0, 0.5, 1.0, 1.0), size=4, min=0.0, max=1.0, description="Keyframe color displayed before removing")
mp_hover_color: bpy.props.FloatVectorProperty(name="Hover", subtype='COLOR', default=(1.0, 0.4, 0.2, 1.0), size=4, min=0.0, max=1.0, description="Color during Hovering")
mp_handle_selection_color: bpy.props.FloatVectorProperty(name="Handles Selection", subtype='COLOR', default=(0.8, 0.65, 0.6, 0.8), size=4, min=0.0, max=1.0, description="Handles selection color")
mp_key_selection_color: bpy.props.FloatVectorProperty(name="Keyframe Selection", subtype='COLOR', default=(0.8, 0.8, 0.6, 0.8), size=4, min=0.0, max=1.0, description="Keyframe selection color")
# addon updater preferences from `__init__`, be sure to copy all of them
auto_check_update: bpy.props.BoolProperty(
name = "Auto-check for Update",
description = "If enabled, auto-check for updates using an interval",
default = True,
)
updater_interval_months: bpy.props.IntProperty(
name='Months',
description = "Number of months between checking for updates",
default=0,
min=0
)
updater_interval_days: bpy.props.IntProperty(
name='Days',
description = "Number of days between checking for updates",
default=7,
min=0,
)
updater_interval_hours: bpy.props.IntProperty(
name='Hours',
description = "Number of hours between checking for updates",
default=0,
min=0,
max=23
)
updater_interval_minutes: bpy.props.IntProperty(
name='Minutes',
description = "Number of minutes between checking for updates",
default=0,
min=0,
max=59
)
#Draw the UI in the preferences
def draw(self, context):
layout = self.layout
addon_updater_ops.update_settings_ui(self, context)
row = layout.row()
col = row.column()
col.label(text="Tab Category:")
col.prop(self, "category", text="")
layout.separator()
col = layout.column()
col.prop(self, "quick_menu", text="Use Quick Icons Menu")
layout.separator()
box = layout.box()
row = box.row()
row.label(text = 'Temp Ctrls: ')
row = box.row()
row.prop(self, 'clear_setup')
row.prop(self, 'in_front')
layout.separator()
box = layout.box()
col = box.column()
col.prop(self, 'mp_pref', icon = 'DOWNARROW_HLT', text = 'Editable Motion Path Preferences')
if self.mp_pref:
col.prop(self, 'keyframes_range', text = 'Keyframe Distance Range')
col.label(text = 'Colors Theme')
row = box.row()
row.prop(self, 'mp_keyframe_color')
row.prop(self, 'mp_handle_color')
row.prop(self, 'mp_remove_color')
row = box.row()
row.prop(self, 'mp_hover_color')
row.prop(self, 'mp_key_selection_color')
row.prop(self, 'mp_handle_selection_color')
layout.separator()
col = layout.column()
col.label(text = 'Include Extras: ')
row = layout.row()
row.prop(self, "multikey", text="Multikey - Edit Multiply keyframes")
row.prop(self, "riggertoolbox", text="RiggerToolBox (Experimental)")
@persistent
def loadanimtoolbox_pre(self, context):
scene = bpy.context.scene
dns = bpy.app.driver_namespace
if scene.animtoolbox.bake_frame_range:
scene.animtoolbox.bake_frame_range = False
if scene.emp.motion_path:
scene.emp.motion_path = False
bpy.context.workspace.status_text_set(None)
if 'mp_dh' in dns:
bpy.types.SpaceView3D.draw_handler_remove(dns['mp_dh'], 'WINDOW')
bpy.app.driver_namespace.pop('mp_dh')
bpy.context.scene.emp.selected_keyframes = '{}'
if 'markers_retimer_dh' in dns:
bpy.types.SpaceView3D.draw_handler_remove(dns['markers_retimer_dh'], 'WINDOW')
bpy.app.driver_namespace.pop('markers_retimer_dh')
#remove the motion path app handler if it's still inside
if emp.mp_value_update in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(emp.mp_value_update)
if emp.mp_frame_change in bpy.app.handlers.frame_change_post:
bpy.app.handlers.frame_change_post.remove(emp.mp_frame_change)
if emp.mp_undo_update in bpy.app.handlers.undo_pre:
bpy.app.handlers.undo_pre.remove(emp.mp_undo_update)
@persistent
def loadanimtoolbox_post(self, context):
scene = bpy.context.scene
dns = bpy.app.driver_namespace
if scene.animtoolbox.isolate_pose_mode:
if Display.isolate_pose_mode not in bpy.app.handlers.depsgraph_update_pre:
bpy.app.handlers.depsgraph_update_pre.append(Display.isolate_pose_mode)
if scene.emp.motion_path:
scene.emp.motion_path = False
bpy.context.workspace.status_text_set(None)
if 'mp_dh' in dns:
bpy.types.SpaceView3D.draw_handler_remove(dns['mp_dh'], 'WINDOW')
bpy.app.driver_namespace.pop('mp_dh')
# Reset keyframe selection for motion paths it is not used in window manager
# Because it is used for undo
bpy.context.scene.emp.selected_keyframes = '{}'
Tools.selection_order(self, context)
classes = (TempCtrlsItems, TempCtrlsOrgIds, TempCtrlsObjectSetups, TempCtrlsSceneSettings,TempCtrlsBoneSettings, MultikeyProperties,
IsolatedRigs, AnimToolBoxObjectSettings, AnimToolBoxUILayout, AnimToolBoxGlobalSettings) + ui.classes
addon_keymaps = []
def register():
# Note that preview collections returned by bpy.utils.previews
# are regular py objects - you can use them to store custom data.
addon_updater_ops.register(bl_info)
register_class(AnimToolBoxPreferences)
addon_updater_ops.make_annotations(AnimToolBoxPreferences) # to avoid blender 2.8 warnings
TempCtrls.register()
Tools.register()
Display.register()
emp.register()
ui.register_custom_icon()
for cls in classes:
# print(cls)
register_class(cls)
bpy.types.Scene.btc = bpy.props.PointerProperty(type = TempCtrlsSceneSettings, override = {'LIBRARY_OVERRIDABLE'})
bpy.types.PoseBone.btc = bpy.props.PointerProperty(type = TempCtrlsBoneSettings, override = {'LIBRARY_OVERRIDABLE'})
bpy.types.Object.animtoolbox = bpy.props.PointerProperty(type = AnimToolBoxObjectSettings, override = {'LIBRARY_OVERRIDABLE'})
bpy.types.Scene.animtoolbox = bpy.props.PointerProperty(type = AnimToolBoxGlobalSettings, override = {'LIBRARY_OVERRIDABLE'})
bpy.types.WindowManager.atb_ui = bpy.props.PointerProperty(type = AnimToolBoxUILayout, override = {'LIBRARY_OVERRIDABLE'})
ui.update_panel(None, bpy.context)
ui.add_multikey(None, bpy.context)
ui.add_riggertoolbox(None, bpy.context)
if loadanimtoolbox_pre not in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.append(loadanimtoolbox_pre)
if loadanimtoolbox_post not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(loadanimtoolbox_post)
if Tools.selection_order not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(Tools.selection_order)
#Make sure TAB hotkey in the NLA goes into full stack mode
wm = bpy.context.window_manager
kc = wm.keyconfigs.addon
km = kc.keymaps.new(name= '3D View', space_type= 'VIEW_3D')
if 'view3d.gizmo_size_up' not in km.keymap_items:
kmi = km.keymap_items.new('view3d.gizmo_size_up', type= 'NUMPAD_PLUS', value= 'PRESS', alt = True, repeat = True)
addon_keymaps.append((km, kmi))
if 'view3d.gizmo_size_down' not in km.keymap_items:
kmi = km.keymap_items.new('view3d.gizmo_size_down', type= 'NUMPAD_MINUS', value= 'PRESS', alt = True, repeat = True)
addon_keymaps.append((km, kmi))
#Add Tools to the Toolbar
bpy.utils.register_tool(ui.KeyframeOffsetTool, separator=True)
#Add tools to the menu
bpy.types.VIEW3D_MT_editor_menus.append(ui.draw_menu)
def unregister():
for pcoll in ui.preview_collections.values():
bpy.utils.previews.remove(pcoll)
ui.preview_collections.clear()
#addon_updater_ops.unregister()
addon_updater_ops.unregister()
unregister_class(AnimToolBoxPreferences)
TempCtrls.unregister()
# Rigger_Toolbox.unregister()
Tools.unregister()
Display.unregister()
emp.unregister()
ui.add_multikey(None, bpy.context)
ui.add_riggertoolbox(None, bpy.context)
for cls in classes:
unregister_class(cls)
bpy.utils.unregister_tool(ui.KeyframeOffsetTool)
#Remove the header menu ui
bpy.types.VIEW3D_MT_editor_menus.remove(ui.draw_menu)
del bpy.types.Scene.btc
# del bpy.types.Bone.btc
del bpy.types.Object.animtoolbox
del bpy.types.Scene.animtoolbox
if hasattr(bpy.types.Object, 'keyframes_offset'):
del bpy.types.Object.keyframes_offset
if hasattr(bpy.types.PoseBone, 'keyframes_offset'):
del bpy.types.PoseBone.keyframes_offset
if loadanimtoolbox_pre in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.remove(loadanimtoolbox_pre)
if loadanimtoolbox_post in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(loadanimtoolbox_post)
if Tools.selection_order in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(Tools.selection_order)
for km, kmi in addon_keymaps:
km.keymap_items.remove(kmi)
addon_keymaps.clear()
if __name__ == "__main__":
register()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
{
"last_check": "2026-02-09 15:54:54.419194",
"backup_date": "September-23-2025",
"update_ready": true,
"ignore": false,
"just_restored": false,
"just_updated": false,
"version_text": {
"link": "https://gitlab.com/api/v4/projects/45739913/repository/archive.zip?sha=7a9ad24a463bf1c8ae095ec853409979344e0738",
"version": [
0,
2,
3
]
}
}
File diff suppressed because it is too large Load Diff
@@ -1,527 +0,0 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
import bpy
import random
import numpy as np
from mathutils import Quaternion
from . import Tools
def attr_default(obj, fcu_key):
#check if the fcurve source belongs to a bone or obj
if fcu_key[0][:10] == 'pose.bones':
transform = fcu_key[0].split('.')[-1]
attr = fcu_key[0].split('"')[-2]
bone = fcu_key[0].split('"')[1]
if bone in obj.pose.bones:
source = obj.pose.bones[bone]
#if the bone not found still calculate the default based on the path
elif '.rotation_quaternion' in fcu_key[0]:
return [1.0, 0.0, 0.0, 0.0]
elif '.scale' in fcu_key[0]:
return [1.0, 1.0, 1.0]
else:
return [0]
#in case of shapekey animation
elif fcu_key[0][:10] == 'key_blocks':
attr = fcu_key[0].split('"')[1]
if attr not in obj.data.shape_keys.key_blocks:
return [0]
shapekey = obj.data.shape_keys.key_blocks[attr]
return [0] if shapekey.slider_min <= 0 else shapekey.slider_min
#in case of transforms in object mode
else:# fcu_key[0] in transform_types:
source = obj
transform = fcu_key[0]
#check when it's transform property of Blender
if transform in source.bl_rna.properties.keys():
if hasattr(source.bl_rna.properties[transform], 'default_array'):
if len(source.bl_rna.properties[transform].default_array) > fcu_key[1]:
attrvalue = source.bl_rna.properties[transform].default_array
return attrvalue
#in case of property on object
elif len(fcu_key[0].split('"')) > 1:
if fcu_key[0].split('"')[1] in obj.keys():
attr = fcu_key[0].split('"')[1]
if 'attr' not in locals():
return [0]
#since blender 3 access to custom property settings changed
if attr in source:
if not isinstance(source[attr], float) and not isinstance(source[attr], int):
return [0]
id_attr = source.id_properties_ui(attr).as_dict()
attrvalue = id_attr['default']
return [attrvalue]
return [0]
def store_handles(key):
#storing the distance between the handles bezier to the key value
handle_r = key.handle_right[1] - key.co[1]
handle_l = key.handle_left[1] - key.co[1]
return handle_r, handle_l
def apply_handles(key, handle_r, handle_l):
key.handle_right[1] = key.co[1] + handle_r
key.handle_left[1] = key.co[1] + handle_l
def selected_bones_filter(obj, fcu_data_path):
if not bpy.context.window_manager.atb_ui.multikey.selectedbones:
#if not obj.als.onlyselected:
return False
if obj.mode != 'POSE':
return True
transform_types = ['location', 'rotation_euler', 'rotation_quaternion', 'scale']
#filter selected bones if option is turned on
bones = [bone.path_from_id() for bone in bpy.context.selected_pose_bones]
if fcu_data_path.split('].')[0]+']' not in bones and fcu_data_path not in transform_types:
return True
# def filter_properties(obj, fcu):
# 'Filter the W X Y Z attributes of the transform properties'
# transform = fcu.data_path.split('"].')[1] if obj.mode == 'POSE' else fcu.data_path
# index = fcu.array_index
# if 'rotation' in transform:
# if transform == 'rotation_euler':
# index -= 1
# transform = 'rotation'
# transform = 'filter_' + transform
# if not hasattr(bpy.context.scene.multikey, transform):
# return False
# attr = getattr(bpy.context.scene.multikey, transform)
# #print(fcu.data_path, index, transform, attr[index])
# return True if attr[index] else False
def add_value(key, value):
if key.select_control_point:
#store handle values in relative to the keyframe value
handle_r, handle_l = store_handles(key)
key.co[1] += value
apply_handles(key, handle_r, handle_l)
#calculate the difference between current value and the fcurve value
def add_diff(fcurves, path, current_value, eval_array):
array_value = current_value - eval_array
if not any(array_value):
return
for i, value in enumerate(array_value):
fcu = fcurves.find(path, index = i)
if fcu is None or Tools.filter_properties(bpy.context.scene.animtoolbox, fcu):
continue
for key in fcu.keyframe_points:
add_value(key, value)
fcu.update()
class ScaleValuesOp(bpy.types.Operator):
"""Modal operator used while scale value is running before release"""
bl_idname = "animtoolbox.multikey_scale_value"
bl_label = "Scale Values"
bl_options = {'REGISTER', 'UNDO'}
def invoke(self, context, event):
#reset the values for dragging
self.stop = False
ui = context.window_manager.atb_ui
ui['is_dragging'] = True
self.avg_value = dict()
#dictionary of the keyframes and their original INITIAL values
self.keyframes_values = dict()
self.keyframes_handle_right = dict()
self.keyframes_handle_left = dict()
#the average value for each fcurve
self.keyframes_avg_value = dict()
for obj in context.selected_objects:
if obj.animation_data.action is None:
continue
fcurves = Tools.get_fcurves_channelbag(obj, obj.animation_data.action)
for fcu in fcurves:
if obj.mode == 'POSE':
if selected_bones_filter(obj, fcu.data_path):
continue
if Tools.filter_properties(context.scene.animtoolbox, fcu):
continue
#avg and value list per fcurve
avg_value = []
value_list = []
for key in fcu.keyframe_points:
if key.select_control_point:
value_list.append(key.co[1])
self.keyframes_values.update({key : key.co[1]})
self.keyframes_handle_right.update({key : key.handle_right[1]})
self.keyframes_handle_left.update({key : key.handle_left[1]})
if len(value_list)>1:
#the average value with the scale property added to it
avg_value = sum(value_list) / len(value_list)
for key in fcu.keyframe_points:
if key.select_control_point:
self.keyframes_avg_value.update({key : avg_value})
if not self.keyframes_avg_value:
ui['is_dragging'] = False
ui.multikey['scale'] = 1
Tools.redraw_areas(['VIEW_3D'])
return {'CANCELLED'}
context.window_manager.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
ui = context.window_manager.atb_ui
scale = ui.multikey.scale
#Quit the modal operator when the slider is released
if self.stop:
ui['is_dragging'] = False
ui.multikey['scale'] = 1
Tools.redraw_areas(['VIEW_3D'])
#modal is being cancelled because of undo issue with the modal running through the property
return {'FINISHED'}
if event.value == 'RELEASE': # Stop the modal on next frame. Don't block the event since we want to exit the field dragging
self.stop = True
return {'PASS_THROUGH'}
for key, key_value in self.keyframes_values.items():
if not key.select_control_point:
continue
if key not in self.keyframes_avg_value:
continue
avg_value = self.keyframes_avg_value[key]
handle_right_value = self.keyframes_handle_right[key]
handle_left_value = self.keyframes_handle_left[key]
#add the value of the distance from the average * scale factor
key.co[1] = avg_value + ((key_value - avg_value)*scale)
key.handle_right[1] = avg_value + ((handle_right_value - avg_value)*scale)
key.handle_left[1] = avg_value + ((handle_left_value - avg_value)*scale)
return {'PASS_THROUGH'}
def scale_value(self, context):
ui = context.window_manager.atb_ui
if ui.is_dragging:
return
obj = context.object
if obj is None:
self['scale'] = 1
return
action = obj.animation_data.action
if action is None:
self['scale'] = 1
return
if context.mode == 'POSE' and not context.selected_pose_bones:
self['scale'] = 1
return
bpy.ops.animtoolbox.multikey_scale_value('INVOKE_DEFAULT')
def random_value(self, context):
for obj in context.selected_objects:
if obj.animation_data.action is None:
continue
fcurves = Tools.get_fcurves_channelbag(obj, obj.animation_data.action)
for fcu in fcurves:
if obj.mode == 'POSE':
if selected_bones_filter(obj, fcu.data_path):
continue
if Tools.filter_properties(context.scene.animtoolbox, fcu):
continue
value_list = []
threshold = bpy.context.window_manager.atb_ui.multikey.randomness
for key in fcu.keyframe_points:
if key.select_control_point == True:
value_list.append(key.co[1])
if len(value_list) > 0:
value = max(value_list)- min(value_list)
for key in fcu.keyframe_points:
add_value(key, value * random.uniform(-threshold, threshold))
fcu.update()
self['randomness'] = 0.1
def evaluate_combine(data_path, added_array, eval_array, array_default, influence):
if 'scale' in data_path:
eval_array = eval_array * (added_array / array_default) ** influence
elif 'rotation_quaternion' in data_path:
#multiply first the influence with the w separatly
added_array[0] = added_array[0] + (1- added_array[0])*(1 - influence)
added_array[1:] *= influence
eval_array = np.array(Quaternion(eval_array) @ Quaternion(added_array))# ** influence
#if it's a custom property
elif 'rotation_euler' not in data_path and 'location' not in data_path:
eval_array = eval_array + (added_array - array_default) * influence
return eval_array
def evaluate_array(fcurves, fcu_path, frame, array_default = [0, 0, 0]):
'''Create an array from all the indexes'''
array_len = len(array_default)
#assigning the default array in case
fcu_array = array_default.copy()
#get the missing arrays in case quaternion is not complete
for i in range(array_len):
fcu = fcurves.find(fcu_path, index = i)
if fcu is None:
continue
fcu_array[i] = fcu.evaluate(frame)
if (fcu_array == array_default).all():
return None
return np.array(fcu_array)
def evaluate_layers(context, obj, anim_data, fcu, array_default):
'''Calculate the evaluation of all the layers when using the nla'''
if not hasattr(anim_data, 'nla_tracks') or not anim_data.use_nla:
return None
nla_tracks = anim_data.nla_tracks
if not len(nla_tracks):
return None
frame = context.scene.frame_current
blend_types = {'ADD' : '+', 'SUBTRACT' : '-', 'MULTIPLY' : '*'}
fcu_path = fcu.data_path
eval_array = array_default.copy()
for track in nla_tracks:
if track.mute:
continue
if not len(track.strips):
continue
for strip in track.strips:
if not strip.frame_start < frame < strip.frame_end:
continue
action = strip.action
if action is None:
continue
blend_type = strip.blend_type
#get the influence value either from the attribute or the fcurve. function coming from bake
influence = strip.influence
if len(strip.fcurves):
if not strip.fcurves[0].mute and len(strip.fcurves[0].keyframe_points):
influence = strip.fcurves[0].evaluate(frame)
#evaluate the frame according to the strip settings
frame_eval = frame
#change the frame if the strip is on hold
if frame < strip.frame_start:
if strip.extrapolation == 'HOLD':
frame_eval = strip.frame_start
elif frame >= strip.frame_end:
if strip.extrapolation == 'HOLD' or strip.extrapolation == 'HOLD_FORWARD':
frame_eval = strip.frame_end
last_frame = strip.frame_start + (strip.frame_end - strip.frame_start) / strip.repeat
if strip.repeat > 1 and (frame) >= last_frame:
action_range = (strip.action_frame_end * strip.scale - strip.action_frame_start * strip.scale)
frame_eval = (((frame_eval - strip.frame_start) % (action_range)) + strip.frame_start)
if strip.use_reverse:
frame_eval = last_frame - (frame_eval - strip.frame_start)
offset = (strip.frame_start * 1/strip.scale - strip.action_frame_start) * strip.scale
frame_eval = strip.frame_start * 1/strip.scale + (frame_eval - strip.frame_start) * 1/strip.scale - offset * 1/strip.scale
fcurves = Tools.get_fcurves_channelbag(obj, action)
eval_array = evaluate_blend_type(fcurves, eval_array, fcu_path, frame_eval, influence, array_default, blend_type, blend_types)
#Adding an extra layer from the action outside and on top of the nla
tweak_mode = anim_data.use_tweak_mode
if tweak_mode:
anim_data.use_tweak_mode = False
action = anim_data.action
if action:
influence = anim_data.action_influence
blend_type = anim_data.action_blend_type
fcurves = Tools.get_fcurves_channelbag(obj, action)
eval_array = evaluate_blend_type(fcurves, eval_array, fcu_path, frame, influence, array_default, blend_type, blend_types)
anim_data.use_tweak_mode = tweak_mode
return eval_array
def evaluate_blend_type(fcurves, eval_array, fcu_path, frame, influence,
array_default, blend_type, blend_types):
'''Calculate the value based on the blend type'''
fcu_array = evaluate_array(fcurves, fcu_path, frame, array_default)
if fcu_array is None:
return eval_array
###EVALUATION###
if blend_type =='COMBINE':
if 'location' in fcu_path or 'rotation_euler' in fcu_path:
blend_type = 'ADD'
if blend_type =='REPLACE':
eval_array = eval_array * (1 - influence) + fcu_array * influence
elif blend_type =='COMBINE':
eval_array = evaluate_combine(fcu_path, fcu_array, eval_array, array_default, influence)
else:
eval_array = eval('eval_array' + blend_types[blend_type] + 'fcu_array' + '*' + str(influence))
return eval_array
def evaluate_value(self, context):
ui = context.window_manager.atb_ui
for obj in context.selected_objects:
anim_data = obj.animation_data
if anim_data is None:
return
if anim_data.action is None:
return
action = obj.animation_data.action
fcu_paths = []
transformations = ["rotation_quaternion","rotation_euler", "location", "scale"]
if obj.mode == 'POSE':
bonelist = context.selected_pose_bones if ui.multikey.selectedbones else obj.pose.bones
fcurves = Tools.get_fcurves_channelbag(obj, action)
for fcu in fcurves:
if fcu in fcu_paths:
continue
current_value = None
if Tools.filter_properties(context.scene.animtoolbox, fcu):
continue
if obj.mode == 'POSE':
if selected_bones_filter(obj, fcu.data_path):
continue
for bone in bonelist:
#find the fcurve of the bone
if fcu.data_path.rfind(bone.name) != 12 or fcu.data_path[12 + len(bone.name)] != '"':
continue
path_split = fcu.data_path.split('"].')
if len(path_split) <= 1:
continue
else:
transform = fcu.data_path.split('"].')[1]
if transform not in transformations:
continue
current_value = getattr(obj.pose.bones[bone.name], transform)
else:
transform = fcu.data_path
current_value = getattr(obj, transform)
#In case it was completly filtered out and not current value available
if not current_value:
continue
array_default = np.array(attr_default(obj, (fcu.data_path, fcu.array_index)))
eval_array = evaluate_layers(context, obj, anim_data, fcu, array_default)
if eval_array is None:
fcurves = Tools.get_fcurves_channelbag(obj, action)
eval_array = evaluate_array(fcurves, fcu.data_path, context.scene.frame_current, array_default)
#calculate the difference between current value and the fcurve value
add_diff(fcurves, fcu.data_path, np.array(current_value), eval_array)
class MULTIKEY_OT_Multikey(bpy.types.Operator):
"""Edit all selected keyframes"""
bl_label = "Edit Selected Keyframes"
bl_idname = "animtoolbox.multikey"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return context.active_object and context.active_object.animation_data and bpy.context.scene.tool_settings.use_keyframe_insert_auto == False
def execute(self, context):
evaluate_value(self, context)
return {'FINISHED'}
class MultikeyProperties(bpy.types.PropertyGroup):
selectedbones: bpy.props.BoolProperty(name="Selected Bones", description="Affect only selected bones", default=True, options={'HIDDEN'})
handletype: bpy.props.BoolProperty(name="Keep handle types", description="Keep handle types", default=False, options={'HIDDEN'})
scale: bpy.props.FloatProperty(name="Scale Factor", description="Scale percentage of the average value", default=1.0, update = scale_value)
randomness: bpy.props.FloatProperty(name="Randomness", description="Random Threshold of keyframes", default=0.1, min=0.0, max = 1.0, update = random_value)
#filters
# filter_location: bpy.props.BoolVectorProperty(name="Location", description="Filter Location properties", default=(True, True, True), size = 3, options={'HIDDEN'})
# filter_rotation: bpy.props.BoolVectorProperty(name="Rotation", description="Filter Rotation properties", default=(True, True, True, True), size = 4, options={'HIDDEN'})
# filter_scale: bpy.props.BoolVectorProperty(name="Scale", description="Filter Scale properties", default=(True, True, True), size = 3, options={'HIDDEN'})
# class FilterProperties(bpy.types.Operator):
# """Filter Location Rotation and Scale Properties"""
# bl_idname = "fcurves.filter"
# bl_label = "Filter Properties W X Y Z"
# bl_options = {'REGISTER', 'UNDO'}
# def invoke(self, context, event):
# wm = context.window_manager
# return wm.invoke_props_dialog(self, width = 200)
# def draw(self, context):
# layout = self.layout
# row = layout.row()
# row.label(text = 'Location')
# row.prop(context.scene.multikey, 'filter_location', text = '')
# row = layout.row()
# row.label(text = 'Rotation')
# row.prop(context.scene.multikey, 'filter_rotation', text = '')
# row = layout.row()
# row.label(text = 'Scale')
# row.prop(context.scene.multikey, 'filter_scale', text = '')
# def execute(self, context):
# return {'CANCELLED'}
classes = (ScaleValuesOp, MULTIKEY_OT_Multikey)
#register, unregister = bpy.utils.register_classes_factory(classes)
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
# bpy.types.Scene.animtoolbox.multikey = bpy.props.PointerProperty(type = MultikeyProperties, options={'LIBRARY_EDITABLE'}, override = {'LIBRARY_OVERRIDABLE'})
def unregister():
from bpy.utils import unregister_class
for cls in classes:
unregister_class(cls)
# del bpy.types.Scene.animtoolbox.multikey
@@ -1,725 +0,0 @@
# ***** BEGIN GPL LICENSE BLOCK *****
#
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ***** END GPL LICENCE BLOCK *****
import bpy
from . import Rigger_Toolbox
from . import multikey
from . import Rigger_Toolbox
from pathlib import Path
from bpy.utils import register_class
from bpy.utils import unregister_class
import os
######################################################## MENUS ########################################################
class ANIMTOOLBOX_MT_Copy_Paste_Matrix(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Copy_Paste_Matrix'
bl_label = "Copy Paste Matrix"
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
layout.operator('anim.copy_matrix', text = 'Copy World Matrix', icon ='DUPLICATE')
layout.operator('anim.paste_matrix', text = 'Paste World Matrix', icon ='PASTEDOWN')
layout.separator()
layout.operator('anim.copy_relative_matrix', text = 'Copy Relative Matrix', icon ='DUPLICATE')
layout.operator('anim.paste_relative_matrix', text = 'Paste Relative Matrix', icon ='PASTEDOWN')
layout.separator()
layout.prop_menu_enum(context.scene.animtoolbox, 'range_type', text = 'Frame Range Type')
if context.scene.animtoolbox.range_type == 'RANGE':
# split = layout.split(factor = 0.3)
# layout.prop(context.scene.animtoolbox, 'bake_frame_start', text = 'Start')
# layout.prop(context.scene.animtoolbox, 'bake_frame_end', text = 'End')
layout.operator("anim.markers_bakerange", icon = 'MARKER', text ='Frame Range Markers Widget', depress = context.scene.animtoolbox.bake_frame_range)
class ANIMTOOLBOX_MT_Temp_Ctrls(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Temp_Ctrls'
bl_label = "Temp Ctrls"
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
custom_icons = preview_collections["main"]
layout.operator('anim.bake_to_ctrl', text="World Space Ctrls", icon = 'WORLD')
# layout.operator('anim.worldspace_cursor', text="World Space Cursor Ctrls", icon ='ORIENTATION_CURSOR')
layout.operator('anim.bake_to_temp_fk', text="Temp FK Ctrls", icon = 'BONE_DATA')
layout.operator('anim.bake_to_temp_ik', text="Temp IK Ctrls", icon = 'CON_KINEMATIC')
layout.separator()
layout.operator('anim.link_temp_chains', text="Link Temp Chains", icon = 'DECORATE_LINKED')
layout.operator('anim.unlink_temp_chains', text="UnLink Temp Chains", icon = 'DECORATE_LINKED')
layout.separator()
layout.operator('anim.bake_temp_ctrls', text="Bake Temp Ctrls", icon_value = custom_icons["oven"].icon_id)
layout.operator('anim.remove_bones_constraints', text="Cleanup", icon_value = custom_icons["trash"].icon_id)
layout.prop_menu_enum(context.scene.btc, 'target', text = 'Bake To', icon = 'MOD_ARMATURE')
layout.separator()
ctrl = context.object.animtoolbox.controller if context.object.animtoolbox.controller else context.object
if ctrl:
layout.prop(ctrl.animtoolbox, 'ctrls_enabled', text ='Temp Ctrls On/Off')
# layout.operator('anim.enable_tempctrls', text="On" , icon = 'HIDE_OFF')#icon = 'CONSTRAINT_BONE'
# layout.operator('anim.disable_tempctrls', text="Off", icon = 'HIDE_ON')
layout.separator()
layout.prop_menu_enum(context.scene.btc, 'shape_type', icon = 'MESH_DATA')
layout.prop(context.scene.btc, 'shape_size', slider = False, icon ='CON_SIZELIKE')
layout.prop(context.scene.btc, 'color_set', text = '')
# layout.prop(context.scene.btc, 'shape_type', icon = 'MESH_DATA', text ='')
class ANIMTOOLBOX_MT_Keyframe_Offset(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Keyframe_Offset'
bl_label = "Interactive Keyframe Offset"
def draw(self, context):
layout = self.layout
layout.prop(context.scene.animtoolbox, 'keyframes_offset', slider = False)
layout.operator('anim.select_keyframes_offset', text="Select Offset Keyframes", icon ='RESTRICT_SELECT_OFF')
layout.operator('anim.apply_keyframes_offset', text="Apply Offset", icon = 'ANIM')
class ANIMTOOLBOX_MT_Blendings(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Blendings'
bl_label = "Blendings"
def draw(self, context):
layout = self.layout
ui = context.window_manager.atb_ui
layout.prop(ui, 'inbetween_worldmatrix', slider = True)
layout.prop(context.scene.animtoolbox, 'inbetweener', slider = True)
layout.prop(ui, 'blend_mirror', slider = True)
class ANIMTOOLBOX_MT_Multikey(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Multikey'
bl_label = "Multikey"
def draw(self, context):
layout = self.layout
ui = context.window_manager.atb_ui
layout.operator("animtoolbox.multikey", icon = 'ACTION_TWEAK')
layout.prop(ui.multikey, 'scale', slider = True)
layout.prop(ui.multikey, 'randomness', slider = True)
class ANIMTOOLBOX_MT_Convert_Rotations(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_Convert_Rotations'
bl_label = "Convert Rotations"
def draw(self, context):
layout = self.layout
layout.operator("anim.convert_rotation_mode", icon = 'DRIVER_ROTATIONAL_DIFFERENCE', text = 'Convert Rotation To')
layout.operator("anim.find_rotation_mode", icon = 'VIEWZOOM', text = 'Recommend Euler Rotation')
layout.prop_menu_enum(context.scene.animtoolbox, 'rotation_mode', text = 'Convert To')
class ANIMTOOLBOX_MT_operators(bpy.types.Menu):
bl_idname = 'ANIMTOOLBOX_MT_menu_operators'
bl_label = "AnimToolBox"
@classmethod
def poll(cls, context):
return True
def draw(self, context):
layout = self.layout
scene = context.scene
ui = context.window_manager.atb_ui
custom_icons = preview_collections["main"]
if context.area.type == 'VIEW_3D':
layout.menu('ANIMTOOLBOX_MT_Temp_Ctrls', text = 'Temp Ctrls', icon_value = custom_icons["puppet"].icon_id)
layout.separator()
layout.operator('anim.share_keyframes', text = 'Share Keyframes', icon_value = custom_icons["sharekeys"].icon_id)
layout.operator('anim.relative_cursor', text = 'Relative Cursor Pivot', icon_value = custom_icons["relative_cursor"].icon_id, depress = ui.relative_cursor)
layout.operator("anim.markers_retimer", icon_value = custom_icons["retime"].icon_id, text ='Markers Retimer', depress = ui.markers_retimer)
layout.menu('ANIMTOOLBOX_MT_Convert_Rotations', text = 'Convert Rotations', icon = 'DRIVER_ROTATIONAL_DIFFERENCE')
layout.separator()
layout.menu('ANIMTOOLBOX_MT_Keyframe_Offset', text = 'Interactive Keyframe Offset', icon_value = custom_icons["keyframe_offset"].icon_id)
layout.menu('ANIMTOOLBOX_MT_Blendings', text = 'Blendings and inbetweens', icon_value = custom_icons["sliders"].icon_id)
layout.separator()
layout.menu('ANIMTOOLBOX_MT_Copy_Paste_Matrix', text = 'Copy Paste Matrix', icon_value = custom_icons["copy_matrix"].icon_id)
if bpy.context.preferences.addons[__package__].preferences.multikey:
layout.separator()
layout.menu('ANIMTOOLBOX_MT_Multikey', text = 'Multikey', icon_value = custom_icons["multikey"].icon_id)
layout.separator(factor = 0.5)
layout.operator('anim.switch_collections_visibility', icon = 'COLLECTION_COLOR_06', text = 'Animated Collections Visibilty')
layout.operator('anim.isolate_pose_mode', icon_value = custom_icons["isolate"].icon_id, depress = scene.animtoolbox.isolate_pose_mode)
layout.operator('object.motion_path_operator', text = 'Editable Motion Path', depress = scene.emp.motion_path, icon_value = custom_icons["mt"].icon_id)
layout.separator()
layout.prop(context.preferences.addons[__package__].preferences, 'quick_menu', text = 'Use Quick Icons Menu')
# layout.prop(context.window_manager.atb_ui, 'quick_menu', text = 'Use Quick Icons Menu')
# elif context.area.type == 'DOPESHEET_EDITOR':
# layout.operator('wm.call_menu_pie', text="Pie AnimOffset").name = 'ANIMAIDE_MT_pie_anim_offset'
# layout.menu('ANIMAIDE_MT_anim_offset')
# layout.menu('ANIMAIDE_MT_anim_offset_mask')
# elif context.area.type == 'GRAPH_EDITOR':
# layout.menu('ANIMAIDE_MT_curve_tools_pie')
######################################################## WorkSpaceTools ########################################################
class KeyframeOffsetTool(bpy.types.WorkSpaceTool):
bl_space_type = 'VIEW_3D'
bl_context_mode = 'POSE'
bl_idname = 'animtoolbox.keyframe_offset'
bl_label = 'Keyframe Offset Tool'
bl_description = (
'Offset the keyframes of the selected bone\n'
'Shift + Click to select the bones with an offset\n'
'Ctrl + Alt + Click to Apply the offset'
)
bl_icon = (Path(__file__).parent / "icons" / "ops.anim.keyframe_offset").as_posix()
bl_keymap = (
('anim.keyframe_offset', {'type': 'LEFTMOUSE', 'value': 'CLICK_DRAG'}, None),
# ('anim.select_keyframes_offset', {'type': 'LEFTMOUSE', 'value': 'CLICK', 'shift': True}, None),
('anim.apply_keyframes_offset', {'type': 'LEFTMOUSE', 'value': 'CLICK', 'ctrl': True, 'alt': True}, None),
('view3d.select_box', {'type': 'LEFTMOUSE', 'value': 'CLICK_DRAG', 'shift': True}, {'properties': [('mode', 'ADD')]}),
('view3d.select_box', {'type': 'LEFTMOUSE', 'value': 'CLICK_DRAG', 'ctrl': True}, {'properties': [('mode', 'SUB')]}),
('view3d.select_box', {'type': 'LEFTMOUSE', 'value': 'CLICK_DRAG', 'shift': True, 'ctrl': True}, {'properties': [('mode', 'AND')]}),
('view3d.select', {'type': 'LEFTMOUSE', 'value': 'CLICK'}, {'properties': [('deselect_all', True)]}),
('view3d.select', {'type': 'LEFTMOUSE', 'value': 'CLICK', 'shift': True}, {'properties': [('toggle', True)]}),
('view3d.select', {'type': 'LEFTMOUSE', 'value': 'CLICK', 'alt': True}, {'properties': [('enumerate', True)]}),
('view3d.select', {'type': 'LEFTMOUSE', 'value': 'CLICK', 'shift': True, 'alt': True}, {'properties': [('toggle', True), ('enumerate', True)]})
)
######################################################## PANELS ########################################################
class ANIMTOOLBOX_PT_Panel:
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Animation"
#bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return context.object is not None
class ANIMTOOLBOX_PT_MainPanel(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
#bl_category = "Animation"
bl_label = "AnimToolBox"
bl_idname = "ANIMTOOLBOX_PT_MainPanel"
bl_options = {"DEFAULT_CLOSED"}
def draw_header(self, context):
custom_icons = preview_collections["main"]
self.layout.label(text='', icon_value = custom_icons["animtoolbox"].icon_id)
def draw(self, context):
obj = context.object
if obj is None:
return
# layout = self.layout
# row = layout.row(align = True)
# row.label(text = 'Support me on ')
# row.operator("wm.url_open", text="Patreon", icon = 'FUND').url = "https://www.patreon.com/animtoolbox"
class TEMPCTRLS_PT_Panel(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
#bl_category = "Animation"
bl_label = "Temp Controls"
bl_idname = "TEMPCTRLS_PT_Panel"
bl_parent_id = 'ANIMTOOLBOX_PT_MainPanel'
bl_options = {"DEFAULT_CLOSED"}
def draw_header(self, context):
custom_icons = preview_collections["main"]
self.layout.label(text="", icon_value = custom_icons["puppet"].icon_id)
def draw(self, context):
obj = context.object
btc = context.scene.btc
if obj is None:
return
custom_icons = preview_collections["main"]
oven_icon = custom_icons["oven"]
# row = layout.row()
ui = context.window_manager.atb_ui
layout = self.layout
box = layout.box()
col = box.column()
row = col.row()
row.operator('anim.bake_to_ctrl', text="WorldSpace Ctrls", icon ='WORLD') #
row.operator('anim.add_empty_ctrl', text="", icon ='EMPTY_AXIS')
# col.operator('anim.worldspace_cursor', text="WorldSpace Cursor Ctrls", icon ='ORIENTATION_CURSOR')
col.operator('anim.bake_to_temp_fk', text="Temp FK Ctrls", icon = 'BONE_DATA')
col.operator('anim.bake_to_temp_ik', text="Temp IK Ctrls", icon = 'CON_KINEMATIC')
# layout.separator(factor = 0.1)
# box = layout.box()
row = box.row()
row.operator('anim.link_temp_chains', text="Link Temp Chains", icon = 'DECORATE_LINKED')
row.prop(btc, 'linksettings', text = '', icon = 'SETTINGS')
if btc.linksettings:
# insidebox = box.box()
row = box.row()
row.label(text = 'Link to Active Chain: ')
row.prop(btc, 'link_to', text = '')
row = box.row()
row.operator('anim.unlink_temp_chains', text="UnLink Temp Chains", icon = 'UNLINKED')
# row = insidebox.row()
# row.label(text = 'Link from Chain: ')
# row.prop(btc, 'link_from', text = '')
# layout.separator(factor = 0.1)
box = layout.box()
row = box.row()
row.operator('anim.bake_to_empties', text="WorldSpace Empties")
row.operator('anim.empties_to_bones', text="", icon = 'EMPTY_AXIS')
box = layout.box()
row = box.row()
# row.operator('anim.bake_constrained_bones', text="Quick Bake", icon = 'REC')
row.operator('anim.bake_temp_ctrls', text="Bake Temp Ctrls", icon_value = oven_icon.icon_id)
row.prop(btc, 'bakesettings', text = '', icon = 'SETTINGS')
if btc.bakesettings:
insidebox = box.box()
# split = insidebox.split(factor = 0.9)
# split.prop(btc, 'smartbake', text = 'Smart Bake')
# split.operator('fcurves.filter_ui', icon ='FILTER', text = '')
# if btc.smartbake:
# row.prop(btc, 'inbetween_keyframes')
split = insidebox.split(factor = 0.3)
split.label(text = 'Bake To:')
# split.split(factor = 0.9)
split_2 = split.split(factor = 0.9)
split_2.prop(btc, 'target', text = '')
split_2.operator('fcurves.filter_ui', icon ='FILTER', text = '')
split = insidebox.split(factor = 0.3)
split.label(text = 'Bake Range:')
split.prop(btc, 'bake_range_type', text = '')
if btc.bake_range_type == 'KEYFRAMES':
row = insidebox.row()
row.label(text = 'Keyframes: ')
row.prop(btc, 'smartbake', text = 'Smart Bake')
row.prop(btc, 'bake_layers', text = 'All Layers')
row = insidebox.row()
row.label(text = 'From:')
row.prop(btc, 'from_origin', text = 'Origin')
row.prop(btc, 'from_ctrl', text = 'Ctrls')
elif btc.bake_range_type == 'CUSTOM':
row = insidebox.row()
row.prop(btc, 'bake_range', text = '')
insidebox.separator(factor=0.1)
row = insidebox.row()
row.label(text = 'Clean: ')
row.prop(btc, 'clean_constraints', text = 'Constraints')
if btc.clean_constraints:
row.prop(btc, 'clean_ctrls', text = 'Ctrls')
row = box.row()
row.operator('anim.remove_bones_constraints', text="Cleanup", icon_value = custom_icons["trash"].icon_id)
row.prop(btc, 'cleansettings', text = '', icon = 'SETTINGS')
if btc.cleansettings:
insidebox = box.box()
split = insidebox.split(factor = 0.4)
split.label(text = 'Target: ')
split.prop(btc, 'target', text = '')
row = insidebox.row()
row.label(text = 'Clean: ')
row.prop(btc, 'clean_constraints', text = 'Constraints')
if btc.clean_constraints:
row.prop(btc, 'clean_ctrls', text = 'Ctrls')
if btc.target == 'SELECTED':
split = insidebox.split(factor = 0.68)
split.label(text = 'ReBake Link Ctrls to Original: ')
split.prop(btc, 'rebake_to_org', text = '')
box = layout.box()
row = box.row()
# row = layout.row()
# row.prop(obj.animtoolbox, 'influence', text = 'Influence', slider = True)
ctrl = obj.animtoolbox.controller if obj.animtoolbox.controller else obj
if ctrl:
enabled_icon = 'HIDE_OFF' if ctrl.animtoolbox.ctrls_enabled else 'HIDE_ON'
row.prop(ctrl.animtoolbox, 'ctrls_enabled', text ='Temp Ctrls On/Off', icon = enabled_icon)
# row = box.row()
# row.prop(ui, 'temp_ctrls_switch', icon = 'RESTRICT_VIEW_OFF', text = '')
# row.label(text = 'Temp Ctrls Switch: ')
# row.prop(btc, 'target', text = '', icon = 'MOD_ARMATURE', icon_only = True)
# if ui.temp_ctrls_switch:
# # split = layout.split(factor = 0.225)
# row = box.row()
# row.operator('anim.enable_tempctrls', text="On" , icon = 'HIDE_OFF')#icon = 'CONSTRAINT_BONE'
# row.operator('anim.disable_tempctrls', text="Off", icon = 'HIDE_ON')
box.separator(factor=0.1)
row = box.row()
row.prop(ui, 'temp_ctrls_shapes', icon = 'MESH_DATA', text = '')
row.label(text = 'Temp Ctrls Shapes: ')
row.prop(btc, 'target', text = '', icon = 'MOD_ARMATURE', icon_only = True)
if ui.temp_ctrls_shapes:
row = box.row()
row.prop(btc, 'shape_size', slider = False)
row.prop(btc, 'shape_type', text = '')
row = layout.row()
# split.label(text = 'Theme Color: ')
row.prop(btc, 'color_set', text = '')
layout.separator()
row = layout.row()
row.operator('anim.btc_selections', text="Select", icon = 'CONSTRAINT_BONE')
row.prop(btc, 'selection', text = '', icon = 'CON_ARMATURE')
class ANIMTOOLBOX_PT_Tools(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
bl_label = "Anim Tools"
bl_idname = "ANIMTOOLBOX_PT_Tools"
bl_parent_id = 'ANIMTOOLBOX_PT_MainPanel'
bl_options = {"DEFAULT_CLOSED"}
def draw_header(self, context):
custom_icons = preview_collections["main"]
layout = self.layout
layout.label(text="", icon_value = custom_icons["toolbox"].icon_id)
def draw(self, context):
obj = context.object
scene = context.scene
atb = scene.animtoolbox
if obj is None:
return
custom_icons = preview_collections["main"]
layout = self.layout
ui = context.window_manager.atb_ui
row = layout.row()
filter_text = 'Filter Tools Properties' if atb.filter_name == '' else 'Filters: ' + atb.filter_name
filter_depress = False if atb.filter_name == '' else True
row.operator('fcurves.filter_ui', icon ='FILTER', text = filter_text, depress = filter_depress)
layout.separator()
#layout.operator('anim.keyframes_offset', text="Offset Keyframes", icon = 'NEXT_KEYFRAME')
split = layout.split(factor = 0.9)
split.prop(context.scene.animtoolbox, 'keyframes_offset', slider = True)
split.operator('anim.apply_keyframes_offset', text="", icon_value = custom_icons["apply"].icon_id)
split.operator('anim.select_keyframes_offset', text="", icon_value = custom_icons["select"].icon_id)
row = layout.row()
row.operator('anim.share_keyframes', text = 'Share Keyframes', icon_value = custom_icons["sharekeys"].icon_id)
row = layout.row()
row.operator("anim.relative_cursor", text ='Relative Cursor Pivot', icon_value = custom_icons["relative_cursor"].icon_id, depress = ui.relative_cursor)
row = layout.row()
row.operator("anim.markers_retimer", icon_value = custom_icons["retime"].icon_id, text ='Markers Retimer', depress = ui.markers_retimer)
layout.separator(factor = 0.5)
box = layout.box()
# icon = 'DOWNARROW_HLT' if ui.copy_paste_matrix is True else 'RIGHTARROW_THIN'
# split = box.split(factor = 0.9)
# split.prop(ui, 'copy_paste_matrix', icon_value = custom_icons["copy_matrix"].icon_id, text = 'Copy Paste Matrices')
# split.operator('fcurves.filter', icon ='FILTER', text = '')
row = box.row()
row.prop(ui, 'copy_paste_matrix', icon_value = custom_icons["copy_matrix"].icon_id, text = 'Copy Paste Matrices')
if ui.copy_paste_matrix:
row = box.row()
row.label(text = 'Copy World Matrix :', icon = 'WORLD')
row.operator('anim.copy_matrix', text = '', icon ='DUPLICATE')
row.operator('anim.paste_matrix', text = '', icon ='PASTEDOWN')
row = box.row()
row.label(text = 'Copy Relative Matrix :', icon = 'LINKED')
row.operator('anim.copy_relative_matrix', text = '', icon ='DUPLICATE')
row.operator('anim.paste_relative_matrix', text = '', icon ='PASTEDOWN')
split = box.split(factor = 0.32)
split.label(text = 'Paste To')
split.prop(context.scene.animtoolbox, 'range_type', text = '')
if context.scene.animtoolbox.range_type == 'RANGE':
row = box.row()
row.prop(context.scene.animtoolbox, 'bake_frame_start', text = 'Start')
row.prop(context.scene.animtoolbox, 'bake_frame_end', text = 'End')
row.operator("anim.markers_bakerange", icon = 'MARKER', text ='', depress = context.scene.animtoolbox.bake_frame_range)
layout.separator(factor = 0.5)
box = layout.box()
# icon = 'DOWNARROW_HLT' if ui.Inbetweens is True else 'RIGHTARROW_THIN'
# split = box.split(factor = 0.9)
# split.prop(ui, 'Inbetweens', icon_value = custom_icons["sliders"].icon_id, text = 'Blendings / Inbetweens')
# split.operator('fcurves.filter', icon ='FILTER', text = '')
row = box.row()
row.prop(ui, 'Inbetweens', icon_value = custom_icons["sliders"].icon_id, text = 'Blendings / Inbetweens')
if ui.Inbetweens:
col = box.column()
col.prop(ui, 'inbetween_worldmatrix', slider = True)
col.prop(scene.animtoolbox, 'inbetweener', slider = True)
col.prop(ui, 'blend_mirror', slider = True)
layout.separator(factor = 1)
# my_icon = custom_icons["rotations"]
split = layout.split(factor = 0.6)
split.operator("anim.convert_rotation_mode", icon = 'DRIVER_ROTATIONAL_DIFFERENCE', text = 'Convert Rotation To')
# split.operator("anim.convert_rotation_mode", text = 'Convert Rotation To', icon_value = my_icon.icon_id)
split = split.split(factor = 0.3)
split.operator("anim.find_rotation_mode", icon = 'VIEWZOOM', text = '')
split.prop(context.scene.animtoolbox, 'rotation_mode', text = '')
class MULTIKEY_PT_Panel(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
"""Add random value to selected keyframes"""
bl_label = "Multikey"
bl_idname = "MULTIKEY_PT_Panel"
# bl_space_type = 'VIEW_3D'
# bl_region_type = 'UI'
# bl_category= 'Animation'
bl_parent_id = 'ANIMTOOLBOX_PT_Tools'
bl_options = {"DEFAULT_CLOSED"}
def draw_header(self, context):
custom_icons = preview_collections["main"]
layout = self.layout
layout.label(text="", icon_value = custom_icons["multikey"].icon_id)
def draw(self, context):
custom_icons = preview_collections["main"]
layout = self.layout
ui = context.window_manager.atb_ui
# layout.separator()
split = layout.split(factor=0.1, align = True)
split.prop(ui.multikey, 'selectedbones', icon_value = custom_icons["selected_bones"].icon_id, text = '')
# split = split.split(factor=0.1, align = True)
#split.operator('fcurves.filter', icon ='FILTER', text = '')
split.operator("animtoolbox.multikey", icon = 'ACTION_TWEAK')
layout.separator()
row = layout.row(align = True)
row.prop(ui.multikey, 'scale')
row.prop(ui.multikey, 'randomness', slider = True)
class ANIMTOOLBOX_PT_Display(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
bl_label = "Display"
bl_idname = "ANIMTOOLBOX_PT_Display"
bl_parent_id = 'ANIMTOOLBOX_PT_MainPanel'
bl_options = {"DEFAULT_CLOSED"}
def draw_header(self, context):
custom_icons = preview_collections["main"]
self.layout.label(text="", icon_value = custom_icons["display"].icon_id)
def draw(self, context):
scene = context.scene
layout = self.layout
ui = context.window_manager.atb_ui
custom_icons = preview_collections["main"]
box = layout.box()
row = box.row()
icon = 'DOWNARROW_HLT' if ui.gizmo_size is True else 'RIGHTARROW_THIN'
row.prop(ui, 'gizmo_size', icon = icon, text = 'Add to Gizmo Size:')
if ui.gizmo_size:
row = box.row()
row.prop(scene.animtoolbox, 'gizmo_size', text = '')
row.operator('view3d.gizmo_size_up', text="", icon ='ADD')
row.operator('view3d.gizmo_size_down', text="", icon ='REMOVE')
layout.separator(factor = 0.5)
row = layout.row()
col_vis_icon = 'OUTLINER_COLLECTION' if scene.animtoolbox.col_vis else 'COLLECTION_COLOR_06'
row.operator('anim.switch_collections_visibility', icon = col_vis_icon, text = 'Animated Collections Visibilty', depress = scene.animtoolbox.col_vis)
layout.separator(factor = 0.5)
row = layout.row()
row.operator('anim.isolate_pose_mode', icon_value = custom_icons["isolate"].icon_id, depress = scene.animtoolbox.isolate_pose_mode)
layout.separator(factor = 0.5)
box = layout.box()
row = box.row()
row.operator('object.motion_path_operator', text = 'Editable Motion Path', depress = scene.emp.motion_path, icon_value = custom_icons["mt"].icon_id)
emp = scene.emp
row.prop(scene.emp, 'settings', text = '', icon = 'SETTINGS')
if scene.emp.settings:
split = box.split(factor = 0.4)
split.label(text ='Frame Range')
# row.prop(scene.animtoolbox, 'mp_frame_range', text = '')
split.prop(emp, 'frame_range', text = '')
if emp.frame_range == 'MANUAL':
row = box.row()
row.prop(emp, 'frame_start', text = 'Start')
row.prop(emp, 'frame_end', text = 'End')
# row.operator("anim.markers_bakerange", icon = 'MARKER', text ='', depress = scene.animtoolbox.bake_frame_range)
elif emp.frame_range == 'AROUND':
row = box.row()
row.prop(emp, 'before', text = 'Before')
row.prop(emp, 'after', text = 'After')
box.separator(factor = 0.1)
col = box.column()
col.prop(emp, 'display_size', icon = 'DOWNARROW_HLT')
if emp.display_size:
col.prop(emp, 'thickness', text = 'Line Thickness')
col.prop(emp, 'keyframe_size', text = 'Keyframes Size')
col.prop(emp, 'frame_size', text = 'Frames Size')
box.separator(factor = 0.1)
row = box.row()
row.label(text = 'Visualization Type')
row.prop(emp, 'vis_type', text = '')
if emp.vis_type == 'VELOCITY':
row = box.row()
row.prop(emp, 'velocity_factor', text = '')
row.prop(emp, 'clamp_min', text = '')
row.prop(emp, 'clamp_max', text = '')
row = box.row()
row.prop(emp, 'color_before', text = '')
row.prop(emp, 'color_after', text = '')
row = box.row()
row.prop(emp, 'points', text = 'Points')
row.prop(emp, 'lines', text = 'Lines')
row = box.row()
row.prop(emp, 'handles', text = 'Handles')
row.prop(emp, 'infront', text = 'In Front')
row = box.row()
row.prop(emp, 'display_frames', text = 'Display Keyframe Numbers')
row.separator()
row = box.row()
row.operator('anim.go_to_keyframe')
# row = box.row()
# row.prop(context.preferences.addons[__package__].preferences, 'keyframes_range', text = 'Keyframe Distance Range')
# if context.object is None:
# return
# row.operator("anim.markers_bakerange", icon = 'MARKER', text ='', depress = scene.animtoolbox.bake_frame_range)
class RIGGERTOOLBOX_PT_Panel(ANIMTOOLBOX_PT_Panel, bpy.types.Panel):
bl_label = "Rigger Toolbox"
bl_idname = "RIGGERTOOLBOX_PT_Panel"
bl_parent_id = 'ANIMTOOLBOX_PT_MainPanel'
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
obj = context.object
if obj is None:
return
layout = self.layout
col = layout.column()
col.operator("armature.add_bbone_widgets", text = "Add Bbone Widgets", icon = 'IPO_BEZIER')
col.separator()
col.operator("armature.add_chain_ctrls", text = "Add Chain Controls", icon = 'OUTLINER_DATA_ARMATURE')
col.separator()
col.operator("armature.merge", text = "Merge Rigs", icon = 'MOD_ARMATURE')
# Add-ons Preferences Update Panel
# Define Panel classes for updating
panels = [ANIMTOOLBOX_PT_MainPanel, TEMPCTRLS_PT_Panel, ANIMTOOLBOX_PT_Tools, ANIMTOOLBOX_PT_Display]
def update_panel(self, context):
message = "Animtoolbox: Updating Panel locations has failed"
try:
for panel in panels:
if "bl_rna" in panel.__dict__:
unregister_class(panel)
for panel in panels:
panel.bl_category = context.preferences.addons[__package__].preferences.category
register_class(panel)
except Exception as e:
print("\n[{}]\n{}\n\nError:\n{}".format(__package__, message, e))
pass
def add_multikey(self, context):
if bpy.context.preferences.addons[__package__].preferences is None:
return
if bpy.context.preferences.addons[__package__].preferences.multikey:
multikey.register()
register_class(MULTIKEY_PT_Panel)
register_class(ANIMTOOLBOX_MT_Multikey)
panels.append(MULTIKEY_PT_Panel)
#panels = panels + (MULTIKEY_PT_Panel)
elif hasattr(bpy.types, 'MULTIKEY_PT_Panel'):
multikey.unregister()
panels.remove(MULTIKEY_PT_Panel)
unregister_class(MULTIKEY_PT_Panel)
unregister_class(ANIMTOOLBOX_MT_Multikey)
def add_riggertoolbox(self, context):
if bpy.context.preferences.addons[__package__].preferences is None:
return
if bpy.context.preferences.addons[__package__].preferences.riggertoolbox:
Rigger_Toolbox.register()
register_class(RIGGERTOOLBOX_PT_Panel)
panels.append(RIGGERTOOLBOX_PT_Panel)
#panels = panels + (RIGGERTOOLBOX_PT_Panel)
elif hasattr(bpy.types, 'RIGGERTOOLBOX_PT_Panel'):
Rigger_Toolbox.unregister()
panels.remove(RIGGERTOOLBOX_PT_Panel)
unregister_class(RIGGERTOOLBOX_PT_Panel)
classes = (ANIMTOOLBOX_MT_Copy_Paste_Matrix, ANIMTOOLBOX_MT_Temp_Ctrls, ANIMTOOLBOX_MT_operators, ANIMTOOLBOX_MT_Keyframe_Offset,
ANIMTOOLBOX_MT_Blendings, ANIMTOOLBOX_MT_Convert_Rotations) + tuple(panels)
preview_collections = {}
#list of all the custom icons
icons_list = {'relative_cursor' : 'relative_cursor.png', 'puppet' : 'puppet.png', 'animtoolbox' : 'animtoolbox.png',
'sliders' : 'slider-navigation.png', 'world_space' : 'earth-rotation.png', 'isolate' : 'isolation.png',
'mt' : 'curve.png', 'toolbox' : 'tools.png', 'oven' : 'oven.png', 'retime' : 'retime.png', 'copy_matrix' : 'copy_world.png',
'sharekeys' : 'sharekeys.png', 'sliders' : 'sliders.png', 'keyframe_offset' : 'keyframes_offset.png', 'display' : 'display.png',
'switch' : 'switch.png', 'trash' : 'trash.png', 'apply' : 'apply.png', 'select' : 'select.png', 'worldspace' : 'WorldSpace.png',
'multikey' : 'multikey.png', 'selected_bones' : 'selected_bones.png'}
def register_custom_icon():
# Note that preview collections returned by bpy.utils.previews
# are regular py objects - you can use them to store custom data.
import bpy.utils.previews
custom_icons = bpy.utils.previews.new()
# path to the folder where the icon is
# the path is calculated relative to this py file inside the addon folder
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
# load a preview thumbnail of a file and store in the previews collection
for iconname, icon_file in icons_list.items():
custom_icons.load(iconname, os.path.join(icons_dir, icon_file), 'IMAGE')
preview_collections["main"] = custom_icons
def draw_menu(self, context):
if context.mode == 'OBJECT' or context.mode == 'POSE':
layout = self.layout
scene = context.scene
ui = context.window_manager.atb_ui
custom_icons = preview_collections["main"]
layout.menu('ANIMTOOLBOX_MT_menu_operators') #, icon_value = custom_icons["toolbox"].icon_id, text =''
if not context.preferences.addons[__package__].preferences.quick_menu:
return
#Only Icons Menu
temp_ctrls = custom_icons["puppet"]
layout.menu('ANIMTOOLBOX_MT_Temp_Ctrls' , icon_value = temp_ctrls.icon_id, text ='')
# layout.separator()
layout.menu('ANIMTOOLBOX_MT_Copy_Paste_Matrix' , icon_value = custom_icons["copy_matrix"].icon_id, text ='')
layout.separator()
layout.operator('anim.relative_cursor', text = '', depress = ui.relative_cursor, icon_value = custom_icons["relative_cursor"].icon_id)
layout.separator()
layout.operator('anim.markers_retimer', text = '', depress = ui.markers_retimer, icon_value = custom_icons["retime"].icon_id)
layout.separator()
layout.operator('anim.isolate_pose_mode', text = '', depress = scene.animtoolbox.isolate_pose_mode, icon_value = custom_icons["isolate"].icon_id)
layout.separator()
layout.operator('object.motion_path_operator', text = '', depress = scene.emp.motion_path, icon_value = custom_icons["mt"].icon_id)
+6 -18
View File
@@ -2007,7 +2007,7 @@ def remove_draw_handlers(self):
# Operator with modal and draw handler
class MotionPathOperator(bpy.types.Operator):
"""Creates an editable motion path, Ctrl click to refresh"""
"""Creates a custom motion path"""
bl_idname = "object.motion_path_operator"
bl_label = "Motion Path Operator"
bl_options = {'REGISTER', 'UNDO'}
@@ -2028,15 +2028,10 @@ class MotionPathOperator(bpy.types.Operator):
scene = context.scene
emp = scene.emp
emp.refresh = False
if emp.motion_path:
if event.ctrl:
# Refresh the motion path
emp.refresh = True
else:
emp.motion_path = False
emp.motion_path = False
return {'CANCELLED'}
if context.selected_pose_bones is None and context.selected_objects is None:
emp.motion_path = False
return {'CANCELLED'}
@@ -2242,6 +2237,9 @@ class MotionPathOperator(bpy.types.Operator):
#return negative frame range to what it was
if context.preferences.edit.use_negative_frames != nfr:
context.preferences.edit.use_negative_frames = nfr
#assign the draw handler
context.window_manager.modal_handler_add(self)
#If overlays are turned off then skip drawing
if self.mp_vis:
@@ -2257,9 +2255,6 @@ class MotionPathOperator(bpy.types.Operator):
self.area.tag_redraw()
# Tools.redraw_areas(['VIEW_3D'])
#assign the draw handler
context.window_manager.modal_handler_add(self)
bpy.ops.ed.undo_push(message = 'Initialize Motion Path')
return {'RUNNING_MODAL'}
@@ -2284,11 +2279,6 @@ class MotionPathOperator(bpy.types.Operator):
if not emp.motion_path:
quit_mp(self, context)
return {'FINISHED'}
if emp.refresh:
get_mp_points(self, context)
emp.refresh = False
return {'PASS_THROUGH'}
if not self.mp_vis:
check_mp_vis_on(self, context)
@@ -4874,8 +4864,6 @@ def disbale_non_smooth(self, context):
class EditableMotionPathSettings(bpy.types.PropertyGroup):
'''All the settings for Editable motion path'''
motion_path: bpy.props.BoolProperty(name = "Motion Path", description = "Flag when Motion Path is on", default = False, override = {'LIBRARY_OVERRIDABLE'})
refresh: bpy.props.BoolProperty(name = "Refresh Motion Path", description = "Refresh the motion paths with hotkey ctrl", default = False, override = {'LIBRARY_OVERRIDABLE'})
settings: bpy.props.BoolProperty(name = "Motion Path Settings", description = "Open the settings Menu", default = False, override = {'LIBRARY_OVERRIDABLE'})
# mp_keyframe_scale: bpy.props.FloatProperty(name = "Scale Selecgted Keyframes Bounding Box", description = "Change the scale of the bounding box around the selected keyframes ", default = 0.1, step = 0.1, precision = 3)
color_before: bpy.props.FloatVectorProperty(name="Motion Path Before Color", subtype='COLOR', default=(1.0, 0.0, 0.0), min=0.0, max=1.0, description="Motion path color before the current frame", update=update_prop_callback)
+12 -5
View File
@@ -5,14 +5,14 @@
bl_info = {
"name": "Flamenco",
"author": "Sybren A. Stüvel",
"version": (3, 8, 5),
"version": (3, 10),
"blender": (3, 1, 0),
"description": "Flamenco client for Blender.",
"location": "Output Properties > Flamenco",
"doc_url": "https://flamenco.blender.org/",
"category": "System",
"support": "COMMUNITY",
"warning": "",
"warning": "This is version 3.10-alpha0 of the add-on, which is not a stable release",
}
from pathlib import Path
@@ -20,14 +20,14 @@ from pathlib import Path
__is_first_load = "operators" not in locals()
if __is_first_load:
from . import (
operators,
comms,
gui,
job_types,
comms,
manager_info,
operators,
preferences,
projects,
worker_tags,
manager_info,
)
else:
import importlib
@@ -99,11 +99,18 @@ def register() -> None:
bpy.app.handlers.save_pre.append(_unset_flamenco_job_name)
bpy.app.handlers.save_post.append(_set_flamenco_job_name)
bpy.types.WindowManager.flamenco_can_abort = bpy.props.BoolProperty(
name="Flamenco Can Abort",
default=False,
description="Whether the Flamenco submission can be aborted",
)
bpy.types.WindowManager.flamenco_bat_status = bpy.props.EnumProperty(
items=[
("IDLE", "IDLE", "Not doing anything."),
("SAVING", "SAVING", "Saving your file."),
("INVESTIGATING", "INVESTIGATING", "Finding all dependencies."),
("REWRITING", "REWRITING", "Rewriting blend files."),
("TRANSFERRING", "TRANSFERRING", "Transferring all dependencies."),
("COMMUNICATING", "COMMUNICATING", "Communicating with Flamenco Server."),
("DONE", "DONE", "Not doing anything, but doing something earlier."),
+11
View File
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
# This is the interface to BAT v1.x
def bat_version() -> str:
from .submodules import bat_toplevel
bat_version: str = bat_toplevel.__version__
return bat_version
+9 -2
View File
@@ -539,6 +539,13 @@ def _encode_original_filename_header(filename: str) -> str:
"""
# This is a no-op when the filename is already in ASCII.
fake_header = email.header.Header()
fake_header = email.header.Header(maxlinelen=0)
fake_header.append(filename, charset="utf-8")
return fake_header.encode()
encoded_header = fake_header.encode()
# Make sure that there are no newlines in the returned value.
# HTTP Header line folding is obsolete, see RFC9112 section 5.2 in
# https://www.rfc-editor.org/rfc/rfc9112#name-obsolete-line-folding
assert "\n" not in encoded_header
return encoded_header
@@ -4,5 +4,6 @@ from .. import wheels
_bat_modules = wheels.load_wheel(
"blender_asset_tracer",
("blendfile", "pack", "pack.progress", "pack.transfer", "pack.shaman", "bpathlib"),
filename_prefix="blender_asset_tracer-1.",
)
bat_toplevel, blendfile, pack, progress, transfer, shaman, bpathlib = _bat_modules
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 support for Flamenco.
NOTE: This module uses late imports to avoid importing BAT v2 until it's really
necessary. Functions should _only_ be called from Blender 5.1+ (Python 3.13+),
as BAT uses language features that were not available in 5.0 (Python 3.11).
"""
def bat_version() -> str:
from .submodules import bat_toplevel
return bat_toplevel.__version__
+39
View File
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 packing to the filesystem."""
from __future__ import annotations
__all__ = ("pack_start",)
from pathlib import Path
from typing import Any, TypeAlias
# Alias some types from blender_asset_tracer so that we can use type annotations
# without having to import from BATv2.
BATPackReporter: TypeAlias = Any
BATPacker: TypeAlias = Any
def pack_start(
project_root: Path,
reporter: BATPackReporter,
*,
use_relative_only: bool,
pack_target_dir: Path,
ignore_globs: set[str] = set(),
) -> BATPacker:
"""Investigate what's needed to create a BAT pack."""
from .submodules import file_usage, pack
batpacker = pack.BATPacker(
project_root,
file_usage.Options(
use_relative_only=use_relative_only,
ignore_globs=ignore_globs,
),
reporter,
pack_target_dir=pack_target_dir,
)
return batpacker
@@ -0,0 +1,594 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
# SPDX-License-Identifier: GPL-3.0-or-later
"""BAT v2 packing to a Shaman server."""
from __future__ import annotations
__all__ = ("pack_start",)
import dataclasses
import email.header
import logging
import random
from functools import partial
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, TypeAlias
import bpy
if TYPE_CHECKING:
# _BATPacker: TypeAlias = pack.BATPacker
from ..manager import ApiClient as _ApiClient
from ..manager.apis import ShamanApi as _ShamanApi
from ..manager.models import (
ShamanCheckoutResult as _ShamanCheckoutResult,
)
from ..manager.models import (
ShamanFileSpec as _ShamanFileSpec,
)
from ..manager.models import (
ShamanRequirementsRequest as _ShamanRequirementsRequest,
)
from .submodules.file_usage import FileInfo as _FileInfo
from .submodules.pack import BATPacker as _BATPacker
from .submodules.pack import QueueingExecutor as _QueueingExecutor
else:
_ApiClient = object
_ShamanApi = object
_ShamanCheckoutResult = object
_ShamanRequirementsRequest = object
_ShamanFileSpec = object
_BATPacker = object
_FileInfo = object
_QueueingExecutor = object
log = logging.getLogger(__name__)
MAX_DEFERRED_PATHS = 8
MAX_FAILED_PATHS = 8
HASH_STORAGE_PATH = Path(bpy.app.cachedir) / "flamenco/shaman"
HASH_METHOD = "sha256"
SHAMAN_JOBS_VARIABLE = "{jobs}"
HashableShamanFileSpec = tuple[str, int, str]
"""Tuple of the 'sha', 'size', and 'path' fields of a ShamanFileSpec."""
# Alias some types from blender_asset_tracer so that we can use type annotations
# without having to import from BATv2.
BATPackReporter: TypeAlias = Any
BATPacker: TypeAlias = Any
def pack_start(
project_root: Path,
reporter: BATPackReporter,
*,
use_relative_only: bool,
api_client: _ApiClient,
checkout_path: PurePosixPath,
ignore_globs: set[str] = set(),
) -> BATPacker:
"""Investigate what's needed to create a BAT pack."""
from ..manager.apis import ShamanApi
from .submodules import file_usage, pack
shaman_api = ShamanApi(api_client)
executor = pack.QueueingExecutor()
shaman_transferer = ShamanPacker(shaman_api, checkout_path, executor, reporter)
batpacker = pack.BATPacker(
project_root,
file_usage.Options(
use_relative_only=use_relative_only,
ignore_globs=ignore_globs,
),
reporter,
file_transfer=shaman_transferer,
)
return batpacker
@dataclasses.dataclass
class ShamanUploadProgress:
# When another client is already uploading a file that we also want to
# upload, we defer the file. That way, when we do get around to uploading
# it, the other person may already have finished their upload, saving us
# time.
#
# Mapping from 'path in pack' to the BAT FileInfo and Shaman FileSpec.
deferred: dict[PurePosixPath, tuple[_FileInfo, _ShamanFileSpec]] = (
dataclasses.field(default_factory=dict)
)
# When a file doesn't want to get uploaded, it's stored here to retry. If
# too many files fail, or the retry counter reaches its max, it'll get
# reported as an actual error.
# The string value is the error message.
failures: dict[PurePosixPath, tuple[_FileInfo, _ShamanFileSpec, str]] = (
dataclasses.field(default_factory=dict)
)
retry_counter: int = 0
max_retries: int = 50
def is_deferred(self, relpath_in_pack: PurePosixPath) -> bool:
return relpath_in_pack in self.deferred
@property
def num_deferred(self) -> int:
return len(self.deferred)
@property
def num_failed(self) -> int:
return len(self.failures)
def defer(
self,
bat_file_info: _FileInfo,
shaman_file_spec: _ShamanFileSpec,
) -> None:
# A file should only be deferred once. Once an upload has been deferred,
# the next attempt shouldn't be deferred again.
assert bat_file_info.relpath_in_pack not in self.deferred
self.deferred[bat_file_info.relpath_in_pack] = (bat_file_info, shaman_file_spec)
def failed(
self,
bat_file_info: _FileInfo,
shaman_file_spec: _ShamanFileSpec,
errormsg: str,
) -> None:
# A file should only be added to the 'failures' dict once. When its
# upload is retried, it should be removed from the 'failures' dict first.
assert bat_file_info.relpath_in_pack not in self.failures
self.failures[bat_file_info.relpath_in_pack] = (
bat_file_info,
shaman_file_spec,
errormsg,
)
@dataclasses.dataclass
class ShamanPacker:
shaman_api: _ShamanApi
checkout_path: PurePosixPath
executor: _QueueingExecutor
reporter: BATPackReporter
# Shaman may decide to create the checkout at another path than requested.
# This will be set to the actually-used path on the farm, relative to the
# Shaman's "jobs" directory.
#
# NOTE: It is the checkout path of the job, NOT the path to the blend file.
_checkout_path_final: PurePosixPath | None = None
_source_file_relpath_in_pack: PurePosixPath | None = None
_num_files_to_transfer_total: int = -1
_num_files_to_transfer_done: int = 0
@property
def is_succes(self) -> bool:
"""Return whether the Shaman operation was completed succesfully."""
return bool(self._checkout_path_final)
def start(self, batpacker: _BATPacker) -> None:
files_to_copy = batpacker.all_files_to_copy()
# Initial value is the total number of files to copy. Once the Shaman
# server has told us how many files to submit, this will be adjusted.
self._num_files_to_transfer_total = len(files_to_copy)
self._num_files_to_transfer_done = 0
# Remember where the main blend file sits in the BAT pack.
source_file_info = batpacker.deps_repo.source_file_info()
assert source_file_info.relpath_in_pack is not None
self._source_file_relpath_in_pack = PurePosixPath(
source_file_info.relpath_in_pack.as_posix()
)
self.executor.queue(partial(self._step_queue_hashing, files_to_copy))
def step(self) -> bool:
"""Perform a single step in the Shaman file transfer.
Returns whether there are more steps to do (True) or the process is done (False).
"""
if self.executor.is_done:
return False
self.executor.run_step()
return not self.executor.is_done
def blendfile_location_in_pack(self) -> PurePosixPath:
assert self._checkout_path_final is not None
assert self._source_file_relpath_in_pack is not None
return (
PurePosixPath(SHAMAN_JOBS_VARIABLE)
/ self._checkout_path_final
/ self._source_file_relpath_in_pack
)
def num_files_to_transfer(self) -> tuple[int, int]:
"""Return the number of files that need to be transferred.
This is a tuple [total, done] with the total number of files to
transfer, and the number of transferred files so far.
The number may change during the packing process, as it takes time
for the Shaman protocol to get this information. Or some paths may
turn out to be multiple paths (UDIMs for example).
"""
return self._num_files_to_transfer_total, self._num_files_to_transfer_done
def _step_queue_hashing(self, files_to_copy: dict[Path, _FileInfo]) -> None:
from ..manager.models import ShamanRequirementsRequest
# Shaman Spec that's shared between the queued function calls. They
# can all just append to the same list.
shaman_spec = ShamanRequirementsRequest(files=[])
assert isinstance(shaman_spec, ShamanRequirementsRequest)
# Tracks deferred files and failed uploads.
upload_progress = ShamanUploadProgress()
# Queue up all the hash computations.
for file_info in files_to_copy.values():
self.executor.queue(
partial(
self._step_hash_file,
file_info,
shaman_spec,
)
)
# After the hashes are gathered in 'filespecs', send the spec to Shaman.
self.executor.queue(
partial(
self._step_queue_uploads_of_files,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_hash_file(
self,
file_info: _FileInfo,
shaman_spec: _ShamanRequirementsRequest,
) -> None:
from _bpy_internal import disk_file_hash_service
from ..manager.models import ShamanFileSpec
path_to_pack = file_info.path_to_pack
if not path_to_pack.exists():
# If the file is missing, there's little else to do than reporting
# it as such and continue with the next file.
if path_to_pack == file_info.source_path:
log.info("File missing: %s", path_to_pack)
else:
log.info(
"File missing after rewriting %s to %s",
file_info.source_path,
path_to_pack,
)
self.reporter.on_missing_file(
file_info.source_path, file_info.relpath_in_pack
)
return
# It might be tempting to use the same Disk File Hash Service as BAT's
# path rewriting system is using. However, that only hashes the files
# that need rewriting, and the code below only deals with paths after
# rewriting (or where rewriting was not necessary). That means that
# there is no benefit in sharing the same database.
dfhs = disk_file_hash_service.get_service(HASH_STORAGE_PATH)
checksum = dfhs.get_hash(path_to_pack, HASH_METHOD)
filesize = path_to_pack.stat().st_size
filespec = ShamanFileSpec(
sha=checksum,
size=filesize,
path=str(file_info.relpath_in_pack),
)
assert isinstance(filespec, ShamanFileSpec)
shaman_spec.files.append(filespec)
def _step_queue_uploads_of_files(
self,
files_to_copy: dict[Path, _FileInfo],
shaman_spec: _ShamanRequirementsRequest,
upload_progress: ShamanUploadProgress,
) -> None:
"""Send the spec file to Shaman, and queue file uploads."""
# Query Shaman to figure out which files still need uploading.
to_upload = self._send_spec_to_shaman(shaman_spec)
if to_upload is None:
# Errors have been reported already, so just stop.
return
log.info(
"Feeding %d/%d files to the Shaman", len(to_upload), len(shaman_spec.files)
)
self._num_files_to_transfer_total = len(to_upload)
# Create a mapping from the path in the pack (which is used in
# `filespecs`) to the FileInfo.
path_in_pack_to_abs: dict[str, _FileInfo] = {
str(file_info.relpath_in_pack): file_info
for file_info in files_to_copy.values()
}
# Queue the file uploads.
for index, file_spec in enumerate(to_upload):
file_info = path_in_pack_to_abs[file_spec.path]
is_last_file = index == len(to_upload)
self.executor.queue(
partial(
self._step_queue_upload_file,
file_info,
file_spec,
is_last_file,
upload_progress,
)
)
self.executor.queue(
partial(
self._step_check_upload_success,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_queue_upload_file(
self,
file_info: _FileInfo,
file_spec: _ShamanFileSpec,
is_last_file: bool,
upload_progress: ShamanUploadProgress,
) -> None:
# Pre-flight check. The generated API code will load the entire file
# into memory before sending it to the Shaman. It's faster to do a check
# at Shaman first, to see if we need uploading at all.
check_resp = self.shaman_api.shaman_file_store_check(
checksum=file_spec.sha,
filesize=file_spec.size,
)
if check_resp.status.value == "stored":
log.info(" %s: skipping, already on server", file_spec.path)
return
# Do the 'start' reporting in a separate step, so that the Blender UI
# can be updated for it. The 'done'/'error' reports are done at the end
# of the file upload step, and so these don't need a separate step.
self.executor.queue(partial(self._step_report_upload_file_start, file_info))
self.executor.queue(
partial(
self._step_upload_file,
file_info,
file_spec,
is_last_file,
upload_progress,
)
)
def _step_report_upload_file_start(self, file_info: _FileInfo) -> None:
self.reporter.on_copy_start(file_info.source_path, file_info.relpath_in_pack)
def _step_upload_file(
self,
file_info: _FileInfo,
file_spec: _ShamanFileSpec,
is_last_file: bool,
upload_progress: ShamanUploadProgress,
) -> None:
from ..manager.exceptions import ApiException
# See whether we may be able to defer uploading this file or not.
can_defer = bool(
not is_last_file
and upload_progress.num_deferred < MAX_DEFERRED_PATHS
and not upload_progress.is_deferred(file_info.relpath_in_pack)
)
filename_header = _encode_original_filename_header(file_spec.path)
try:
with file_info.path_to_pack.open("rb") as file_reader:
self.shaman_api.shaman_file_store(
checksum=file_spec.sha,
filesize=file_spec.size,
body=file_reader,
x_shaman_can_defer_upload=can_defer,
x_shaman_original_filename=filename_header,
)
except ApiException as ex:
if ex.status == 425:
# Too Early, i.e. defer uploading this file.
log.info(
" %s: someone else is uploading this file, deferring",
file_spec.path,
)
upload_progress.defer(file_info, file_spec)
return
elif ex.status == 417:
# Expectation Failed; mismatch of checksum or file size.
msg = "Error from Shaman uploading %s, code %d: %s" % (
file_spec.path,
ex.status,
ex.body,
)
else: # Unknown error
msg = "API exception\nHeaders: %s\nBody: %s\n" % (
ex.headers,
ex.body,
)
log.error(msg)
upload_progress.failed(file_info, file_spec, msg)
return
self._num_files_to_transfer_done += 1
self.reporter.on_copy_done(file_info.source_path, file_info.relpath_in_pack)
def _step_check_upload_success(
self,
files_to_copy: dict[Path, _FileInfo],
shaman_spec: _ShamanRequirementsRequest,
upload_progress: ShamanUploadProgress,
) -> None:
"""See if there were any deferred or failed files.
If there were, re-queue the uploading of the remaining files.
Unless the number of retries has been exceeded, in which case the
failures are final.
"""
if upload_progress.num_deferred == 0 and upload_progress.num_failed == 0:
# Nothing left to do, so move on to the next stage.
self.executor.queue(partial(self._step_request_checkout, shaman_spec))
return
upload_progress.retry_counter += 1
if upload_progress.retry_counter >= upload_progress.max_retries:
# Failed uploads have really failed now.
#
# Deferred uploads shouldn't be mentioned, because they only get
# deferred on the first upload attempt. After that, if they fail,
# they get into the failures.
for fileinfo, _, errormsg in upload_progress.failures.values():
self.reporter.on_copy_error(
fileinfo.source_path, fileinfo.relpath_in_pack, errormsg
)
return
# Retry uploading.
self.executor.queue(
partial(
self._step_queue_uploads_of_files,
files_to_copy,
shaman_spec,
upload_progress,
)
)
def _step_request_checkout(self, shaman_spec: _ShamanRequirementsRequest) -> None:
"""Ask the Shaman to create a checkout of this BAT pack."""
assert self.checkout_path
from ..manager.exceptions import ApiException
from ..manager.models import ShamanCheckout, ShamanCheckoutResult
log.info(
"Requesting checkout at Shaman for checkout_path=%s", self.checkout_path
)
checkoutRequest = ShamanCheckout(
files=shaman_spec.files,
checkout_path=str(self.checkout_path),
)
try:
result: ShamanCheckoutResult = self.shaman_api.shaman_checkout(
checkoutRequest
)
except ApiException as ex:
if ex.status == 424: # Files were missing
msg = "We did not upload some files, checkout aborted"
elif ex.status == 409: # Checkout already exists
msg = "There is already an existing checkout at %s" % self.checkout_path
else: # Unknown error
msg = "API exception\nHeaders: %s\nBody: %s\n" % (
ex.headers,
ex.body,
)
log.error(msg)
self.reporter.on_error(msg)
return
log.info("Shaman created checkout at %s", result.checkout_path)
self._checkout_path_final = result.checkout_path
def _send_spec_to_shaman(
self,
requirements: _ShamanRequirementsRequest,
) -> list[_ShamanFileSpec] | None:
"""Send the checkout definition file to the Shaman.
:return: A list of file specs that still need to be uploaded, or
None if there was an error.
"""
from ..manager.exceptions import ApiException
from ..manager.models import ShamanRequirementsResponse
requested_relpaths = {file.path for file in requirements.files}
try:
resp = self.shaman_api.shaman_checkout_requirements(requirements)
except ApiException as ex:
# TODO: the body should be JSON of a predefined type, parse it to get the actual message.
msg = "Error from Shaman, code %d: %s" % (ex.status, ex.body)
log.error(msg)
self.reporter.on_error(msg)
return None
assert isinstance(resp, ShamanRequirementsResponse)
# Go over the response, and create two queues for uploading. Any file
# that's already being uploaded by somebody else will be put in the
# low-priority queue.
to_upload_normal_prio: list[_ShamanFileSpec] = []
to_upload_low_prio: list[_ShamanFileSpec] = []
for file_spec in resp.files:
if file_spec.path not in requested_relpaths:
msg = (
"Shaman requested path we did not intend to upload: %r" % file_spec
)
log.error(msg)
self.reporter.on_error(msg)
return None
log.debug(" %s: %s", file_spec.status, file_spec.path)
status = file_spec.status.value
if status == "unknown":
to_upload_normal_prio.append(file_spec)
elif status == "uploading":
to_upload_low_prio.append(file_spec)
else:
msg = "Unknown status in response from Shaman: %r" % file_spec
log.error(msg)
self.reporter.on_error(msg)
return None
# Randomize the two lists, so that when two clients upload similar sets
# of files, collissions are minimized.
random.shuffle(to_upload_normal_prio)
random.shuffle(to_upload_low_prio)
return to_upload_normal_prio + to_upload_low_prio
def _encode_original_filename_header(filename: str) -> str:
"""Encode the 'original filename' as valid HTTP Header.
See the specs for the X-Shaman-Original-Filename header in the OpenAPI
operation `shamanFileStore`, defined in flamenco-openapi.yaml.
"""
# This is a no-op when the filename is already in ASCII.
fake_header = email.header.Header(maxlinelen=0)
fake_header.append(filename, charset="utf-8")
encoded_header = fake_header.encode()
# Make sure that there are no newlines in the returned value.
# HTTP Header line folding is obsolete, see RFC9112 section 5.2 in
# https://www.rfc-editor.org/rfc/rfc9112#name-obsolete-line-folding
assert "\n" not in encoded_header
return encoded_header
@@ -0,0 +1,72 @@
import os
from pathlib import Path
from typing import TYPE_CHECKING
# SORRY FOR THE COMPLEXITY!
#
# Flamenco needs to be able to deal with BAT versions 1 and 2 at the same time,
# by loading them from different wheel files, depending on which version of
# Blender is running.
#
# At the same time, there's developers who will be really happy when mypy can do
# its thing, and when Blender can load mypy and BAT from virtual environments.
WHEEL_MODULE = "blender_asset_tracer"
WHEEL_FILENAME_PREFIX = "blender_asset_tracer-2."
if TYPE_CHECKING:
# When type-checking, BAT should be importable.
import blender_asset_tracer as bat_toplevel
from blender_asset_tracer import (
file_usage,
pack,
path_rewriting,
path_rewriting_process,
)
else:
# For development only: if we can import BAT directly, just assume it's the
# right version and go with it.
if "VIRTUAL_ENV" in os.environ:
import site
from pathlib import Path
venv_path = Path(os.environ["VIRTUAL_ENV"])
print(f"Reactivating virtualenv: {venv_path}")
# Add the virtual environments libraries.
lib_dirs_posix = list(venv_path.rglob("lib/*/site-packages"))
lib_dirs_windows = list(venv_path.rglob("Lib/site-packages"))
for lib_dir in lib_dirs_posix + lib_dirs_windows:
site.addsitedir(str(lib_dir))
try:
import blender_asset_tracer as bat_toplevel
from blender_asset_tracer import (
file_usage,
pack,
path_rewriting,
path_rewriting_process,
)
except ImportError:
# At runtime, some trickery is necessary to load BAT from the bundled wheel file, without making
# it available in `sys.modules` (to prevent interaction with other add-ons).
from .. import wheels
# Load all the submodules we need from BAT in one go.
_bat_modules = wheels.load_wheel(
WHEEL_MODULE,
("file_usage", "pack", "path_rewriting", "path_rewriting_process"),
filename_prefix=WHEEL_FILENAME_PREFIX,
)
bat_toplevel, file_usage, pack, path_rewriting, path_rewriting_process = (
_bat_modules
)
# Expose the location of the wheel file to BAT by setting an environment
# variable. This is necessary for BAT's path rewriting sub-process, in
# order to know where to load its own sources from.
wheel_filename: Path = wheels.filename(
WHEEL_MODULE,
filename_prefix=WHEEL_FILENAME_PREFIX,
)
os.environ["BAT_WHEEL"] = str(wheel_filename)
+14 -7
View File
@@ -1,16 +1,18 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# <pep8 compliant>
from typing import Optional, TYPE_CHECKING
from . import preferences, job_types
from .job_types_propgroup import JobTypePropertyGroup
from typing import TYPE_CHECKING, Optional
import bpy
from . import job_types, preferences
from .job_types_propgroup import JobTypePropertyGroup
if TYPE_CHECKING:
from flamenco.manager.models import (
AvailableJobSetting as _AvailableJobSetting,
)
from flamenco.manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
@@ -178,13 +180,18 @@ class FLAMENCO_PT_job_submission(bpy.types.Panel):
elif flamenco_status == "INVESTIGATING":
row = layout.row(align=True)
row.label(text="Investigating your files")
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif flamenco_status == "REWRITING":
row = layout.row(align=True)
row.label(text="Rewriting files")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif flamenco_status == "COMMUNICATING":
layout.label(text="Communicating with Flamenco Server")
elif flamenco_status == "ABORTING":
row = layout.row(align=True)
row.label(text="Aborting, please wait.")
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
if flamenco_status == "TRANSFERRING":
row = layout.row(align=True)
row.prop(
@@ -192,7 +199,7 @@ class FLAMENCO_PT_job_submission(bpy.types.Panel):
"flamenco_bat_progress",
text=context.window_manager.flamenco_bat_status_txt,
)
# row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL")
row.operator("flamenco.abort", text="", icon="CANCEL")
elif (
flamenco_status != "IDLE" and context.window_manager.flamenco_bat_status_txt
):
+13 -7
View File
@@ -1,20 +1,23 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import platform
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Optional, Union
import platform
import logging
import bpy
from .job_types_propgroup import JobTypePropertyGroup
from .bat.submodules import bpathlib
from . import manager_info
from .bat.submodules import bpathlib
if TYPE_CHECKING:
from .manager import ApiClient as _ApiClient
from .manager.models import (
AvailableJobType as _AvailableJobType,
)
from .manager.models import (
Job as _Job,
)
from .manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
@@ -32,8 +35,11 @@ log = logging.getLogger(__name__)
def job_for_scene(scene: bpy.types.Scene) -> Optional[_SubmittedJob]:
from flamenco.manager.models import SubmittedJob, JobMetadata
from flamenco.manager.model.job_status import JobStatus
from flamenco.manager.models import JobMetadata, SubmittedJob
# Do a late import, to make the order in which this file is imported less sensitive.
from .job_types_propgroup import JobTypePropertyGroup
propgroup = getattr(scene, "flamenco_job_settings", None)
assert isinstance(propgroup, JobTypePropertyGroup), "did not expect %s" % (
@@ -106,7 +112,7 @@ def submit_job(job: _SubmittedJob, api_client: _ApiClient) -> _Job:
"""Send the given job to Flamenco Manager."""
from flamenco.manager import ApiClient
from flamenco.manager.api import jobs_api
from flamenco.manager.models import SubmittedJob, Job
from flamenco.manager.models import Job, SubmittedJob
assert isinstance(job, SubmittedJob), "got %s" % type(job)
assert isinstance(api_client, ApiClient), "got %s" % type(api_client)
@@ -122,7 +128,7 @@ def submit_job_check(job: _SubmittedJob, api_client: _ApiClient) -> None:
"""Check the given job at Flamenco Manager to see if it is acceptable."""
from flamenco.manager import ApiClient
from flamenco.manager.api import jobs_api
from flamenco.manager.models import SubmittedJob, Job
from flamenco.manager.models import Job, SubmittedJob
assert isinstance(job, SubmittedJob), "got %s" % type(job)
assert isinstance(api_client, ApiClient), "got %s" % type(api_client)
+1 -1
View File
@@ -10,7 +10,7 @@
"""
__version__ = "3.8.5"
__version__ = "3.10-alpha0"
# import ApiClient
from flamenco.manager.api_client import ApiClient
@@ -76,7 +76,7 @@ class ApiClient(object):
self.default_headers[header_name] = header_value
self.cookie = cookie
# Set default User-Agent.
self.user_agent = 'Flamenco/3.8.5 (Blender add-on)'
self.user_agent = 'Flamenco/3.10-alpha0 (Blender add-on)'
def __enter__(self):
return self
@@ -404,7 +404,7 @@ conf = flamenco.manager.Configuration(
"OS: {env}\n"\
"Python Version: {pyversion}\n"\
"Version of the API: 1.0.0\n"\
"SDK Package Version: 3.8.5".\
"SDK Package Version: 3.10-alpha0".\
format(env=sys.platform, pyversion=sys.version)
def get_host_settings(self):
+1 -1
View File
@@ -4,7 +4,7 @@ Render Farm manager API
The `flamenco.manager` package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.0.0
- Package version: 3.8.5
- Package version: 3.10-alpha0
- Build package: org.openapitools.codegen.languages.PythonClientCodegen
For more information, please visit [https://flamenco.blender.org/](https://flamenco.blender.org/)
+346 -382
View File
@@ -3,29 +3,33 @@
import datetime
import logging
import os
import time
from pathlib import Path, PurePosixPath
from typing import Optional, TYPE_CHECKING
from urllib3.exceptions import HTTPError, MaxRetryError
from pathlib import Path, PurePath, PurePosixPath
from types import ModuleType
from typing import TYPE_CHECKING, Optional
import bpy
from urllib3.exceptions import HTTPError, MaxRetryError
from . import job_types, job_submission, preferences, manager_info
from .job_types_propgroup import JobTypePropertyGroup
from . import job_submission, job_types, manager_info, preferences
from .bat.submodules import bpathlib
from .job_types_propgroup import JobTypePropertyGroup
if TYPE_CHECKING:
from .bat.interface import (
PackThread as _PackThread,
Message as _Message,
)
from .manager.models import (
Error as _Error,
SubmittedJob as _SubmittedJob,
from .bat.interface import (
PackThread as _PackThread,
)
from .manager.api_client import ApiClient as _ApiClient
from .manager.exceptions import ApiException as _ApiException
from .manager.models import (
Error as _Error,
)
from .manager.models import (
SubmittedJob as _SubmittedJob,
)
else:
_PackThread = object
_Message = object
@@ -34,6 +38,21 @@ else:
_ApiException = object
_Error = object
# Conditionally import BAT v2, as that version requires Blender 5.1+.
#
# Blender 5.1.0 still had some limitations, most importantly missing support for geometry nodes
# simulation caches (https://projects.blender.org/blender/blender/issues/155953). That should be
# fixed in 5.1.1 though.
bat_v2: ModuleType | None
if bpy.app.version >= (5, 1, 1) or TYPE_CHECKING:
from . import bat_v2
from .bat_v2.pack_fs import BATPacker
_BATPacker = BATPacker
else:
bat_v2 = None
_BATPacker = object
_log = logging.getLogger(__name__)
@@ -111,15 +130,21 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
job_name: bpy.props.StringProperty(name="Job Name") # type: ignore
job: Optional[_SubmittedJob] = None
temp_blendfile: Optional[Path] = None
ignore_version_mismatch: bpy.props.BoolProperty( # type: ignore
name="Ignore Version Mismatch",
default=False,
)
TIMER_PERIOD = 0.25 # seconds
TIMER_PERIOD_BAT_V2 = 0.01 # seconds
timer: Optional[bpy.types.Timer] = None
# For BAT v1:
packthread: Optional[_PackThread] = None
# For BAT v2:
bat_v2_packer: _BATPacker | None = None
bat_v2_packer_reported_error: bool = False
bat_v2_packer_missing_files: list[Path]
log = _log.getChild(bl_idname)
@@ -131,55 +156,96 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return job_type is not None
def execute(self, context: bpy.types.Context) -> set[str]:
"""Submit the job files in a blocking way.
This allows scripted submission, which blocks the main thread until
the process is done.
"""
filepath, ok = self._presubmit_check(context)
if not ok:
return {"CANCELLED"}
is_running = self._submit_files(context, filepath)
# Use BAT v2 if available.
if bat_v2 is not None:
is_running = self._submit_files_bat_v2(context, filepath)
if not is_running:
return {"CANCELLED"}
# self.bat_v2_packer is None when no packing is necessary, that is, when the file is
# already on the shared storage.
while self.bat_v2_packer and self.bat_v2_packer.step():
pass
return self.bat_v2_packer_finalize_and_quit(context)
is_running = self._submit_files_bat_v1(context, filepath)
if not is_running:
return {"CANCELLED"}
if self.packthread is None:
# If there is no pack thread running, there isn't much we can do.
self.report({"ERROR"}, "No pack thread running, please report a bug")
self._quit(context)
return {"CANCELLED"}
# Keep handling messages from the background thread. That's only necessary if there is a
# background thread.
if self.packthread:
while True:
# Block for 5 seconds at a time. The exact duration doesn't matter,
# as this while-loop is blocking the main thread anyway.
msg = self.packthread.poll(timeout=5)
if not msg:
# No message received, is fine, just wait for another one.
continue
# Keep handling messages from the background thread.
while True:
# Block for 5 seconds at a time. The exact duration doesn't matter,
# as this while-loop is blocking the main thread anyway.
msg = self.packthread.poll(timeout=5)
if not msg:
# No message received, is fine, just wait for another one.
continue
result = self._on_bat_pack_msg(context, msg)
if "RUNNING_MODAL" not in result:
break
result = self._on_bat_pack_msg(context, msg)
if "RUNNING_MODAL" not in result:
break
self.packthread.join(timeout=5)
self._quit(context)
self.packthread.join(timeout=5)
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
filepath, ok = self._presubmit_check(context)
if not ok:
return {"CANCELLED"}
is_running = self._submit_files(context, filepath)
if bat_v2 is None:
is_running = self._submit_files_bat_v1(context, filepath)
else:
is_running = self._submit_files_bat_v2(context, filepath)
if not is_running:
return {"CANCELLED"}
context.window_manager.modal_handler_add(self)
if bat_v2:
# Only BATv2 supports aborting the packing.
self.report({"INFO"}, "Flamenco: Submitting files, press ESC to abort")
wm = context.window_manager
self.timer = wm.event_timer_add(self.TIMER_PERIOD_BAT_V2, window=context.window)
wm.modal_handler_add(self)
return {"RUNNING_MODAL"}
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
wm = bpy.context.window_manager
# Only BAT v2 has support for aborting the packing operation.
should_abort = event.type == "ESC" or wm.flamenco_bat_status == "ABORTING"
if self.bat_v2_packer is not None and should_abort:
self.report({"WARNING"}, "Flamenco: Job submission aborted")
wm.flamenco_bat_status = "ABORTED"
wm.flamenco_bat_status_txt = ""
return self._quit(context)
# This function is called for TIMER events to poll the BAT pack thread.
if event.type != "TIMER":
return {"PASS_THROUGH"}
if self.bat_v2_packer is not None:
# BAT v2 pack is underway.
keep_going = self.bat_v2_packer.step()
if keep_going:
return {"RUNNING_MODAL"}
return self.bat_v2_packer_finalize_and_quit(context)
if self.packthread is None:
# If there is no pack thread running, there isn't much we can do.
return self._quit(context)
@@ -304,13 +370,7 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
We shouldn't overwrite the artist's file.
We can compress, since this file won't be managed by SVN and doesn't need diffability.
"""
render = context.scene.render
prefs = context.preferences
# Remember settings we need to restore after saving.
old_use_file_extension = render.use_file_extension
old_use_overwrite = render.use_overwrite
old_use_placeholder = render.use_placeholder
old_use_all_linked_data_direct = getattr(
prefs.experimental, "use_all_linked_data_direct", None
)
@@ -318,44 +378,23 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
# TODO: see about disabling the denoiser (like the old Blender Cloud addon did).
try:
# The file extension should be determined by the render settings, not necessarily
# by the settings in the output panel.
render.use_file_extension = True
# Rescheduling should not overwrite existing frames.
render.use_overwrite = False
render.use_placeholder = False
# To work around a shortcoming of BAT, ensure that all
# To work around a shortcoming of BAT v1, ensure that all
# indirectly-linked data is still saved as directly-linked.
#
# See `133dde41bb5b: Improve handling of (in)directly linked status
# for linked IDs` in Blender's Git repository.
if old_use_all_linked_data_direct is not None:
if bat_v2 is None and old_use_all_linked_data_direct is not None:
self.log.info(
"Overriding prefs.experimental.use_all_linked_data_direct = True"
)
prefs.experimental.use_all_linked_data_direct = True
filepath = Path(context.blend_data.filepath)
if job_submission.is_file_inside_job_storage(context, filepath):
self.log.info(
"Saving blendfile, already in shared storage: %s", filepath
)
if bpy.data.is_dirty:
self.log.info("Saving blendfile: %s", filepath)
bpy.ops.wm.save_as_mainfile()
else:
filepath = filepath.with_suffix(".flamenco.blend")
self.log.info("Saving copy to temporary file %s", filepath)
bpy.ops.wm.save_as_mainfile(
filepath=str(filepath), compress=True, copy=True
)
self.temp_blendfile = filepath
finally:
# Restore the settings we changed, even after an exception.
render.use_file_extension = old_use_file_extension
render.use_overwrite = old_use_overwrite
render.use_placeholder = old_use_placeholder
# Only restore if the property exists to begin with:
if old_use_all_linked_data_direct is not None:
prefs.experimental.use_all_linked_data_direct = (
@@ -364,135 +403,183 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return filepath
def _bat_project_path(
self, context: bpy.types.Context, blendfile: Path
) -> Path:
"""BAT 'project' directory: preference root, or wider if links lie outside it.
Linked blends outside the configured project force BAT to use KEEP_PATH;
Shaman packing can then fail path rewriting. Using a common ancestor of
the submission blend and all linked library paths keeps assets inside
the project tree for BAT.
"""
prefs = preferences.get(context)
configured = bpathlib.make_absolute(
Path(bpy.path.abspath(str(prefs.project_root())))
)
blend_abs = bpathlib.make_absolute(blendfile)
lib_paths: list[Path] = []
for lib in bpy.data.libraries:
if not lib.filepath:
continue
lib_paths.append(
bpathlib.make_absolute(Path(bpy.path.abspath(lib.filepath)))
)
def is_under(root: Path, path: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
need_widen = (not is_under(configured, blend_abs)) or any(
not is_under(configured, lp) for lp in lib_paths
)
if not need_widen:
return configured
all_paths = [blend_abs] + lib_paths
try:
common = Path(os.path.commonpath([str(p) for p in all_paths])).resolve()
except ValueError:
self.log.warning(
"Could not compute common path for BAT project root, using preferences"
)
return configured
self.log.info(
"BAT project root widened from %s to %s (assets outside preference project)",
configured,
common,
)
return common
def _convert_relpaths_to_absolute(self, context: bpy.types.Context) -> None:
"""Convert all relative paths in the blend file to absolute paths.
Covers libraries, images, clips, sounds, fonts, volumes, and cache_files
(point caches / GN caches). Allows the blend to be sent as-is without BAT.
"""
# Convert library paths to absolute
for library in bpy.data.libraries:
if library.filepath:
old_path = library.filepath
abs_path = bpy.path.abspath(library.filepath)
library.filepath = abs_path
self.log.debug("Converted library path: %s -> %s", old_path, abs_path)
# Convert image paths to absolute
for image in bpy.data.images:
if image.filepath and not image.packed_file:
old_path = image.filepath
abs_path = bpy.path.abspath(image.filepath)
image.filepath = abs_path
self.log.debug("Converted image path: %s -> %s", old_path, abs_path)
# Convert movie paths to absolute
for movie in bpy.data.movieclips:
if movie.filepath:
old_path = movie.filepath
abs_path = bpy.path.abspath(movie.filepath)
movie.filepath = abs_path
self.log.debug("Converted movie path: %s -> %s", old_path, abs_path)
# Convert sound paths to absolute
for sound in bpy.data.sounds:
if sound.filepath:
old_path = sound.filepath
abs_path = bpy.path.abspath(sound.filepath)
sound.filepath = abs_path
self.log.debug("Converted sound path: %s -> %s", old_path, abs_path)
# Convert font paths to absolute (skip VectorFont - its filepath is read-only)
for font in bpy.data.fonts:
if font.filepath:
try:
old_path = font.filepath
abs_path = bpy.path.abspath(font.filepath)
font.filepath = abs_path
self.log.debug("Converted font path: %s -> %s", old_path, abs_path)
except (TypeError, AttributeError):
self.log.debug("Skipping font %s (filepath is read-only)", font.name)
# Convert volume paths to absolute
for volume in bpy.data.volumes:
if volume.filepath:
old_path = volume.filepath
abs_path = bpy.path.abspath(volume.filepath)
volume.filepath = abs_path
self.log.debug("Converted volume path: %s -> %s", old_path, abs_path)
# Point / mesh cache files (e.g. .pc2), geometry-nodes caches, etc.
for cache_file in bpy.data.cache_files:
if cache_file.filepath:
old_path = cache_file.filepath
abs_path = bpy.path.abspath(cache_file.filepath)
cache_file.filepath = abs_path
self.log.debug("Converted cache_file path: %s -> %s", old_path, abs_path)
def _submit_files(self, context: bpy.types.Context, blendfile: Path) -> bool:
def _submit_files_bat_v2(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Ensure that the files are somewhere in the shared storage.
Returns True if a packing thread has been started, and False otherwise.
"""
prefs = preferences.get(context)
if prefs.bat_bypass:
return self._submit_files_bat_bypass(context, blendfile)
from .bat_v2 import bat_version, pack_fs, pack_shaman
self.log.info("Submitting files via BAT %s", bat_version())
# Reset state from any previous run.
self.bat_v2_packer = None
self.bat_v2_packer_reported_error = False
self.bat_v2_packer_missing_files = []
manager = self._manager_info(context)
if not manager:
return False
# Get the project root and double-check its existence.
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
assert project_path.is_absolute(), (
"Expecting project path {!s} to be an absolute path".format(project_path)
)
if not project_path.exists():
self.report(
{"ERROR"}, "Project path {!s} does not exist".format(project_path)
)
raise FileNotFoundError()
if job_submission.is_file_inside_job_storage(context, blendfile):
self.log.info(
"File is already in job storage location, submitting it as-is"
)
self._use_blendfile_directly(context, blendfile)
return True
if manager.shared_storage.shaman_enabled:
# Pack to the Shaman server.
self.log.info("Copying BAT pack to Shaman storage")
batpacker = pack_shaman.pack_start(
project_root=project_path,
reporter=self,
use_relative_only=prefs.use_relative_only,
api_client=self.get_api_client(context),
checkout_path=PurePosixPath(self.job_name),
ignore_globs=prefs.ignore_globs(),
)
batpacker.start()
# When packing via Shaman, the Shaman server determines the final
# location of the blend file, and so it's not known yet.
self.blendfile_on_farm = None
else:
# Pack to the filesystem.
unique_dir = "%s-%s" % (
datetime.datetime.now().isoformat("-").replace(":", ""),
self.job_name,
)
pack_target_dir = Path(manager.shared_storage.location) / unique_dir
self.log.info("Copying BAT pack to shared storage: %s", pack_target_dir)
batpacker = pack_fs.pack_start(
project_root=project_path,
reporter=self,
use_relative_only=prefs.use_relative_only,
pack_target_dir=pack_target_dir,
ignore_globs=prefs.ignore_globs(),
)
batpacker.start()
# When packing to the filesystem, the final path of the file on the
# farm is known immediately.
source_file_info = batpacker.source_file_info()
abspath_on_farm = pack_target_dir / source_file_info.relpath_in_pack
self.blendfile_on_farm = PurePosixPath(abspath_on_farm.as_posix())
self.log.info(" %s", abspath_on_farm)
self.bat_v2_packer = batpacker
# Start the timer for periodic updates of the packing process. This
# needs a relatively fast update cycle, as each file to be copied needs
# its own update.
#
# TODO: if blocking the UI for each file copy gets too annoying, move
# the process to a separate thread.
wm = context.window_manager
wm.flamenco_bat_status = "INVESTIGATING"
wm.flamenco_can_abort = True # Only BAT v2 can abort.
return True
def bat_v2_packer_finalize_and_quit(self, context: bpy.types.Context) -> set[str]:
if self.bat_v2_packer_reported_error:
# The errors themselves should have been reported already.
context.window_manager.flamenco_bat_status = "ABORTED"
return self._quit(context)
if self.bat_v2_packer is not None:
# BAT v2 pack is done.
self.blendfile_on_farm = self.bat_v2_packer.blendfile_location_in_pack()
self._submit_job(context)
return self._quit(context)
# Reporter Protocol for our BAT v2 interface.
# See `BATPackReporter` in BAT's `blender_asset_tracer/pack.py`.
def on_error_on_error(self, errormsg: str, ex: Exception) -> None:
import traceback
self.bat_v2_packer_reported_error = True
# This callback is only called on serious errors that likely indicate
# bugs, namely when either `on_copy_error()` or `on_rewrite_error()`
# caused an exception themselves.
print(60 * "-")
print("Flamenco ran into an error while sending files to the farm:")
print()
print(errormsg)
print()
traceback.print_exception(ex)
bug_report_url = "https://flamenco.blender.org/get-involved"
print("Please copy-paste the above into a bug report at", bug_report_url)
print()
print(60 * "-")
self.report({"ERROR"}, "Flamenco: Error sending files, check the terminal")
def on_copy_start(self, src: Path, dest: PurePath) -> None:
bpy.context.window_manager.flamenco_bat_status = "TRANSFERRING"
self.log.info("Uploading %s", dest)
bpy.context.window_manager.flamenco_bat_status_txt = "Uploading {!s}".format(
dest.name
)
def on_copy_done(self, src: Path, dest: PurePath) -> None:
assert self.bat_v2_packer is not None
num_total, num_done = self.bat_v2_packer.num_files_to_transfer()
if num_total < 0:
progress = 0
else:
progress = int(100 * num_done / num_total)
bpy.context.window_manager.flamenco_bat_progress = progress
def on_copy_error(self, src: Path, dest: PurePath, errormsg: str) -> None:
self.bat_v2_packer_reported_error = True
self.report({"ERROR"}, "Copying {!s} to {!s}: {!s}".format(src, dest, errormsg))
def on_rewrite_error(self, blendfile: Path, save_as: Path, errormsg: str) -> None:
self.bat_v2_packer_reported_error = True
self.report({"ERROR"}, "Rewriting {!s}: {!s}".format(blendfile, errormsg))
def on_rewrite_start(self, blendfile: Path, save_as: Path) -> None:
self.report({"INFO"}, "Rewriting {!s}".format(blendfile.name))
wm = bpy.context.window_manager
wm.flamenco_bat_status = "REWRITING"
wm.flamenco_bat_status_txt = blendfile.name
def on_rewrite_done(self, blendfile: Path, save_as: Path) -> None:
pass
def on_missing_file(self, blendfile: Path, relpath_in_pack: PurePath) -> None:
self.bat_v2_packer_missing_files.append(blendfile)
self.report({"WARNING"}, "Missing file: {!s}".format(blendfile))
# End of Reporter Protocol.
def _submit_files_bat_v1(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Ensure that the files are somewhere in the shared storage.
Returns True if a packing thread has been started, and False otherwise.
"""
from .bat import bat_version
from .bat import interface as bat_interface
self.log.info("Submitting files via BAT %s", bat_version())
if bat_interface.is_packing():
self.report({"ERROR"}, "Another packing operation is running")
self._quit(context)
@@ -517,190 +604,19 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"File is not already in job storage location, copying it there"
)
try:
self.blendfile_on_farm = self._bat_pack_filesystem(context, blendfile)
self.blendfile_on_farm = self._bat_v1_pack_filesystem(
context, blendfile
)
except FileNotFoundError:
self._quit(context)
return False
wm = context.window_manager
self.timer = wm.event_timer_add(self.TIMER_PERIOD, window=context.window)
wm.flamenco_can_abort = False # Only BAT v2 can abort.
return True
def _submit_files_bat_bypass(self, context: bpy.types.Context, blendfile: Path) -> bool:
"""Bypass BAT: absolute paths, then upload or copy the blend only."""
manager = self._manager_info(context)
if not manager:
return False
self.log.info("Converting all relative paths to absolute")
self._convert_relpaths_to_absolute(context)
self.log.info("Saving blend file with absolute paths")
blendfile = self._save_blendfile(context)
blendfile = bpathlib.make_absolute(blendfile)
if manager.shared_storage.shaman_enabled:
self.log.info("Uploading blend file directly to Shaman (bypassing BAT)")
self._upload_blendfile_to_shaman(context, blendfile)
self._quit(context)
return False
if job_submission.is_file_inside_job_storage(context, blendfile):
self.log.info(
"File is already in job storage location, submitting it as-is"
)
self._use_blendfile_directly(context, blendfile)
return False
self.log.info(
"File is not already in job storage location, copying it there"
)
try:
self._copy_blendfile_to_storage(context, blendfile)
except FileNotFoundError:
self._quit(context)
return False
return False
def _upload_blendfile_to_shaman(
self, context: bpy.types.Context, blendfile: Path
) -> None:
"""Upload blend file directly to Shaman without BAT.
Creates a Shaman checkout with just the blend file, maintaining its
relative path from the project root.
"""
from .bat import cache
from .manager.apis import ShamanApi
from .manager.models import (
ShamanFileSpec,
ShamanCheckout,
)
from .manager.exceptions import ApiException
from . import preferences
api_client = self.get_api_client(context)
shaman_api = ShamanApi(api_client)
# Get project root to calculate relative path
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
project_path = bpathlib.make_absolute(Path(bpy.path.abspath(str(project_path))))
# Calculate relative path from project root
try:
blendfile_rel_path = blendfile.relative_to(project_path)
# Convert to POSIX path for Shaman
blendfile_path_in_checkout = PurePosixPath(blendfile_rel_path.as_posix())
except ValueError:
# Blend file is not under project root, use just the filename
self.log.warning(
"Blend file %s is not under project root %s, using filename only",
blendfile,
project_path,
)
blendfile_path_in_checkout = PurePosixPath(blendfile.name)
# Compute checksum and file size
self.log.info("Computing checksum for %s", blendfile.name)
checksum = cache.compute_cached_checksum(blendfile)
filesize = blendfile.stat().st_size
# Upload the blend file to Shaman
self.log.info("Uploading blend file to Shaman: %s", blendfile.name)
try:
with blendfile.open("rb") as file_reader:
shaman_api.shaman_file_store(
checksum=checksum,
filesize=filesize,
body=file_reader,
x_shaman_can_defer_upload=True,
x_shaman_original_filename=blendfile.name,
)
except ApiException as ex:
if ex.status == 208:
# File already known to Shaman
self.log.info("Blend file already known to Shaman")
elif ex.status == 425:
# Defer upload - someone else is uploading
self.log.info("Blend file is being uploaded by another client, deferring")
# Retry after a short delay
import time
time.sleep(1)
with blendfile.open("rb") as file_reader:
shaman_api.shaman_file_store(
checksum=checksum,
filesize=filesize,
body=file_reader,
x_shaman_can_defer_upload=False,
x_shaman_original_filename=blendfile.name,
)
else:
self.log.error("Error uploading to Shaman: %s", ex)
self.report({"ERROR"}, f"Error uploading to Shaman: {ex}")
return
# Create checkout definition with just the blend file
checkout_path = self._shaman_checkout_path()
filespec = ShamanFileSpec(
sha=checksum,
size=filesize,
path=str(blendfile_path_in_checkout), # Relative path from project root
)
# Create the checkout
self.log.info("Creating Shaman checkout: %s", checkout_path)
self.log.info("Blend file path in checkout: %s", blendfile_path_in_checkout)
checkout = ShamanCheckout(
files=[filespec],
checkout_path=str(checkout_path),
)
try:
result = shaman_api.shaman_checkout(checkout)
self.actual_shaman_checkout_path = PurePosixPath(result.checkout_path)
# The checkout itself is created in a unique subdirectory. The job's
# blendfile must include that checkout path.
self.blendfile_on_farm = (
PurePosixPath("{jobs}")
/ self.actual_shaman_checkout_path
/ blendfile_path_in_checkout
)
self.log.info("Shaman checkout created: %s", self.actual_shaman_checkout_path)
self._submit_job(context)
except ApiException as ex:
self.log.error("Error creating Shaman checkout: %s", ex)
self.report({"ERROR"}, f"Error creating Shaman checkout: {ex}")
return
def _copy_blendfile_to_storage(
self, context: bpy.types.Context, blendfile: Path
) -> None:
"""Copy blend file to job storage without BAT."""
import shutil
manager = self._manager_info(context)
if not manager:
raise FileNotFoundError("Manager info not known")
unique_dir = "%s-%s" % (
datetime.datetime.now().isoformat("-").replace(":", ""),
self.job_name,
)
pack_target_dir = Path(manager.shared_storage.location) / unique_dir
pack_target_dir.mkdir(parents=True, exist_ok=True)
pack_target_file = pack_target_dir / blendfile.name
self.log.info("Copying blend file to %s", pack_target_file)
shutil.copy2(blendfile, pack_target_file)
self.blendfile_on_farm = PurePosixPath(pack_target_file.as_posix())
self.actual_shaman_checkout_path = None
self._submit_job(context)
def _bat_pack_filesystem(
def _bat_v1_pack_filesystem(
self, context: bpy.types.Context, blendfile: Path
) -> PurePosixPath:
"""Use BAT to store the pack on the filesystem.
@@ -709,7 +625,9 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"""
from .bat import interface as bat_interface
project_path = self._bat_project_path(context, blendfile)
# Get project path from addon preferences.
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
project_path = bpathlib.make_absolute(Path(bpy.path.abspath(str(project_path))))
if not project_path.exists():
@@ -734,10 +652,8 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
base_blendfile=blendfile,
project=project_path,
target=str(pack_target_dir),
exclusion_filter="", # TODO: get from GUI.
# False: relative_only=True can leave linked blends on KEEP_PATH and hit BAT
# _rewrite_paths assertions with Shaman (same as stock 3.8.x + BAT 1.x).
relative_only=False,
exclusion_filter=prefs.exclusion_filter,
relative_only=prefs.use_relative_only,
)
return PurePosixPath(pack_target_file.as_posix())
@@ -762,21 +678,23 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
"""
from .bat import (
interface as bat_interface,
)
from .bat import (
shaman as bat_shaman,
)
assert self.job is not None
self.log.info("Sending BAT pack to Shaman")
project_path = self._bat_project_path(context, blendfile)
prefs = preferences.get(context)
project_path: Path = prefs.project_root()
self.packthread = bat_interface.copy(
base_blendfile=blendfile,
project=project_path,
target="/", # Target directory irrelevant for Shaman transfers.
exclusion_filter="", # TODO: get from GUI.
# See _bat_pack_filesystem: avoid BAT+Shaman KEEP_PATH / _rewrite_paths failure.
relative_only=False,
exclusion_filter=prefs.exclusion_filter,
relative_only=prefs.use_relative_only,
packer_class=bat_shaman.Packer,
packer_kwargs=dict(
api_client=self.get_api_client(context),
@@ -821,10 +739,6 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
def _use_blendfile_directly(
self, context: bpy.types.Context, blendfile: Path
) -> None:
# The temporary '.flamenco.blend' file should not be deleted, as it
# will be used directly by the render job.
self.temp_blendfile = None
# The blend file is contained in the job storage path, no need to
# copy anything.
self.blendfile_on_farm = bpathlib.make_absolute(blendfile)
@@ -872,10 +786,13 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
if not self._prepare_job_for_submission(context):
return
context.window_manager.flamenco_bat_status = "COMMUNICATING"
context.window_manager.flamenco_bat_status_txt = ""
api_client = self.get_api_client(context)
try:
submitted_job = job_submission.submit_job(self.job, api_client)
except MaxRetryError as ex:
except MaxRetryError:
self.report({"ERROR"}, "Unable to reach Flamenco Manager")
return
except HTTPError as ex:
@@ -895,7 +812,27 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
self.report({"ERROR"}, f"Could not submit job: {ex.reason}")
return
self.report({"INFO"}, "Job %s submitted" % submitted_job.name)
# Show a final report.
if self.bat_v2_packer:
num_missing_files = len(self.bat_v2_packer_missing_files)
else:
# Only BATv2 tracks missing files like this.
num_missing_files = 0
if num_missing_files:
self.report(
{"WARNING"},
"Job {!s} submitted (with {:d} missing files)".format(
submitted_job.name, num_missing_files
),
)
context.window_manager.flamenco_bat_status_txt = (
"Submitted with {:d} missing files".format(num_missing_files)
)
else:
self.report({"INFO"}, "Job {!s} submitted".format(submitted_job.name))
context.window_manager.flamenco_bat_status_txt = ""
context.window_manager.flamenco_bat_status = "DONE"
def _check_job(self, context: bpy.types.Context) -> bool:
"""Use the Flamenco API to check the Job before submitting files.
@@ -911,7 +848,7 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
api_client = self.get_api_client(context)
try:
job_submission.submit_job_check(self.job, api_client)
except MaxRetryError as ex:
except MaxRetryError:
self.report({"ERROR"}, "Unable to reach Flamenco Manager")
return False
except HTTPError as ex:
@@ -938,9 +875,10 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
Does neither check nor abort the BAT pack thread.
"""
if self.temp_blendfile is not None:
self.log.info("Removing temporary file %s", self.temp_blendfile)
self.temp_blendfile.unlink(missing_ok=True)
if self.bat_v2_packer is not None and not self.bat_v2_packer.is_done:
self.log.info("Aborting BAT packer")
self.bat_v2_packer.abort()
self.bat_v2_packer = None
if self.timer is not None:
context.window_manager.event_timer_remove(self.timer)
@@ -948,6 +886,31 @@ class FLAMENCO_OT_submit_job(FlamencoOpMixin, bpy.types.Operator):
return {"FINISHED"}
class FLAMENCO_OT_abort(bpy.types.Operator):
bl_idname = "flamenco.abort"
bl_label = "Abort"
bl_description = (
"Abort a running job submission.\nBlender make take a while to respond to this"
)
ABORTABLE_STATES = {
"INVESTIGATING",
"REWRITING",
"TRANSFERRING",
"COMMUNICATING",
}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
wm = context.window_manager
return wm.flamenco_can_abort and wm.flamenco_bat_status in cls.ABORTABLE_STATES
def execute(self, context: bpy.types.Context) -> set[str]:
wm = context.window_manager
wm.flamenco_bat_status = "ABORTING"
return {"FINISHED"}
class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
"""Opens the given path in a file explorer.
@@ -963,8 +926,8 @@ class FLAMENCO3_OT_explore_file_path(bpy.types.Operator):
)
def execute(self, context):
import platform
import pathlib
import platform
# Possibly open a parent of the path
to_open = pathlib.Path(self.path)
@@ -1001,6 +964,7 @@ classes = (
FLAMENCO_OT_ping_manager,
FLAMENCO_OT_eval_setting,
FLAMENCO_OT_submit_job,
FLAMENCO_OT_abort,
FLAMENCO3_OT_explore_file_path,
)
register, unregister = bpy.utils.register_classes_factory(classes)
+26 -17
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import bpy
from . import projects, manager_info
from . import manager_info, projects
def discard_flamenco_client(context):
@@ -65,16 +65,6 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
items=_project_finder_enum_items,
)
bat_bypass: bpy.props.BoolProperty( # type: ignore
name="Bypass BAT",
description=(
"When enabled, submission skips Blender Asset Tracer: paths in the blend "
"are written as absolute, then the blend is uploaded or copied without a BAT pack. "
"When disabled, Flamenco uses the normal BAT pack (Shaman or job storage)"
),
default=True,
)
# Property that gets its value from the above _job_storage, and cannot be
# set. This makes it read-only in the GUI.
job_storage_for_gui: bpy.props.StringProperty( # type: ignore
@@ -86,6 +76,21 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
get=lambda prefs: prefs._job_storage(),
)
use_relative_only: bpy.props.BoolProperty( # type: ignore
name="Relative Paths Only",
default=True,
description="When sending files to Flamenco, only include assets that are referenced by "
"relative path. Absolute paths are then assumed to be valid on all Workers. When turned "
"off, all files are sent, regardless of how they are referenced",
)
exclusion_filter: bpy.props.StringProperty( # type: ignore
name="Exclusion Filter",
default="",
description="Space-separated list of file glob patterns. When sending files to Flamenco, "
"exclude any file that matches a pattern in this list. For example: '*.abc *.vdb' to skip "
"copying all Alembic and OpenVDB files",
)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_decorate = False
@@ -128,9 +133,9 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
else:
text_row(col, str(project_root))
col = layout.column(align=True)
col.label(text="Submission")
col.prop(self, "bat_bypass")
col = layout.column(heading="File Submission")
col.prop(self, "use_relative_only")
col.prop(self, "exclusion_filter")
def project_root(self) -> Path:
"""Use the configured project finder to find the project root directory."""
@@ -150,13 +155,17 @@ class FlamencoPreferences(bpy.types.AddonPreferences):
return "Unknown, refresh first."
return str(info.shared_storage.location)
def ignore_globs(self) -> set[str]:
"""Return exclusion filter as set of strings."""
return set(self.exclusion_filter.strip().split())
def get(context: bpy.types.Context) -> FlamencoPreferences:
"""Return the add-on preferences."""
prefs = context.preferences.addons["flamenco"].preferences
assert isinstance(
prefs, FlamencoPreferences
), "Expected FlamencoPreferences, got %s instead" % (type(prefs))
assert isinstance(prefs, FlamencoPreferences), (
"Expected FlamencoPreferences, got %s instead" % (type(prefs))
)
return prefs
+25 -9
View File
@@ -4,17 +4,34 @@
import contextlib
import importlib
from pathlib import Path
import sys
import logging
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterator, Iterable
from typing import Iterable, Iterator
_my_dir = Path(__file__).parent
_log = logging.getLogger(__name__)
def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
def filename(
module_name: str,
*,
filename_prefix: str = "",
) -> Path:
"""Returns the filename of the wheel file for this module."""
if not filename_prefix:
filename_prefix = _fname_prefix_from_module_name(module_name)
return _wheel_filename(filename_prefix)
def load_wheel(
module_name: str,
submodules: Iterable[str],
*,
filename_prefix: str = "",
) -> list[ModuleType]:
"""Loads modules from a wheel file 'module_name*.whl'.
Loads `module_name`, and if submodules are given, loads
@@ -25,8 +42,7 @@ def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
Returns the loaded modules, so [module, submodule, submodule, ...].
"""
fname_prefix = _fname_prefix_from_module_name(module_name)
wheel = _wheel_filename(fname_prefix)
wheel = filename(module_name, filename_prefix=filename_prefix)
loaded_modules: list[ModuleType] = []
to_load = [module_name] + [f"{module_name}.{submodule}" for submodule in submodules]
@@ -47,9 +63,9 @@ def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
loaded_modules.append(module)
_log.info("Loaded %s from %s", modname, module.__file__)
assert len(loaded_modules) == len(
to_load
), f"expecting to load {len(to_load)} modules, but only have {len(loaded_modules)}: {loaded_modules}"
assert len(loaded_modules) == len(to_load), (
f"expecting to load {len(to_load)} modules, but only have {len(loaded_modules)}: {loaded_modules}"
)
return loaded_modules
+2
View File
@@ -0,0 +1,2 @@
# goo_physics
StudioGoo bone physics addon
+34
View File
@@ -0,0 +1,34 @@
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from bpy.utils import register_submodule_factory
bl_info = {
"name": "Goo Physics",
"author": "Cody Winchester (CodyWinch)",
"description": "",
"blender": (4, 1, 0),
"version": (1, 0, 4),
"location": "",
"warning": "",
"category": "Animation",
}
submodules = [
"ui",
"properties",
"operators",
]
register, unregister = register_submodule_factory(__name__, submodules)
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
{
"DEFAULTCLOTH": {
"Name": "Default Cloth",
"Description": "Default settings for cloth physics",
"Settings": {
"gp_chain_speed": 2.0,
"gp_chain_mass": 0.3,
"gp_chain_air_dampening": 0.5,
"gp_chain_bend_damping": 0.5,
"gp_chain_collision_dist": 0.025,
"gp_chain_gravity": 1.0,
"gp_sim_influence": 1.0
}
},
"STIFFCLOTH": {
"Name": "Stiff Cloth",
"Description": "Best used for stiff chained rigs falling down, like a flag. First bone in the chain will not be simulated",
"Settings": {
"gp_chain_speed": 0.8,
"gp_chain_mass": 0.3,
"gp_chain_air_dampening": 2.0,
"gp_chain_bend_damping": 10.0,
"gp_chain_collision_dist": 0.025,
"gp_chain_gravity": 1.0,
"gp_sim_influence": 1.0
}
},
"FLOPPYCLOTH": {
"Name": "Floppy Cloth",
"Description": "Best used for a heavy floppy chained rigs dangling down",
"Settings": {
"gp_chain_speed": 2.0,
"gp_chain_mass": 1.0,
"gp_chain_air_dampening": 2.0,
"gp_chain_bend_damping": 2.0,
"gp_chain_collision_dist": 0.025,
"gp_chain_gravity": 1.0,
"gp_sim_influence": 1.0
}
}
}
@@ -0,0 +1,86 @@
{
"DEFAULTGEONODES": {
"Name": "Default Geo Nodes",
"Description": "Default settings for geo nodes physics",
"Settings": {
"gp_chain_velocity": 1.0,
"gp_chain_dampening": 0.1,
"gp_chain_gravity": 0.02,
"gp_chain_root_falloff": 0.25,
"gp_chain_stiffness": 0.25,
"gp_chain_stiff_end_fac": 0.25,
"gp_chain_stiff_vel_fac": 0.2,
"gp_chain_stiff_vel_min": 0.1,
"gp_chain_stiff_vel_max": 1.0,
"gp_chain_wind_strength": 0.25,
"gp_chain_wind_noise_strength": 1.0,
"gp_chain_wind_noise_scale": 1.0,
"gp_chain_collision_dist": 0.01,
"gp_chain_collision_friction": 0.25,
"gp_sim_influence": 1.0
}
},
"HAIRFRINGE": {
"Name": "Hair Fringe",
"Description": "Used for Bangs/Fringe, where the base doesnt move much and theres slight movement at the tips to prevent the character going off model",
"Settings": {
"gp_chain_velocity": 1.0,
"gp_chain_dampening": 0.1,
"gp_chain_gravity": 0.02,
"gp_chain_root_falloff": 0.92,
"gp_chain_stiffness": 0.5,
"gp_chain_stiff_end_fac": 0.2,
"gp_chain_stiff_vel_fac": 0.2,
"gp_chain_stiff_vel_min": 0.1,
"gp_chain_stiff_vel_max": 1.0,
"gp_chain_wind_strength": 0.06,
"gp_chain_wind_noise_strength": 2.1,
"gp_chain_wind_noise_scale": 10.0,
"gp_chain_collision_dist": 0.01,
"gp_chain_collision_friction": 0.25,
"gp_sim_influence": 1.0
}
},
"HAIRSIDE": {
"Name": "Hair Side",
"Description": "Medium length hair strands, usually for side burns longer than the bangs. Can also be used for skirts",
"Settings": {
"gp_chain_velocity": 1.0,
"gp_chain_dampening": 0.1,
"gp_chain_gravity": 0.02,
"gp_chain_root_falloff": 0.5,
"gp_chain_stiffness": 0.4,
"gp_chain_stiff_end_fac": 0.2,
"gp_chain_stiff_vel_fac": 0.2,
"gp_chain_stiff_vel_min": 0.1,
"gp_chain_stiff_vel_max": 1.0,
"gp_chain_wind_strength": 0.08,
"gp_chain_wind_noise_strength": 2.0,
"gp_chain_wind_noise_scale": 5.0,
"gp_chain_collision_dist": 0.01,
"gp_chain_collision_friction": 0.25,
"gp_sim_influence": 0.5
}
},
"HAIRPONYTAIL": {
"Name": "Hair Ponytail",
"Description": "Long length bone chains with lots of drag at the end. Can be used for dresses and capes as well",
"Settings": {
"gp_chain_velocity": 1.0,
"gp_chain_dampening": 0.1,
"gp_chain_gravity": 0.02,
"gp_chain_root_falloff": 0.42,
"gp_chain_stiffness": 0.0,
"gp_chain_stiff_end_fac": 0.2,
"gp_chain_stiff_vel_fac": 0.2,
"gp_chain_stiff_vel_min": 0.1,
"gp_chain_stiff_vel_max": 1.0,
"gp_chain_wind_strength": 0.125,
"gp_chain_wind_noise_strength": 2.02,
"gp_chain_wind_noise_scale": 5.0,
"gp_chain_collision_dist": 0.01,
"gp_chain_collision_friction": 0.25,
"gp_sim_influence": 0.5
}
}
}
@@ -0,0 +1,41 @@
{
"DEFAULTJIGGLE": {
"Name": "Default Jiggle",
"Description": "Default settings for jiggle physics",
"Settings": {
"gp_sim_speed": 0.8,
"gp_sim_friction": 5.0,
"gp_sim_mass": 0.15,
"gp_sim_stiffness": 0.1,
"gp_sim_damping": 8.0,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
},
"JIGGLELOOSE": {
"Name": "Jiggle Loose",
"Description": "Location based Jiggle offset for Secondary Animation",
"Settings": {
"gp_sim_speed": 0.77,
"gp_sim_friction": 5.0,
"gp_sim_mass": 0.2,
"gp_sim_stiffness": 0.1,
"gp_sim_damping": 10.06,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
},
"JIGGLESTIFF": {
"Name": "Jiggle Stiff",
"Description": "Location based Jiggle offset for Secondary Animation with less bounce",
"Settings": {
"gp_sim_speed": 0.83,
"gp_sim_friction": 5.0,
"gp_sim_mass": 0.15,
"gp_sim_stiffness": 0.3,
"gp_sim_damping": 10.06,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
}
}
@@ -0,0 +1,494 @@
{
"DEFAULTSOFTBODY": {
"Name": "Default Soft Body",
"Description": "Default settings for soft body physics",
"Settings": {
"gp_sim_speed": 0.8,
"gp_sim_friction": 5.0,
"gp_sim_mass": 0.15,
"gp_sim_stiffness": 0.1,
"gp_sim_damping": 8.0,
"gp_sim_strength": 0.95,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
},
"SLOWMO": {
"Name": "Slowmo",
"Description": "Preset for Slow motion or underwater scenarios",
"Settings": {
"gp_sim_speed": 0.4,
"gp_sim_friction": 10.0,
"gp_sim_mass": 0.2,
"gp_sim_stiffness": 0.22,
"gp_sim_damping": 15.0,
"gp_sim_strength": 0.68,
"gp_sim_influence": 0.44,
"gp_sim_gravity": 1.0
}
},
"GOOFLAPPY": {
"Name": "Goo Flappy",
"Description": "Settings for very light objects with a lot of drag (end chains of hair/skirts)",
"Settings": {
"gp_sim_speed": 0.8,
"gp_sim_friction": 10.0,
"gp_sim_mass": 0.2,
"gp_sim_stiffness": 0.1,
"gp_sim_damping": 1.0,
"gp_sim_strength": 0.8,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
},
"GOOSTIFF": {
"Name": "Goo Stiff",
"Description": "Settings for stiff objects with resistance, used for the first bone in long chains where the base shouldnt move much",
"Settings": {
"gp_sim_speed": 0.8,
"gp_sim_friction": 5.0,
"gp_sim_mass": 0.15,
"gp_sim_stiffness": 0.1,
"gp_sim_damping": 8.0,
"gp_sim_strength": 0.95,
"gp_sim_influence": 0.5,
"gp_sim_gravity": 1.0
}
},
"METAL/JEWELRY": {
"Name": "Metal/Jewelry",
"Description": "Mainly for earrings and necklaces",
"Settings": {
"gp_sim_speed": 0.8,
"gp_sim_friction": 10.0,
"gp_sim_mass": 0.25,
"gp_sim_stiffness": 1.0,
"gp_sim_damping": 4.57,
"gp_sim_strength": 0.43,
"gp_sim_influence": 1.0,
"gp_sim_gravity": 1.0
}
},
"GOOSIM": {
"Name": "Goo Sim_CHAIN",
"Description": "Chain preset with Goo Stiff at the base and Goo Flappy with a influence falloff down the chain",
"Settings": {
"gp_sim_speed": [
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8,
0.8
],
"gp_sim_friction": [
5.0,
6.018315315246582,
7.02327823638916,
7.975924968719482,
8.800024032592773,
9.410675048828125,
9.774394989013672,
9.93781852722168,
9.989137649536133,
9.999103546142578,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0,
10.0
],
"gp_sim_mass": [
0.15000000596046448,
0.1601831614971161,
0.17023277282714844,
0.17975923418998718,
0.18800023198127747,
0.19410674273967743,
0.1977439522743225,
0.19937819242477417,
0.19989138841629028,
0.19999104738235474,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224,
0.20000000298023224
],
"gp_sim_stiffness": [
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
],
"gp_sim_damping": [
8.0,
6.574358940124512,
5.1674113273620605,
3.8337059020996094,
2.679966688156128,
1.8250558376312256,
1.3158482313156128,
1.0870535373687744,
1.0152064561843872,
1.0012555122375488,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0
],
"gp_sim_strength": [
0.9499989748001099,
0.9194496870040894,
0.889301061630249,
0.8607218265533447,
0.835999071598053,
0.8176796436309814,
0.8067681789398193,
0.8018654584884644,
0.800325870513916,
0.8000268936157227,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929,
0.800000011920929
],
"gp_sim_influence": [
0.00826446246355772,
0.013331254944205284,
0.018442191183567047,
0.023726072162389755,
0.029434900730848312,
0.035850003361701965,
0.04309063404798508,
0.05104939639568329,
0.059576407074928284,
0.06869472563266754,
0.07856345176696777,
0.08924980461597443,
0.10063616931438446,
0.11259077489376068,
0.12515480816364288,
0.13848046958446503,
0.15261253714561462,
0.16742651164531708,
0.18280872702598572,
0.19881850481033325,
0.2156010866165161,
0.23317888379096985,
0.25142043828964233,
0.2702302634716034,
0.28968575596809387,
0.30992525815963745,
0.33094877004623413,
0.35261791944503784,
0.3748553693294525,
0.39775657653808594,
0.42145299911499023,
0.44592225551605225,
0.47101902961730957,
0.49668407440185547,
0.5230309963226318,
0.5501843690872192,
0.578099250793457,
0.606623649597168,
0.6357163190841675,
0.6655089855194092,
0.6961193084716797,
0.7274799346923828,
0.7594319581985474,
0.7919522523880005,
0.8251906037330627,
0.8592544794082642,
0.8940246105194092,
0.9292197227478027,
0.9645878076553345,
1.0
],
"gp_sim_gravity": [
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0,
1.0
]
}
}
}
File diff suppressed because it is too large Load Diff
+689
View File
@@ -0,0 +1,689 @@
from .functions import *
#
# UI
#
def draw_subpanel(layout, prefs, menu_name, label, icon):
box = layout.box()
row = box.row()
row.alignment = "LEFT"
row.prop(
prefs,
menu_name,
icon="TRIA_DOWN" if getattr(prefs, menu_name) else "TRIA_RIGHT",
icon_only=True,
emboss=False,
)
row.label(text=label, icon=icon)
return box, getattr(prefs, menu_name)
#
class GOO_PT_main_panel(bpy.types.Panel):
"""Creates a Panel in the scene context of the properties editor"""
bl_idname = "GOO_PT_main_panel"
bl_label = "Goo Animation Tools"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Goo Animation Tools"
def draw(self, context):
layout = self.layout
class GP_PT_goophys_panel(Panel):
"""Creates a Panel in the scene context of the properties editor"""
bl_idname = "GP_PT_goophys_panel"
bl_label = "Goo Physics"
bl_parent_id = "GOO_PT_main_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Goo Animation Tools"
bl_order = 1
def draw(self, context):
layout = self.layout
pb = context.active_pose_bone
scn = context.scene
prefs = context.preferences.addons["goo_physics"].preferences
if pb is not None:
box = layout.box()
if (pb.gp_has_sb_physics or pb.gp_has_cl_physics or pb.gp_has_gn_physics or pb.gp_has_jg_physics) == False:
row = box.row(align=True)
row.label(text="Mode:")
row = box.row(align=True)
row.prop(prefs, "gp_physics_type", text="")
if prefs.gp_physics_type == "GEO_NODES":
row = box.row(align=True)
row.operator(
"goophys.add_geonodes_physics_to_selected",
text="Add Physics",
)
row.scale_y = 2.5
if prefs.gp_physics_type == "SIMP_SOFTBODY":
row = box.row(align=True)
row.operator(
"goophys.add_soft_physics_to_selected",
text="Add Physics",
)
row.scale_y = 2.5
if prefs.gp_physics_type == "CLOTH":
row = box.row(align=True)
row.operator(
"goophys.add_cloth_physics_to_selected",
text="Add Physics",
)
row.scale_y = 2.5
if prefs.gp_physics_type == "JIGGLE":
row = box.row(align=True)
row.operator(
"goophys.add_jiggle_physics_to_selected",
text="Add Physics",
)
row.scale_y = 2.5
row = box.row(align=True)
row.alignment = "CENTER"
row.prop(prefs, "gp_physics_active", text="Physics Active")
row.alignment = "CENTER"
else:
#
row = box.row(align=True)
row.operator(
"goophys.remove_physics_from_selected",
text="Remove Physics from Selected Bones",
)
row.scale_y = 1.5
row = box.row(align=True)
row.operator("goophys.bake_physics", text="Bake Bone Physics")
row.scale_y = 2
box = layout.box()
row = box.row(align=True)
row.operator("goophys.select_dynamic_bones", text="Select Physics Bones")
row = box.row(align=True)
row.operator("goophys.select_last_dynamic_bones", text="Select Last Bone in Chains")
row = box.row(align=True)
row.alignment = "CENTER"
row.prop(prefs, "gp_physics_active", text="Physics Active")
row.alignment = "CENTER"
#
box = layout.box()
if pb.gp_has_cl_physics:
label = "Cloth Physics Settings"
icon = "MOD_CLOTH"
row = box.row(align=True)
row.label(text="Presets:")
row = box.row(align=True)
row.prop(prefs, "cl_presets", text="")
row.operator("goophys.add_preset", text="", icon="ADD")
row.operator("goophys.delete_preset", text="", icon="REMOVE")
row = box.row(align=True)
row.operator("goophys.apply_preset", text="Apply Preset")
if pb.gp_has_sb_physics:
label = "Soft Body Physics Settings"
icon = "MOD_SOFT"
row = box.row(align=True)
row.label(text="Presets:")
row = box.row(align=True)
row.prop(prefs, "sb_presets", text="")
row.operator("goophys.add_preset", text="", icon="ADD")
row.operator("goophys.delete_preset", text="", icon="REMOVE")
row = box.row(align=True)
row.operator("goophys.apply_preset", text="Apply Preset")
if pb.gp_has_jg_physics:
label = "Jiggle Physics Settings"
icon = "MOD_SCREW"
row = box.row(align=True)
row.label(text="Presets:")
row = box.row(align=True)
row.prop(prefs, "jg_presets", text="")
row.operator("goophys.add_preset", text="", icon="ADD")
row.operator("goophys.delete_preset", text="", icon="REMOVE")
row = box.row(align=True)
row.operator("goophys.apply_preset", text="Apply Preset")
if pb.gp_has_gn_physics:
label = "Geo Nodes Physics Settings"
icon = "GEOMETRY_NODES"
row = box.row(align=True)
row.label(text="Presets:")
row = box.row(align=True)
row.prop(prefs, "gn_presets", text="")
row.operator("goophys.add_preset", text="", icon="ADD")
row.operator("goophys.delete_preset", text="", icon="REMOVE")
row = box.row(align=True)
row.operator("goophys.apply_preset", text="Apply Preset")
boxx, expanded = draw_subpanel(box, prefs, "settings_menu", label, icon)
if expanded:
if pb.gp_has_cl_physics:
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_chains")
boxxx, expanded = draw_subpanel(boxx, prefs, "chain_menu", "Chain Settings", "RESTRICT_INSTANCED_OFF")
if expanded:
boxxxx, expanded = draw_subpanel(boxxx, prefs, "cl_time_menu", "Time", "TIME")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_speed", text="Sim Speed")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "cl_physical_menu", "Physical", "MOD_SOLIDIFY")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_mass", text="Sim Mass")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_gravity", text="Gravity")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "cl_structure_menu", "Structure", "SELECT_INTERSECT")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_air_dampening", text="Sim Air Dampening")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_bend_damping", text="Sim Bend Damping")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "cl_collision_menu", "Collision", "MOD_PHYSICS")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_use_collision", text="Use Collision")
if pb.gp_chain_use_collision:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_collision_dist", text="Collision Distance")
row = boxxxx.row(align=True)
row.operator(
"goophys.refresh_collision_collection",
text="Refresh Collision Objects")
if pb.gp_chain_limit_collision:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Set Collisions to All")
else:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Limit Collisions to Collection")
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_bones")
boxxx, expanded = draw_subpanel(boxx, prefs, "bone_menu", "Bone Settings", "BONE_DATA")
if expanded:
row = boxxx.row(align=True)
row.prop(pb, "gp_sim_influence", text="Sim Influence")
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Linear Sim Influence Falloff",
).type = 0
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Quadratic Sim Influence Falloff",
).type = 1
if pb.gp_has_sb_physics:
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_bones")
boxxx, expanded = draw_subpanel(boxx, prefs, "bone_menu", "Bone Settings", "BONE_DATA")
if expanded:
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_time_menu", "Time", "TIME")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_speed", text="Sim Speed")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_physical_menu", "Physical", "MOD_SOLIDIFY")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_friction", text="Sim Friction")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_mass", text="Sim Mass")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_gravity", text="Gravity")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_structure_menu", "Structure", "SELECT_INTERSECT")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_stiffness", text="Sim Stiffness")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_damping", text="Sim Damping")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_strength", text="Sim Strength")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_collision_menu", "Collision", "MOD_PHYSICS")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_use_collision", text="Use Collision")
row = boxxxx.row(align=True)
row.operator(
"goophys.refresh_collision_collection",
text="Refresh Collision Objects")
if pb.gp_sim_limit_collision:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Set Collisions to All")
else:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Limit Collisions to Collection")
row = boxxx.row(align=True)
row.prop(pb, "gp_sim_influence", text="Sim Influence")
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Linear Sim Influence Falloff",
).type = 0
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Quadratic Sim Influence Falloff",
).type = 1
if pb.gp_has_jg_physics:
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_bones")
boxxx, expanded = draw_subpanel(boxx, prefs, "bone_menu", "Bone Settings", "BONE_DATA")
if expanded:
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_time_menu", "Time", "TIME")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_speed", text="Sim Speed")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "sb_physical_menu", "Physical", "MOD_SOLIDIFY")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_friction", text="Sim Friction")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_mass", text="Sim Mass")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_gravity", text="Gravity")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "jg_physical_menu", "Physical", "MOD_SOLIDIFY")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_stiffness", text="Sim Stiffness")
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_damping", text="Sim Damping")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "jg_collision_menu", "Collision", "MOD_PHYSICS")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_sim_use_collision", text="Use Collision")
row = boxxxx.row(align=True)
row.operator(
"goophys.refresh_collision_collection",
text="Refresh Collision Objects")
if pb.gp_sim_limit_collision:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Set Collisions to All")
else:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Limit Collisions to Collection")
row = boxxx.row(align=True)
row.prop(pb, "gp_sim_influence", text="Sim Influence")
if pb.gp_has_gn_physics:
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_chains")
boxxx, expanded = draw_subpanel(boxx, prefs, "chain_menu", "Chain Settings", "RESTRICT_INSTANCED_OFF")
if expanded:
boxxxx, expanded = draw_subpanel(boxxx, prefs, "gn_general_menu", "General", "OUTLINER_DATA_GP_LAYER")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_velocity", text="Velocity Scaler")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_dampening", text="Velocity Dampening")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_gravity", text="Gravity")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_root_falloff", text="Root Falloff")
boxxxx, expanded = draw_subpanel(boxxx, prefs, "gn_stiff_menu", "Stiffness", "AUTOMERGE_ON")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_stiffness", text="Stiffness")
row = boxxxx.row(align=True)
row.prop(
pb, "gp_chain_stiff_end_fac", text="Tip Stiffness Factor"
)
boxxxxx, expanded = draw_subpanel(boxxxx, prefs, "gn_stiff_adv_menu", "Advanced Stiffness", "CON_TRANSLIKE")
if expanded:
row = boxxxxx.row(align=True)
row.prop(
pb, "gp_chain_stiff_vel_fac", text="Stiffness Velocity Factor"
)
row = boxxxxx.row(align=True)
row.prop(
pb, "gp_chain_stiff_vel_min", text="Stiffness Velocity Min"
)
row = boxxxxx.row(align=True)
row.prop(
pb, "gp_chain_stiff_vel_max", text="Stiffness Velocity Max"
)
boxxxx, expanded = draw_subpanel(boxxx, prefs, "gn_wind_menu", "Wind", "FORCE_WIND")
if expanded:
has_wind = False
ob = bpy.data.objects.get(pb.gp_sim_object)
mod = None
if ob:
mod = ob.modifiers.get("GP_Nodes Sim")
if mod:
has_wind = get_geo_nodes_input(mod, "Wind Object") is not None
if has_wind == False:
row = boxxxx.row(align=True)
row.operator(
"goophys.add_wind_objects", text="Add Wind Controller to Chains"
)
row.scale_y = 2.0
boxxxx.separator()
if mod is not None:
wind_ob_ctr, wind_ob_str = get_geo_nodes_input_str(mod, "Wind Object")
row = boxxxx.row(align=True)
row.prop(wind_ob_ctr, wind_ob_str, text="Wind Controller")
else:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_wind_strength", text="Wind Strength")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_wind_noise_strength", text="Wind Noise Strength")
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_wind_noise_scale", text="Wind Noise Scale")
boxxxx.separator()
if mod is not None:
wind_ob_ctr, wind_ob_str = get_geo_nodes_input_str(mod, "Wind Object")
row = boxxxx.row(align=True)
row.prop(wind_ob_ctr, wind_ob_str, text="Wind Controller")
row = boxxxx.row(align=True)
row.operator(
"goophys.copy_active_wind_controller",
text="Copy Controller to Selected Chains",
)
row.scale_y = 1.5
row = boxxxx.row(align=True)
row.operator(
"goophys.remove_wind_objects", text="Remove Controller from Chains"
)
row.scale_y = 2.0
boxxxx, expanded = draw_subpanel(boxxx, prefs, "gn_collision_menu", "Collision", "MOD_PHYSICS")
if expanded:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_use_collision", text="Use Collision")
if pb.gp_chain_use_collision:
row = boxxxx.row(align=True)
row.prop(pb, "gp_chain_collision_dist", text="Collision Distance")
row = boxxxx.row(align=True)
row.prop(
pb, "gp_chain_collision_friction", text="Collision Friction"
)
row = boxxxx.row(align=True)
row.operator(
"goophys.refresh_collision_collection",
text="Refresh Collision Objects")
if pb.gp_chain_limit_collision:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Set Collisions to All")
else:
row = boxxxx.row(align=True)
row.operator(
"goophys.limit_to_type",
text="Limit Collisions to Collection")
row = boxx.row(align=True)
row.prop(prefs, "apply_to_all_bones")
boxxx, expanded = draw_subpanel(boxx, prefs, "bone_menu", "Bone Settings", "BONE_DATA")
if expanded:
row = boxxx.row(align=True)
row.prop(pb, "gp_sim_influence", text="Sim Influence")
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Linear Sim Influence Falloff",
).type = 0
row = boxxx.row(align=True)
row.operator(
"goophys.apply_falloff_to_selected",
text="Apply Quadratic Sim Influence Falloff",
).type = 1
#
elif context.mode == "POSE":
box = layout.box()
row = box.row(align=True)
row.operator("goophys.select_dynamic_bones", text="Select Physics Bones")
row = box.row(align=True)
row.operator("goophys.select_last_dynamic_bones", text="Select Last Bone in Chains")
row = box.row(align=True)
row.alignment = "CENTER"
row.prop(prefs, "gp_physics_active", text="Physics Active")
row.alignment = "CENTER"
class GP_PT_goophys_collider_panel(Panel):
"""Creates a Panel in the scene context of the properties editor"""
bl_idname = "GP_PT_goophys_collider_panel"
bl_label = "Colliders"
bl_parent_id = "GP_PT_goophys_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Goo Animation Tools"
bl_order = 1
@classmethod
def poll(cls, context):
return context.active_object and context.mode == "OBJECT"
def draw(self, context):
layout = self.layout
row = layout.row(align=True)
row.operator(
"goophys.add_collider", text="Collider From Selected Objects"
)
row = layout.row(align=True)
row.operator(
"goophys.colliders_from_mesh", text="Colliders From Mesh Deform Weights"
)
class GP_PT_goophys_advanced_panel(Panel):
"""Creates a Panel in the scene context of the properties editor"""
bl_idname = "GP_PT_goophys_advanced_panel"
bl_label = "Advanced"
bl_parent_id = "GP_PT_goophys_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Goo Animation Tools"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 2
@classmethod
def poll(cls, context):
return context.active_object and context.mode == "POSE"
def draw(self, context):
layout = self.layout
prefs = context.preferences.addons["goo_physics"].preferences
row = layout.row(align=True)
row.label(text="Bone Map Presets:")
row = layout.row(align=True)
row.prop(prefs, "bm_presets", text="")
row.operator("goophys.add_bone_map_preset", text="", icon="ADD")
row.operator("goophys.delete_bone_map_preset", text="", icon="REMOVE")
row = layout.row(align=True)
row.operator("goophys.apply_bone_map_preset", text="Apply Bone Map Preset")
class GP_PT_goophys_trouble_panel(Panel):
"""Creates a Panel in the scene context of the properties editor"""
bl_idname = "GP_PT_goophys_trouble_panel"
bl_label = "Troubleshooting"
bl_parent_id = "GP_PT_goophys_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Goo Animation Tools"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 3
def draw(self, context):
layout = self.layout
row = layout.row(align=True)
row.operator("goophys.sync_frame_range", text="Sync Frame Range")
row = layout.row(align=True)
row.operator(
"goophys.refresh_physics_status", text="Refresh Bone Physics Status"
)
#
#
#
_classes = [
GP_PT_goophys_panel,
GP_PT_goophys_collider_panel,
GP_PT_goophys_advanced_panel,
GP_PT_goophys_trouble_panel,
]
_register, _unregister = register_classes_factory(_classes)
def register():
if hasattr(bpy.types, "GOO_PT_main_panel") == False:
bpy.utils.register_class(GOO_PT_main_panel)
_register()
def unregister():
_unregister()