EG/ui/panels/animation_tools.py
2026-04-02 11:15:05 +08:00

2482 lines
105 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
from pathlib import Path
from direct.actor.Actor import Actor
from direct.task.TaskManagerGlobal import taskMgr
from panda3d.core import NodePath, PartSubset, Filename, BitMask32
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:
"""Animation and actor helper methods extracted from main world."""
def __init__(self, app):
self.app = app
def __getattr__(self, name):
return getattr(self.app, name)
def __setattr__(self, name, value):
if name == "app" or name in self.__dict__ or hasattr(type(self), name):
object.__setattr__(self, name, value)
else:
setattr(self.app, name, value)
def _ensure_animation_runtime_state(self):
if not hasattr(self, "_animation_sync_tasks") or self._animation_sync_tasks is None:
self._animation_sync_tasks = {}
if not hasattr(self, "_animation_hide_tasks") or self._animation_hide_tasks is None:
self._animation_hide_tasks = {}
if not hasattr(self, "_animation_debug_enabled"):
self._animation_debug_enabled = False
def _mark_runtime_animation_node(self, runtime_node, owner_model):
"""Bind runtime animation nodes back to their logical owner and disable scene picking."""
try:
if isinstance(runtime_node, _BoundAnimationProxy):
runtime_node = getattr(runtime_node, "_node", None)
if not runtime_node or runtime_node.isEmpty() or not owner_model or owner_model.isEmpty():
return
runtime_node.setPythonTag("animation_source_owner", owner_model)
runtime_node.setTag("runtime_animation_actor", "true")
try:
runtime_node.setCollideMask(BitMask32.allOff())
except Exception:
pass
try:
for child in runtime_node.findAllMatches("**"):
if not child or child.isEmpty():
continue
child.setPythonTag("animation_source_owner", owner_model)
child.setTag("runtime_animation_actor", "true")
try:
child.setCollideMask(BitMask32.allOff())
except Exception:
pass
except Exception:
pass
except Exception:
pass
def _set_owner_animation_lock(self, owner_model, locked):
"""Mark whether the logical owner should stay hidden while animation is active."""
try:
if not owner_model or owner_model.isEmpty():
return
owner_model.setPythonTag("animation_owner_locked_hidden", bool(locked))
except Exception:
pass
def _set_owner_stashed_for_animation(self, owner_model, stashed):
try:
if not owner_model or owner_model.isEmpty():
return
owner_model.setPythonTag("animation_owner_stashed", bool(stashed))
except Exception:
pass
def _is_owner_stashed_for_animation(self, owner_model):
try:
if not owner_model or owner_model.isEmpty():
return False
return bool(owner_model.getPythonTag("animation_owner_stashed"))
except Exception:
return False
def _is_owner_animation_locked(self, owner_model):
try:
if not owner_model or owner_model.isEmpty():
return False
return bool(owner_model.getPythonTag("animation_owner_locked_hidden"))
except Exception:
return False
def _show_owner_model_if_allowed(self, owner_model, force=False):
try:
if not owner_model or owner_model.isEmpty():
return
if (not force) and self._is_owner_animation_locked(owner_model):
self._set_owner_stashed_for_animation(owner_model, True)
owner_model.stash()
return
if self._is_owner_stashed_for_animation(owner_model):
owner_model.unstash()
self._set_owner_stashed_for_animation(owner_model, False)
owner_model.show()
except Exception:
pass
def _refresh_active_animation_visibility(self):
"""
Re-assert visibility constraints for active animation owners.
Used after selection/UI code that may call show() on scene nodes.
"""
try:
for owner_model, actor in list(getattr(self, "_actor_cache", {}).items()):
try:
if not owner_model or owner_model.isEmpty() or not actor or actor.isEmpty():
continue
except Exception:
continue
try:
self._apply_actor_owner_visibility(owner_model, actor)
except Exception:
continue
except Exception:
pass
def _can_swap_actor_owner_visibility(self, owner_model, actor):
try:
if not owner_model or owner_model.isEmpty() or not actor or actor.isEmpty():
return False
if self._is_scene_root_node(owner_model):
return False
if actor == owner_model:
return False
except Exception:
return False
try:
if isinstance(actor, _BoundAnimationProxy):
return getattr(actor, "_node", None) != owner_model
except Exception:
pass
return True
def _apply_actor_owner_visibility(self, owner_model, actor, prefer_actor_visible=None):
"""
Hard invariant:
- actor visible => owner hidden
- owner visible => actor hidden
"""
try:
if not self._can_swap_actor_owner_visibility(owner_model, actor):
return
if prefer_actor_visible is None:
try:
prefer_actor_visible = not actor.isHidden()
except Exception:
prefer_actor_visible = self._is_owner_animation_locked(owner_model)
if prefer_actor_visible:
self._set_owner_animation_lock(owner_model, True)
self._set_owner_stashed_for_animation(owner_model, True)
owner_model.stash()
actor.show()
else:
self._set_owner_animation_lock(owner_model, False)
if self._is_owner_stashed_for_animation(owner_model):
owner_model.unstash()
self._set_owner_stashed_for_animation(owner_model, False)
actor.hide()
owner_model.show()
except Exception:
pass
def _ensure_owner_hidden_lock_task(self, owner_model, enabled):
self._ensure_animation_runtime_state()
task_name = self._animation_hide_tasks.get(owner_model)
if not enabled:
if task_name:
try:
taskMgr.remove(task_name)
except Exception:
pass
self._animation_hide_tasks.pop(owner_model, None)
return
task_name = f"maintain_anim_owner_hidden_{id(owner_model)}"
self._animation_hide_tasks[owner_model] = task_name
def maintain_hidden(task):
try:
if not owner_model or owner_model.isEmpty():
self._animation_hide_tasks.pop(owner_model, None)
return task.done
if not self._is_owner_animation_locked(owner_model):
self._animation_hide_tasks.pop(owner_model, None)
return task.done
actor = self._actor_cache.get(owner_model)
if actor:
self._apply_actor_owner_visibility(owner_model, actor, prefer_actor_visible=True)
else:
self._set_owner_stashed_for_animation(owner_model, True)
owner_model.stash()
return task.cont
except Exception:
self._animation_hide_tasks.pop(owner_model, None)
return task.done
try:
taskMgr.remove(task_name)
except Exception:
pass
taskMgr.add(maintain_hidden, task_name)
def _anim_log(self, message):
self._ensure_animation_runtime_state()
if self._animation_debug_enabled:
print(message)
def _acquire_playback_actor(self, origin_model, owner_model, force_rebuild=False):
"""
获取用于播放的 Actor。
默认优先复用缓存,只有缓存失效或明确要求时才重建,避免点击播放时阻塞。
"""
self._ensure_animation_runtime_state()
if force_rebuild:
self._cleanup_conflicting_animation_actors(owner_model)
self._discard_cached_actor(owner_model, restore_owner=True)
actor = self._getActor(owner_model)
if not actor:
return None
# 若仍在“无路径的内存代理”,仅在确实恢复出路径后重建一次真实 Actor。
if isinstance(actor, _BoundAnimationProxy):
try:
owner_has_path = owner_model.hasTag("model_path") and bool(owner_model.getTag("model_path"))
except Exception:
owner_has_path = False
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)
return actor
def _sync_actor_transform_for_playback(self, owner_model, actor):
is_scene_bound_proxy = isinstance(actor, _BoundAnimationProxy) and (not getattr(actor, "_owns_node", False))
if is_scene_bound_proxy:
return True
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.setPos(owner_model.getPos())
actor.setHpr(owner_model.getHpr())
actor.setScale(owner_model.getScale())
return False
def _ensure_actor_sync_task(self, owner_model, actor, enabled):
self._ensure_animation_runtime_state()
old_task_name = self._animation_sync_tasks.get(owner_model)
if old_task_name and old_task_name != f"maintain_anim_pos_{id(actor)}":
try:
taskMgr.remove(old_task_name)
except Exception:
pass
if not enabled:
self._animation_sync_tasks.pop(owner_model, None)
return
task_name = f"maintain_anim_pos_{id(actor)}"
self._animation_sync_tasks[owner_model] = task_name
def maintainWorldPosition(task):
try:
if not actor.isEmpty() and not owner_model.isEmpty():
owner_transform = owner_model.getTransform(self.render)
last_transform = owner_model.getPythonTag("_last_anim_sync_transform")
if last_transform == owner_transform:
return task.cont
owner_model.setPythonTag("_last_anim_sync_transform", owner_transform)
actor.setTransform(self.render, owner_transform)
return task.cont
self._animation_sync_tasks.pop(owner_model, None)
return task.done
except Exception:
self._animation_sync_tasks.pop(owner_model, None)
return task.done
taskMgr.remove(task_name)
taskMgr.add(maintainWorldPosition, task_name)
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
fallback_count = 0
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
fallback_count += 1
if score > fallback_score:
fallback_score = score
fallback_best = model
except Exception:
continue
if fallback_best and fallback_count == 1:
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 _find_animation_driver_node(self, node):
"""Find the concrete node that should drive animation playback."""
try:
if not node or node.isEmpty():
return None
cached_driver = node.getPythonTag("animation_driver_node")
if cached_driver and (not cached_driver.isEmpty()):
return cached_driver
except Exception:
pass
try:
candidates = []
def add_candidate(candidate):
try:
if not candidate or candidate.isEmpty():
return
for existing in candidates:
if existing == candidate:
return
candidates.append(candidate)
except Exception:
return
add_candidate(node)
current = node
for _ in range(32):
if not current or current.isEmpty() or self._is_scene_root_node(current):
break
add_candidate(current)
try:
for child in current.getChildren():
add_candidate(child)
except Exception:
pass
parent = current.getParent()
if not parent or parent.isEmpty() or parent == current:
break
current = parent
best = None
best_score = -1
for candidate in candidates:
try:
has_anim = self._node_has_animation_nodes(candidate)
if not has_anim:
continue
has_geom = self._node_has_geom(candidate)
character_count = candidate.findAllMatches("**/+Character").getNumPaths()
bundle_count = candidate.findAllMatches("**/+AnimBundleNode").getNumPaths()
is_character = character_count > 0
is_bundle = bundle_count > 0
is_same_node = candidate == node
is_descendant = False
is_ancestor = False
try:
if candidate != node:
is_descendant = node.isAncestorOf(candidate)
is_ancestor = candidate.isAncestorOf(node)
except Exception:
is_descendant = False
is_ancestor = False
score = 0
score += 140 if has_anim and has_geom else 0
score += 90 if has_geom else 0
score += 80 if is_character else 0
score += 40 if is_bundle else 0
score += 35 if is_descendant else 0
score += 15 if is_same_node else 0
score -= 20 if is_ancestor else 0
score += character_count * 5
score += bundle_count * 3
if score > best_score:
best_score = score
best = candidate
except Exception:
continue
if best:
try:
node.setPythonTag("animation_driver_node", best)
except Exception:
pass
try:
best.setPythonTag("animation_driver_node", best)
except Exception:
pass
return best
except Exception:
pass
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_ssbo_source_owner(self, node):
"""将 SSBO 运行时选择节点映射回 source_model_root 中的真实模型根。"""
try:
ssbo_editor = getattr(self, "ssbo_editor", None)
if not ssbo_editor:
return None
source_root = getattr(ssbo_editor, "source_model_root", None)
if not source_root:
return None
try:
if source_root.isEmpty():
return None
except Exception:
return None
selection_key = None
try:
if node and (not node.isEmpty()) and node.hasTag("ssbo_selection_key"):
selection_key = node.getTag("ssbo_selection_key")
except Exception:
selection_key = None
if not selection_key:
try:
selection_key = ssbo_editor._find_tree_key_for_scene_node(node)
except Exception:
selection_key = None
if not selection_key:
selection_key = getattr(ssbo_editor, "selected_name", None)
if not selection_key:
return None
try:
source_owner = ssbo_editor._resolve_source_node_by_tree_key(selection_key)
except Exception:
source_owner = None
if not source_owner:
return None
try:
if source_owner.isEmpty():
return None
except Exception:
return None
try:
if node and (not node.isEmpty()):
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if (
(not source_owner.hasTag(tag_name) or not source_owner.getTag(tag_name))
and node.hasTag(tag_name)
and node.getTag(tag_name)
):
source_owner.setTag(tag_name, node.getTag(tag_name))
except Exception:
pass
return source_owner
except Exception:
return None
def _resolve_animation_owner_model(self, node):
"""向上查找动画所属的模型根节点,避免选中子节点时绑定失败。"""
try:
if node and (not node.isEmpty()):
# If the user explicitly selected the animated skeleton node,
# respect that directly only when it also carries visible geom.
# Skeleton-only nodes like Armature still need a deeper renderable
# driver node, otherwise playback can report success but show no motion.
if self._node_has_animation_nodes(node) and self._node_has_geom(node):
return node
driver = self._find_animation_driver_node(node)
if driver and (not driver.isEmpty()):
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
try:
if (
(not driver.hasTag(tag_name) or not driver.getTag(tag_name))
and node.hasTag(tag_name)
and node.getTag(tag_name)
):
driver.setTag(tag_name, node.getTag(tag_name))
except Exception:
continue
return driver
except Exception:
pass
ssbo_owner = self._resolve_ssbo_source_owner(node)
if ssbo_owner and not ssbo_owner.isEmpty():
try:
if node and not node.isEmpty():
for tag_name in ("model_path", "original_path", "saved_model_path", "file"):
if (
(not node.hasTag(tag_name) or not node.getTag(tag_name))
and ssbo_owner.hasTag(tag_name)
and ssbo_owner.getTag(tag_name)
):
node.setTag(tag_name, ssbo_owner.getTag(tag_name))
try:
node.setPythonTag("animation_source_owner", ssbo_owner)
except Exception:
pass
return node
except Exception:
pass
return ssbo_owner
scene_owner = self._find_scene_model_owner(node)
chain_best = None
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"))
has_saved_path = c.hasTag("saved_model_path") and bool(c.getTag("saved_model_path"))
has_original_path = c.hasTag("original_path") and bool(c.getTag("original_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 += 140 if has_anim and has_geom else 0
score += 90 if has_model_path and has_geom else 0
score += 55 if has_saved_path and has_geom else 0
score += 45 if has_original_path and has_geom else 0
score += 40 if has_anim else 0
score += 20 if has_geom else 0
score += 25 if has_model_path else 0
score += 15 if has_saved_path else 0
score += 12 if has_original_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
chain_best = best
except Exception:
chain_best = None
if scene_owner and not scene_owner.isEmpty():
try:
# SSBO 聚合场景下scene_manager.models 常常只有总根节点。
# 如果祖先链里存在更具体、带路径/动画/几何体的节点,应优先使用它,
# 否则会退回到场景级内存代理,导致播放时原模型不隐藏、位置不同步。
if chain_best and chain_best != scene_owner:
scene_score = 0
chain_score = 0
for candidate, target in ((scene_owner, "scene"), (chain_best, "chain")):
try:
has_anim = (
candidate.findAllMatches("**/+Character").getNumPaths() > 0 or
candidate.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
has_geom = candidate.findAllMatches("**/+GeomNode").getNumPaths() > 0
has_path = (
(candidate.hasTag("model_path") and bool(candidate.getTag("model_path"))) or
(candidate.hasTag("saved_model_path") and bool(candidate.getTag("saved_model_path"))) or
(candidate.hasTag("original_path") and bool(candidate.getTag("original_path")))
)
score = 0
score += 140 if has_anim and has_geom else 0
score += 60 if has_path and has_geom else 0
score += 25 if has_anim else 0
score += 15 if has_geom else 0
score += 20 if has_path else 0
except Exception:
score = 0
if target == "scene":
scene_score = score
else:
chain_score = score
if chain_score > scene_score:
scene_owner = chain_best
# 同步路径标签,避免后续格式识别为“未知”
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版本经过验证的实现方式
"""
owner_model = self._resolve_animation_owner_model(origin_model)
owner_model = self._prefer_owner_with_visible_geometry(origin_model, owner_model)
# owner 节点缺失路径标签时,尝试从当前节点及其祖先继承路径,避免只能走内存分支
try:
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:
resolved_source = source
if isinstance(source, (str, os.PathLike)):
src_text = os.fspath(source)
resolved_source = src_text
for ctor_name in ("fromOsSpecificW", "from_os_specific_w", "fromOsSpecific", "from_os_specific"):
ctor = getattr(Filename, ctor_name, None)
if not ctor:
continue
try:
candidate = ctor(src_text).get_fullpath()
if candidate:
resolved_source = candidate
break
except Exception:
continue
actor = Actor(resolved_source)
# 无论是否已检测到动画名,都显式绑定一次,避免“有名字但无可播放控制”
try:
actor.bindAllAnims(allowAsyncBind=False)
except Exception:
pass
anims = actor.getAnimNames()
self._anim_log(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:
self._anim_log(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:
self._anim_log(f"[Actor加载] {source_desc} 无可见几何体,尝试其他加载路径")
_cleanup_actor(actor)
return None
actor.reparentTo(self._get_owner_parent_node(owner_model))
actor.hide()
self._mark_runtime_animation_node(actor, owner_model)
return actor
except Exception as e:
self._anim_log(f"[Actor加载] {source_desc} 失败: {e}")
return None
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:
self._anim_log(f"[Actor加载] {source_desc} autoBind 检测到动画但无可见几何体,尝试其他节点")
return None
self._anim_log(f"[Actor加载] {source_desc} autoBind 检测到动画: {list(controls.keys())}")
return _BoundAnimationProxy(model_np, controls, owns_node=owns_node)
except Exception as e:
self._anim_log(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()
self._mark_runtime_animation_node(proxy, owner_model)
succeeded = True
return proxy
except Exception as e:
self._anim_log(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:
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
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")
lower_filepath = str(filepath).lower() if filepath else ""
is_gltf_family = lower_filepath.endswith(".glb") or lower_filepath.endswith(".gltf")
def _try_memory_fallback(prefer_scene_proxy=False):
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._mark_runtime_animation_node(mem_actor, owner_model)
self._actor_cache[owner_model] = mem_actor
owner_model.setTag("has_animations", "true")
return mem_actor
except Exception as e:
self._anim_log(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._mark_runtime_animation_node(mem_proxy, owner_model)
self._actor_cache[owner_model] = mem_proxy
owner_model.setTag("has_animations", "true")
if source_node != owner_model:
self._anim_log(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}")
return mem_proxy
return None
if is_gltf_family and has_animation_nodes:
# For imported GLTF/GLB, prefer binding controls directly onto the
# visible scene model. A separate runtime Armature proxy can report
# playing=True while not driving the visible mesh the user selected.
direct_proxy = _try_create_autobind_proxy(
owner_model,
f"当前模型({owner_model.getName()})",
owns_node=False,
)
if direct_proxy:
self._mark_runtime_animation_node(direct_proxy, owner_model)
self._actor_cache[owner_model] = direct_proxy
owner_model.setTag("has_animations", "true")
return direct_proxy
if filepath:
actor = _try_create_actor_via_gltf_path(filepath)
if actor:
owner_model.setTag("model_path", filepath)
owner_model.setTag("has_animations", "true")
self._mark_runtime_animation_node(actor, owner_model)
self._actor_cache[owner_model] = actor
return actor
actor = _try_memory_fallback()
if actor:
return actor
# 始终优先尝试从文件路径加载,因为底层 gltf 插件只有在加载文件时才能抽取完整的动画名称。
self._anim_log(f"[Actor加载调试] 传入的 origin_model: {origin_model.getName() if origin_model else 'None'}")
self._anim_log(f"[Actor加载调试] origin_model tags: {origin_model.getTags() if origin_model else 'None'}")
self._anim_log(f"[Actor加载调试] 解析出的 owner_model: {owner_model.getName() if owner_model else 'None'}")
try:
self._anim_log(f"[Actor加载调试] owner_model tags: {owner_model.getTags() if owner_model else 'None'}")
self._anim_log(f"[Actor加载调试] owner_model path tag: {owner_model.getTag('model_path') if owner_model.hasTag('model_path') else 'MISSING'}")
except Exception as e:
self._anim_log(f"[Actor加载调试] 获取 tags 异常: {e}")
filepath = owner_model.getTag("resolved_actor_path") if owner_model.hasTag("resolved_actor_path") else ""
if not filepath and owner_model.hasTag("model_path"):
filepath = owner_model.getTag("model_path")
if not filepath and owner_model.hasTag("original_path"):
filepath = owner_model.getTag("original_path")
self._anim_log(f"[Actor加载调试] 获取到的 filepath: '{filepath}'")
model_ext = str(filepath).lower() if filepath else ""
try:
character_count = owner_model.findAllMatches("**/+Character").getNumPaths()
except Exception:
character_count = 0
prefer_scene_proxy = (
(model_ext.endswith(".glb") or model_ext.endswith(".gltf")) and
character_count > 1
)
if not filepath:
self._anim_log(f"[Actor加载调试] filepath为空触发 _try_memory_fallback()")
return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy)
# 针对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))
# 仅在当前路径和基础变体都不可直接访问时,才走较重的路径修复/搜索。
should_normalize_search = not any(os.path.exists(p) for p in candidate_paths if isinstance(p, str) and p)
if should_normalize_search:
try:
from scene import util
normalized_candidate = util.normalize_model_path(filepath)
if normalized_candidate:
candidate_paths.append(normalized_candidate)
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]
if prefer_scene_proxy:
memory_actor = _try_memory_fallback(prefer_scene_proxy=True)
if memory_actor:
return memory_actor
for p in load_paths:
self._anim_log(f"[Actor加载验证] 正在尝试通过路径读取骨骼和动画文件: {p}")
lower_p = str(p).lower()
if lower_p.endswith(".glb") or lower_p.endswith(".gltf"):
actor = _try_create_actor_via_gltf_path(p)
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._mark_runtime_animation_node(actor, owner_model)
self._actor_cache[owner_model] = actor
return actor
else:
actor = _try_create_actor_from_source(p, f"文件路径({p})")
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
actor = _try_create_actor_via_gltf_path(p)
if actor:
owner_model.setTag("model_path", p)
owner_model.setTag("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._mark_runtime_animation_node(actor, owner_model)
self._actor_cache[owner_model] = actor
return actor
if is_gltf_family:
continue
try:
model_source = p
if isinstance(p, (str, os.PathLike)):
model_source = Filename.from_os_specific(os.fspath(p))
loaded_model = self.loader.loadModel(model_source)
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("resolved_actor_path", p)
owner_model.setTag("has_animations", "true")
self._mark_runtime_animation_node(proxy, owner_model)
self._actor_cache[owner_model] = proxy
return proxy
loaded_model.removeNode()
except Exception:
pass
# 所有创建路径失败时由内存加载进行兜底
return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy)
def _getModelFormat(self, origin_model):
"""获取模型格式信息"""
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")
if filepath:
ext = filepath.lower().split('.')[-1]
format_name = ext.upper()
# 如果是转换后的文件,显示转换信息
if converted_from and original_path:
original_ext = converted_from.upper()
format_name = f"{format_name} (从{original_ext}转换)"
return format_name
return "未知"
def _processAnimationNames(self, origin_model, anim_names, actor=None):
"""处理和分析动画名称,返回 [(显示名称, 原始名称), ...]。"""
format_info = self._getModelFormat(origin_model)
processed = []
seen_original_names = set()
self._anim_log(f"[动画分析] 格式: {format_info}, 原始动画名称: {anim_names}")
for name in anim_names:
if not name or name in seen_original_names:
continue
control = None
frame_count = -1
if actor:
try:
control = actor.getAnimControl(name)
except Exception:
control = None
if control:
try:
frame_count = control.getNumFrames()
except Exception:
frame_count = -1
# 过滤掉无法实际播放的“伪动画名”,避免把骨骼/Bundle 名字暴露到下拉框里。
if actor and (control is None or frame_count <= 1):
self._anim_log(f"[动画分析] 跳过无效动画名: {name} (control={bool(control)}, frames={frame_count})")
continue
display_name = name
original_name = name
format_upper = format_info.upper()
if format_upper.startswith("GLB") or format_upper.startswith("GLTF"):
# GLB 格式通常有真实的动画名称
if "|" in name:
# 处理类似 'Armature|mixamo.com|Layer0' 的名称
parts = name.split("|")
if "mixamo" in name.lower():
# Mixamo 动画
display_name = f"Mixamo_{parts[-1]}" if len(parts) > 1 else name
elif len(parts) > 2:
# 其他复杂命名
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_upper.startswith("FBX"):
# FBX 格式可能需要特殊处理
if self._isLikelyBoneGroup(name):
# 检查是否是骨骼组而非动画
self._anim_log(f"[警告] '{name}' 可能不是真正的动画序列,而是骨骼组")
display_name = f"⚠️ {name} (可能非动画)"
else:
display_name = name
elif format_upper.startswith("EGG") or format_upper.startswith("BAM"):
# 原生格式通常命名规范
display_name = name
processed.append((display_name, original_name))
seen_original_names.add(original_name)
self._anim_log(f"[动画分析] {original_name}{display_name}")
# 某些导入链路拿不到 AnimControl 时,退回原始列表,避免面板完全为空。
if not processed and anim_names:
for name in anim_names:
if not name or name in seen_original_names:
continue
processed.append((name, name))
seen_original_names.add(name)
return processed
def _isLikelyBoneGroup(self, name):
"""判断动画名称是否更像骨骼组而不是动画序列"""
bone_indicators = ['joints', 'bones', 'skeleton', 'surface', 'mesh', 'beta', 'rig']
name_lower = name.lower()
# 如果包含这些关键词,可能是骨骼组
for indicator in bone_indicators:
if indicator in name_lower:
return True
# 如果名称太简单少于3个字符可能不是动画
if len(name) < 3:
return True
return False
def _analyzeAnimationQuality(self, actor, anim_names, format_info):
"""分析动画质量和类型(优化版本,减少详细分析以提高性能)"""
try:
valid_anims = 0
# 简化分析:只检查动画是否存在,不详细分析帧数
for anim_name in anim_names:
try:
control = actor.getAnimControl(anim_name)
if control and control.getNumFrames() > 1:
valid_anims += 1
except Exception:
# 忽略单个动画的分析错误,继续处理其他动画
continue
if valid_anims == 0:
return "⚠️ 无有效动画"
elif valid_anims < len(anim_names):
return f"⚠️ {valid_anims}/{len(anim_names)} 个有效"
else:
return f"{valid_anims} 个动画"
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
preferred_names = []
try:
preferred_names.append(origin_model.getPythonTag("selected_animation"))
except Exception:
pass
if owner_model and owner_model != origin_model:
try:
preferred_names.append(owner_model.getPythonTag("selected_animation"))
except Exception:
pass
valid_names = []
best_name = None
best_frames = -1
for anim_name in anim_names:
try:
control = actor.getAnimControl(anim_name)
except Exception:
control = None
if not control:
continue
frame_count = -1
try:
frame_count = control.getNumFrames()
if frame_count <= 1:
continue
except Exception:
pass
valid_names.append(anim_name)
if frame_count > best_frames:
best_frames = frame_count
best_name = anim_name
if not valid_names:
valid_names = list(anim_names)
current_anim = None
for preferred in preferred_names:
if preferred in valid_names:
current_anim = preferred
break
if current_anim not in valid_names:
current_anim = best_name or valid_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 _cleanup_conflicting_animation_actors(self, owner_model):
"""Remove stale cached actors that belong to the same logical model."""
try:
owner_path_candidates = set()
for tag_name in ("model_path", "original_path", "saved_model_path"):
try:
if owner_model.hasTag(tag_name):
value = owner_model.getTag(tag_name)
if value:
owner_path_candidates.add(os.path.normcase(os.path.normpath(value)))
except Exception:
continue
for cached_owner, actor in list(self._actor_cache.items()):
if cached_owner == owner_model:
continue
related = False
try:
related = (
cached_owner == owner_model or
cached_owner.isAncestorOf(owner_model) or
owner_model.isAncestorOf(cached_owner)
)
except Exception:
related = False
if (not related) and owner_path_candidates:
for tag_name in ("model_path", "original_path", "saved_model_path"):
try:
if not cached_owner.hasTag(tag_name):
continue
value = cached_owner.getTag(tag_name)
if not value:
continue
if os.path.normcase(os.path.normpath(value)) in owner_path_candidates:
related = True
break
except Exception:
continue
if not related:
continue
try:
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
except Exception:
pass
try:
if self._should_swap_visibility_for_actor(cached_owner, actor):
self._show_owner_model_if_allowed(cached_owner)
except Exception:
pass
try:
actor.hide()
except Exception:
pass
try:
if hasattr(actor, "cleanup"):
actor.cleanup()
except Exception:
pass
try:
if hasattr(actor, "removeNode"):
actor.removeNode()
except Exception:
pass
try:
del self._actor_cache[cached_owner]
except Exception:
pass
except Exception:
pass
def _stop_all_active_animations(self, except_owner=None):
"""Destroy all cached animation actors except the optional owner."""
try:
for cached_owner in list(self._actor_cache.keys()):
try:
if except_owner is not None and cached_owner == except_owner:
continue
except Exception:
pass
self._discard_cached_actor(cached_owner, restore_owner=True)
except Exception:
pass
def _discard_cached_actor(self, owner_model, restore_owner=True):
"""Destroy one owner's cached actor before rebuilding it."""
try:
actor = self._actor_cache.get(owner_model)
if not actor:
return
try:
actor.stop()
except Exception:
pass
try:
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
except Exception:
pass
try:
self._ensure_animation_runtime_state()
self._animation_sync_tasks.pop(owner_model, None)
except Exception:
pass
try:
self._ensure_owner_hidden_lock_task(owner_model, enabled=False)
except Exception:
pass
try:
if self._is_owner_stashed_for_animation(owner_model):
owner_model.unstash()
self._set_owner_stashed_for_animation(owner_model, False)
except Exception:
pass
try:
actor.hide()
except Exception:
pass
if restore_owner:
try:
if owner_model and not owner_model.isEmpty():
self._show_owner_model_if_allowed(owner_model)
except Exception:
pass
try:
if hasattr(actor, "cleanup"):
actor.cleanup()
except Exception:
pass
try:
if hasattr(actor, "removeNode"):
actor.removeNode()
except Exception:
pass
try:
del self._actor_cache[owner_model]
except Exception:
pass
except Exception:
pass
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):
"""播放动画"""
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)
self._stop_all_active_animations(except_owner=owner_model)
actor = self._acquire_playback_actor(origin_model, owner_model, force_rebuild=False)
if not actor:
return
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if not current_anim:
print("『动画播放』未找到可播放的有效动画,保留原模型显示")
self._show_owner_model_if_allowed(owner_model)
return
try:
control = actor.getAnimControl(current_anim)
except Exception:
control = None
if not control:
print(f"『动画播放』动画控制器无效: {current_anim},保留原模型显示")
self._show_owner_model_if_allowed(owner_model)
return
is_scene_bound_proxy = self._sync_actor_transform_for_playback(owner_model, actor)
debug_actor_name = getattr(actor, "getName", None)
try:
debug_actor_name = debug_actor_name() if callable(debug_actor_name) else getattr(getattr(actor, "_node", None), "getName", lambda: "Unknown")()
except Exception:
debug_actor_name = "Unknown"
debug_frames = -1
try:
debug_frames = control.getNumFrames()
except Exception:
pass
if self._can_swap_actor_owner_visibility(owner_model, actor):
self._ensure_owner_hidden_lock_task(owner_model, enabled=True)
self._apply_actor_owner_visibility(owner_model, actor, prefer_actor_visible=True)
else:
self._set_owner_animation_lock(owner_model, False)
self._ensure_owner_hidden_lock_task(owner_model, enabled=False)
self._show_owner_model_if_allowed(owner_model)
actor.show()
self._ensure_actor_sync_task(owner_model, actor, enabled=(not is_scene_bound_proxy))
try:
actor.stop()
except Exception:
pass
actor.play(current_anim)
try:
is_playing = control.isPlaying()
except Exception:
is_playing = "unknown"
print(f"[动画调试] owner={owner_model.getName()} actor={debug_actor_name} anim={current_anim} frames={debug_frames} playing={is_playing} scene_proxy={is_scene_bound_proxy}")
print(f"『动画播放』:{current_anim}")
def _pauseAnimation(self, origin_model):
"""暂停动画"""
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
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.stop()
# 显式暂停时解除隐藏锁并恢复本体显示
self._ensure_owner_hidden_lock_task(owner_model, enabled=False)
if self._can_swap_actor_owner_visibility(owner_model, actor):
self._apply_actor_owner_visibility(owner_model, actor, prefer_actor_visible=False)
self._show_owner_model_if_allowed(owner_model, force=True)
print("『动画』暂停")
def _stopAnimation(self, 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
# 停止动画
actor.stop()
# 获取当前选中的动画
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显示原始模型
self._ensure_owner_hidden_lock_task(owner_model, enabled=False)
if self._can_swap_actor_owner_visibility(owner_model, actor):
self._apply_actor_owner_visibility(owner_model, actor, prefer_actor_visible=False)
self._show_owner_model_if_allowed(owner_model, force=True)
# 移除位置维护任务
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
print("『动画』停止切换至原始模型")
def _loopAnimation(self, 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)
self._stop_all_active_animations(except_owner=owner_model)
actor = self._acquire_playback_actor(origin_model, owner_model, force_rebuild=False)
if not actor:
return
# 获取当前选中的动画
current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model)
if not current_anim:
print("[动画] 未找到可循环播放的有效动画,保留原模型显示")
self._show_owner_model_if_allowed(owner_model)
return
try:
control = actor.getAnimControl(current_anim)
except Exception:
control = None
if not control:
print(f"[动画] 动画控制器无效: {current_anim},保留原模型显示")
self._show_owner_model_if_allowed(owner_model)
return
is_scene_bound_proxy = self._sync_actor_transform_for_playback(owner_model, actor)
if self._can_swap_actor_owner_visibility(owner_model, actor):
self._ensure_owner_hidden_lock_task(owner_model, enabled=True)
self._apply_actor_owner_visibility(owner_model, actor, prefer_actor_visible=True)
else:
self._set_owner_animation_lock(owner_model, False)
self._ensure_owner_hidden_lock_task(owner_model, enabled=False)
self._show_owner_model_if_allowed(owner_model)
actor.show()
self._ensure_actor_sync_task(owner_model, actor, enabled=(not is_scene_bound_proxy))
try:
actor.stop()
except Exception:
pass
actor.loop(current_anim)
print(f"[动画] 循环: {current_anim}")
def _setAnimationSpeed(self, origin_model, speed):
"""设置动画播放速度"""
owner_model = self._resolve_animation_owner_model(origin_model)
actor = self._getActor(owner_model)
if not actor:
return
# 获取当前选中的动画
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})")
else:
# 兜底:尝试所有动画
anim_names = actor.getAnimNames()
for anim_name in anim_names:
actor.setPlayRate(speed, anim_name)
print(f"[动画] 速度设为: {speed} (所有动画)")
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("cached_has_animation_nodes", None)
node.setPythonTag("animation", None) # 同时清除动画检测结果
if owner_node and owner_node != node:
owner_node.setPythonTag("cached_anim_info", None)
owner_node.setPythonTag("cached_processed_names", None)
owner_node.setPythonTag("cached_has_animation_nodes", None)
owner_node.setPythonTag("animation", None)
# 如果Actor在缓存中也需要清理
if owner_node in self._actor_cache:
actor = self._actor_cache[owner_node]
try:
# 清理相关任务
taskMgr.remove(f"maintain_anim_pos_{id(actor)}")
# 清理Actor
if not actor.isEmpty():
actor.cleanup()
actor.removeNode()
except Exception as e:
print(f"清理Actor缓存失败: {e}")
finally:
del self._actor_cache[owner_node]
print(f"[缓存清理] 清除节点 {owner_node.getName()} 的动画缓存")