From 405e8a9ad3ecc67d1d3b440feee3e2f9505dbebe Mon Sep 17 00:00:00 2001 From: Hector <145347438+hudomn@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:52:42 +0800 Subject: [PATCH] =?UTF-8?q?=E7=81=AF=E5=85=89=E7=A7=BB=E5=8A=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D=EF=BC=8C=E6=A8=A1=E5=9E=8B=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E5=8F=AF=E4=BB=A5=E6=AD=A3=E5=B8=B8=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TransformGizmo/transform_gizmo.py | 24 + core/Command_System.py | 52 +- core/selection.py | 51 +- main.py | 71 ++ scene/scene_manager.py | 105 +- ui/panels/animation_tools.py | 1510 +++++++++++++++++++++++++---- ui/panels/app_actions.py | 20 +- ui/panels/editor_panels.py | 176 +++- 8 files changed, 1739 insertions(+), 270 deletions(-) diff --git a/TransformGizmo/transform_gizmo.py b/TransformGizmo/transform_gizmo.py index 89ae8e8b..82d77574 100644 --- a/TransformGizmo/transform_gizmo.py +++ b/TransformGizmo/transform_gizmo.py @@ -373,6 +373,7 @@ class TransformGizmo(DirectObject): old_scale = action.get("old_scale") if old_scale is not None: node.setScale(old_scale) + self._sync_light_position_if_needed(node) self._redo_history.append(action) return True @@ -402,6 +403,7 @@ class TransformGizmo(DirectObject): if new_scale is not None: node.setScale(new_scale) + self._sync_light_position_if_needed(node) self._history.append(action) return True @@ -464,6 +466,28 @@ class TransformGizmo(DirectObject): # New user action invalidates redo chain. self._redo_history.clear() + def _sync_light_position_if_needed(self, node: Optional[NodePath]) -> None: + """When target node wraps an RP light, keep RP light position in sync.""" + try: + if node is None or node.isEmpty() or (not node.hasPythonTag("rp_light_object")): + return + light_obj = node.getPythonTag("rp_light_object") + if not light_obj: + return + world_pos = node.getPos(self.world.render) + try: + light_obj.setPos(world_pos) + except Exception: + try: + light_obj.setPos(world_pos.x, world_pos.y, world_pos.z) + except Exception: + try: + light_obj.pos = world_pos + except Exception: + pass + except Exception: + pass + # ------------------------------------------------------------------ # # Input helpers (hotkeys / mouse states) # ------------------------------------------------------------------ # diff --git a/core/Command_System.py b/core/Command_System.py index 95b3c088..d3fbb356 100644 --- a/core/Command_System.py +++ b/core/Command_System.py @@ -616,24 +616,40 @@ class CompositeCommand(Command): for command in self.commands: command.redo() -class MoveLightCommand(Command): - def __init__(self, node, old_pos, new_pos, light_object=None): - self.node = node - self.old_pos = Point3(old_pos) - self.new_pos = Point3(new_pos) - self.light_object = light_object - - def execute(self): # 将原来的 do() 改为 execute() - if self.light_object: - self.light_object.pos = self.new_pos - if self.node: - self.node.setPos(self.new_pos) - - def undo(self): - if self.light_object: - self.light_object.pos = self.old_pos - if self.node: - self.node.setPos(self.old_pos) +class MoveLightCommand(Command): + def __init__(self, node, old_pos, new_pos, light_object=None): + self.node = node + self.old_pos = Point3(old_pos) + self.new_pos = Point3(new_pos) + self.light_object = light_object + + def _apply_light_position(self, pos): + if not self.light_object: + return + try: + self.light_object.setPos(pos) + return + except Exception: + pass + try: + self.light_object.setPos(pos.x, pos.y, pos.z) + return + except Exception: + pass + try: + self.light_object.pos = Point3(pos) + except Exception: + pass + + def execute(self): # 将原来的 do() 改为 execute() + self._apply_light_position(self.new_pos) + if self.node: + self.node.setPos(self.new_pos) + + def undo(self): + self._apply_light_position(self.old_pos) + if self.node: + self.node.setPos(self.old_pos) def redo(self): self.execute() # 调用 execute() 而不是 do() diff --git a/core/selection.py b/core/selection.py index a8565272..0f6e993d 100644 --- a/core/selection.py +++ b/core/selection.py @@ -122,6 +122,46 @@ class SelectionSystem: tg = getattr(self.world, "newTransform", None) return tg is not None + def _sync_rp_light_position(self, light_node, light_object=None): + """同步灯光包装节点与 RenderPipeline 灯光对象位置。""" + try: + if not light_node or light_node.isEmpty(): + return False + if light_object is None: + light_object = light_node.getPythonTag("rp_light_object") + if not light_object and light_node.hasTag("light_type"): + # 兼容旧数据:节点存在 light_type 但未绑定 rp_light_object 时尝试重建绑定 + scene_manager = getattr(self.world, "scene_manager", None) + if scene_manager: + try: + light_type = light_node.getTag("light_type") + if light_type == "spot_light" and hasattr(scene_manager, "_recreateSpotLight"): + scene_manager._recreateSpotLight(light_node) + elif light_type == "point_light" and hasattr(scene_manager, "_recreatePointLight"): + scene_manager._recreatePointLight(light_node) + light_object = light_node.getPythonTag("rp_light_object") + except Exception: + pass + if not light_object: + return False + + world_pos = light_node.getPos(self.world.render) + + # 优先使用 RP Light 的 setPos 接口 + try: + light_object.setPos(world_pos) + except Exception: + try: + light_object.setPos(world_pos.x, world_pos.y, world_pos.z) + except Exception: + try: + light_object.pos = Point3(world_pos) + except Exception: + return False + return True + except Exception: + return False + def sync_transform_gizmo_mode(self): """Sync TransformGizmo mode with current tool.""" if not self._has_new_transform_gizmo(): @@ -738,9 +778,9 @@ class SelectionSystem: light_object = self.gizmoTarget.getPythonTag("rp_light_object") if light_object: - light_pos = light_object.pos - self.gizmo.setPos(light_object.pos) - self.gizmoTarget.setPos(light_pos) + # 以节点位置为真值并回写 RP 灯光,避免“手柄能动但灯光不动” + self._sync_rp_light_position(self.gizmoTarget, light_object) + self.gizmo.setPos(self.gizmoTarget.getPos(self.world.render)) else: # 只在必要时更新位置和朝向 self._updateGizmoPositionAndOrientation() @@ -1530,7 +1570,8 @@ class SelectionSystem: light_object = self.gizmoTarget.getPythonTag("rp_light_object") if light_object: - self.gizmoTargetStartPos = Point3(light_object.pos) + # 起始位置统一使用节点世界坐标,避免依赖 light_object.pos 的陈旧值 + self.gizmoTargetStartPos = Point3(self.gizmoTarget.getPos(self.world.render)) else: self.gizmoTargetStartPos = self.gizmoTarget.getPos() @@ -1818,8 +1859,8 @@ class SelectionSystem: # 应用新位置到目标节点 light_object = self.gizmoTarget.getPythonTag("rp_light_object") if light_object: - light_object.pos = newPos self.gizmoTarget.setPos(newPos) + self._sync_rp_light_position(self.gizmoTarget, light_object) print(f"🔄 光源拖拽移动: {currentPos} -> {newPos}") else: self.gizmoTarget.setPos(newPos) diff --git a/main.py b/main.py index e15559bd..72ab4fdb 100644 --- a/main.py +++ b/main.py @@ -154,6 +154,7 @@ class MyWorld(CoreWorld): # 新的坐标系 self.newTransform = TransformGizmo(self) + self._setup_transform_gizmo_light_sync() # 初始化视频管理 if VideoManager is not None: @@ -816,6 +817,76 @@ class MyWorld(CoreWorld): # 顶部工具栏 if self.showToolbar: self._draw_toolbar() + + def _sync_rp_light_from_node(self, node): + """将灯光包装节点的位置同步到 RenderPipeline 灯光对象。""" + try: + if not node or node.isEmpty() or (not node.hasPythonTag("rp_light_object")): + # 兼容旧场景:仅有 light_type 标签时尝试补绑 rp_light_object + if not node or node.isEmpty() or (not node.hasTag("light_type")): + return False + scene_manager = getattr(self, "scene_manager", None) + if scene_manager: + try: + light_type = node.getTag("light_type") + if light_type == "spot_light" and hasattr(scene_manager, "_recreateSpotLight"): + scene_manager._recreateSpotLight(node) + elif light_type == "point_light" and hasattr(scene_manager, "_recreatePointLight"): + scene_manager._recreatePointLight(node) + except Exception: + pass + light_obj = node.getPythonTag("rp_light_object") if node.hasPythonTag("rp_light_object") else None + if not light_obj: + return False + + world_pos = node.getPos(self.render) + try: + light_obj.setPos(world_pos) + except Exception: + try: + light_obj.setPos(world_pos.x, world_pos.y, world_pos.z) + except Exception: + try: + light_obj.pos = Point3(world_pos) + except Exception: + return False + return True + except Exception: + return False + + def _on_transform_gizmo_drag_event(self, payload): + """TransformGizmo 拖拽事件回调:实时同步灯光位置。""" + try: + node = payload.get("target") if isinstance(payload, dict) else None + if node and (not node.isEmpty()): + self._sync_rp_light_from_node(node) + except Exception: + pass + + def _setup_transform_gizmo_light_sync(self): + """为 newTransform 注册灯光同步事件钩子。""" + tg = getattr(self, "newTransform", None) + if not tg: + return + try: + from TransformGizmo.events import GizmoEvent + hooks = { + "move": { + GizmoEvent.DRAG_MOVE: [self._on_transform_gizmo_drag_event], + GizmoEvent.DRAG_END: [self._on_transform_gizmo_drag_event], + }, + "rotate": { + GizmoEvent.DRAG_MOVE: [self._on_transform_gizmo_drag_event], + GizmoEvent.DRAG_END: [self._on_transform_gizmo_drag_event], + }, + "scale": { + GizmoEvent.DRAG_MOVE: [self._on_transform_gizmo_drag_event], + GizmoEvent.DRAG_END: [self._on_transform_gizmo_drag_event], + }, + } + tg.set_event_hooks(hooks, replace=False) + except Exception as e: + print(f"绑定 TransformGizmo 灯光同步事件失败: {e}") def _draw_menu_bar(self): self.editor_panels.draw_menu_bar() diff --git a/scene/scene_manager.py b/scene/scene_manager.py index c3d207ac..c070b3f5 100644 --- a/scene/scene_manager.py +++ b/scene/scene_manager.py @@ -227,6 +227,12 @@ class SceneManager: if normalize_scales: model.setTag("scale_normalization_applied", "true") + # 初始化动画标签,避免属性面板首次读取时误判“无动画” + try: + self._processModelAnimations(model) + except Exception as e: + print(f"初始化模型动画标签失败: {e}") + # 添加到模型列表 self.models.append(model) @@ -2086,10 +2092,15 @@ class SceneManager: def _processModelAnimations(self, model_node): """处理模型动画,确保在场景加载时正确识别动画信息""" try: - # 检查模型是否已经有动画信息标签 + # 已检测过则直接复用,避免重复开销 + if model_node.hasTag("has_animations_checked"): + return model_node.hasTag("has_animations") and model_node.getTag("has_animations").lower() == "true" + + # 检查模型是否已经有动画信息标签(兼容旧数据) if model_node.hasTag("has_animations"): has_animations = model_node.getTag("has_animations").lower() == "true" if has_animations: + model_node.setTag("has_animations_checked", "true") print(f"模型 {model_node.getName()} 已有动画信息") return True @@ -2099,6 +2110,46 @@ class SceneManager: has_animations = (character_nodes.getNumPaths() > 0 or anim_bundle_nodes.getNumPaths() > 0) + + # 如果模型树中没检测到,再尝试通过 Actor 从文件路径检测 + if not has_animations: + model_path = model_node.getTag("model_path") if model_node.hasTag("model_path") else "" + if model_path: + try: + from direct.actor.Actor import Actor + from panda3d.core import Filename + + candidate_paths = [model_path] + candidate_paths.append(Filename.from_os_specific(model_path).get_fullpath()) + try: + normalized = util.normalize_model_path(model_path) + if normalized: + candidate_paths.append(normalized) + except Exception: + pass + + seen = set() + unique_paths = [] + for p in candidate_paths: + if not p or p in seen: + continue + seen.add(p) + unique_paths.append(p) + + for candidate in unique_paths: + try: + actor = Actor(candidate) + anim_names = actor.getAnimNames() + actor.cleanup() + actor.removeNode() + if anim_names: + print(f"通过 Actor 路径检测到动画: {candidate} -> {anim_names}") + has_animations = True + break + except Exception: + continue + except Exception: + pass if has_animations: print(f"检测到模型 {model_node.getName()} 包含动画:") @@ -2114,6 +2165,8 @@ class SceneManager: model_node.setTag("can_create_actor_from_memory", "true") else: model_node.setTag("has_animations", "false") + + model_node.setTag("has_animations_checked", "true") return has_animations @@ -2645,8 +2698,9 @@ class SceneManager: # 保存光源对象引用 light_node.setPythonTag("rp_light_object", light) - # 添加到管理列表 - self.Spotlight.append(light_node) + # 添加到管理列表(去重) + if light_node not in self.Spotlight: + self.Spotlight.append(light_node) # 确保灯光节点有正确的标签,以便在场景树更新时被识别 if not light_node.hasTag("is_scene_element"): @@ -2703,8 +2757,9 @@ class SceneManager: # 保存光源对象引用 light_node.setPythonTag("rp_light_object", light) - # 添加到管理列表 - self.Pointlight.append(light_node) + # 添加到管理列表(去重) + if light_node not in self.Pointlight: + self.Pointlight.append(light_node) # 确保灯光节点有正确的标签,以便在场景树更新时被识别 if not light_node.hasTag("is_scene_element"): @@ -2877,9 +2932,8 @@ class SceneManager: # 创建挂载节点 - 挂载到选中的父节点 light_np = NodePath(light_name) light_np.reparentTo(parent_node) # 挂载到父节点而不是render - light_np.setPos(*pos) - light_np.setTransform(TransformState.makeIdentity()) + light_np.setPos(*pos) # 创建聚光灯对象 light = SpotLight() @@ -2986,10 +3040,9 @@ class SceneManager: # 创建挂载节点 - 挂载到选中的父节点 light_np = NodePath(light_name) light_np.reparentTo(parent_node) # 挂载到父节点而不是render - light_np.setPos(*pos) - # 确保变换矩阵有效 light_np.setTransform(TransformState.makeIdentity()) + light_np.setPos(*pos) # 创建点光源对象 light = PointLight() @@ -4694,8 +4747,16 @@ except Exception as e: print(f"✓ RenderPipeline聚光灯创建成功,位置: {pos}") # 创建包装节点用于场景树显示 - spotlight_node = self.world.render.attachNewNode("spotlight_wrapper") - spotlight_node.setPos(pos) + light_name = f"Spotlight_{len(self.Spotlight)}" + spotlight_node = self.world.render.attachNewNode(light_name) + spotlight_node.setPos(*pos) + spotlight_node.setTag("light_type", "spot_light") + spotlight_node.setTag("is_scene_element", "1") + spotlight_node.setTag("tree_item_type", "LIGHT_NODE") + spotlight_node.setTag("light_energy", str(getattr(spotlight, "energy", 5000))) + spotlight_node.setTag("created_by_user", "1") + spotlight_node.setTag("element_type", "spotlight") + spotlight_node.setPythonTag("rp_light_object", spotlight) self.Spotlight.append(spotlight_node) return spotlight_node else: @@ -4713,6 +4774,11 @@ except Exception as e: # 创建光源节点 spotlight_node = self.world.render.attachNewNode(spotlight) spotlight_node.setPos(pos) + spotlight_node.setTag("light_type", "spot_light") + spotlight_node.setTag("is_scene_element", "1") + spotlight_node.setTag("tree_item_type", "LIGHT_NODE") + spotlight_node.setTag("created_by_user", "1") + spotlight_node.setTag("element_type", "spotlight") # 设置聚光灯方向(向下照射) spotlight_node.lookAt(pos[0], pos[1], pos[2] - 5) # 向下看5个单位 @@ -4769,8 +4835,16 @@ except Exception as e: print(f"✓ RenderPipeline点光源创建成功,位置: {pos}") # 创建包装节点用于场景树显示 - pointlight_node = self.world.render.attachNewNode("pointlight_wrapper") - pointlight_node.setPos(pos) + light_name = f"Pointlight_{len(self.Pointlight)}" + pointlight_node = self.world.render.attachNewNode(light_name) + pointlight_node.setPos(*pos) + pointlight_node.setTag("light_type", "point_light") + pointlight_node.setTag("is_scene_element", "1") + pointlight_node.setTag("tree_item_type", "LIGHT_NODE") + pointlight_node.setTag("light_energy", str(getattr(pointlight, "energy", 3000))) + pointlight_node.setTag("created_by_user", "1") + pointlight_node.setTag("element_type", "pointlight") + pointlight_node.setPythonTag("rp_light_object", pointlight) self.Pointlight.append(pointlight_node) return pointlight_node else: @@ -4788,6 +4862,11 @@ except Exception as e: # 创建光源节点 pointlight_node = self.world.render.attachNewNode(pointlight) pointlight_node.setPos(pos) + pointlight_node.setTag("light_type", "point_light") + pointlight_node.setTag("is_scene_element", "1") + pointlight_node.setTag("tree_item_type", "LIGHT_NODE") + pointlight_node.setTag("created_by_user", "1") + pointlight_node.setTag("element_type", "pointlight") # 添加到光源列表 self.Pointlight.append(pointlight_node) diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index 5d295af8..cc0dfbc3 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -3,7 +3,104 @@ from pathlib import Path from direct.actor.Actor import Actor from direct.task.TaskManagerGlobal import taskMgr -from panda3d.core import NodePath +from panda3d.core import NodePath, PartSubset + + +class _BoundAnimationProxy: + """Actor-compatible wrapper backed by auto-bound AnimControls.""" + + def __init__(self, node, controls, owns_node=False): + self._node = node + self._controls = controls + self._owns_node = owns_node + + def isEmpty(self): + return self._node.isEmpty() + + def show(self): + self._node.show() + + def hide(self): + self._node.hide() + + def setPos(self, *args): + self._node.setPos(*args) + + def setHpr(self, *args): + self._node.setHpr(*args) + + def setScale(self, *args): + self._node.setScale(*args) + + def setTransform(self, *args): + self._node.setTransform(*args) + + def getTransform(self, *args): + return self._node.getTransform(*args) + + def reparentTo(self, *args): + self._node.reparentTo(*args) + + def getAnimNames(self): + return list(self._controls.keys()) + + def getAnimControl(self, anim_name): + return self._controls.get(anim_name) + + def play(self, anim_name): + control = self.getAnimControl(anim_name) + if control: + try: + control.stop() + except Exception: + pass + try: + control.pose(0) + except Exception: + pass + control.play() + + def loop(self, anim_name): + control = self.getAnimControl(anim_name) + if control: + # AnimControl.loop 需要 restart 参数 + try: + control.stop() + except Exception: + pass + try: + control.pose(0) + except Exception: + pass + control.loop(True) + + def stop(self): + for control in self._controls.values(): + try: + control.stop() + except Exception: + pass + + def setPlayRate(self, speed, anim_name): + control = self.getAnimControl(anim_name) + if control: + control.setPlayRate(speed) + + def cleanup(self): + if self._owns_node: + try: + if not self._node.isEmpty(): + self._node.removeNode() + except Exception: + pass + + def removeNode(self): + if self._owns_node: + try: + if not self._node.isEmpty(): + self._node.removeNode() + except Exception: + pass class AnimationTools: @@ -21,138 +118,1054 @@ class AnimationTools: else: setattr(self.app, name, value) + def _is_scene_root_node(self, node): + try: + if not node or node.isEmpty(): + return False + name = node.getName() + return name in ("render", "render2d", "aspect2d", "pixel2d") + except Exception: + return False + + def _get_owner_parent_node(self, owner_model): + """获取动画对象的挂接父节点,确保不是空父节点。""" + try: + if owner_model and not owner_model.isEmpty(): + parent = owner_model.getParent() + if parent and not parent.isEmpty(): + return parent + except Exception: + pass + return self.render + + def _node_has_geom(self, node): + try: + if not node or node.isEmpty(): + return False + return node.findAllMatches("**/+GeomNode").getNumPaths() > 0 + except Exception: + return False + + def _node_has_animation_nodes(self, node): + try: + if not node or node.isEmpty(): + return False + return ( + node.findAllMatches("**/+Character").getNumPaths() > 0 or + node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + except Exception: + return False + + def _prefer_owner_with_visible_geometry(self, origin_model, owner_model): + """ + 优先选择“带可见几何体”的动画 owner,避免绑定到纯骨骼节点导致播放时不可见。 + """ + try: + if self._node_has_geom(owner_model): + return owner_model + + candidates = [] + + def _add_candidate(node, bonus=0): + if not node or node.isEmpty() or self._is_scene_root_node(node): + return + for existing, _ in candidates: + try: + if existing == node: + return + except Exception: + continue + candidates.append((node, bonus)) + + _add_candidate(owner_model, 100) + _add_candidate(origin_model, 90) + + # origin/owner 祖先链中优先找可见几何体节点 + for seed, base_bonus in ((origin_model, 80), (owner_model, 70)): + current = seed + for depth in range(48): + if not current or current.isEmpty() or self._is_scene_root_node(current): + break + _add_candidate(current, base_bonus - depth) + parent = current.getParent() + if not parent or parent.isEmpty() or parent == current: + break + current = parent + + # 从 scene_manager.models 中补充候选(含弱关联兜底) + scene_manager = getattr(self, "scene_manager", None) + models = getattr(scene_manager, "models", None) if scene_manager else None + if models: + origin_name = "" + try: + origin_name = (origin_model.getName() or "").strip().lower() + except Exception: + origin_name = "" + + related_models = [] + weak_name_match_models = [] + anim_geom_models = [] + for model in list(models): + try: + if not model or model.isEmpty() or self._is_scene_root_node(model): + continue + related = ( + model == origin_model or + model == owner_model or + model.isAncestorOf(origin_model) or + origin_model.isAncestorOf(model) or + model.isAncestorOf(owner_model) or + owner_model.isAncestorOf(model) + ) + if related: + related_models.append(model) + + model_name = (model.getName() or "").strip().lower() + if origin_name and model_name == origin_name: + weak_name_match_models.append(model) + + if self._node_has_geom(model) and self._node_has_animation_nodes(model): + anim_geom_models.append(model) + except Exception: + continue + + for m in related_models: + _add_candidate(m, 75) + for m in weak_name_match_models: + _add_candidate(m, 60) + if len(anim_geom_models) == 1: + _add_candidate(anim_geom_models[0], 85) + + best = owner_model + best_score = -10**9 + for node, bonus in candidates: + try: + has_geom = self._node_has_geom(node) + has_anim = self._node_has_animation_nodes(node) + has_path = ( + (node.hasTag("model_path") and bool(node.getTag("model_path"))) or + (node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or + (node.hasTag("original_path") and bool(node.getTag("original_path"))) + ) + score = bonus + score += 220 if has_anim and has_geom else 0 + score += 90 if has_geom else 0 + score += 35 if has_anim else 0 + score += 15 if has_path else 0 + if score > best_score: + best_score = score + best = node + except Exception: + continue + + # 把路径标签补齐到最终 owner,避免后续回退到“格式未知/纯内存” + try: + if best and best != owner_model: + for tag_name in ("model_path", "original_path", "saved_model_path", "file"): + if best.hasTag(tag_name) and best.getTag(tag_name): + continue + if owner_model.hasTag(tag_name) and owner_model.getTag(tag_name): + best.setTag(tag_name, owner_model.getTag(tag_name)) + except Exception: + pass + + return best or owner_model + except Exception: + return owner_model + + def _find_scene_model_owner(self, node): + """优先从 scene_manager.models 反查真实模型根节点。""" + try: + scene_manager = getattr(self, "scene_manager", None) + models = getattr(scene_manager, "models", None) + if not models or not node or node.isEmpty(): + return None + + best = None + best_score = -1 + related_found = False + for model in list(models): + try: + if not model or model.isEmpty() or self._is_scene_root_node(model): + continue + + # 必须与当前节点存在祖先关系 + relation = 0 + if model == node: + relation = 3 + elif model.isAncestorOf(node): + relation = 2 + elif node.isAncestorOf(model): + relation = 1 + if relation == 0: + continue + related_found = True + + has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path")) + has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path")) + has_anim = ( + model.findAllMatches("**/+Character").getNumPaths() > 0 or + model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 + is_model_root = model.hasTag("is_model_root") and model.getTag("is_model_root") == "1" + is_imported_root = model.hasTag("tree_item_type") and model.getTag("tree_item_type") == "IMPORTED_MODEL_NODE" + + score = 0 + score += relation * 100 + score += 60 if has_model_path else 0 + score += 40 if has_saved_path else 0 + score += 30 if has_anim else 0 + score += 20 if has_geom else 0 + score += 10 if is_model_root else 0 + score += 8 if is_imported_root else 0 + + if score > best_score: + best_score = score + best = model + except Exception: + continue + if best: + return best + + # 关系反查失败时的兜底:同名优先(常见于场景重建后层级丢失) + node_name = "" + try: + node_name = (node.getName() or "").strip().lower() + except Exception: + node_name = "" + + if not related_found and node_name: + fallback_best = None + fallback_score = -1 + for model in list(models): + try: + if not model or model.isEmpty() or self._is_scene_root_node(model): + continue + model_name = (model.getName() or "").strip().lower() + if model_name != node_name: + continue + + has_model_path = model.hasTag("model_path") and bool(model.getTag("model_path")) + has_saved_path = model.hasTag("saved_model_path") and bool(model.getTag("saved_model_path")) + has_anim = ( + model.findAllMatches("**/+Character").getNumPaths() > 0 or + model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 + score = 0 + score += 80 if has_anim and has_geom else 0 + score += 40 if has_model_path else 0 + score += 25 if has_saved_path else 0 + if score > fallback_score: + fallback_score = score + fallback_best = model + except Exception: + continue + if fallback_best: + return fallback_best + + # 单模型场景兜底 + valid_models = [] + for model in list(models): + try: + if model and (not model.isEmpty()) and (not self._is_scene_root_node(model)): + valid_models.append(model) + except Exception: + continue + if len(valid_models) == 1: + return valid_models[0] + + # 若场景中仅有一个“动画+几何体”模型,也直接使用它 + anim_models = [] + for model in valid_models: + try: + has_anim = ( + model.findAllMatches("**/+Character").getNumPaths() > 0 or + model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + has_geom = model.findAllMatches("**/+GeomNode").getNumPaths() > 0 + if has_anim and has_geom: + anim_models.append(model) + except Exception: + continue + if len(anim_models) == 1: + return anim_models[0] + + return None + except Exception: + return None + + def _recover_model_path_from_tags(self, node): + """从常见标签恢复模型路径,尽量避免退化到纯内存 autoBind。""" + try: + project_root = Path(__file__).resolve().parents[2] + search_roots = [ + project_root, + project_root / "Resources", + project_root / "Resources" / "models", + ] + + raw_candidates = [] + if node and not node.isEmpty(): + for tag_name in ("model_path", "original_path", "saved_model_path", "file"): + try: + if node.hasTag(tag_name): + val = node.getTag(tag_name) + if val: + raw_candidates.append(val) + except Exception: + continue + + # 父链继续补齐 + walker = node.getParent() if (node and not node.isEmpty()) else None + for _ in range(64): + if not walker or walker.isEmpty() or self._is_scene_root_node(walker): + break + for tag_name in ("model_path", "original_path", "saved_model_path", "file"): + try: + if walker.hasTag(tag_name): + val = walker.getTag(tag_name) + if val: + raw_candidates.append(val) + except Exception: + continue + parent = walker.getParent() + if not parent or parent.isEmpty() or parent == walker: + break + walker = parent + + # scene_manager.models 补齐 + model_owner = self._find_scene_model_owner(node) + if model_owner and not model_owner.isEmpty(): + for tag_name in ("model_path", "original_path", "saved_model_path", "file"): + try: + if model_owner.hasTag(tag_name): + val = model_owner.getTag(tag_name) + if val: + raw_candidates.append(val) + except Exception: + continue + + # 全局兜底:当当前节点标签全丢失时,从 scene_manager.models 收集唯一可用路径 + scene_manager = getattr(self, "scene_manager", None) + models = getattr(scene_manager, "models", None) if scene_manager else None + if models: + scene_paths = [] + best_related_path = "" + best_related_score = -1 + for model in list(models): + try: + if not model or model.isEmpty() or self._is_scene_root_node(model): + continue + relation = 0 + try: + if model == node: + relation = 3 + elif model.isAncestorOf(node): + relation = 2 + elif node.isAncestorOf(model): + relation = 1 + except Exception: + relation = 0 + + has_anim = self._node_has_animation_nodes(model) + has_geom = self._node_has_geom(model) + + candidate_path = "" + for tag_name in ("model_path", "original_path", "saved_model_path", "file"): + if model.hasTag(tag_name): + val = model.getTag(tag_name) + if val: + scene_paths.append(val) + if (not candidate_path) and tag_name in ("model_path", "original_path", "saved_model_path"): + candidate_path = val + if relation > 0 and candidate_path: + score = relation * 100 + score += 40 if has_anim else 0 + score += 25 if has_geom else 0 + try: + if os.path.exists(candidate_path) or os.path.exists(os.path.normpath(candidate_path)): + score += 35 + except Exception: + pass + if score > best_related_score: + best_related_score = score + best_related_path = candidate_path + except Exception: + continue + # 去重 + uniq_scene_paths = [] + seen_scene = set() + for p in scene_paths: + try: + key = os.path.normcase(os.path.normpath(p)) + except Exception: + key = p + if key in seen_scene: + continue + seen_scene.add(key) + uniq_scene_paths.append(p) + # 仅在唯一时采用,避免误绑到别的模型 + if len(uniq_scene_paths) == 1: + raw_candidates.append(uniq_scene_paths[0]) + elif best_related_path: + raw_candidates.append(best_related_path) + + # 1) 直接存在的路径优先 + for c in raw_candidates: + try: + if os.path.exists(c): + return c + norm = os.path.normpath(c) + if os.path.exists(norm): + return norm + except Exception: + continue + + # 2) 基于文件名在项目目录中补查 + filenames = [] + for c in raw_candidates: + try: + name = os.path.basename(c) + if name and "." in name: + filenames.append(name) + except Exception: + continue + + tried = set() + for filename in filenames: + for root in search_roots: + candidate = str(root / filename) + key = os.path.normcase(os.path.normpath(candidate)) + if key in tried: + continue + tried.add(key) + if os.path.exists(candidate): + return candidate + + return "" + except Exception: + return "" + + + def _resolve_animation_owner_model(self, node): + """向上查找动画所属的模型根节点,避免选中子节点时绑定失败。""" + scene_owner = self._find_scene_model_owner(node) + if scene_owner and not scene_owner.isEmpty(): + try: + # 同步路径标签,避免后续格式识别为“未知” + if scene_owner.hasTag("model_path") and scene_owner.getTag("model_path"): + if (not node.hasTag("model_path")) or (not node.getTag("model_path")): + node.setTag("model_path", scene_owner.getTag("model_path")) + if scene_owner.hasTag("original_path") and scene_owner.getTag("original_path"): + if (not node.hasTag("original_path")) or (not node.getTag("original_path")): + node.setTag("original_path", scene_owner.getTag("original_path")) + except Exception: + pass + return scene_owner + + try: + current = node + max_depth = 64 + chain = [] + for _ in range(max_depth): + if not current or current.isEmpty(): + break + # 绝不把场景根节点当作动画 owner,否则 hide() 会把全场景隐藏 + if self._is_scene_root_node(current): + break + + chain.append(current) + parent = current.getParent() + if not parent or parent.isEmpty() or parent == current: + break + current = parent + + # 标签缺失/层级复杂时:按可见播放优先级打分选 owner(动画+几何体最高) + if chain: + path_source = None + original_path_source = None + + def score_candidate(c): + try: + has_character = c.findAllMatches("**/+Character").getNumPaths() > 0 + has_bundle = c.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + has_anim = has_character or has_bundle + has_geom = c.findAllMatches("**/+GeomNode").getNumPaths() > 0 + except Exception: + has_anim = False + has_geom = False + + has_model_path = c.hasTag("model_path") and bool(c.getTag("model_path")) + is_model_root = c.hasTag("is_model_root") and c.getTag("is_model_root") == "1" + is_imported_root = c.hasTag("tree_item_type") and c.getTag("tree_item_type") == "IMPORTED_MODEL_NODE" + + # 分数越高越优先:保证可见播放 > 仅有骨骼 > 空节点 + score = 0 + score += 100 if has_anim and has_geom else 0 + score += 60 if has_model_path and has_geom else 0 + score += 40 if has_anim else 0 + score += 20 if has_geom else 0 + score += 15 if has_model_path else 0 + score += 10 if is_model_root else 0 + score += 8 if is_imported_root else 0 + return score + + best = chain[0] + best_score = -1 + for candidate in chain: + try: + if not path_source and candidate.hasTag("model_path") and candidate.getTag("model_path"): + path_source = candidate + if not original_path_source and candidate.hasTag("original_path") and candidate.getTag("original_path"): + original_path_source = candidate + except Exception: + pass + + s = score_candidate(candidate) + if s > best_score: + best_score = s + best = candidate + + # 把路径标签补到最终 owner,避免后续格式识别为“未知” + try: + if path_source and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))): + best.setTag("model_path", path_source.getTag("model_path")) + if original_path_source and ((not best.hasTag("original_path")) or (not best.getTag("original_path"))): + best.setTag("original_path", original_path_source.getTag("original_path")) + if best.hasTag("saved_model_path") and ((not best.hasTag("model_path")) or (not best.getTag("model_path"))): + best.setTag("model_path", best.getTag("saved_model_path")) + except Exception: + pass + + return best + except Exception: + pass + + try: + if node and not node.isEmpty() and not self._is_scene_root_node(node): + return node + except Exception: + pass + return node + def _getActor(self, origin_model): """ 获取或创建模型的Actor,用于动画控制 复用Qt版本经过验证的实现方式 """ - # 检查缓存 - if origin_model in self._actor_cache: - return self._actor_cache[origin_model] - - # 尝试直接从内存创建 - if origin_model.hasTag("can_create_actor_from_memory"): - try: - test_actor = Actor(origin_model) - anims = test_actor.getAnimNames() - self._actor_cache[origin_model] = test_actor - print(f"[Actor加载] 内存创建检测到动画: {anims}") - if anims: - return test_actor - else: - test_actor.cleanup() - test_actor.removeNode() - except Exception as e: - print(f"从内存模型创建Actor失败: {e}") - - # 如果不能直接从内存创建,再尝试通过文件路径加载 - filepath = origin_model.getTag("model_path") - if not filepath: - return None + owner_model = self._resolve_animation_owner_model(origin_model) + owner_model = self._prefer_owner_with_visible_geometry(origin_model, owner_model) - print(f"[Actor加载] 尝试加载: {filepath}") - - # 处理跨平台路径问题 - import os - # 检查路径是否有效,如果无效则尝试修复 - if not os.path.exists(filepath): - original_filepath = filepath - # 尝试多种修复策略 - fixed = False - - import platform - # 策略1: 处理Linux风格路径在Windows上的问题 - if filepath.startswith('/') and platform.system() == "Windows": - print("[路径转换] 尝试处理Linux风格路径:", filepath) - path_parts = filepath.split('/') - print(platform.system()) - if len(path_parts) > 1: - drive_letter = path_parts[1].upper() + ':\\' # 添加反斜杠确保正确路径格式 - remaining_path = '\\'.join(path_parts[2:]) if len(path_parts) > 2 else '' - potential_path = os.path.join(drive_letter, remaining_path) - print(f"[路径转换] 构造的潜在路径: {potential_path}") - if os.path.exists(potential_path): - filepath = potential_path - fixed = True - print(f"[路径转换] 成功: {original_filepath} -> {filepath}") - else: - print(f"[路径转换] 文件不存在: {potential_path}") - - - # 策略2: 处理路径分隔符问题 - if not fixed: - # 尝试规范化路径 - normalized_path = os.path.normpath(filepath) - print(f"[路径规范化] 尝试规范化路径: {filepath} -> {normalized_path}") - if os.path.exists(normalized_path): - filepath = normalized_path - fixed = True - print(f"[路径规范化] 成功: {filepath}") - else: - print(f"[路径规范化] 文件不存在: {normalized_path}") - - # 策略3: 在Resources目录中查找 - if not fixed: - # 尝试在Resources目录中查找文件 - resources_path = str(Path(__file__).resolve().parents[2] / "Resources") - filename = os.path.basename(filepath) - potential_path = os.path.join(resources_path, filename) - print(f"[Resources查找] 尝试在Resources目录查找: {potential_path}") - if os.path.exists(potential_path): - filepath = potential_path - fixed = True - print(f"[Resources查找] 成功: {filepath}") - else: - print(f"[Resources查找] 文件不存在: {potential_path}") - - if fixed: - print(f"路径修复: {original_filepath} -> {filepath}") - # 更新模型标签 - origin_model.setTag("model_path", filepath) - else: - print(f"[警告] 模型文件不存在: {filepath}") - return None - - # 检查是否是 FBX 文件,如果是,使用专门的 FBX 动画加载器 - if filepath.lower().endswith('.fbx'): - pass - #return self._createFBXActor(origin_model, filepath) - - # 其他格式使用标准 Actor 加载 + # owner 节点缺失路径标签时,尝试从当前节点及其祖先继承路径,避免只能走内存分支 try: - import gltf - from panda3d.core import Filename - - # 将Panda3D路径转换为操作系统特定路径 - panda_filename = Filename(filepath) - os_specific_path = panda_filename.to_os_specific() - print(f"[路径转换] {filepath} -> {os_specific_path}") - - print(f"[GLTF加载] 尝试加载: {os_specific_path}") - - # 使用明确的设置确保动画被加载 - gltf_settings = gltf.GltfSettings(skip_animations=False) - model_root = gltf.load_model(os_specific_path, gltf_settings) - model_node = NodePath(model_root) - test_actor = Actor(model_node) - anims = test_actor.getAnimNames() - test_actor.reparentTo(self.render) - self._actor_cache[origin_model] = test_actor - print(f"[Actor加载] 标准加载检测到动画: {anims}") - if not anims: - test_actor.cleanup() - test_actor.removeNode() + needs_path = (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path")) + if needs_path: + walker = origin_model + for _ in range(64): + if not walker or walker.isEmpty() or self._is_scene_root_node(walker): + break + if walker.hasTag("model_path") and walker.getTag("model_path"): + owner_model.setTag("model_path", walker.getTag("model_path")) + if walker.hasTag("original_path") and walker.getTag("original_path"): + owner_model.setTag("original_path", walker.getTag("original_path")) + break + if walker.hasTag("saved_model_path") and walker.getTag("saved_model_path"): + owner_model.setTag("model_path", walker.getTag("saved_model_path")) + break + parent = walker.getParent() + if not parent or parent.isEmpty() or parent == walker: + break + walker = parent + + # 仍缺路径时,尝试更激进的标签恢复 + if (not owner_model.hasTag("model_path")) or (not owner_model.getTag("model_path")): + recovered = self._recover_model_path_from_tags(owner_model) + if recovered: + owner_model.setTag("model_path", recovered) + if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): + owner_model.setTag("original_path", recovered) + except Exception: + pass + + def _cleanup_actor(actor): + try: + if actor is None: + return + if hasattr(actor, "cleanup"): + actor.cleanup() + if hasattr(actor, "removeNode"): + actor.removeNode() + except Exception: + pass + + def _try_create_actor_from_source(source, source_desc): + try: + actor = Actor(source) + # 无论是否已检测到动画名,都显式绑定一次,避免“有名字但无可播放控制” + try: + actor.bindAllAnims(allowAsyncBind=False) + except Exception: + pass + + anims = actor.getAnimNames() + print(f"[Actor加载] {source_desc} 检测到动画: {anims}") + if not anims: + _cleanup_actor(actor) + return None + + # 确认至少有一个可用的 AnimControl + playable = False + for anim_name in anims: + try: + control = actor.getAnimControl(anim_name) + if control and control.getNumFrames() > 1: + playable = True + break + except Exception: + continue + + if not playable: + print(f"[Actor加载] {source_desc} 动画控制无效,尝试其他加载路径") + _cleanup_actor(actor) + return None + + # 无可见几何体的 Actor 会导致“播放后什么都看不到” + has_geom = False + try: + has_geom = actor.findAllMatches("**/+GeomNode").getNumPaths() > 0 + except Exception: + has_geom = False + if not has_geom: + print(f"[Actor加载] {source_desc} 无可见几何体,尝试其他加载路径") + _cleanup_actor(actor) + return None + + actor.reparentTo(self._get_owner_parent_node(owner_model)) + actor.hide() + return actor + except Exception as e: + print(f"[Actor加载] {source_desc} 失败: {e}") return None - return test_actor - except Exception as e: - print(f"创建Actor失败: {e}") + + def _try_create_autobind_proxy(model_np, source_desc, owns_node=False): + try: + from panda3d.core import AnimControlCollection, autoBind + + controls = {} + + def collect_controls(np): + if not np or np.isEmpty(): + return + try: + acc = AnimControlCollection() + autoBind(np.node(), acc, ~0) + for i in range(acc.getNumAnims()): + name = acc.getAnimName(i) or f"anim_{i}" + control = acc.getAnim(i) + if not control: + continue + if name in controls: + try: + old_frames = controls[name].getNumFrames() + except Exception: + old_frames = -1 + try: + new_frames = control.getNumFrames() + except Exception: + new_frames = -1 + # 重名时保留帧数更多的控制,避免默认选到“看起来无效”的同名动画 + if new_frames >= old_frames: + controls[name] = control + else: + controls[name] = control + except Exception: + pass + + # 先在根节点尝试,再补扫 Character 节点 + collect_controls(model_np) + character_nodes = model_np.findAllMatches("**/+Character") + for i in range(character_nodes.getNumPaths()): + collect_controls(character_nodes.getPath(i)) + + # autoBind 无结果时,尝试手动把 AnimBundle 绑定到 Character 的 PartBundle + if not controls and character_nodes.getNumPaths() > 0: + anim_bundle_nodes = model_np.findAllMatches("**/+AnimBundleNode") + subset = PartSubset() + for ci in range(character_nodes.getNumPaths()): + try: + character = character_nodes.getPath(ci).node() + for bi in range(character.getNumBundles()): + part_bundle = character.getBundle(bi) + for ai in range(anim_bundle_nodes.getNumPaths()): + try: + anim_bundle_node = anim_bundle_nodes.getPath(ai).node() + anim_bundle = anim_bundle_node.getBundle() + if not anim_bundle: + continue + anim_name = anim_bundle.getName() or anim_bundle_node.getName() or f"anim_{ai}" + control = part_bundle.bindAnim(anim_bundle, -1, subset) + if not control: + continue + if anim_name in controls: + try: + old_frames = controls[anim_name].getNumFrames() + except Exception: + old_frames = -1 + try: + new_frames = control.getNumFrames() + except Exception: + new_frames = -1 + if new_frames >= old_frames: + controls[anim_name] = control + else: + controls[anim_name] = control + except Exception: + continue + except Exception: + continue + + if not controls: + return None + + # 仅骨骼无几何体时会出现“能触发播放但场景不可见” + try: + has_geom = model_np.findAllMatches("**/+GeomNode").getNumPaths() > 0 + except Exception: + has_geom = False + if not has_geom: + print(f"[Actor加载] {source_desc} autoBind 检测到动画但无可见几何体,尝试其他节点") + return None + + print(f"[Actor加载] {source_desc} autoBind 检测到动画: {list(controls.keys())}") + return _BoundAnimationProxy(model_np, controls, owns_node=owns_node) + except Exception as e: + print(f"[Actor加载] {source_desc} autoBind 失败: {e}") + return None + + def _try_create_actor_via_gltf_path(path): + """针对部分 GLB/GLTF 文件,使用 gltf 插件强制加载动画后再创建 Actor。""" + lower_path = str(path).lower() + if not (lower_path.endswith(".glb") or lower_path.endswith(".gltf")): + return None + + model_np = None + succeeded = False + try: + import gltf + from panda3d.core import Filename + + panda_path = Filename.from_os_specific(path).get_fullpath() + os_path = Filename(panda_path).to_os_specific() + settings = gltf.GltfSettings(skip_animations=False) + model_root = gltf.load_model(os_path, settings) + if not model_root: + return None + + model_np = NodePath(model_root) + actor = _try_create_actor_from_source(model_np, f"GLTF专用加载({path})") + if actor: + succeeded = True + return actor + + proxy = _try_create_autobind_proxy(model_np, f"GLTF专用加载({path})", owns_node=True) + if proxy: + proxy.reparentTo(self._get_owner_parent_node(owner_model)) + proxy.hide() + succeeded = True + return proxy + except Exception as e: + print(f"[Actor加载] GLTF专用加载失败 ({path}): {e}") + finally: + # 仅在失败且本地临时节点仍存在时清理,避免泄漏 + try: + if (not succeeded) and model_np and not model_np.isEmpty(): + model_np.removeNode() + except Exception: + pass return None + + # 检查缓存(无效缓存自动清理,避免“无动画”结果被永久缓存) + cached_actor = self._actor_cache.get(owner_model) + if cached_actor: + # 若已获得模型路径,优先重建真实 Actor,避免长期停留在 autoBind 代理导致“能触发但不可见” + try: + owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) + except Exception: + owner_has_path = False + if owner_has_path and isinstance(cached_actor, _BoundAnimationProxy): + _cleanup_actor(cached_actor) + self._actor_cache.pop(owner_model, None) + cached_actor = None + + if cached_actor: + try: + has_geom = False + if isinstance(cached_actor, _BoundAnimationProxy): + has_geom = self._node_has_geom(getattr(cached_actor, "_node", None)) + else: + try: + has_geom = cached_actor.findAllMatches("**/+GeomNode").getNumPaths() > 0 + except Exception: + has_geom = False + + if (not cached_actor.isEmpty()) and cached_actor.getAnimNames() and has_geom: + return cached_actor + except Exception: + pass + _cleanup_actor(cached_actor) + self._actor_cache.pop(owner_model, None) + + # 先检测模型树中的动画结构,并写入标签供属性面板快速判断 + try: + character_nodes = owner_model.findAllMatches("**/+Character") + anim_bundle_nodes = owner_model.findAllMatches("**/+AnimBundleNode") + has_animation_nodes = ( + character_nodes.getNumPaths() > 0 or + anim_bundle_nodes.getNumPaths() > 0 + ) + owner_model.setTag("has_animations", "true" if has_animation_nodes else "false") + if has_animation_nodes: + owner_model.setTag("can_create_actor_from_memory", "true") + except Exception: + has_animation_nodes = False + + def _try_memory_fallback(): + def _collect_autobind_source_candidates(): + candidates = [] + + def add_candidate(node): + try: + if not node or node.isEmpty() or self._is_scene_root_node(node): + return + for existing in candidates: + if existing == node: + return + candidates.append(node) + except Exception: + return + + add_candidate(owner_model) + add_candidate(origin_model) + add_candidate(self._prefer_owner_with_visible_geometry(origin_model, owner_model)) + add_candidate(self._find_scene_model_owner(origin_model)) + add_candidate(self._find_scene_model_owner(owner_model)) + + # 祖先链补齐 + for seed in (origin_model, owner_model): + current = seed + for _ in range(48): + if not current or current.isEmpty() or self._is_scene_root_node(current): + break + add_candidate(current) + parent = current.getParent() + if not parent or parent.isEmpty() or parent == current: + break + current = parent + + # scene_manager.models 补齐候选 + scene_manager = getattr(self, "scene_manager", None) + models = getattr(scene_manager, "models", None) if scene_manager else None + if models: + for model in list(models): + add_candidate(model) + + def score(node): + try: + has_geom = self._node_has_geom(node) + has_anim = self._node_has_animation_nodes(node) + has_path = ( + (node.hasTag("model_path") and bool(node.getTag("model_path"))) or + (node.hasTag("saved_model_path") and bool(node.getTag("saved_model_path"))) or + (node.hasTag("original_path") and bool(node.getTag("original_path"))) + ) + related = False + try: + related = ( + node == owner_model or + node == origin_model or + node.isAncestorOf(origin_model) or + origin_model.isAncestorOf(node) or + node.isAncestorOf(owner_model) or + owner_model.isAncestorOf(node) + ) + except Exception: + related = False + + s = 0 + s += 220 if has_geom and has_anim else 0 + s += 120 if has_geom else 0 + s += 45 if has_anim else 0 + s += 35 if has_path else 0 + s += 40 if node == owner_model else 0 + s += 30 if node == origin_model else 0 + s += 25 if related else 0 + return s + except Exception: + return -1 + + candidates.sort(key=score, reverse=True) + return candidates + + can_create_from_memory = False + if owner_model.hasTag("can_create_actor_from_memory"): + can_create_from_memory = owner_model.getTag("can_create_actor_from_memory").lower() == "true" + if not can_create_from_memory and has_animation_nodes: + can_create_from_memory = True + + if can_create_from_memory: + # 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。 + # 先用副本创建真实 Actor,只有失败时才退回 autoBind 代理。 + clone_parent = self._get_owner_parent_node(owner_model) + clone_np = None + try: + clone_np = owner_model.copyTo(clone_parent) + clone_np.setName(f"{owner_model.getName()}__anim_runtime") + mem_actor = _try_create_actor_from_source(clone_np, "内存模型副本") + if mem_actor: + self._actor_cache[owner_model] = mem_actor + owner_model.setTag("has_animations", "true") + return mem_actor + except Exception as e: + print(f"[Actor加载] 创建内存模型副本失败: {e}") + finally: + # _try_create_actor_from_source 失败时,清理临时副本 + try: + if clone_np and not clone_np.isEmpty() and owner_model not in self._actor_cache: + clone_np.removeNode() + except Exception: + pass + + # Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源 + for source_node in _collect_autobind_source_candidates(): + mem_proxy = _try_create_autobind_proxy( + source_node, + f"内存模型({source_node.getName()})", + owns_node=False + ) + if mem_proxy: + self._actor_cache[owner_model] = mem_proxy + owner_model.setTag("has_animations", "true") + if source_node != owner_model: + print(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}") + return mem_proxy + return None + + # 始终优先尝试从文件路径加载,因为底层 gltf 插件只有在加载文件时才能抽取完整的动画名称。 + print(f"[Actor加载调试] 传入的 origin_model: {origin_model.getName() if origin_model else 'None'}") + print(f"[Actor加载调试] origin_model tags: {origin_model.getTags() if origin_model else 'None'}") + print(f"[Actor加载调试] 解析出的 owner_model: {owner_model.getName() if owner_model else 'None'}") + try: + print(f"[Actor加载调试] owner_model tags: {owner_model.getTags() if owner_model else 'None'}") + print(f"[Actor加载调试] owner_model path tag: {owner_model.getTag('model_path') if owner_model.hasTag('model_path') else 'MISSING'}") + except Exception as e: + print(f"[Actor加载调试] 获取 tags 异常: {e}") + + filepath = owner_model.getTag("model_path") if owner_model.hasTag("model_path") else "" + if not filepath and owner_model.hasTag("original_path"): + filepath = owner_model.getTag("original_path") + + print(f"[Actor加载调试] 获取到的 filepath: '{filepath}'") + if not filepath: + print(f"[Actor加载调试] filepath为空,触发 _try_memory_fallback()") + return _try_memory_fallback() + + # 针对Actor加载,必须使用Panda3D规范的Unix风格路径,否则Windows绝对路径会导致加载彻底崩溃并返回空节点 + panda_specific_path = "" + try: + from panda3d.core import Filename + panda_specific_path = Filename.from_os_specific(filepath).get_fullpath() + except: + panda_specific_path = filepath.replace('\\', '/') + + candidate_paths = [panda_specific_path, filepath, os.path.normpath(filepath)] + + # 处理 /d/... 这类路径在 Windows 上无法直接访问的问题 (补充兜底OS路径) + if filepath.startswith("/") and os.name == "nt": + parts = filepath.split("/") + if len(parts) > 2 and len(parts[1]) == 1: + win_path = f"{parts[1].upper()}:\\{os.path.join(*parts[2:])}" if parts[2:] else f"{parts[1].upper()}:\\" + candidate_paths.append(win_path) + candidate_paths.append(os.path.normpath(win_path)) + + # 尝试 Panda3D 路径标准化 + try: + from scene import util + candidate_paths.append(util.normalize_model_path(filepath)) + except Exception: + pass + + # 在 Resources/models 中按文件名兜底查找 + filename = os.path.basename(filepath) + if filename: + candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / "models" / filename)) + candidate_paths.append(str(Path(__file__).resolve().parents[2] / "Resources" / filename)) + + # 去重并优先使用真实存在的路径 (同时确保panda专属路径排在第一位尝试) + unique_paths = [] + seen = set() + for p in candidate_paths: + if not p: + continue + key = os.path.normcase(os.path.normpath(p)) + if key in seen: + continue + seen.add(key) + unique_paths.append(p) + + # 过滤时注意如果是以 / 开头的 Panda 路径,os.path.exists 可能判断为 False,所以要额外豁免 panda_specific_path 保底加载 + existing_paths = [p for p in unique_paths if os.path.exists(p) or p == panda_specific_path] + load_paths = existing_paths + [p for p in unique_paths if p not in existing_paths] + + for p in load_paths: + print(f"[Actor加载验证] 正在尝试通过路径读取骨骼和动画文件: {p}") + actor = _try_create_actor_from_source(p, f"文件路径({p})") + if actor: + owner_model.setTag("model_path", p) + owner_model.setTag("has_animations", "true") + self._actor_cache[owner_model] = actor + return actor + + # 标准 Actor 路径失败时,针对 GLTF/GLB 走插件加载兜底 + actor = _try_create_actor_via_gltf_path(p) + if actor: + owner_model.setTag("model_path", p) + owner_model.setTag("has_animations", "true") + self._actor_cache[owner_model] = actor + return actor + + # 路径 Actor 失败后,再尝试把文件作为普通模型加载并 autoBind + try: + loaded_model = self.loader.loadModel(p) + if loaded_model and not loaded_model.isEmpty(): + proxy = _try_create_autobind_proxy(loaded_model, f"文件路径({p})", owns_node=True) + if proxy: + loaded_model.reparentTo(self.render) + loaded_model.hide() + owner_model.setTag("model_path", p) + owner_model.setTag("has_animations", "true") + self._actor_cache[owner_model] = proxy + return proxy + loaded_model.removeNode() + except Exception: + pass + + # 所有创建路径失败时由内存加载进行兜底 + return _try_memory_fallback() def _getModelFormat(self, origin_model): """获取模型格式信息""" - filepath = origin_model.getTag("model_path") + filepath = origin_model.getTag("model_path") if origin_model.hasTag("model_path") else "" + if not filepath and origin_model.hasTag("original_path"): + filepath = origin_model.getTag("original_path") + if not filepath and origin_model.hasTag("saved_model_path"): + filepath = origin_model.getTag("saved_model_path") + if not filepath and origin_model.hasTag("file"): + filepath = origin_model.getTag("file") original_path = origin_model.getTag("original_path") converted_from = origin_model.getTag("converted_from") @@ -179,7 +1192,9 @@ class AnimationTools: display_name = name original_name = name - if format_info == "GLB": + format_upper = format_info.upper() + + if format_upper.startswith("GLB") or format_upper.startswith("GLTF"): # GLB 格式通常有真实的动画名称 if "|" in name: # 处理类似 'Armature|mixamo.com|Layer0' 的名称 @@ -192,8 +1207,13 @@ class AnimationTools: display_name = f"{parts[0]}_{parts[-1]}" else: display_name = parts[-1] + elif ":" in name: + # 一些导入器会产生前缀:动作名 + display_name = name.split(":")[-1] + elif "/" in name: + display_name = name.split("/")[-1] - elif format_info == "FBX": + elif format_upper.startswith("FBX"): # FBX 格式可能需要特殊处理 if self._isLikelyBoneGroup(name): # 检查是否是骨骼组而非动画 @@ -202,7 +1222,7 @@ class AnimationTools: else: display_name = name - elif format_info in ["EGG", "BAM"]: + elif format_upper.startswith("EGG") or format_upper.startswith("BAM"): # 原生格式通常命名规范 display_name = name @@ -252,32 +1272,110 @@ class AnimationTools: except Exception as e: # 简化错误处理 return "分析异常" + + def _resolve_selected_animation_name(self, origin_model, actor, owner_model=None): + """返回可播放的动画名;当当前选择无效时自动回退到首个动画并回写标签。""" + try: + anim_names = actor.getAnimNames() if actor else [] + except Exception: + anim_names = [] + + if not anim_names: + return None + + current_anim = origin_model.getPythonTag("selected_animation") + if owner_model and owner_model != origin_model and current_anim not in anim_names: + current_anim = owner_model.getPythonTag("selected_animation") + + if current_anim not in anim_names: + current_anim = anim_names[0] + + try: + origin_model.setPythonTag("selected_animation", current_anim) + if owner_model and owner_model != origin_model: + owner_model.setPythonTag("selected_animation", current_anim) + except Exception: + pass + + return current_anim + + def _should_swap_visibility_for_actor(self, owner_model, actor): + """ + 是否需要“隐藏原模型/显示Actor”的切换。 + 当 actor 直接绑定到 owner_model 本体时,不应切换可见性,否则会导致选中节点瞬间丢失。 + """ + if self._is_scene_root_node(owner_model): + # 绝不隐藏场景根节点 + return False + + try: + if isinstance(actor, _BoundAnimationProxy): + # 非 owns_node 代理直接绑定场景节点,不能做“隐藏原模型/显示Actor”切换 + if not getattr(actor, "_owns_node", False): + return False + return getattr(actor, "_node", None) != owner_model + except Exception: + pass + + try: + return actor != owner_model + except Exception: + return True def _playAnimation(self, origin_model): """播放动画""" - actor = self._getActor(origin_model) + try: + if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): + self.render.show() + except Exception: + pass + + owner_model = self._resolve_animation_owner_model(origin_model) + actor = self._getActor(owner_model) if not actor: return - # 保存原始世界坐标 - original_world_pos = origin_model.getPos(self.render) - original_world_hpr = origin_model.getHpr(self.render) - original_world_scale = origin_model.getScale(self.render) - - # 设置Actor位置和姿态 - actor.setPos(origin_model.getPos()) - actor.setHpr(origin_model.getHpr()) - actor.setScale(origin_model.getScale()) + # 若仍在“无路径的内存代理”,播放前强制再尝试一次路径恢复并重建真实 Actor + if isinstance(actor, _BoundAnimationProxy): + owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) + if not owner_has_path: + recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model) + if recovered_path: + try: + owner_model.setTag("model_path", recovered_path) + if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): + owner_model.setTag("original_path", recovered_path) + except Exception: + pass + self._clear_animation_cache(owner_model) + actor = self._getActor(owner_model) + if not actor: + return - # 隐藏原始模型,显示Actor - origin_model.hide() + is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) + + original_world_pos = owner_model.getPos(self.render) + original_world_hpr = owner_model.getHpr(self.render) + original_world_scale = owner_model.getScale(self.render) + + if not is_scene_bound_proxy: + actor.setPos(owner_model.getPos()) + actor.setHpr(owner_model.getHpr()) + actor.setScale(owner_model.getScale()) + + if self._should_swap_visibility_for_actor(owner_model, actor): + owner_model.hide() + else: + owner_model.show() actor.show() - # 创建任务来维持世界坐标不变 + task_name = f"maintain_anim_pos_{id(actor)}" + + # 维持 Actor 的世界变换,避免父节点层级差异导致位置抖动/漂移 def maintainWorldPosition(task): try: - if not actor.isEmpty(): + if not actor.isEmpty() and not owner_model.isEmpty(): actor.setPos(self.render, original_world_pos) actor.setHpr(self.render, original_world_hpr) actor.setScale(self.render, original_world_scale) @@ -287,34 +1385,54 @@ class AnimationTools: except: return task.done - # 添加维持位置的任务 - taskMgr.add(maintainWorldPosition, f"maintain_anim_pos_{id(actor)}") + taskMgr.remove(task_name) + if not is_scene_bound_proxy: + taskMgr.add(maintainWorldPosition, task_name) # 获取当前选中的动画 - current_anim = origin_model.getPythonTag("selected_animation") + current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: + try: + actor.stop() + except Exception: + pass actor.play(current_anim) print(f"『动画播放』:{current_anim}") - else: - # 兜底:使用第一个可用动画 - anim_names = actor.getAnimNames() - if anim_names: - actor.play(anim_names[0]) - print(f"『动画播放』:{anim_names[0]}") def _pauseAnimation(self, origin_model): """暂停动画""" - actor = self._getActor(origin_model) + owner_model = self._resolve_animation_owner_model(origin_model) + actor = self._getActor(owner_model) if not actor: return - - # 设置Actor位置和姿态 - actor.setPos(origin_model.getPos()) - actor.setHpr(origin_model.getHpr()) - actor.setScale(origin_model.getScale()) + + if isinstance(actor, _BoundAnimationProxy): + owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path")) + if not owner_has_path: + recovered_path = self._recover_model_path_from_tags(owner_model) or self._recover_model_path_from_tags(origin_model) + if recovered_path: + try: + owner_model.setTag("model_path", recovered_path) + if (not owner_model.hasTag("original_path")) or (not owner_model.getTag("original_path")): + owner_model.setTag("original_path", recovered_path) + except Exception: + pass + self._clear_animation_cache(owner_model) + actor = self._getActor(owner_model) + if not actor: + return + + is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) + if not is_scene_bound_proxy: + actor.setPos(self.render, owner_model.getPos(self.render)) + actor.setHpr(self.render, owner_model.getHpr(self.render)) + actor.setScale(self.render, owner_model.getScale(self.render)) # 隐藏原始模型,显示Actor - origin_model.hide() + if self._should_swap_visibility_for_actor(owner_model, actor): + owner_model.hide() + else: + owner_model.show() actor.show() # 停止动画(保持当前姿势) @@ -323,7 +1441,14 @@ class AnimationTools: def _stopAnimation(self, origin_model): """停止动画""" - actor = self._getActor(origin_model) + try: + if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): + self.render.show() + except Exception: + pass + + owner_model = self._resolve_animation_owner_model(origin_model) + actor = self._getActor(owner_model) if not actor: return @@ -331,43 +1456,55 @@ class AnimationTools: actor.stop() # 获取当前选中的动画 - current_anim = origin_model.getPythonTag("selected_animation") + current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim and actor.getAnimControl(current_anim): actor.getAnimControl(current_anim).pose(0) # 隐藏Actor,显示原始模型 - actor.hide() - origin_model.show() + if self._should_swap_visibility_for_actor(owner_model, actor): + actor.hide() + owner_model.show() - # 移除维持位置的任务 + # 移除位置维护任务 taskMgr.remove(f"maintain_anim_pos_{id(actor)}") print("『动画』停止切换至原始模型") def _loopAnimation(self, origin_model): """循环播放动画""" - actor = self._getActor(origin_model) + try: + if hasattr(self, "render") and not self.render.isEmpty() and self.render.isHidden(): + self.render.show() + except Exception: + pass + + owner_model = self._resolve_animation_owner_model(origin_model) + actor = self._getActor(owner_model) if not actor: return - # 保存原始世界坐标 - original_world_pos = origin_model.getPos(self.render) - original_world_hpr = origin_model.getHpr(self.render) - original_world_scale = origin_model.getScale(self.render) - - # 设置Actor位置和姿态 - actor.setPos(origin_model.getPos()) - actor.setHpr(origin_model.getHpr()) - actor.setScale(origin_model.getScale()) + is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False)) - # 隐藏原始模型,显示Actor - origin_model.hide() + original_world_pos = owner_model.getPos(self.render) + original_world_hpr = owner_model.getHpr(self.render) + original_world_scale = owner_model.getScale(self.render) + + if not is_scene_bound_proxy: + actor.setPos(owner_model.getPos()) + actor.setHpr(owner_model.getHpr()) + actor.setScale(owner_model.getScale()) + + if self._should_swap_visibility_for_actor(owner_model, actor): + owner_model.hide() + else: + owner_model.show() actor.show() - # 创建任务来维持世界坐标不变 + task_name = f"maintain_anim_pos_{id(actor)}" + def maintainWorldPosition(task): try: - if not actor.isEmpty(): + if not actor.isEmpty() and not owner_model.isEmpty(): actor.setPos(self.render, original_world_pos) actor.setHpr(self.render, original_world_hpr) actor.setScale(self.render, original_world_scale) @@ -377,29 +1514,29 @@ class AnimationTools: except: return task.done - # 添加维持位置的任务 - taskMgr.add(maintainWorldPosition, f"maintain_anim_pos_{id(actor)}") + taskMgr.remove(task_name) + if not is_scene_bound_proxy: + taskMgr.add(maintainWorldPosition, task_name) # 获取当前选中的动画 - current_anim = origin_model.getPythonTag("selected_animation") + current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: + try: + actor.stop() + except Exception: + pass actor.loop(current_anim) print(f"[动画] 循环: {current_anim}") - else: - # 兜底:使用第一个可用动画 - anim_names = actor.getAnimNames() - if anim_names: - actor.loop(anim_names[0]) - print(f"[动画] 循环: {anim_names[0]}") def _setAnimationSpeed(self, origin_model, speed): """设置动画播放速度""" - actor = self._getActor(origin_model) + owner_model = self._resolve_animation_owner_model(origin_model) + actor = self._getActor(owner_model) if not actor: return # 获取当前选中的动画 - current_anim = origin_model.getPythonTag("selected_animation") + current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: actor.setPlayRate(speed, current_anim) print(f"[动画] 速度设为: {speed} ({current_anim})") @@ -413,13 +1550,14 @@ class AnimationTools: def _clear_animation_cache(self, node): """清除节点的动画缓存,当模型发生变化时调用""" + owner_node = self._resolve_animation_owner_model(node) node.setPythonTag("cached_anim_info", None) node.setPythonTag("cached_processed_names", None) node.setPythonTag("animation", None) # 同时清除动画检测结果 # 如果Actor在缓存中,也需要清理 - if node in self._actor_cache: - actor = self._actor_cache[node] + if owner_node in self._actor_cache: + actor = self._actor_cache[owner_node] try: # 清理相关任务 taskMgr.remove(f"maintain_anim_pos_{id(actor)}") @@ -430,5 +1568,5 @@ class AnimationTools: except Exception as e: print(f"清理Actor缓存失败: {e}") finally: - del self._actor_cache[node] - print(f"[缓存清理] 清除节点 {node.getName()} 的动画缓存") + del self._actor_cache[owner_node] + print(f"[缓存清理] 清除节点 {owner_node.getName()} 的动画缓存") diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index 8c6143af..15d0e8fd 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -1054,11 +1054,21 @@ class AppActions: self.ssbo_editor.load_model(file_path) model_np = getattr(self.ssbo_editor, 'model', None) # Keep legacy ray-pick fallback usable by adding a collision body. - if model_np and hasattr(self, 'scene_manager') and self.scene_manager: - try: - self.scene_manager.setupCollision(model_np) - except Exception as e: - print(f"[SSBO] setupCollision failed: {e}") + if model_np: + # Apply vital tags manually since SSBO overrides SceneManager loader + model_np.setTag("model_path", file_path) + model_np.setTag("original_path", file_path) + model_np.setTag("is_model_root", "1") + model_np.setTag("is_scene_element", "1") + model_np.setTag("file", os.path.basename(file_path)) + model_np.setName(os.path.basename(file_path)) + + if hasattr(self, 'scene_manager') and self.scene_manager: + try: + self.scene_manager.setupCollision(model_np) + self.scene_manager._processModelAnimations(model_np) + except Exception as e: + print(f"[SSBO] setup components failed: {e}") return model_np except Exception as e: print(f"[SSBO] load_model failed: {e}") diff --git a/ui/panels/editor_panels.py b/ui/panels/editor_panels.py index cd944023..208226ed 100644 --- a/ui/panels/editor_panels.py +++ b/ui/panels/editor_panels.py @@ -1102,29 +1102,41 @@ class EditorPanels: # 动画徽章(优化检测逻辑,避免重复创建Actor) has_animation = False if node_type == "模型": # 只对模型类型进行动画检测 - # 首先检查是否已经缓存了检测结果 - cached_result = node.getPythonTag('animation') - if cached_result is not None: - has_animation = cached_result - else: - # 只有在未缓存时才进行检测 + model_path = node.getTag("model_path") if node.hasTag("model_path") else "" + likely_anim_format = bool(model_path and model_path.lower().endswith(('.glb', '.gltf', '.fbx', '.bam', '.egg'))) + + # 优先使用场景标签(导入/加载时会写入) + if node.hasTag("has_animations"): + has_animation = node.getTag("has_animations").lower() == "true" + + # 再做轻量结构检测(不依赖 Actor) + if not has_animation: try: - # 使用轻量级检测:先检查文件扩展名 - model_path = node.getTag("model_path") - if model_path and model_path.lower().endswith(('.glb', '.gltf', '.fbx')): - # 对于可能包含动画的格式,才进行Actor检测 - actor = self._getActor(node) - if actor and actor.getAnimNames(): - has_animation = True - # 缓存检测结果 - node.setPythonTag('animation', has_animation) - print(f"[动画检测] {node.getName()}: {'有动画' if has_animation else '无动画'}") - else: - # 对于不太可能有动画的格式,直接标记为无动画 - node.setPythonTag('animation', False) + has_character = node.findAllMatches("**/+Character").getNumPaths() > 0 + has_bundle = node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + has_animation = has_character or has_bundle + if has_animation: + node.setTag("has_animations", "true") + node.setTag("can_create_actor_from_memory", "true") + except Exception: + pass + + # 最后才尝试 Actor 检测(只缓存“有动画”,避免把失败结果永久缓存) + cached_result = node.getPythonTag('animation') + if cached_result is True: + has_animation = True + elif not has_animation and likely_anim_format: + try: + actor = self._getActor(node) + if actor and actor.getAnimNames(): + has_animation = True + node.setTag("has_animations", "true") + node.setPythonTag('animation', True) + print(f"[动画检测] {node.getName()}: 有动画") except Exception as e: print(f"动画检测失败: {e}") - node.setPythonTag('animation', False) + elif cached_result is False and not likely_anim_format: + has_animation = False else: # 对于非模型类型,检查已有的动画标签 has_animation = hasattr(node, 'getPythonTag') and node.getPythonTag('animation') @@ -1492,39 +1504,116 @@ class EditorPanels: def _draw_animation_properties(self, node): """绘制动画控制属性面板(优化版本,使用缓存避免重复计算)""" + anim_node = node + try: + if hasattr(self, "_resolve_animation_owner_model"): + resolved = self._resolve_animation_owner_model(node) + if resolved and not resolved.isEmpty(): + anim_node = resolved + except Exception: + pass + + # 路径兜底:当 anim_node 缺少路径时,从 scene_manager.models 中反查祖先模型 + try: + needs_path = (not anim_node.hasTag("model_path")) or (not anim_node.getTag("model_path")) + if needs_path and hasattr(self, "scene_manager") and self.scene_manager: + models = getattr(self.scene_manager, "models", []) + for model in list(models): + try: + if not model or model.isEmpty(): + continue + if (model == anim_node or model.isAncestorOf(anim_node) or anim_node.isAncestorOf(model)): + if model.hasTag("model_path") and model.getTag("model_path"): + anim_node.setTag("model_path", model.getTag("model_path")) + if model.hasTag("original_path") and model.getTag("original_path"): + anim_node.setTag("original_path", model.getTag("original_path")) + break + if model.hasTag("saved_model_path") and model.getTag("saved_model_path"): + anim_node.setTag("model_path", model.getTag("saved_model_path")) + break + except Exception: + continue + except Exception: + pass + + # 先刷新一次模型动画标签,避免“导入后未初始化”导致误判 + try: + if hasattr(self, "scene_manager") and self.scene_manager and hasattr(self.scene_manager, "_processModelAnimations"): + self.scene_manager._processModelAnimations(anim_node) + except Exception: + pass + + has_animation_tag = anim_node.hasTag("has_animations") and anim_node.getTag("has_animations").lower() == "true" + has_animation_nodes = False + try: + has_animation_nodes = ( + anim_node.findAllMatches("**/+Character").getNumPaths() > 0 or + anim_node.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + except Exception: + pass + # 检查是否已经缓存了动画信息 - cached_anim_info = node.getPythonTag("cached_anim_info") - cached_processed_names = node.getPythonTag("cached_processed_names") + cached_anim_info = anim_node.getPythonTag("cached_anim_info") + cached_processed_names = anim_node.getPythonTag("cached_processed_names") + + # 如果之前缓存的是“格式未知”,但现在已有路径,强制重建缓存 + try: + now_has_path = anim_node.hasTag("model_path") and bool(anim_node.getTag("model_path")) + if now_has_path and isinstance(cached_anim_info, str) and "格式: 未知" in cached_anim_info: + cached_anim_info = None + cached_processed_names = None + anim_node.setPythonTag("cached_anim_info", None) + anim_node.setPythonTag("cached_processed_names", None) + anim_node.setPythonTag("animation", None) + self._clear_animation_cache(anim_node) + except Exception: + pass + + # 如果节点已被检测为有动画,但缓存是“无动画”,强制重新检测一次 + if (has_animation_tag or has_animation_nodes) and (cached_anim_info == "无动画" or cached_processed_names == []): + cached_anim_info = None + cached_processed_names = None + anim_node.setPythonTag("cached_anim_info", None) + anim_node.setPythonTag("cached_processed_names", None) + anim_node.setPythonTag("animation", None) # 只有在没有缓存时才进行完整的动画检测和处理 if cached_anim_info is None or cached_processed_names is None: # 获取Actor - actor = self._getActor(node) + actor = self._getActor(anim_node) if not actor: - imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型不包含动画") + if has_animation_tag or has_animation_nodes: + imgui.text_colored((1.0, 0.7, 0.3, 1.0), "检测到动画结构,但当前未成功绑定Actor") + else: + imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型不包含动画") return # 获取和分析动画名称 anim_names = actor.getAnimNames() - processed_names = self._processAnimationNames(node, anim_names) + processed_names = self._processAnimationNames(anim_node, anim_names) if not processed_names: imgui.text_colored((0.7, 0.7, 0.7, 1.0), "未检测到动画序列") - # 缓存空结果 - node.setPythonTag("cached_processed_names", []) - node.setPythonTag("cached_anim_info", "无动画") + # 只在明确无动画时缓存空结果,避免误缓存导致后续无法重试 + if not (has_animation_tag or has_animation_nodes): + anim_node.setPythonTag("cached_processed_names", []) + anim_node.setPythonTag("cached_anim_info", "无动画") return + + anim_node.setTag("has_animations", "true") + anim_node.setPythonTag("animation", True) # 计算并缓存动画信息 - format_info = self._getModelFormat(node) + format_info = self._getModelFormat(anim_node) animation_info = self._analyzeAnimationQuality(actor, anim_names, format_info) info_text = f"格式: {format_info} | 动画数量: {len(processed_names)}" if animation_info: info_text += f" | {animation_info}" # 缓存结果 - node.setPythonTag("cached_anim_info", info_text) - node.setPythonTag("cached_processed_names", processed_names) + anim_node.setPythonTag("cached_anim_info", info_text) + anim_node.setPythonTag("cached_processed_names", processed_names) else: # 使用缓存的数据 @@ -1548,10 +1637,11 @@ class EditorPanels: imgui.same_line() # 获取当前选中的动画 - current_anim = node.getPythonTag("selected_animation") - if current_anim is None: + current_anim = anim_node.getPythonTag("selected_animation") + valid_original_names = [original_name for _, original_name in processed_names] + if current_anim is None or current_anim not in valid_original_names: current_anim = processed_names[0][1] if processed_names else "" - node.setPythonTag("selected_animation", current_anim) + anim_node.setPythonTag("selected_animation", current_anim) # 查找当前动画的索引 current_index = 0 @@ -1566,7 +1656,7 @@ class EditorPanels: if changed and new_index < len(processed_names): selected_display, selected_original = processed_names[new_index] - node.setPythonTag("selected_animation", selected_original) + anim_node.setPythonTag("selected_animation", selected_original) print(f"选择动画: {selected_display} (原始名称: {selected_original})") imgui.spacing() @@ -1576,22 +1666,22 @@ class EditorPanels: # 播放按钮 if imgui.button("播放##play_animation"): - self._playAnimation(node) + self._playAnimation(anim_node) imgui.same_line() # 暂停按钮 if imgui.button("暂停##pause_animation"): - self._pauseAnimation(node) + self._pauseAnimation(anim_node) imgui.same_line() # 停止按钮 if imgui.button("停止##stop_animation"): - self._stopAnimation(node) + self._stopAnimation(anim_node) imgui.same_line() # 循环按钮 if imgui.button("循环##loop_animation"): - self._loopAnimation(node) + self._loopAnimation(anim_node) imgui.spacing() @@ -1600,16 +1690,16 @@ class EditorPanels: imgui.same_line() # 获取当前速度 - current_speed = node.getPythonTag("anim_speed") + current_speed = anim_node.getPythonTag("anim_speed") if current_speed is None: current_speed = 1.0 - node.setPythonTag("anim_speed", current_speed) + anim_node.setPythonTag("anim_speed", current_speed) # 速度滑块 changed, new_speed = imgui.slider_float("##anim_speed", current_speed, 0.1, 5.0, "%.1f") if changed: - node.setPythonTag("anim_speed", new_speed) - self._setAnimationSpeed(node, new_speed) + anim_node.setPythonTag("anim_speed", new_speed) + self._setAnimationSpeed(anim_node, new_speed) imgui.same_line() imgui.text_colored((0.7, 0.7, 0.7, 1.0), "倍速")