[max,maya,c4d,blender]改轴心改空间,动画不变工具。

1、有些时候动画k完了,发现物体的轴心不是在自己想要的位置,这时候需要用这个工具进行调整,动画不变。

2、有些时候要把动画从子物体里拿到世界空间,又想把子物体从世界空间拿到目标物体下面,动画不变。


offset_animation_c4d.pyp

python 复制代码
import c4d
from c4d import gui, plugins

# -----------------------------------------------------------------------------
# Plugin IDs
# -----------------------------------------------------------------------------
# 注意:正式发布前请到 Maxon 官网申请唯一的 Plugin ID
# 这里使用临时 ID (1000000 - 1000010 是预留给测试用的)
PLUGIN_ID = 1060001 

# -----------------------------------------------------------------------------
# UI Constants
# -----------------------------------------------------------------------------
DLG_ID_X = 1001
DLG_ID_Y = 1002
DLG_ID_Z = 1003
DLG_BTN_ADD = 1004
DLG_BTN_SUB = 1005
DLG_BTN_BAKE_WORLD = 1006
DLG_LINKBOX = 1007
DLG_BTN_READ_POS = 1008
DLG_BTN_BAKE_LOCAL = 1009
DLG_RB_FRAME_MODE = 1010
DLG_RB_RANGE_MODE = 1011
DLG_SLIDER_THRESH = 1012
DLG_BTN_OPTIMIZE = 1013

# -----------------------------------------------------------------------------
# Dialog Class
# -----------------------------------------------------------------------------
class OffsetDialog(gui.GeDialog):
    def CreateLayout(self):
        self.SetTitle("动画整体偏移工具 (插件版)")
        
        # --- 目标物体选择区域 ---
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "目标/参考物体:", 0)
        # LinkBox 用于拖入物体
        self.linkbox = self.AddCustomGui(DLG_LINKBOX, c4d.CUSTOMGUI_LINKBOX, "", c4d.BFH_SCALEFIT, 0, 0)
        self.GroupEnd()

        self.AddButton(DLG_BTN_READ_POS, c4d.BFH_SCALEFIT, 0, 0, "读取目标物体坐标 -> 填入下方")
        self.AddSeparatorH(0, c4d.BFH_SCALEFIT)

        # --- 输入区域 ---
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
        
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "X 偏移:", 0)
        self.AddEditNumberArrows(DLG_ID_X, c4d.BFH_SCALEFIT, 80, 0)
        
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "Y 偏移:", 0)
        self.AddEditNumberArrows(DLG_ID_Y, c4d.BFH_SCALEFIT, 80, 0)
        
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "Z 偏移:", 0)
        self.AddEditNumberArrows(DLG_ID_Z, c4d.BFH_SCALEFIT, 80, 0)
        
        self.GroupEnd()
        
        self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
        
        # --- 偏移按钮区域 ---
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
        self.AddButton(DLG_BTN_ADD, c4d.BFH_SCALEFIT, 0, 0, "加上偏移 (+)")
        self.AddButton(DLG_BTN_SUB, c4d.BFH_SCALEFIT, 0, 0, "减去偏移 (-)")
        self.GroupEnd()

        self.AddSeparatorH(0, c4d.BFH_SCALEFIT)
        
        # --- 烘焙设置 ---
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 1, 0, "烘焙设置", 0)
        self.GroupBorderSpace(5, 5, 5, 5)
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "帧模式:", 0)
        self.AddRadioGroup(DLG_RB_FRAME_MODE, c4d.BFH_SCALEFIT, 2, 1)
        self.AddChild(DLG_RB_FRAME_MODE, 0, "保持帧数")
        self.AddChild(DLG_RB_FRAME_MODE, 1, "烘焙所有帧")
        
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "范围模式:", 0)
        self.AddRadioGroup(DLG_RB_RANGE_MODE, c4d.BFH_SCALEFIT, 2, 1)
        self.AddChild(DLG_RB_RANGE_MODE, 0, "所有关键帧")
        self.AddChild(DLG_RB_RANGE_MODE, 1, "播放范围")
        self.AddChild(DLG_RB_RANGE_MODE, 2, "选择的帧")
        self.GroupEnd()

        self.AddSeparatorH(0, c4d.BFH_SCALEFIT)

        # --- 烘焙按钮区域 ---
        self.AddButton(DLG_BTN_BAKE_WORLD, c4d.BFH_SCALEFIT, 0, 0, "转换动画为世界坐标 (解绑父级+烘焙)")
        self.AddButton(DLG_BTN_BAKE_LOCAL, c4d.BFH_SCALEFIT, 0, 0, "转换动画为物体坐标 (绑定到目标+烘焙)")
        
        self.AddSeparatorH(0, c4d.BFH_SCALEFIT)

        # --- 优化关键帧 ---
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 1, 0, "优化关键帧", 0)
        self.GroupBorderSpace(5, 5, 5, 5)
        self.GroupBegin(0, c4d.BFH_SCALEFIT, 2, 0)
        self.AddStaticText(0, c4d.BFH_LEFT, 0, 0, "优化阈值:", 0)
        self.AddEditNumberArrows(DLG_SLIDER_THRESH, c4d.BFH_SCALEFIT, 80, 0)
        self.GroupEnd()
        self.AddButton(DLG_BTN_OPTIMIZE, c4d.BFH_SCALEFIT, 0, 0, "优化曲线")
        self.GroupEnd()

        return True

    def InitValues(self):
        # 初始化为 0
        self.SetFloat(DLG_ID_X, 0.0)
        self.SetFloat(DLG_ID_Y, 0.0)
        self.SetFloat(DLG_ID_Z, 0.0)
        self.SetInt32(DLG_RB_FRAME_MODE, 0) # 默认保持帧数
        self.SetInt32(DLG_RB_RANGE_MODE, 0) # 默认所有关键帧
        self.SetFloat(DLG_SLIDER_THRESH, 0.001) # 默认阈值
        return True

    def Command(self, id, msg):
        # 获取输入值
        x = self.GetFloat(DLG_ID_X)
        y = self.GetFloat(DLG_ID_Y)
        z = self.GetFloat(DLG_ID_Z)
        vector = c4d.Vector(x, y, z)

        if id == DLG_BTN_ADD:
            self.ApplyOffset(vector, is_add=True)
            return True
            
        if id == DLG_BTN_SUB:
            self.ApplyOffset(vector, is_add=False)
            return True
            
        if id == DLG_BTN_BAKE_WORLD:
            self.BakeToWorld()
            return True

        if id == DLG_BTN_BAKE_LOCAL:
            self.BakeToLocal()
            return True

        if id == DLG_BTN_READ_POS:
            self.ReadPosFromLink()
            return True

        if id == DLG_BTN_OPTIMIZE:
            self.OptimizeKeys()
            return True

        return True

    def ReadPosFromLink(self):
        """从 LinkBox 读取物体坐标并填入输入框"""
        target_obj = self.linkbox.GetLink()
        if not target_obj:
            gui.MessageDialog("请先将物体拖入上方选框中!")
            return
        
        # 读取世界坐标 (因为偏移通常是基于世界空间的向量)
        pos = target_obj.GetMg().off
        self.SetFloat(DLG_ID_X, pos.x)
        self.SetFloat(DLG_ID_Y, pos.y)
        self.SetFloat(DLG_ID_Z, pos.z)
        print(f"已读取参考坐标: {pos}")

    def GetKeyTimes(self, obj, doc, only_selected=False):
        """Collect unique keyframe times (frames) from an object."""
        fps = doc.GetFps()
        times = set()
        
        # Check Position, Rotation, Scale tracks
        param_ids = [c4d.ID_BASEOBJECT_REL_POSITION, c4d.ID_BASEOBJECT_REL_ROTATION, c4d.ID_BASEOBJECT_REL_SCALE]
        
        for pid in param_ids:
            # Check main vector track
            did = c4d.DescID(c4d.DescLevel(pid))
            track = obj.FindCTrack(did)
            if track:
                curve = track.GetCurve()
                cnt = curve.GetKeyCount()
                for i in range(cnt):
                    key = curve.GetKey(i)
                    if only_selected and not key.GetNBit(c4d.BIT_ACTIVE):
                        continue
                    times.add(key.GetTime().GetFrame(fps))
            
            # Check sub-tracks (X, Y, Z)
            for sub_id in [c4d.VECTOR_X, c4d.VECTOR_Y, c4d.VECTOR_Z]:
                did_sub = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sub_id))
                track_sub = obj.FindCTrack(did_sub)
                if track_sub:
                    curve = track_sub.GetCurve()
                    cnt = curve.GetKeyCount()
                    for i in range(cnt):
                        key = curve.GetKey(i)
                        if only_selected and not key.GetNBit(c4d.BIT_ACTIVE):
                            continue
                        times.add(key.GetTime().GetFrame(fps))
                        
        return sorted(list(times))

    def GetBakeTimes(self, objs, doc):
        """根据设置获取需要烘焙的帧列表"""
        frame_mode = self.GetInt32(DLG_RB_FRAME_MODE) # 0=Keep, 1=Bake All
        range_mode = self.GetInt32(DLG_RB_RANGE_MODE) # 0=All Keys, 1=Playback, 2=Selected
        fps = doc.GetFps()

        min_frame = 0
        max_frame = 0
        
        only_selected = (range_mode == 2)
        
        # 确定时间范围
        if range_mode == 1: # Playback Range
            min_frame = doc.GetMinTime().GetFrame(fps)
            max_frame = doc.GetMaxTime().GetFrame(fps)
        else: # All Keys or Selected - need to find min/max
            if frame_mode == 1: # Bake All
                all_keys = []
                for obj in objs:
                    all_keys.extend(self.GetKeyTimes(obj, doc, only_selected=only_selected))
                if all_keys:
                    min_frame = min(all_keys)
                    max_frame = max(all_keys)
                else:
                    current = doc.GetTime().GetFrame(fps)
                    min_frame, max_frame = current, current

        final_times = set()

        if frame_mode == 0: # Keep Frames
            for obj in objs:
                times = self.GetKeyTimes(obj, doc, only_selected=only_selected)
                for t in times:
                    if range_mode == 1:
                        if min_frame <= t <= max_frame:
                            final_times.add(t)
                    else:
                        final_times.add(t)
        else: # Bake All
            if min_frame is not None and max_frame is not None:
                for f in range(int(min_frame), int(max_frame) + 1):
                    final_times.add(f)
                
        sorted_times = sorted(list(final_times))
        if not sorted_times:
            sorted_times = [doc.GetTime().GetFrame(fps)]
            
        return sorted_times

    def BakeToLocal(self):
        """将选中物体的动画转换为目标物体的局部坐标(绑定父级并烘焙)"""
        target_obj = self.linkbox.GetLink()
        if not target_obj:
            gui.MessageDialog("请先将【父级目标物体】拖入上方选框中!\n此操作需要一个目标作为新父级。")
            return
            
        doc = c4d.documents.GetActiveDocument()
        objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
        if not objs:
            gui.MessageDialog("请选择至少一个要处理的物体!")
            return
            
        # 排除目标物体自己
        if target_obj in objs:
            gui.MessageDialog("错误:目标物体不能在选中列表中(不能做自己的父级)!")
            return

        # 确认提示
        if not gui.QuestionDialog(f"此操作将:\n1. 将选中物体设为【{target_obj.GetName()}】的子集\n2. 根据设置烘焙关键帧\n\n是否继续?"):
            return

        fps = doc.GetFps()
        doc.StartUndo()
        
        # 1. 获取烘焙时间点
        unique_bake_times = self.GetBakeTimes(objs, doc)
        
        # 2. Cache World Transforms AT KEY TIMES
        world_matrices = {} # {obj: {frame: mg}}
        
        for obj in objs:
            world_matrices[obj] = {}
        
        for f in unique_bake_times:
            t = c4d.BaseTime(f, fps)
            doc.SetTime(t)
            doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
            
            for obj in objs:
                world_matrices[obj][f] = obj.GetMg()
                
        # 3. 绑定父级
        for obj in objs:
            doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
            current_mg = obj.GetMg() # 保持当前位置
            obj.Remove()
            obj.InsertUnder(target_obj)
            obj.SetMg(current_mg) # 恢复当前位置
            
        # 4. 更新关键帧
        for f in unique_bake_times:
            t = c4d.BaseTime(f, fps)
            doc.SetTime(t) # Ensure context is at the correct time
            
            for obj in objs:
                if f not in world_matrices[obj]: continue
                
                m_world = world_matrices[obj][f]
                
                # 直接设置世界矩阵,让 C4D 自动计算相对于新父级的局部矩阵
                obj.SetMg(m_world)
                
                # 获取自动计算后的局部变换
                pos = obj.GetRelPos()
                rot = obj.GetRelRot() # HPB
                scale = obj.GetRelScale()
                
                # Helper to set key
                def set_key_val(pid, sid, val):
                    did = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sid))
                    track = obj.FindCTrack(did)
                    if not track:
                        track = c4d.CTrack(obj, did)
                        obj.InsertTrackSorted(track)
                    
                    curve = track.GetCurve()
                    k_dict = curve.AddKey(t)
                    if k_dict:
                        key = k_dict['key']
                        key.SetValue(curve, val)

                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_X, pos.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Y, pos.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Z, pos.z)
                
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_X, rot.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Y, rot.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Z, rot.z)
                
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_X, scale.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Y, scale.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Z, scale.z)
        
        doc.EndUndo()
        c4d.EventAdd()
        print("✅ 物体坐标烘焙完成。")

    def BakeToWorld(self):
        """将选中物体的动画转换为世界坐标(烘焙并解绑父级)"""
        doc = c4d.documents.GetActiveDocument()
        objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
        if not objs:
            gui.MessageDialog("请选择至少一个物体!")
            return
            
        # 确认提示
        if not gui.QuestionDialog("此操作将:\n1. 将选中物体从父级中移出(放到世界层级)\n2. 根据设置烘焙关键帧\n\n是否继续?"):
            return

        fps = doc.GetFps()
        doc.StartUndo()
        
        # 1. 获取烘焙时间点
        unique_bake_times = self.GetBakeTimes(objs, doc)
            
        # 2. Cache World Transforms
        world_matrices = {}
        for obj in objs:
            world_matrices[obj] = {}
            
        for f in unique_bake_times:
            t = c4d.BaseTime(f, fps)
            doc.SetTime(t)
            doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
            for obj in objs:
                world_matrices[obj][f] = obj.GetMg()
                
        # 3. 解绑父级
        for obj in objs:
            doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
            current_mg = obj.GetMg()
            obj.Remove()
            doc.InsertObject(obj)
            obj.SetMg(current_mg)
            
        # 4. 更新关键帧
        for f in unique_bake_times:
            t = c4d.BaseTime(f, fps)
            doc.SetTime(t) # Ensure context is at the correct time
            
            for obj in objs:
                if f not in world_matrices[obj]: continue
                
                mg = world_matrices[obj][f]
                
                # World Bake: Set World Matrix directly
                obj.SetMg(mg)
                
                # Get local values (which are now World values since parent is World/None)
                pos = obj.GetRelPos()
                rot = obj.GetRelRot()
                scale = obj.GetRelScale()
                
                # Helper to set key
                def set_key_val(pid, sid, val):
                    did = c4d.DescID(c4d.DescLevel(pid), c4d.DescLevel(sid))
                    track = obj.FindCTrack(did)
                    if not track:
                        track = c4d.CTrack(obj, did)
                        obj.InsertTrackSorted(track)
                    
                    curve = track.GetCurve()
                    k_dict = curve.AddKey(t)
                    if k_dict:
                        key = k_dict['key']
                        key.SetValue(curve, val)
                
                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_X, pos.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Y, pos.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_POSITION, c4d.VECTOR_Z, pos.z)
                
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_X, rot.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Y, rot.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_ROTATION, c4d.VECTOR_Z, rot.z)
                
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_X, scale.x)
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Y, scale.y)
                set_key_val(c4d.ID_BASEOBJECT_REL_SCALE, c4d.VECTOR_Z, scale.z)
        
        doc.EndUndo()
        c4d.EventAdd()
        print("✅ 世界坐标烘焙完成。")

    def ApplyOffset(self, offset_vector, is_add):
        """执行偏移逻辑"""
        doc = c4d.documents.GetActiveDocument()
        selected_objects = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
        
        if not selected_objects:
            gui.MessageDialog("请先选择至少一个物体!")
            return

        # 如果是减去模式,反转向量
        if not is_add:
            offset_vector = -offset_vector

        print(f"执行偏移: {offset_vector}")
        
        doc.StartUndo()
        
        for obj in selected_objects:
            doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
            print(f"正在处理: {obj.GetName()}")
            
            # 1. 修改静态坐标
            mg = obj.GetMg() # 获取全局矩阵
            mg.off += offset_vector
            obj.SetMg(mg) # 设置回全局矩阵
            
            # 2. 修改动画关键帧 (Position Track)
            axes = [
                (c4d.VECTOR_X, offset_vector.x, "X"),
                (c4d.VECTOR_Y, offset_vector.y, "Y"),
                (c4d.VECTOR_Z, offset_vector.z, "Z")
            ]
            
            for vector_id, offset_val, axis_name in axes:
                if offset_val == 0:
                    continue
                
                track_id = c4d.DescID(
                    c4d.DescLevel(c4d.ID_BASEOBJECT_REL_POSITION, c4d.DTYPE_VECTOR, 0),
                    c4d.DescLevel(vector_id, c4d.DTYPE_REAL, 0)
                )
                track = obj.FindCTrack(track_id)
                
                if track:
                    curve = track.GetCurve()
                    key_count = curve.GetKeyCount()
                    for i in range(key_count):
                        key = curve.GetKey(i)
                        old_val = key.GetValue()
                        key.SetValue(curve, old_val + offset_val)

        doc.EndUndo()
        c4d.EventAdd()
        print("✅ 偏移完成。")

    def OptimizeKeys(self):
        """优化关键帧,移除冗余关键帧"""
        doc = c4d.documents.GetActiveDocument()
        objs = doc.GetActiveObjects(c4d.GETACTIVEOBJECTFLAGS_NONE)
        if not objs:
            gui.MessageDialog("请选择至少一个物体!")
            return
            
        threshold = self.GetFloat(DLG_SLIDER_THRESH)
        range_mode = self.GetInt32(DLG_RB_RANGE_MODE) # 0=All Keys, 1=Playback, 2=Selected
        
        fps = doc.GetFps()
        min_f = None
        max_f = None
        
        if range_mode == 1:
            min_f = doc.GetMinTime().GetFrame(fps)
            max_f = doc.GetMaxTime().GetFrame(fps)
            
        doc.StartUndo()
        
        count = 0
        for obj in objs:
            doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj)
            
            # 遍历所有 Track
            tracks = obj.GetCTracks()
            for track in tracks:
                curve = track.GetCurve()
                if not curve: continue
                
                key_count = curve.GetKeyCount()
                if key_count <= 2: continue
                
                keys_to_delete_indices = []
                
                # 读取所有数据
                key_data = [] # list of (id, time_frame, value, selected)
                for i in range(key_count):
                    key = curve.GetKey(i)
                    t = key.GetTime().GetFrame(fps)
                    val = key.GetValue()
                    sel = key.GetNBit(c4d.BIT_ACTIVE)
                    key_data.append({'id': i, 't': t, 'val': val, 'sel': sel})
                    
                last_val = None
                
                for i in range(len(key_data)):
                    data = key_data[i]
                    val = data['val']
                    t = data['t']
                    sel = data['sel']
                    
                    # 范围检查
                    in_range = True
                    if range_mode == 1:
                        if t < min_f or t > max_f:
                            in_range = False
                    elif range_mode == 2: # Selected
                        if not sel:
                            in_range = False
                            
                    if in_range:
                        if last_val is None:
                            last_val = val
                        else:
                            # 总是保留最后一个点(在该范围内)
                            is_last_in_scope = (i == len(key_data) - 1)
                            
                            if is_last_in_scope:
                                last_val = val
                            else:
                                dist = abs(val - last_val)
                                if dist > threshold:
                                    last_val = val
                                else:
                                    # 标记删除
                                    keys_to_delete_indices.append(data['id'])
                    else:
                        last_val = val
                
                # 执行删除 (倒序)
                if keys_to_delete_indices:
                    for idx in sorted(keys_to_delete_indices, reverse=True):
                        curve.DelKey(idx)
                        count += 1
                        
        doc.EndUndo()
        c4d.EventAdd()
        print(f"✅ 优化完成,共移除了 {count} 个关键帧。")

# -----------------------------------------------------------------------------
# Command Plugin Class
# -----------------------------------------------------------------------------
class OffsetAnimationPlugin(plugins.CommandData):
    dialog = None

    def Execute(self, doc):
        if self.dialog is None:
            self.dialog = OffsetDialog()
        return self.dialog.Open(c4d.DLG_TYPE_ASYNC, pluginid=PLUGIN_ID, defaultw=300, defaulth=350)

    def RestoreLayout(self, sec_ref):
        if self.dialog is None:
            self.dialog = OffsetDialog()
        return self.dialog.Restore(PLUGIN_ID, secret=sec_ref)

if __name__ == "__main__":
    plugins.RegisterCommandPlugin(
        id=PLUGIN_ID,
        str="动画整体偏移工具 (CN)",
        info=0,
        icon=None,
        help="动画偏移与烘焙工具",
        dat=OffsetAnimationPlugin()
    )

offset_animation_max_plugin.ms

python 复制代码
macroScript OffsetAnimationTool
category:"AnimationTools"
tooltip:"Offset Animation Tool"
buttonText:"OffsetAnim"
(
    rollout ro_offset_anim "动画位移工具" width:220
    (
        group "目标对象"
        (
            pickbutton pbtn_target "拾取目标对象" width:190 autoDisplay:true
            button btn_read_coords "读取坐标" width:190
        )
        
        group "偏移数值"
        (
            label lbl_x "X 偏移:" across:2 align:#left
            spinner spn_x "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
            
            label lbl_y "Y 偏移:" across:2 align:#left
            spinner spn_y "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
            
            label lbl_z "Z 偏移:" across:2 align:#left
            spinner spn_z "" range:[-1e9, 1e9, 0] type:#float fieldwidth:80 align:#right
            
            button btn_add "加上偏移 (+)" width:90 across:2
            button btn_sub "减去偏移 (-)" width:90
        )
        
        group "烘焙设置"
        (
            radiobuttons rdo_frame_mode labels:#("保持帧数不变", "烘焙所有帧") default:1
            radiobuttons rdo_range_mode labels:#("所有关键帧", "播放范围的", "选择的帧") default:1
        )
    
        group "烘焙动画"
        (
            button btn_bake_world "烘焙到世界 (解绑)" width:190 height:30
            button btn_bake_local "烘焙到局部 (绑定)" width:190 height:30
        )
        
        group "优化关键帧"
        (
            spinner spn_opt_threshold "阈值:" range:[0, 1000, 0.01] type:#float fieldwidth:50 align:#center
            button btn_optimize "执行优化" width:190 height:30
        )
        
        -- Variables
        local target_obj = undefined
        
        -- Event Handlers
        on pbtn_target picked obj do
        (
            target_obj = obj
            pbtn_target.text = obj.name
        )
        
        on btn_read_coords pressed do
        (
            if isValidNode target_obj then
            (
                local pos = target_obj.pos
                spn_x.value = pos.x
                spn_y.value = pos.y
                spn_z.value = pos.z
            )
            else
            (
                messageBox "请先拾取有效的目标对象。" title:"目标丢失"
            )
        )
        
        fn apply_offset sign =
        (
            local offset_vec = [spn_x.value * sign, spn_y.value * sign, spn_z.value * sign]
            
            undo "Offset Animation" on
            (
                for obj in selection do
                (
                    local processed = false
                    
                    -- 1. Try Position_XYZ sub-controllers
                    try 
                    (
                        local has_sub_keys = false
                        if isProperty obj.pos.controller #x_position do
                        (
                            if obj.pos.controller.x_position.controller.keys.count > 0 do has_sub_keys = true
                            if obj.pos.controller.y_position.controller.keys.count > 0 do has_sub_keys = true
                            if obj.pos.controller.z_position.controller.keys.count > 0 do has_sub_keys = true
                        )
                        
                        if has_sub_keys then
                        (
                            if obj.pos.controller.x_position.controller.keys.count > 0 do
                                for k in obj.pos.controller.x_position.controller.keys do k.value += offset_vec.x
                            if obj.pos.controller.y_position.controller.keys.count > 0 do
                                for k in obj.pos.controller.y_position.controller.keys do k.value += offset_vec.y
                            if obj.pos.controller.z_position.controller.keys.count > 0 do
                                for k in obj.pos.controller.z_position.controller.keys do k.value += offset_vec.z
                            processed = true
                        )
                    ) catch()
                    
                    -- 2. Try generic controller keys
                    if not processed do 
                    try 
                    (
                        if obj.pos.controller.keys.count > 0 then
                        (
                            for k in obj.pos.controller.keys do k.value += offset_vec
                            processed = true
                        )
                    ) catch()
                    
                    -- 3. Fallback: Just Move
                    if not processed do 
                    (
                        move obj offset_vec
                    )
                )
            )
        )
        
        on btn_add pressed do apply_offset 1.0
        on btn_sub pressed do apply_offset -1.0
        
        fn get_key_times obj only_selected:false =
        (
            local times = #()
            
            -- Helper to collect keys
            fn collect_keys cntrl arr only_sel =
            (
                if cntrl != undefined and cntrl.keys.count > 0 do
                (
                    for k in cntrl.keys do
                    (
                        if (not only_sel) or k.selected do append arr k.time
                    )
                )
            )

            -- Collect keys from Position, Rotation, Scale
            collect_keys obj.pos.controller times only_selected
            collect_keys obj.rotation.controller times only_selected
            collect_keys obj.scale.controller times only_selected
                
            -- Also check sub-controllers for Position List / XYZ
            try (
                if isProperty obj.pos.controller #x_position do collect_keys obj.pos.controller.x_position.controller times only_selected
                if isProperty obj.pos.controller #y_position do collect_keys obj.pos.controller.y_position.controller times only_selected
                if isProperty obj.pos.controller #z_position do collect_keys obj.pos.controller.z_position.controller times only_selected
            ) catch()
            
            -- Remove duplicates (Efficient Sort + Linear Scan)
            if times.count == 0 do return #()
            sort times
            local unique_times = #(times[1])
            for i = 2 to times.count do
            (
                if times[i] != times[i-1] do append unique_times times[i]
            )
            return unique_times
        )
        
        fn bake_animation new_parent =
        (
            if selection.count == 0 do 
            (
                messageBox "请选择要烘焙的对象。" title:"未选择对象"
                return false
            )
            
            local sel_objs = selection as array
            
            undo "Bake Animation" on
            (
                disableSceneRedraw()
                try
                (
                    -- 1. Identify Keyframes (Smart Bake)
                    local unique_bake_times = #()
                    local raw_keys = #()
                    
                    -- Determine Range
                    local range_start = animationRange.start
                    local range_end = animationRange.end
                    
                    -- Collect keys if needed (for Range determination or Keep Keyframes mode)
                    -- Optimization: Collect once
                    local objs_keys = #() -- Array of arrays
                    local only_sel_keys = (rdo_range_mode.state == 3)
                    
                    if rdo_range_mode.state == 1 or rdo_range_mode.state == 3 or rdo_frame_mode.state == 1 do
                    (
                        for obj in sel_objs do append objs_keys (get_key_times obj only_selected:only_sel_keys)
                    )

                    if rdo_range_mode.state == 1 or rdo_range_mode.state == 3 then -- All Keyframes OR Selected Keys
                    (
                        local min_t = undefined
                        local max_t = undefined
                        
                        for keys in objs_keys do
                        (
                            if keys.count > 0 do
                            (
                                if min_t == undefined or keys[1] < min_t do min_t = keys[1]
                                if max_t == undefined or keys[keys.count] > max_t do max_t = keys[keys.count]
                            )
                        )
                        
                        -- If keys exist, use their range; otherwise keep animationRange
                        if min_t != undefined do range_start = min_t
                        if max_t != undefined do range_end = max_t
                    )
                    
                    -- Determine Frames to Bake
                    if rdo_frame_mode.state == 1 then -- Keep Keyframes
                    (
                        -- Merge all keys
                        for keys in objs_keys do join raw_keys keys
                        
                        if raw_keys.count > 0 then
                        (
                            sort raw_keys
                            -- Deduplicate and Filter by Range
                            local last_added = undefined
                            for t in raw_keys do
                            (
                                if t >= range_start and t <= range_end do
                                (
                                    if last_added == undefined or t != last_added do
                                    (
                                        append unique_bake_times t
                                        last_added = t
                                    )
                                )
                            )
                        )
                    )
                    else -- Bake All Frames
                    (
                        -- Loop through the determined range BY FRAME (not Ticks!)
                        local s_frame = range_start.frame as integer
                        local e_frame = range_end.frame as integer
                        for f = s_frame to e_frame do append unique_bake_times (f as time)
                    )

                    sort unique_bake_times
                    
                    -- If no keys found (e.g. static object in Keep Keys mode), just use current time
                    if unique_bake_times.count == 0 do unique_bake_times = #(currentTime)
                    
                    -- 2. Cache World Transforms AT KEY TIMES
                    local cached_transforms = #()
                    
                    for i = 1 to sel_objs.count do cached_transforms[i] = #()
                    
                    for t in unique_bake_times do
                    (
                        at time t
                        (
                            for i = 1 to sel_objs.count do
                            (
                                append cached_transforms[i] sel_objs[i].transform
                            )
                        )
                    )
                    
                    -- 3. Reparent (Do NOT delete keys, we want to preserve them if possible)
                    -- However, standard reparenting in Max might keep world pos but mess up keys if "Compensation" is on.
                    -- We want to control the keys explicitly.
                    -- Strategy: Reparent -> Update keys at keyframes.
                    
                    for obj in sel_objs do
                    (
                        obj.parent = new_parent
                    )
                    
                    -- 4. Apply Transforms at Key Times
                    animate on
                    (
                        for idx = 1 to unique_bake_times.count do
                        (
                            local t = unique_bake_times[idx]
                            at time t
                            (
                                for i = 1 to sel_objs.count do
                                (
                                    local world_tm = cached_transforms[i][idx]
                                    
                                    -- Apply world transform. 
                                    -- Since object is already parented, Max will calculate the correct local transform.
                                    -- Since we are in 'animate on', this updates the key at this time.
                                    sel_objs[i].transform = world_tm
                                )
                            )
                        )
                    )
                    
                    -- Optional: Clean up keys? 
                    -- If we created keys where there were none (due to union of times), it's acceptable.
                )
                catch 
                    (
                        messageBox ("烘焙出错: " + getCurrentException())
                    )
                    enableSceneRedraw()
                    redrawViews()
                    completeRedraw()
                )
            )
            
            on btn_bake_world pressed do
            (
                bake_animation undefined
            )
            
            on btn_bake_local pressed do
            (
                if isValidNode target_obj then
                (
                    -- Ensure target is not in selection
                    if (findItem (selection as array) target_obj) > 0 then
                    (
                        messageBox "目标对象不能在选择集中。" title:"错误"
                    )
                    else
                    (
                        bake_animation target_obj
                    )
                )
                else
                (
                    messageBox "请先拾取有效的目标对象。" title:"目标丢失"
                )
            )
            
            fn bake_controller_keys cntrl range_start range_end =
            (
                 if not (isProperty cntrl #keys) do return false
                 
                 local s_frame = range_start.frame as integer
                 local e_frame = range_end.frame as integer
                 for f = s_frame to e_frame do
                 (
                     addNewKey cntrl (f as time)
                 )
            )
    
            fn optimize_controller_keys cntrl threshold range_start range_end check_range only_selected =
            (
                -- Check if keys array is accessible and has enough keys
                -- Some controllers might throw error when accessing .keys directly if not keyable or empty
                local key_count = 0
                try (key_count = cntrl.keys.count) catch(return undefined)
                
                if key_count > 2 do
                (
                    local keys = cntrl.keys
                    local keys_to_delete = #()
                    local last_val = undefined
                    
                    -- We need to find the first valid key within range to start comparison
                    -- If not checking range, start from key 1
                    
                    -- Iterate through all keys
                    for i = 1 to key_count do
                    (
                        local k = undefined
                        try (k = keys[i]) catch()
                        
                        if k != undefined do
                        (
                            local t = k.time
                            
                            -- Check Scope (Range or Selection)
                            local in_scope = true
                            
                            if check_range do
                            (
                                if t < range_start or t > range_end do in_scope = false
                            )
                            
                            if only_selected do
                            (
                                if not k.selected do in_scope = false
                            )
                            
                            if in_scope then
                            (
                                local val = undefined
                                try (val = k.value) catch()
                                
                                if val != undefined do
                                (
                                    -- Always keep the first key we encounter in range (or global first key if range starts before)
                                    -- Also, if it's the very first or very last key of the controller, we generally want to keep it 
                                    -- unless we are strictly optimizing a segment.
                                    -- Let's keep the logic: Keep first key in range as anchor.
                                    
                                    if last_val == undefined then
                                    (
                                        last_val = val
                                    )
                                    else
                                    (
                                        -- Check if it's the last key in range? 
                                        -- If it is the last key of the controller, keep it.
                                        if i == key_count then
                                        (
                                            -- Keep last key
                                            last_val = val
                                        )
                                        else
                                        (
                                            local dist = 0.0
                                            
                                            -- Handle different value types
                                            if (classof val) == Quat then
                                            (
                                                local d = dot val last_val
                                                if d < 0 do d = -d
                                                if d > 1.0 do d = 1.0
                                                dist = (2.0 * acos d)
                                            )
                                            else if (classof val) == Point3 then
                                            (
                                                dist = distance val last_val
                                            )
                                            else if (classof val) == Float then
                                            (
                                                dist = abs (val - last_val)
                                            )
                                            else 
                                            (
                                                if val != last_val do dist = threshold + 1.0
                                            )
                                            
                                            if dist > threshold then
                                            (
                                                last_val = val -- Keep this key, update baseline
                                            )
                                            else
                                            (
                                                append keys_to_delete i
                                            )
                                        )
                                    )
                                )
                            )
                            else
                            (
                                -- If not in scope, update last_val so we bridge the gap correctly
                                -- This ensures we compare the next in-scope key against the last existing key
                                try (last_val = k.value) catch()
                            )
                        )
                    )
                    
                    -- Delete keys (reverse order)
                    if keys_to_delete.count > 0 do
                    (
                        for i = keys_to_delete.count to 1 by -1 do deleteKey cntrl keys_to_delete[i]
                    )
                )
            )
    
            on btn_optimize pressed do
            (
                if selection.count == 0 do 
                (
                    messageBox "请选择要优化关键帧的对象。" title:"未选择对象"
                    return false
                )
                
                local threshold = spn_opt_threshold.value
            
                -- Determine Range based on Radio Buttons (using same UI as Bake)
                local check_range = false
                local only_selected = false
                local range_start = animationRange.start
                local range_end = animationRange.end
                
                if rdo_range_mode.state == 2 then -- Playback Range
                (
                    check_range = true
                )
                else if rdo_range_mode.state == 3 then -- Selected Keys
                (
                    only_selected = true
                )
                
                -- Determine Frame Mode (Resample before Optimize?)
                local resample = (rdo_frame_mode.state == 2) -- Bake Every Frame
                
                -- If Resample is ON, we need a valid range. 
                -- If Range Mode is 'All Keyframes' (1) or 'Selected Keys' (3), we need to calculate the range first.
                if resample and (rdo_range_mode.state == 1 or rdo_range_mode.state == 3) do
                (
                     -- Reusing logic to find range:
                     local min_t = undefined
                     local max_t = undefined
                     for obj in selection do
                     (
                         local keys = get_key_times obj only_selected:only_selected
                         if keys.count > 0 do
                         (
                             if min_t == undefined or keys[1] < min_t do min_t = keys[1]
                             if max_t == undefined or keys[keys.count] > max_t do max_t = keys[keys.count]
                         )
                     )
                     if min_t != undefined do range_start = min_t
                     if max_t != undefined do range_end = max_t
                )
                
                undo "Optimize Keys" on
                (
                    for obj in selection do
                    (
                        -- Helper to process controller
                        fn process_cntrl c thresh start end check sel resamp =
                        (
                            if c != undefined do 
                            (
                                if resamp do bake_controller_keys c start end
                                optimize_controller_keys c thresh start end check sel
                            )
                        )
                    
                        -- Position
                        process_cntrl obj.pos.controller threshold range_start range_end check_range only_selected resample
                        
                        -- Rotation
                        process_cntrl obj.rotation.controller threshold range_start range_end check_range only_selected resample
                        
                        -- Scale
                        process_cntrl obj.scale.controller threshold range_start range_end check_range only_selected resample
                        
                        -- Sub-controllers (X/Y/Z)
                        try (
                            if isProperty obj.pos.controller #x_position do process_cntrl obj.pos.controller.x_position.controller threshold range_start range_end check_range only_selected resample
                            if isProperty obj.pos.controller #y_position do process_cntrl obj.pos.controller.y_position.controller threshold range_start range_end check_range only_selected resample
                            if isProperty obj.pos.controller #z_position do process_cntrl obj.pos.controller.z_position.controller threshold range_start range_end check_range only_selected resample
                            
                            if isProperty obj.rotation.controller #x_rotation do process_cntrl obj.rotation.controller.x_rotation.controller threshold range_start range_end check_range only_selected resample
                            if isProperty obj.rotation.controller #y_rotation do process_cntrl obj.rotation.controller.y_rotation.controller threshold range_start range_end check_range only_selected resample
                            if isProperty obj.rotation.controller #z_rotation do process_cntrl obj.rotation.controller.z_rotation.controller threshold range_start range_end check_range only_selected resample
                        ) catch()
                    )
                )
                messageBox "关键帧优化完成!" title:"完成"
            )
    )
    
    on execute do
    (
        try(destroyDialog ro_offset_anim)catch()
        createDialog ro_offset_anim
    )
)

offset_animation_maya_plugin.py

python 复制代码
# -*- coding: utf-8 -*-
import sys
import math
import maya.cmds as cmds
import maya.mel as mel
import maya.api.OpenMaya as om

# -----------------------------------------------------------------------------
# 核心逻辑 (Core Logic)
# -----------------------------------------------------------------------------
class OffsetAnimationTool:
    def __init__(self):
        self.window_name = "offsetAnimWindowPlugin"
        
    def show_ui(self):
        if cmds.window(self.window_name, exists=True):
            cmds.deleteUI(self.window_name)
            
        self.window = cmds.window(self.window_name, title="动画位移工具", widthHeight=(300, 350))
        
        main_layout = cmds.columnLayout(adjustableColumn=True, rowSpacing=10, columnAttach=('both', 10))
        
        # 目标部分
        cmds.frameLayout(label="目标对象", collapsable=False, parent=main_layout)
        col_target = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
        self.ff_target = cmds.textFieldButtonGrp(label="目标:", text="", buttonLabel="拾取", buttonCommand=self.pick_target)
        cmds.button(label="读取坐标", command=self.read_coords)
        cmds.setParent(main_layout)
        
        # 偏移部分
        cmds.frameLayout(label="偏移数值", collapsable=False, parent=main_layout)
        col_offset = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
        self.ff_offset = cmds.floatFieldGrp(numberOfFields=3, label="XYZ 偏移:", value1=0.0, value2=0.0, value3=0.0, precision=3)
        
        row_btns = cmds.rowLayout(numberOfColumns=2, columnWidth2=(135, 135), adjustableColumn=1, adjustableColumn2=2)
        cmds.button(label="加上偏移 (+)", command=lambda x: self.apply_offset(1.0))
        cmds.button(label="减去偏移 (-)", command=lambda x: self.apply_offset(-1.0))
        cmds.setParent(col_offset)
        cmds.setParent(main_layout)
        
        # 烘焙设置
        cmds.frameLayout(label="烘焙设置", collapsable=False, parent=main_layout)
        cmds.columnLayout(adjustableColumn=True)
        self.rb_frame_mode = cmds.radioButtonGrp(label="", labelArray2=['保持帧数', '烘焙所有帧'], numberOfRadioButtons=2, select=1, columnWidth2=[100, 100], adj=1)
        self.rb_range_mode = cmds.radioButtonGrp(label="", labelArray3=['所有关键帧', '播放范围', '选择的帧'], numberOfRadioButtons=3, select=1, columnWidth3=[80, 80, 80], adj=1)
        cmds.setParent(main_layout)
        
        # 烘焙部分
        cmds.frameLayout(label="烘焙动画", collapsable=False, parent=main_layout)

        col_bake = cmds.columnLayout(adjustableColumn=True, rowSpacing=5)
        cmds.button(label="烘焙到世界 (解绑)", command=lambda x: self.bake("world"))
        cmds.button(label="烘焙到局部 (绑定)", command=lambda x: self.bake("local"))
        cmds.setParent(main_layout)
        
        # 优化部分
        cmds.frameLayout(label="优化关键帧", collapsable=False, parent=main_layout)
        cmds.columnLayout(adjustableColumn=True)
        cmds.rowLayout(numberOfColumns=2, adjustableColumn=2)
        cmds.text(label="阈值: ")
        self.ff_optimize_threshold = cmds.floatField(value=0.01, precision=3)
        cmds.setParent("..")
        cmds.button(label="执行优化", command=self.optimize_keys_callback)
        cmds.setParent(main_layout)
        
        cmds.showWindow(self.window)

    def pick_target(self, *args):
        sel = cmds.ls(selection=True)
        if len(sel) == 1:
            cmds.textFieldButtonGrp(self.ff_target, edit=True, text=sel[0])
        else:
            cmds.warning("请选择一个目标对象。")

    def read_coords(self, *args):
        target = cmds.textFieldButtonGrp(self.ff_target, query=True, text=True)
        if target and cmds.objExists(target):
            pos = cmds.xform(target, query=True, worldSpace=True, translation=True)
            cmds.floatFieldGrp(self.ff_offset, edit=True, value1=pos[0], value2=pos[1], value3=pos[2])
        else:
            cmds.warning("目标对象无效。")

    def apply_offset(self, sign):
        vec = cmds.floatFieldGrp(self.ff_offset, query=True, value=True)
        vec = [v * sign for v in vec]
        
        sel = cmds.ls(selection=True)
        if not sel: return
        
        for obj in sel:
            # 1. 移动静态位置
            cmds.move(vec[0], vec[1], vec[2], obj, relative=True, worldSpace=True)
            
            # 2. 移动动画曲线
            anim_curves = cmds.listConnections(obj, type="animCurve") or []
            for curve in anim_curves:
                plugs = cmds.listConnections(curve, plugs=True) or []
                for plug in plugs:
                    attr = plug.split(".")[-1]
                    idx = -1
                    if attr == "translateX": idx = 0
                    elif attr == "translateY": idx = 1
                    elif attr == "translateZ": idx = 2
                    
                    if idx != -1 and vec[idx] != 0:
                        cmds.keyframe(curve, edit=True, relative=True, valueChange=vec[idx])

    def get_key_times(self, obj, only_selected=False):
        times = set()
        try:
            kt = cmds.keyframe(obj, at=['translate', 'rotate', 'scale'], query=True, timeChange=True, selected=only_selected)
            if kt:
                times.update(kt)
        except:
            pass
        return sorted(list(times))

    def manual_bake(self, objects, target, start, end):
        """
        手动逐帧计算矩阵并烘焙关键帧 (Maya API 2.0)
        target: 目标对象名称。如果为 None,则表示烘焙到世界。
        """
        # Get UI Settings
        frame_mode = cmds.radioButtonGrp(self.rb_frame_mode, query=True, select=True) # 1=Keep, 2=Bake All
        range_mode = cmds.radioButtonGrp(self.rb_range_mode, query=True, select=True) # 1=All Keys, 2=Playback, 3=Selected
        
        # 1. Identify Keyframes
        unique_bake_times = set()
        only_selected = (range_mode == 3)
        
        if frame_mode == 1: # Keep Keyframes
            for obj in objects:
                unique_bake_times.update(self.get_key_times(obj, only_selected=only_selected))
                
            if range_mode == 2: # Playback Range
                unique_bake_times = {t for t in unique_bake_times if start <= t <= end}
                
        else: # Bake All Frames
            min_t = start
            max_t = end
            
            if range_mode == 1 or range_mode == 3: # All Keyframes Range OR Selected Keys Range
                found_keys = False
                calc_min = None
                calc_max = None
                
                for obj in objects:
                    times = self.get_key_times(obj, only_selected=only_selected)
                    if times:
                        if calc_min is None or times[0] < calc_min: calc_min = times[0]
                        if calc_max is None or times[-1] > calc_max: calc_max = times[-1]
                        found_keys = True
                
                if found_keys:
                    min_t = calc_min
                    max_t = calc_max
            
            if min_t is not None and max_t is not None:
                for f in range(int(min_t), int(max_t) + 1):
                    unique_bake_times.add(f)
        
        unique_bake_times = sorted(list(unique_bake_times))
        
        # If no keys, bake current frame
        if not unique_bake_times:
            unique_bake_times = [cmds.currentTime(query=True)]

        # 1. 预先缓存所有帧的世界矩阵
        # 结构: { obj_name: { frame: MMatrix } }
        world_matrices = {}
        
        # 确保对象列表是长名,避免重名冲突
        objects = cmds.ls(objects, long=True)
        if target:
            target = cmds.ls(target, long=True)[0]
        
        for obj in objects:
            world_matrices[obj] = {}
            
        # 遍历每一帧记录世界状态
        # 注意: 必须移动时间滑块来获取每一帧的正确求值状态
        for f in unique_bake_times:
            cmds.currentTime(f)
            
            # 记录对象矩阵
            for obj in objects:
                mat_list = cmds.getAttr(obj + ".worldMatrix[0]")
                world_matrices[obj][f] = om.MMatrix(mat_list)
                
        # 2. 设置父子关系 并 追踪新名称
        # 当物体改变父级时,其 DAG 路径(长名)会发生变化
        # 我们需要维护一个映射:旧长名 -> 新长名
        obj_map = {obj: obj for obj in objects}
        
        for obj in objects:
            if target:
                # 检查当前父级
                parents = cmds.listRelatives(obj, parent=True, fullPath=True)
                if not parents or parents[0] != target:
                    # parent 返回新名称列表
                    new_names = cmds.parent(obj, target)
                    # 获取新的长名
                    if new_names:
                        new_long_name = cmds.ls(new_names[0], long=True)[0]
                        obj_map[obj] = new_long_name
            else:
                # 烘焙到世界
                if cmds.listRelatives(obj, parent=True):
                    new_names = cmds.parent(obj, world=True)
                    if new_names:
                        new_long_name = cmds.ls(new_names[0], long=True)[0]
                        obj_map[obj] = new_long_name
                    
        # 3. 逐帧应用变换并打关键帧
        for f in unique_bake_times:
            cmds.currentTime(f)
            
            for obj in objects:
                if f not in world_matrices[obj]:
                    continue
                
                # 获取当前时刻该物体的正确名称(新 DAG 路径)
                current_obj = obj_map[obj]
                
                desired_world_mg = world_matrices[obj][f]
                
                # 直接设置世界矩阵,Maya 自动计算局部变换
                # Flatten the matrix for cmds.xform
                flat_matrix = [v for row in desired_world_mg for v in row]
                cmds.xform(current_obj, matrix=flat_matrix, worldSpace=True)
                
                # 打关键帧
                cmds.setKeyframe(current_obj, at=['translate', 'rotate', 'scale'])
                
    def optimize_keys_callback(self, *args):
        threshold = cmds.floatField(self.ff_optimize_threshold, query=True, value=True)
        # Get range mode
        range_mode = cmds.radioButtonGrp(self.rb_range_mode, query=True, select=True) # 1=All, 2=Playback, 3=Selected
        
        start = cmds.playbackOptions(query=True, min=True)
        end = cmds.playbackOptions(query=True, max=True)
        
        sel = cmds.ls(selection=True)
        if not sel:
            cmds.warning("请选择要优化的对象")
            return
            
        cmds.undoInfo(openChunk=True)
        try:
            for obj in sel:
                self.optimize_curve(obj, threshold, start, end, range_mode)
            print("关键帧优化完成")
        finally:
            cmds.undoInfo(closeChunk=True)

    def optimize_curve(self, obj, threshold, start, end, range_mode):
        # Find all anim curves
        anim_curves = cmds.listConnections(obj, type="animCurve", destination=False, source=True) or []
        
        for curve in anim_curves:
            # Check if curve is connected to transform attributes
            # Simplification: optimize all found curves for the object
            
            # Get key times and values
            key_count = cmds.keyframe(curve, query=True, keyframeCount=True)
            if not key_count or key_count <= 2:
                continue
                
            times = cmds.keyframe(curve, query=True, timeChange=True)
            values = cmds.keyframe(curve, query=True, valueChange=True)
            
            # Pre-fetch selected times if needed
            selected_times = set()
            if range_mode == 3:
                st = cmds.keyframe(curve, query=True, timeChange=True, selected=True)
                if st:
                    selected_times = set(st)

            keys_to_delete = []
            last_val = None
            
            for i in range(key_count):
                t = times[i]
                val = values[i]
                
                in_range = True
                if range_mode == 2: # Playback
                    if t < start or t > end:
                        in_range = False
                elif range_mode == 3: # Selected
                    if t not in selected_times:
                        in_range = False
                
                if in_range:
                    if last_val is None:
                        last_val = val
                    else:
                        # Check if last key
                        if i == key_count - 1:
                            last_val = val
                        else:
                            dist = abs(val - last_val)
                            if dist > threshold:
                                last_val = val
                            else:
                                keys_to_delete.append(t)
            
            if keys_to_delete:
                for t in keys_to_delete:
                    cmds.cutKey(curve, time=(t,t))

    def bake(self, mode):
        sel = cmds.ls(selection=True)
        if not sel: return
        
        target = cmds.textFieldButtonGrp(self.ff_target, query=True, text=True)
        start = cmds.playbackOptions(query=True, min=True)
        end = cmds.playbackOptions(query=True, max=True)
        
        if mode == "local":
            if not target or not cmds.objExists(target):
                cmds.warning("目标对象无效。")
                return
            # 确保目标不在选中列表中
            if target in sel:
                cmds.warning("目标对象不能在选中列表中。")
                return
            
            self.manual_bake(sel, target, start, end)
            
        elif mode == "world":
            self.manual_bake(sel, None, start, end)
            
        print(f"Bake to {mode} completed (Manual Calculation).")

# -----------------------------------------------------------------------------
# 插件注册 (Plugin Registration)
# -----------------------------------------------------------------------------

# 保持工具实例引用
_tool_instance = None

def show_offset_anim_ui(*args):
    global _tool_instance
    if _tool_instance is None:
        _tool_instance = OffsetAnimationTool()
    _tool_instance.show_ui()

def create_menu_item():
    # 延迟创建菜单,确保主窗口已加载
    main_window = mel.eval("$tmp = $gMainWindow")
    if not cmds.menu("OffsetAnimMenu", exists=True):
        menu = cmds.menu("OffsetAnimMenu", label="Offset Anim", parent=main_window, tearOff=True)
        cmds.menuItem(label="Offset Animation Tool", command=show_offset_anim_ui)

def delete_menu_item():
    if cmds.menu("OffsetAnimMenu", exists=True):
        cmds.deleteUI("OffsetAnimMenu")

# Maya 插件接口
def initializePlugin(mobject):
    # 当插件加载时调用
    create_menu_item()

def uninitializePlugin(mobject):
    # 当插件卸载时调用
    delete_menu_item()

blender(offset_animation_blender_addon)

init.py

python 复制代码
bl_info = {
    "name": "动画整体偏移工具 (Offset Animation Tool)",
    "author": "Assistant",
    "version": (1, 0),
    "blender": (2, 80, 0),
    "location": "View3D > Sidebar > Animation",
    "description": "偏移动画曲线并烘焙相对于世界或局部目标的变换。",
    "warning": "",
    "wiki_url": "",
    "category": "Animation",
}

import bpy
from bpy.props import PointerProperty

# Import classes from sub-modules
from .properties import OffsetToolProperties
from .operators import (
    OT_ReadTargetPos,
    OT_ApplyOffset,
    OT_BakeToWorld,
    OT_BakeToLocal,
    OT_OptimizeKeys
)
from .ui import PT_AnimOffsetPanel

# Classes to register
classes = (
    OffsetToolProperties,
    OT_ReadTargetPos,
    OT_ApplyOffset,
    OT_BakeToWorld,
    OT_BakeToLocal,
    OT_OptimizeKeys,
    PT_AnimOffsetPanel,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)
    bpy.types.Scene.anim_offset_props = PointerProperty(type=OffsetToolProperties)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
    del bpy.types.Scene.anim_offset_props

if __name__ == "__main__":
    register()

operators.py

python 复制代码
import bpy
from bpy.types import Operator
from mathutils import Matrix

# ------------------------------------------------------------------------
# 属性定义 (UI 数据)


# 3. 烘焙 (手动矩阵计算逻辑)

def get_key_times(obj, only_selected=False):
    """
    Get all unique keyframe times from the object's action fcurves.
    """
    times = set()
    if obj.animation_data and obj.animation_data.action:
        for fcurve in obj.animation_data.action.fcurves:
            for kp in fcurve.keyframe_points:
                if only_selected and not kp.select_control_point:
                    continue
                times.add(kp.co[0]) # co[0] is time/frame
    return sorted(list(times))

def get_bake_times(context, objects):
    """根据设置获取需要烘焙的帧列表"""
    props = context.scene.anim_offset_props
    frame_mode = props.frame_mode
    range_mode = props.range_mode
    
    scene = context.scene
    only_selected = (range_mode == 'SELECTED')
    final_times = set()
    
    if range_mode == 'PLAYBACK':
        min_frame = scene.frame_start
        max_frame = scene.frame_end
        
        if frame_mode == 'KEEP':
            for obj in objects:
                times = get_key_times(obj, only_selected=False)
                for t in times:
                    if min_frame <= t <= max_frame:
                        final_times.add(t)
        else: # BAKE All
            start_f = int(min_frame)
            end_f = int(max_frame)
            for f in range(start_f, end_f + 1):
                final_times.add(float(f))
                
    else: # ALL Keys or SELECTED Keys
        all_keys = []
        for obj in objects:
            all_keys.extend(get_key_times(obj, only_selected=only_selected))
            
        if frame_mode == 'KEEP':
            final_times.update(all_keys)
        else: # BAKE All
            if all_keys:
                min_f = int(min(all_keys))
                max_f = int(max(all_keys))
                for f in range(min_f, max_f + 1):
                    final_times.add(float(f))
            
    sorted_times = sorted(list(final_times))
    if not sorted_times:
        sorted_times = [float(scene.frame_current)]
        
    return sorted_times

def manual_bake_animation(context, objects, target):
    """
    手动逐帧计算矩阵并烘焙关键帧,确保坐标变换准确。
    target: 新的父级对象。如果为 None,则表示烘焙到世界 (清除父级)。
    """
    
    # 1. Collect unique bake times
    unique_bake_times = get_bake_times(context, objects)
    
    # 2. 预先缓存所有帧的世界矩阵
    # 结构: { obj_name: { frame: matrix_world } }
    world_matrices = {}
    for obj in objects:
        world_matrices[obj.name] = {}
        
    # 遍历每一帧记录世界状态
    for f in unique_bake_times:
        context.scene.frame_set(int(f))
        for obj in objects:
            world_matrices[obj.name][f] = obj.matrix_world.copy()
            
    # 3. 设置父子关系
    # 只需要设置一次,之后逐帧写入 World 矩阵让 Blender 自动计算 Local
    for obj in objects:
        if target:
            if obj.parent != target:
                obj.parent = target
                # 保持世界变换不变 (Visual Transform)
                obj.matrix_parent_inverse = obj.parent.matrix_world.inverted()
        else:
            if obj.parent:
                # Clear parent, keep transformation
                matrix_world = obj.matrix_world.copy()
                obj.parent = None
                obj.matrix_world = matrix_world

    # 4. 逐帧应用变换并打关键帧
    for f in unique_bake_times:
        context.scene.frame_set(int(f))
        
        for obj in objects:
            if obj.name not in world_matrices or f not in world_matrices[obj.name]:
                continue
            
            desired_world_mg = world_matrices[obj.name][f]
            
            # 直接设置世界矩阵,Blender 会自动计算局部矩阵
            obj.matrix_world = desired_world_mg
            
            # 插入关键帧
            # 必须在设置矩阵后立即插入,否则帧切换会重置
            obj.keyframe_insert(data_path="location")
            
            if obj.rotation_mode == 'QUATERNION':
                obj.keyframe_insert(data_path="rotation_quaternion")
            elif obj.rotation_mode == 'AXIS_ANGLE':
                obj.keyframe_insert(data_path="rotation_axis_angle")
            else:
                obj.keyframe_insert(data_path="rotation_euler")
                
            obj.keyframe_insert(data_path="scale")
            
    # 更新视图
    context.view_layer.update()

# 1. 读取坐标
class OT_ReadTargetPos(Operator):
    bl_idname = "anim_offset.read_pos"
    bl_label = "读取目标坐标"
    bl_description = "读取目标物体的世界坐标填入下方偏移量"

    def execute(self, context):
        props = context.scene.anim_offset_props
        target = props.target_object
        if not target:
            self.report({'ERROR'}, "请先选择目标物体!")
            return {'CANCELLED'}
        
        # 获取世界坐标
        loc = target.matrix_world.translation
        props.offset_x = loc.x
        props.offset_y = loc.y
        props.offset_z = loc.z
        
        self.report({'INFO'}, f"已读取坐标: {loc}")
        return {'FINISHED'}

# 2. 应用偏移 (加/减)
class OT_ApplyOffset(Operator):
    bl_idname = "anim_offset.apply"
    bl_label = "应用偏移"
    bl_description = "将偏移量应用到选中物体的动画上"
    
    operation: bpy.props.EnumProperty(
        items=[('ADD', "Add", ""), ('SUB', "Subtract", "")],
        default='ADD'
    )

    def execute(self, context):
        props = context.scene.anim_offset_props
        
        # 构建偏移向量
        vec = [props.offset_x, props.offset_y, props.offset_z]
        if self.operation == 'SUB':
            vec = [-v for v in vec]
            
        selected_objects = context.selected_objects
        if not selected_objects:
            self.report({'ERROR'}, "请选择至少一个物体!")
            return {'CANCELLED'}
        
        for obj in selected_objects:
            # 1. 移动静态位置 (当前帧)
            obj.location.x += vec[0]
            obj.location.y += vec[1]
            obj.location.z += vec[2]
            
            # 2. 移动关键帧 (如果有动画)
            if obj.animation_data and obj.animation_data.action:
                action = obj.animation_data.action
                for fcurve in action.fcurves:
                    # 只处理位置曲线 (location)
                    if fcurve.data_path == "location":
                        idx = fcurve.array_index # 0=x, 1=y, 2=z
                        offset_val = vec[idx]
                        if offset_val != 0:
                            for kp in fcurve.keyframe_points:
                                kp.co[1] += offset_val
                                kp.handle_left[1] += offset_val
                                kp.handle_right[1] += offset_val
                                
        self.report({'INFO'}, "偏移已应用")
        return {'FINISHED'}

# 3a. 烘焙到世界
class OT_BakeToWorld(Operator):
    bl_idname = "anim_offset.bake_world"
    bl_label = "烘焙到世界坐标"
    bl_description = "解绑父级并烘焙动画为世界坐标"

    def execute(self, context):
        objs = context.selected_objects
        if not objs: return {'CANCELLED'}
        
        # 使用手动烘焙逻辑,target=None 表示世界
        manual_bake_animation(context, objs, None)
        
        self.report({'INFO'}, "已转换为世界坐标动画")
        return {'FINISHED'}

# 3b. 烘焙到局部 (绑定目标)
class OT_BakeToLocal(Operator):
    bl_idname = "anim_offset.bake_local"
    bl_label = "烘焙到局部坐标"
    bl_description = "绑定到目标物体并烘焙局部动画"

    def execute(self, context):
        props = context.scene.anim_offset_props
        target = props.target_object
        objs = context.selected_objects
        
        if not target:
            self.report({'ERROR'}, "请先设置目标物体!")
            return {'CANCELLED'}
        if not objs:
            return {'CANCELLED'}
        if target in objs:
            self.report({'ERROR'}, "目标物体不能在选中列表中!")
            return {'CANCELLED'}
        
        # 使用手动烘焙逻辑,传入 target
        manual_bake_animation(context, objs, target)
        
        self.report({'INFO'}, "已转换为局部坐标动画")
        return {'FINISHED'}

# 4. 优化关键帧
class OT_OptimizeKeys(Operator):
    bl_idname = "anim_offset.optimize_keys"
    bl_label = "优化关键帧"
    bl_description = "移除冗余的关键帧"
    
    def execute(self, context):
        props = context.scene.anim_offset_props
        threshold = props.optimize_threshold
        range_mode = props.range_mode
        
        objs = context.selected_objects
        if not objs:
            self.report({'ERROR'}, "请选择至少一个物体!")
            return {'CANCELLED'}

        # 确定范围
        min_f = -float('inf')
        max_f = float('inf')
        if range_mode == 'PLAYBACK':
            min_f = context.scene.frame_start
            max_f = context.scene.frame_end
            
        count = 0
        
        for obj in objs:
            if not obj.animation_data or not obj.animation_data.action:
                continue
                
            action = obj.animation_data.action
            for fcurve in action.fcurves:
                kp_list = fcurve.keyframe_points
                if len(kp_list) <= 2:
                    continue
                
                to_delete = []
                points_data = [] # list of (index, frame, value, selected)
                for i, kp in enumerate(kp_list):
                    points_data.append((i, kp.co[0], kp.co[1], kp.select_control_point))
                    
                valid_points = []
                for p in points_data:
                    idx, t, val, sel = p
                    # 范围检查
                    if range_mode == 'PLAYBACK':
                        if t < min_f or t > max_f:
                            continue
                    elif range_mode == 'SELECTED':
                        if not sel:
                            continue
                    valid_points.append(p)
                    
                if not valid_points:
                    continue
                    
                last_val = None
                
                for i in range(len(valid_points)):
                    p = valid_points[i]
                    idx, t, val = p
                    
                    if last_val is None:
                        last_val = val
                    else:
                        is_last_in_scope = (i == len(valid_points) - 1)
                        
                        if is_last_in_scope:
                            last_val = val
                        else:
                            dist = abs(val - last_val)
                            if dist > threshold:
                                last_val = val
                            else:
                                to_delete.append(idx)
                                
                if to_delete:
                    kps_to_remove = []
                    for idx in to_delete:
                        kps_to_remove.append(kp_list[idx])
                        
                    for kp in reversed(kps_to_remove):
                        try:
                            kp_list.remove(kp)
                            count += 1
                        except:
                            pass
                            
            fcurve.update()
            
        self.report({'INFO'}, f"优化完成,移除了 {count} 个关键帧")
        return {'FINISHED'}

properties.py

python 复制代码
import bpy
from bpy.props import FloatProperty, PointerProperty, EnumProperty
from bpy.types import PropertyGroup

class OffsetToolProperties(PropertyGroup):
    offset_x: FloatProperty(name="X 偏移", default=0.0)
    offset_y: FloatProperty(name="Y 偏移", default=0.0)
    offset_z: FloatProperty(name="Z 偏移", default=0.0)
    
    # 目标物体 (用于读取坐标 或 作为父级)
    target_object: PointerProperty(name="目标物体", type=bpy.types.Object)

    # 烘焙设置
    frame_mode: EnumProperty(
        name="帧模式",
        items=[
            ('KEEP', "保持帧数", "只在原有关键帧处烘焙"),
            ('BAKE', "烘焙所有帧", "在指定范围内逐帧烘焙")
        ],
        default='KEEP'
    )
    
    range_mode: EnumProperty(
        name="范围模式",
        items=[
            ('ALL', "所有关键帧", "使用物体自身的关键帧范围"),
            ('PLAYBACK', "播放范围", "使用场景的播放范围"),
            ('SELECTED', "选择的帧", "只处理选中的关键帧")
        ],
        default='ALL'
    )
    
    # 优化设置
    optimize_threshold: FloatProperty(name="优化阈值", default=0.001, min=0.0, precision=4)

ui.py

python 复制代码
import bpy
from bpy.types import Panel

class PT_AnimOffsetPanel(Panel):
    bl_label = "动画整体偏移工具"
    bl_idname = "VIEW3D_PT_anim_offset"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = 'Animation' # 在侧边栏的 Animation 标签页

    def draw(self, context):
        layout = self.layout
        props = context.scene.anim_offset_props
        
        # 目标选择
        layout.label(text="目标/参考物体:")
        layout.prop(props, "target_object", text="")
        layout.operator("anim_offset.read_pos", text="读取目标坐标", icon='EYEDROPPER')
        
        layout.separator()
        
        # 偏移输入
        col = layout.column(align=True)
        col.prop(props, "offset_x")
        col.prop(props, "offset_y")
        col.prop(props, "offset_z")
        
        row = layout.row(align=True)
        op_add = row.operator("anim_offset.apply", text="加上偏移 (+)", icon='ADD')
        op_add.operation = 'ADD'
        op_sub = row.operator("anim_offset.apply", text="减去偏移 (-)", icon='REMOVE')
        op_sub.operation = 'SUB'
        
        layout.separator()
        
        # 烘焙操作
        layout.operator("anim_offset.bake_world", text="转为世界坐标 (解绑+烘焙)", icon='WORLD')
        layout.operator("anim_offset.bake_local", text="转为局部坐标 (绑定+烘焙)", icon='CONSTRAINT')

        layout.separator()
        
        # 烘焙设置
        box = layout.box()
        box.label(text="烘焙设置")
        box.prop(props, "frame_mode", expand=True)
        box.prop(props, "range_mode", expand=True)
        
        layout.separator()
        
        # 优化关键帧
        box = layout.box()
        box.label(text="优化关键帧")
        box.prop(props, "optimize_threshold")
        box.operator("anim_offset.optimize_keys", text="优化曲线", icon='GRAPH')
相关推荐
BYSJMG2 小时前
大数据分析案例:基于大数据的肺癌数据分析与可视化系统
java·大数据·vue.js·python·mysql·数据分析·课程设计
卖个几把萌2 小时前
基于 ApiTesting 框架的二次开发实践:功能增强与问题修复
python
wfeqhfxz25887822 小时前
交通手势识别实战:YOLO11-Seg与DAttention融合方案详解
python
养猫的程序猿2 小时前
Libvio.link爬虫技术解析大纲
python
a11177610 小时前
医院挂号预约系统(开源 Fastapi+vue2)
前端·vue.js·python·html5·fastapi
0思必得010 小时前
[Web自动化] Selenium处理iframe和frame
前端·爬虫·python·selenium·自动化·web自动化
摘星编程13 小时前
OpenHarmony + RN:Calendar日期选择功能
python
Yvonne爱编码13 小时前
JAVA数据结构 DAY3-List接口
java·开发语言·windows·python
一方_self13 小时前
了解和使用python的click命令行cli工具
开发语言·python