EG/ui/panels/animation_tools.py

1563 lines
64 KiB
Python
Raw Permalink 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
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 _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 _sync_owner_model_path_tags(self, origin_model, owner_model):
# Backfill model path tags from origin node or ancestor chain.
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
# Try aggressive recovery when path tags are still missing.
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_instance(self, 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(self, source, source_desc, owner_model):
try:
resolved_source = source
if isinstance(source, (str, os.PathLike)):
src_text = os.fspath(source)
resolved_source = Filename.from_os_specific(src_text).get_fullpath()
actor = Actor(resolved_source)
try:
actor.bindAllAnims(allowAsyncBind=False)
except Exception:
pass
anims = actor.getAnimNames()
print(f"[ActorLoad] {source_desc} anims: {anims}")
if not anims:
self._cleanup_actor_instance(actor)
return None
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"[ActorLoad] {source_desc} has no playable controls")
self._cleanup_actor_instance(actor)
return None
has_geom = False
try:
has_geom = actor.findAllMatches("**/+GeomNode").getNumPaths() > 0
except Exception:
has_geom = False
if not has_geom:
print(f"[ActorLoad] {source_desc} has no visible geometry")
self._cleanup_actor_instance(actor)
return None
actor.reparentTo(self._get_owner_parent_node(owner_model))
actor.hide()
return actor
except Exception as e:
print(f"[ActorLoad] {source_desc} failed: {e}")
return None
def _try_create_autobind_proxy(self, 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
collect_controls(model_np)
character_nodes = model_np.findAllMatches("**/+Character")
for i in range(character_nodes.getNumPaths()):
collect_controls(character_nodes.getPath(i))
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"[ActorLoad] {source_desc} autobind has no visible geometry")
return None
print(f"[ActorLoad] {source_desc} autobind anims: {list(controls.keys())}")
return _BoundAnimationProxy(model_np, controls, owns_node=owns_node)
except Exception as e:
print(f"[ActorLoad] {source_desc} autobind failed: {e}")
return None
def _try_create_actor_via_gltf_path(self, path, owner_model):
"""GLTF-specific path loader fallback for animation extraction."""
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
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 = self._try_create_actor_from_source(model_np, f"GLTF-special({path})", owner_model)
if actor:
succeeded = True
return actor
proxy = self._try_create_autobind_proxy(model_np, f"GLTF-special({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"[ActorLoad] GLTF-special failed ({path}): {e}")
finally:
try:
if (not succeeded) and model_np and not model_np.isEmpty():
model_np.removeNode()
except Exception:
pass
return None
def _finalize_actor_cache(self, owner_model, actor, model_path=""):
if model_path:
owner_model.setTag("model_path", model_path)
owner_model.setTag("has_animations", "true")
self._actor_cache[owner_model] = actor
return actor
def _get_valid_cached_actor(self, owner_model):
cached_actor = self._actor_cache.get(owner_model)
if cached_actor:
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):
self._cleanup_actor_instance(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
self._cleanup_actor_instance(cached_actor)
self._actor_cache.pop(owner_model, None)
return None
def _detect_animation_nodes(self, owner_model):
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")
return has_animation_nodes
except Exception:
return False
def _collect_autobind_source_candidates(self, origin_model, owner_model):
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 = 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
def _try_memory_fallback_actor(self, origin_model, owner_model, has_animation_nodes):
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:
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 = self._try_create_actor_from_source(clone_np, "memory-clone", owner_model)
if mem_actor:
return self._finalize_actor_cache(owner_model, mem_actor)
except Exception as e:
print(f"[ActorLoad] memory-clone failed: {e}")
finally:
try:
if clone_np and not clone_np.isEmpty() and owner_model not in self._actor_cache:
clone_np.removeNode()
except Exception:
pass
for source_node in self._collect_autobind_source_candidates(origin_model, owner_model):
mem_proxy = self._try_create_autobind_proxy(
source_node,
f"memory-node({source_node.getName()})",
owns_node=False
)
if mem_proxy:
if source_node != owner_model:
print(f"[ActorLoad] use visible source: {owner_model.getName()} -> {source_node.getName()}")
return self._finalize_actor_cache(owner_model, mem_proxy)
return None
def _collect_actor_candidate_paths(self, filepath):
panda_specific_path = ""
try:
panda_specific_path = Filename.from_os_specific(filepath).get_fullpath()
except Exception:
panda_specific_path = filepath.replace('\\', '/')
candidate_paths = [panda_specific_path, filepath, os.path.normpath(filepath)]
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))
try:
from scene import util
candidate_paths.append(util.normalize_model_path(filepath))
except Exception:
pass
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))
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)
existing_paths = [p for p in unique_paths if os.path.exists(p) or p == panda_specific_path]
return existing_paths + [p for p in unique_paths if p not in existing_paths]
def _try_actor_from_path(self, owner_model, path):
actor = self._try_create_actor_from_source(path, f"file-path({path})", owner_model)
if actor:
return self._finalize_actor_cache(owner_model, actor, model_path=path)
actor = self._try_create_actor_via_gltf_path(path, owner_model)
if actor:
return self._finalize_actor_cache(owner_model, actor, model_path=path)
try:
model_source = path
if isinstance(path, (str, os.PathLike)):
model_source = Filename.from_os_specific(os.fspath(path))
loaded_model = self.loader.loadModel(model_source)
if loaded_model and not loaded_model.isEmpty():
proxy = self._try_create_autobind_proxy(loaded_model, f"file-path({path})", owns_node=True)
if proxy:
loaded_model.reparentTo(self.render)
loaded_model.hide()
return self._finalize_actor_cache(owner_model, proxy, model_path=path)
loaded_model.removeNode()
except Exception:
pass
return None
def _getActor(self, origin_model):
"""
Get or create Actor for animation control.
"""
owner_model = self._resolve_animation_owner_model(origin_model)
owner_model = self._prefer_owner_with_visible_geometry(origin_model, owner_model)
self._sync_owner_model_path_tags(origin_model, owner_model)
cached_actor = self._get_valid_cached_actor(owner_model)
if cached_actor:
return cached_actor
has_animation_nodes = self._detect_animation_nodes(owner_model)
print(f"[ActorLoadDebug] origin_model: {origin_model.getName() if origin_model else 'None'}")
print(f"[ActorLoadDebug] origin_model tags: {origin_model.getTags() if origin_model else 'None'}")
print(f"[ActorLoadDebug] owner_model: {owner_model.getName() if owner_model else 'None'}")
try:
print(f"[ActorLoadDebug] owner_model tags: {owner_model.getTags() if owner_model else 'None'}")
print(f"[ActorLoadDebug] owner_model path tag: {owner_model.getTag('model_path') if owner_model.hasTag('model_path') else 'MISSING'}")
except Exception as e:
print(f"[ActorLoadDebug] get tags failed: {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"[ActorLoadDebug] filepath: '{filepath}'")
if not filepath:
print("[ActorLoadDebug] empty filepath, fallback to memory path")
return self._try_memory_fallback_actor(origin_model, owner_model, has_animation_nodes)
for path in self._collect_actor_candidate_paths(filepath):
print(f"[ActorLoadDebug] try path: {path}")
actor = self._try_actor_from_path(owner_model, path)
if actor:
return actor
return self._try_memory_fallback_actor(origin_model, owner_model, has_animation_nodes)
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):
"""处理和分析动画名称,返回 [(显示名称, 原始名称), ...]"""
format_info = self._getModelFormat(origin_model)
processed = []
print(f"[动画分析] 格式: {format_info}, 原始动画名称: {anim_names}")
for name in anim_names:
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):
# 检查是否是骨骼组而非动画
print(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))
print(f"[动画分析] {original_name}{display_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
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):
"""播放动画"""
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
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))
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() 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)
return task.cont
else:
return task.done
except:
return task.done
taskMgr.remove(task_name)
if not is_scene_bound_proxy:
taskMgr.add(maintainWorldPosition, task_name)
# 获取当前选中的动画
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}")
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
if self._should_swap_visibility_for_actor(owner_model, actor):
owner_model.hide()
else:
owner_model.show()
actor.show()
# 停止动画(保持当前姿势)
actor.stop()
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显示原始模型
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):
"""循环播放动画"""
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
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)}"
def maintainWorldPosition(task):
try:
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)
return task.cont
else:
return task.done
except:
return task.done
taskMgr.remove(task_name)
if not is_scene_bound_proxy:
taskMgr.add(maintainWorldPosition, task_name)
# 获取当前选中的动画
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}")
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("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()} 的动画缓存")