Files
2026-03-17 14:30:01 -06:00

491 lines
20 KiB
Python

# ##### 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 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
# 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 LICENSE BLOCK #####
# <pep8 compliant>
import bpy
import bpy_extras.anim_utils
import mathutils
import math
import itertools
import re
def cursor(cursor_mode):
def cursor_decorator(func):
def wrapper(self, context, *args, **kwargs):
context.window.cursor_modal_set(cursor_mode)
try:
return func(self, context, *args, **kwargs)
finally:
context.window.cursor_modal_restore()
return wrapper
return cursor_decorator
def bone_name(prefix, position, side, index=0):
if index == 0:
return '%s.%s.%s' % (prefix, position, side)
else:
return '%s.%s.%s.%03d' % (prefix, position, side, index)
def bone_range(bones, name_prefix, position, side):
for index in itertools.count():
name = bone_name(name_prefix, position, side, index)
if name in bones:
yield bones[name]
else:
break
def find_wheelbrake_bone(bones, position, side, index):
other_side = 'R' if side == 'L' else 'L'
name_prefix = 'WheelBrake'
bone = bones.get(bone_name(name_prefix, position, side, index))
if bone:
return bone
bone = bones.get(bone_name(name_prefix, position, other_side, index))
if bone:
return bone
if index > 0:
bone = bones.get(bone_name(name_prefix, position, side))
if bone:
return bone
bone = bones.get(bone_name(name_prefix, position, other_side))
if bone:
return bone
backward_compatible_bone_name = '%s Wheels' % ('Front' if position == 'Ft' else 'Back')
return bones.get(backward_compatible_bone_name)
def clear_property_animation(context, property_name, remove_keyframes=True):
if remove_keyframes and context.object.animation_data and context.object.animation_data.action:
fcurve_datapath = '["%s"]' % property_name
action = context.object.animation_data.action
fcurve = action.fcurves.find(fcurve_datapath)
if fcurve is not None:
action.fcurves.remove(fcurve)
context.object[property_name] = .0
def create_property_animation(context, property_name):
action = context.object.animation_data.action
fcurve_datapath = '["%s"]' % property_name
return action.fcurves.new(fcurve_datapath, index=0, action_group='Wheels rotation')
class FCurvesEvaluator(object):
"""Encapsulates a bunch of FCurves for vector animations."""
def __init__(self, fcurves, default_value):
self.default_value = default_value
self.fcurves = fcurves
def evaluate(self, f):
result = []
for fcurve, value in zip(self.fcurves, self.default_value):
if fcurve is not None:
result.append(fcurve.evaluate(f))
else:
result.append(value)
return result
class VectorFCurvesEvaluator(object):
def __init__(self, fcurves_evaluator):
self.fcurves_evaluator = fcurves_evaluator
def evaluate(self, f):
return mathutils.Vector(self.fcurves_evaluator.evaluate(f))
class EulerToQuaternionFCurvesEvaluator(object):
def __init__(self, fcurves_evaluator):
self.fcurves_evaluator = fcurves_evaluator
def evaluate(self, f):
return mathutils.Euler(self.fcurves_evaluator.evaluate(f)).to_quaternion()
class QuaternionFCurvesEvaluator(object):
def __init__(self, fcurves_evaluator):
self.fcurves_evaluator = fcurves_evaluator
def evaluate(self, f):
return mathutils.Quaternion(self.fcurves_evaluator.evaluate(f))
def fix_old_steering_rotation(rig_object):
"""
Fix armature generated with Rigacar version < 6.0
"""
if rig_object.pose and rig_object.pose.bones:
if 'MCH-Steering.rotation' in rig_object.pose.bones:
rig_object.pose.bones['MCH-Steering.rotation'].rotation_mode = 'QUATERNION'
def serialize_bake_options():
# For older versions than 4.1
if bpy.app.version < (4, 1, 0):
return dict(
only_selected=True,
do_pose=True,
do_object=False,
do_visual_keying=True
)
# For latest versions
return dict(bake_options=bpy_extras.anim_utils.BakeOptions(
only_selected=True,
do_pose=True,
do_object=False,
do_visual_keying=True,
do_constraint_clear=False,
do_parents_clear=False,
do_clean=False,
do_location=True,
do_scale=True,
do_rotation=True,
do_bbone=True,
do_custom_props=True
)
)
class BakingOperator(object):
frame_start: bpy.props.IntProperty(name='Start Frame', min=1)
frame_end: bpy.props.IntProperty(name='End Frame', min=1)
keyframe_tolerance: bpy.props.FloatProperty(name='Keyframe tolerance', min=0, default=.01)
@classmethod
def poll(cls, context):
return ('Car Rig' in context.object.data and
context.object.data['Car Rig'] and
context.object.mode in ('POSE', 'OBJECT'))
def invoke(self, context, event):
if context.object.animation_data is None:
context.object.animation_data_create()
if context.object.animation_data.action is None:
context.object.animation_data.action = bpy.data.actions.new("%sAction" % context.object.name)
action = context.object.animation_data.action
self.frame_start = int(action.frame_range[0])
self.frame_end = int(action.frame_range[1])
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
self.layout.use_property_split = True
self.layout.use_property_decorate = False
self.layout.prop(self, 'frame_start')
self.layout.prop(self, 'frame_end')
self.layout.prop(self, 'keyframe_tolerance')
def _create_euler_evaluator(self, action, source_bone):
fcurve_name = 'pose.bones["%s"].rotation_euler' % source_bone.name
fc_root_rot = [action.fcurves.find(fcurve_name, index=i) for i in range(3)]
return EulerToQuaternionFCurvesEvaluator(FCurvesEvaluator(fc_root_rot, default_value=(.0, .0, .0)))
def _create_quaternion_evaluator(self, action, source_bone):
fcurve_name = 'pose.bones["%s"].rotation_quaternion' % source_bone.name
fc_root_rot = [action.fcurves.find(fcurve_name, index=i) for i in range(4)]
return QuaternionFCurvesEvaluator(FCurvesEvaluator(fc_root_rot, default_value=(1.0, .0, .0, .0)))
def _create_location_evaluator(self, action, source_bone):
fcurve_name = 'pose.bones["%s"].location' % source_bone.name
fc_root_loc = [action.fcurves.find(fcurve_name, index=i) for i in range(3)]
return VectorFCurvesEvaluator(FCurvesEvaluator(fc_root_loc, default_value=(.0, .0, .0)))
def _create_scale_evaluator(self, action, source_bone):
fcurve_name = 'pose.bones["%s"].scale' % source_bone.name
fc_root_loc = [action.fcurves.find(fcurve_name, index=i) for i in range(3)]
return VectorFCurvesEvaluator(FCurvesEvaluator(fc_root_loc, default_value=(1.0, 1.0, 1.0)))
def _bake_action(self, context, *source_bones):
action = context.object.animation_data.action
nla_tweak_mode = context.object.animation_data.use_tweak_mode if hasattr(context.object.animation_data,
'use_tweak_mode') else False
# saving context
selected_bones = [b for b in context.object.data.bones if b.select]
mode = context.object.mode
for b in selected_bones:
b.select = False
bpy.ops.object.mode_set(mode='OBJECT')
source_bones_matrix_basis = []
for source_bone in source_bones:
source_bones_matrix_basis.append(context.object.pose.bones[source_bone.name].matrix_basis.copy())
source_bone.select = True
bake_options = serialize_bake_options()
baked_action = bpy_extras.anim_utils.bake_action(
context.object,
action=None,
frames=range(self.frame_start, self.frame_end + 1),
**bake_options
)
# restoring context
for source_bone, matrix_basis in zip(source_bones, source_bones_matrix_basis):
context.object.pose.bones[source_bone.name].matrix_basis = matrix_basis
source_bone.select = False
for b in selected_bones:
b.select = True
bpy.ops.object.mode_set(mode=mode)
if nla_tweak_mode:
context.object.animation_data.use_tweak_mode = nla_tweak_mode
else:
context.object.animation_data.action = action
return baked_action
class ANIM_OT_carWheelsRotationBake(bpy.types.Operator, BakingOperator):
bl_idname = 'anim.car_wheels_rotation_bake'
bl_label = 'Bake wheels rotation'
bl_description = 'Automatically generates wheels animation based on Root bone animation.'
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
context.object['wheels_on_y_axis'] = False
self._bake_wheels_rotation(context)
return {'FINISHED'}
@cursor('WAIT')
def _bake_wheels_rotation(self, context):
bones = context.object.data.bones
wheel_bones = []
brake_bones = []
for position, side in itertools.product(('Ft', 'Bk'), ('L', 'R')):
for index, wheel_bone in enumerate(bone_range(bones, 'MCH-Wheel.rotation', position, side)):
wheel_bones.append(wheel_bone)
brake_bones.append(find_wheelbrake_bone(bones, position, side, index) or wheel_bone)
for property_name in map(lambda wheel_bone: wheel_bone.name.replace('MCH-', ''), wheel_bones):
clear_property_animation(context, property_name)
bones = set(wheel_bones + brake_bones)
baked_action = self._bake_action(context, *bones)
if baked_action is None:
self.report({'WARNING'}, "Existing action failed to bake. Won't bake wheel rotation")
return
try:
for wheel_bone, brake_bone in zip(wheel_bones, brake_bones):
self._bake_wheel_rotation(context, baked_action, wheel_bone, brake_bone)
finally:
bpy.data.actions.remove(baked_action)
def _evaluate_distance_per_frame(self, action, bone, brake_bone):
loc_evaluator = self._create_location_evaluator(action, bone)
rot_evaluator = self._create_euler_evaluator(action, bone)
brake_evaluator = self._create_scale_evaluator(action, brake_bone)
radius = bone.length if bone.length > .0 else 1.0
bone_init_vector = (bone.head_local - bone.tail_local).normalized()
prev_pos = loc_evaluator.evaluate(self.frame_start)
prev_speed = 0
distance = 0
yield self.frame_start, distance
for f in range(self.frame_start + 1, self.frame_end):
pos = loc_evaluator.evaluate(f)
speed_vector = pos - prev_pos
speed_vector *= 2 * brake_evaluator.evaluate(f).y - 1
rotation_quaternion = rot_evaluator.evaluate(f)
bone_orientation = rotation_quaternion @ bone_init_vector
speed = math.copysign(speed_vector.magnitude, bone_orientation.dot(speed_vector))
speed /= radius
drop_keyframe = False
if speed == .0:
drop_keyframe = prev_speed == speed
elif prev_speed != .0:
drop_keyframe = abs(1 - prev_speed / speed) < self.keyframe_tolerance / 10
if not drop_keyframe:
prev_speed = speed
yield f - 1, distance
distance += speed
prev_pos = pos
yield self.frame_end, distance
def _bake_wheel_rotation(self, context, baked_action, bone, brake_bone):
fc_rot = create_property_animation(context, bone.name.replace('MCH-', ''))
# Reset the transform of the wheel bone, otherwise baking yields wrong results
pb: bpy.types.PoseBone = context.object.pose.bones[bone.name]
pb.matrix_basis.identity()
for f, distance in self._evaluate_distance_per_frame(baked_action, bone, brake_bone):
kf = fc_rot.keyframe_points.insert(f, distance)
kf.interpolation = 'LINEAR'
kf.type = 'JITTER'
class ANIM_OT_carSteeringBake(bpy.types.Operator, BakingOperator):
bl_idname = 'anim.car_steering_bake'
bl_label = 'Bake car steering'
bl_description = 'Automatically generates steering animation based on Root bone animation.'
bl_options = {'REGISTER', 'UNDO'}
rotation_factor: bpy.props.FloatProperty(name='Rotation factor', min=.1, default=1)
def draw(self, context):
self.layout.use_property_split = True
self.layout.use_property_decorate = False
self.layout.prop(self, 'frame_start')
self.layout.prop(self, 'frame_end')
self.layout.prop(self, 'rotation_factor')
self.layout.prop(self, 'keyframe_tolerance')
def execute(self, context):
if self.frame_end > self.frame_start:
if 'Steering' in context.object.data.bones and 'MCH-Steering.rotation' in context.object.data.bones:
steering = context.object.data.bones['Steering']
mch_steering_rotation = context.object.data.bones['MCH-Steering.rotation']
bone_offset = abs(steering.head_local.y - mch_steering_rotation.head_local.y)
self._bake_steering_rotation(context, bone_offset, mch_steering_rotation)
return {'FINISHED'}
def _evaluate_rotation_per_frame(self, action, bone_offset, bone):
loc_evaluator = self._create_location_evaluator(action, bone)
rot_evaluator = self._create_quaternion_evaluator(action, bone)
distance_threshold = pow(bone_offset * max(self.keyframe_tolerance, .001), 2)
steering_threshold = bone_offset * self.keyframe_tolerance * .1
bone_direction_vector = (bone.head_local - bone.tail_local).normalized()
bone_normal_vector = mathutils.Vector((1, 0, 0))
current_pos = loc_evaluator.evaluate(self.frame_start)
previous_steering_position = None
for f in range(self.frame_start, self.frame_end - 1):
next_pos = loc_evaluator.evaluate(f + 1)
steering_direction_vector = next_pos - current_pos
if steering_direction_vector.length_squared < distance_threshold:
continue
rotation_quaternion = rot_evaluator.evaluate(f)
world_space_bone_direction_vector = rotation_quaternion @ bone_direction_vector
world_space_bone_normal_vector = rotation_quaternion @ bone_normal_vector
projected_steering_direction = steering_direction_vector.dot(world_space_bone_direction_vector)
if projected_steering_direction == 0:
continue
length_ratio = bone_offset * self.rotation_factor / projected_steering_direction
steering_direction_vector *= length_ratio
steering_position = mathutils.geometry.distance_point_to_plane(steering_direction_vector,
world_space_bone_direction_vector,
world_space_bone_normal_vector)
if previous_steering_position is not None \
and abs(steering_position - previous_steering_position) < steering_threshold:
continue
yield f, steering_position
current_pos = next_pos
previous_steering_position = steering_position
@cursor('WAIT')
def _bake_steering_rotation(self, context, bone_offset, bone):
clear_property_animation(context, 'Steering.rotation')
fix_old_steering_rotation(context.object)
fc_rot = create_property_animation(context, 'Steering.rotation')
baked_action = self._bake_action(context, bone)
if baked_action is None:
self.report({'WARNING'}, "Existing action failed to bake. Won't bake steering rotation")
return
try:
# Reset the transform of the steering bone, because baking action manipulates the transform
# and evaluate_rotation_frame expects it at it's default position
pb: bpy.types.PoseBone = context.object.pose.bones[bone.name]
pb.matrix_basis.identity()
for f, steering_pos in self._evaluate_rotation_per_frame(baked_action, bone_offset, bone):
kf = fc_rot.keyframe_points.insert(f, steering_pos)
kf.type = 'JITTER'
kf.interpolation = 'LINEAR'
finally:
bpy.data.actions.remove(baked_action)
class ANIM_OT_carClearSteeringWheelsRotation(bpy.types.Operator):
bl_idname = "anim.car_clear_steering_wheels_rotation"
bl_label = "Clear baked animation"
bl_description = "Clear generated rotation for steering and wheels"
bl_options = {'REGISTER', 'UNDO'}
clear_steering: bpy.props.BoolProperty(name="Steering", description="Clear generated animation for steering",
default=True)
clear_wheels: bpy.props.BoolProperty(name="Wheels", description="Clear generated animation for wheels",
default=True)
def draw(self, context):
self.layout.use_property_decorate = False
self.layout.label(text='Clear generated keyframes for')
self.layout.prop(self, property='clear_steering')
self.layout.prop(self, property='clear_wheels')
@classmethod
def poll(cls, context):
return context.object is not None and context.object.data is not None and context.object.data.get('Car Rig')
def execute(self, context):
re_wheel_propname = re.compile(r'^Wheel\.rotation\.(Ft|Bk)\.[LR](\.\d+)?$')
for prop in context.object.keys():
if prop == 'Steering.rotation':
clear_property_animation(context, prop, remove_keyframes=self.clear_steering)
elif re_wheel_propname.match(prop):
clear_property_animation(context, prop, remove_keyframes=self.clear_wheels)
# this is a hack to force Blender to take into account the modification
# of the properties by changing the object mode.
# Don't know yet if it is specific to blender 2.80
mode = context.object.mode
bpy.ops.object.mode_set(mode='OBJECT' if mode == 'POSE' else 'POSE')
bpy.ops.object.mode_set(mode=mode)
return {'FINISHED'}
def register():
bpy.utils.register_class(ANIM_OT_carWheelsRotationBake)
bpy.utils.register_class(ANIM_OT_carSteeringBake)
bpy.utils.register_class(ANIM_OT_carClearSteeringWheelsRotation)
def unregister():
bpy.utils.unregister_class(ANIM_OT_carClearSteeringWheelsRotation)
bpy.utils.unregister_class(ANIM_OT_carSteeringBake)
bpy.utils.unregister_class(ANIM_OT_carWheelsRotationBake)
if __name__ == "__main__":
register()