diff --git a/core/selection.py b/core/selection.py index d2fd78b4..4b6e7649 100644 --- a/core/selection.py +++ b/core/selection.py @@ -2089,6 +2089,13 @@ class SelectionSystem: node_name = nodePath.getName() #print(f"新选择的节点: {node_name}") + animation_tools = getattr(self.world, "animation_tools", None) + if animation_tools and hasattr(animation_tools, "_stop_all_active_animations"): + try: + animation_tools._stop_all_active_animations() + except Exception as e: + print(f"停止活动动画失败: {e}") + ssbo_editor = getattr(self.world, "ssbo_editor", None) if ssbo_editor: try: diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index 6f5201b1..e272ac57 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -551,6 +551,7 @@ class ObjectController: # Even medium flattening can still collapse or rewrite per-object PBR/effect # state after save/load, which manifests as black materials until selection # switches back to the dynamic objects. + static_np.flatten_strong() chunk["static_np"] = static_np chunk["dirty"] = False diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 417f966d..5911de79 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -1033,6 +1033,14 @@ class SSBOEditor: self._set_node_name(source_model, unique_root_name) imported_root = source_model.copyTo(source_root) self._set_node_name(imported_root, unique_root_name) + try: + imported_root.setTag("model_path", model_path) + imported_root.setTag("original_path", model_path) + imported_root.setTag("saved_model_path", model_path) + imported_root.setTag("is_model_root", "1") + imported_root.setTag("tree_item_type", "IMPORTED_MODEL_NODE") + except Exception: + pass if keep_source_model and not append: self.source_model = imported_root @@ -2110,6 +2118,35 @@ class SSBOEditor: "is_group": len(self.selected_ids) > 1 and not self._is_root_selection(), } + def estimate_selection_cost(self, key): + """Estimate how expensive it is to activate a tree node in dynamic mode.""" + if not self.controller or key is None: + return None + + try: + object_ids = list(self.controller.name_to_ids.get(key, [])) + except Exception: + object_ids = [] + + chunk_ids = set() + for gid in object_ids: + try: + chunk_id = self.controller.id_to_chunk.get(gid) + except Exception: + chunk_id = None + if chunk_id is not None: + chunk_ids.add(chunk_id) + + return { + "key": key, + "object_count": len(object_ids), + "chunk_count": len(chunk_ids), + "is_root": bool( + self.controller and + key == getattr(self.controller, "tree_root_key", None) + ), + } + def _sync_editor_selection_reference(self, node): selection = getattr(self.base, "selection", None) if not selection: diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index dda9678b..5c3cda7c 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -339,6 +339,7 @@ class AnimationTools: 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): @@ -358,12 +359,13 @@ class AnimationTools: 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: + if fallback_best and fallback_count == 1: return fallback_best # 单模型场景兜底 @@ -549,12 +551,214 @@ class AnimationTools: 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): """向上查找动画所属的模型根节点,避免选中子节点时绑定失败。""" + 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")): @@ -1317,6 +1521,146 @@ class AnimationTools: 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): + cached_owner.show() + 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: + actor.hide() + except Exception: + pass + + if restore_owner: + try: + if owner_model and not owner_model.isEmpty(): + owner_model.show() + 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”的切换。 @@ -1350,6 +1694,9 @@ class AnimationTools: pass owner_model = self._resolve_animation_owner_model(origin_model) + self._stop_all_active_animations() + 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 @@ -1373,9 +1720,14 @@ class AnimationTools: 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(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)) + + # 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()) @@ -1394,9 +1746,9 @@ class AnimationTools: 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) + 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)) return task.cont else: return task.done @@ -1497,6 +1849,9 @@ class AnimationTools: pass owner_model = self._resolve_animation_owner_model(origin_model) + self._stop_all_active_animations() + 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 @@ -1508,9 +1863,9 @@ class AnimationTools: 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()) + 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)) if self._should_swap_visibility_for_actor(owner_model, actor): owner_model.hide() @@ -1523,9 +1878,9 @@ class AnimationTools: 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) + 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)) return task.cont else: return task.done diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index 706b61be..111d1be4 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -1497,7 +1497,19 @@ class AppActions: if hasattr(self.scene_manager, 'models'): if getattr(self, "use_ssbo_mouse_picking", False): - self.scene_manager.models = [model_node] + try: + existing_models = [] + for existing in getattr(self.scene_manager, "models", []): + try: + if existing and not existing.isEmpty(): + existing_models.append(existing) + except Exception: + continue + if model_node and all(existing != model_node for existing in existing_models): + existing_models.append(model_node) + self.scene_manager.models = existing_models + except Exception: + self.scene_manager.models = [model_node] if model_node else [] elif model_node not in self.scene_manager.models: self.scene_manager.models.append(model_node) @@ -1507,7 +1519,33 @@ class AppActions: and getattr(self, "ssbo_editor", None) and getattr(self.ssbo_editor, "last_import_tree_key", None) ): - self.ssbo_editor.select_node(self.ssbo_editor.last_import_tree_key) + selected_key = self.ssbo_editor.last_import_tree_key + selection_cost = None + if hasattr(self.ssbo_editor, "estimate_selection_cost"): + try: + selection_cost = self.ssbo_editor.estimate_selection_cost(selected_key) + except Exception: + selection_cost = None + + should_auto_select = True + if selection_cost: + object_count = int(selection_cost.get("object_count", 0)) + chunk_count = int(selection_cost.get("chunk_count", 0)) + is_root = bool(selection_cost.get("is_root")) + # SSBO 导入后的顶层节点通常覆盖整片模型; + # 只要它跨多个对象或多个 chunk,被自动选中后就会让静态批处理失效。 + # 因此这里只允许极小代价的单对象选择自动激活,其余统一保持未选中。 + if is_root or object_count > 1 or chunk_count > 1: + should_auto_select = False + + if should_auto_select: + self.ssbo_editor.select_node(selected_key) + else: + self.ssbo_editor.clear_selection(sync_world_selection=False) + if hasattr(self.ssbo_editor, "_sync_editor_selection_reference"): + self.ssbo_editor._sync_editor_selection_reference(None) + if show_info_message: + self.add_info_message("模型导入完成,已保持 SSBO 静态模式以避免导入后掉帧") elif hasattr(self, 'selection') and self.selection: self.selection.updateSelection(model_node) diff --git a/ui/panels/editor_panels_right.py b/ui/panels/editor_panels_right.py index 3192eba6..41d9d97c 100644 --- a/ui/panels/editor_panels_right.py +++ b/ui/panels/editor_panels_right.py @@ -620,21 +620,23 @@ class EditorPanelsRightMixin( needs_path = (not anim_node.hasTag("model_path")) or (not anim_node.getTag("model_path")) if needs_path and hasattr(self, "scene_manager") and self.scene_manager: models = getattr(self.scene_manager, "models", []) + matched_models = [] for model in list(models): try: if not model or model.isEmpty(): continue if (model == anim_node or model.isAncestorOf(anim_node) or anim_node.isAncestorOf(model)): - if model.hasTag("model_path") and model.getTag("model_path"): - anim_node.setTag("model_path", model.getTag("model_path")) - if model.hasTag("original_path") and model.getTag("original_path"): - anim_node.setTag("original_path", model.getTag("original_path")) - break - if model.hasTag("saved_model_path") and model.getTag("saved_model_path"): - anim_node.setTag("model_path", model.getTag("saved_model_path")) - break + matched_models.append(model) except Exception: continue + if len(matched_models) == 1: + model = matched_models[0] + if model.hasTag("model_path") and model.getTag("model_path"): + anim_node.setTag("model_path", model.getTag("model_path")) + if model.hasTag("original_path") and model.getTag("original_path"): + anim_node.setTag("original_path", model.getTag("original_path")) + elif model.hasTag("saved_model_path") and model.getTag("saved_model_path"): + anim_node.setTag("model_path", model.getTag("saved_model_path")) except Exception: pass @@ -690,18 +692,11 @@ class EditorPanelsRightMixin( should_force_probe = False if not (has_animation_tag or has_animation_nodes or has_cached_animation): - imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型未检测到动画结构") - if self.app.style_manager.draw_toolbar_button( - "尝试强制检测", - size=(108, 26), - tooltip="重新扫描当前模型的动画结构", - ): - should_force_probe = True - anim_node.setPythonTag("cached_anim_info", None) - anim_node.setPythonTag("cached_processed_names", None) - has_animation_nodes = self._get_cached_animation_structure_state(anim_node, force_refresh=True) - else: - return + should_force_probe = True + anim_node.setPythonTag("cached_anim_info", None) + anim_node.setPythonTag("cached_processed_names", None) + anim_node.setPythonTag("cached_has_animation_nodes", None) + has_animation_nodes = self._get_cached_animation_structure_state(anim_node, force_refresh=True) # 只有在没有缓存时才进行完整的动画检测和处理 if cached_anim_info is None or cached_processed_names is None: @@ -711,7 +706,7 @@ class EditorPanelsRightMixin( if has_animation_tag or has_animation_nodes: imgui.text_colored((1.0, 0.7, 0.3, 1.0), "检测到动画结构,但当前未成功绑定Actor") elif should_force_probe: - imgui.text_colored((0.9, 0.6, 0.3, 1.0), "强制检测未发现可播放动画") + imgui.text_colored((0.7, 0.7, 0.7, 1.0), "此模型未检测到动画结构") anim_node.setPythonTag("animation", False) anim_node.setPythonTag("cached_processed_names", []) anim_node.setPythonTag("cached_anim_info", "无动画") diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py index 7972aa41..b60a67b9 100644 --- a/ui/panels/panel_delegates.py +++ b/ui/panels/panel_delegates.py @@ -123,8 +123,40 @@ class PanelDelegates: # 检查是否为相机 if "camera" in node_name.lower() or "Camera" in node_name: return "相机" - + # 检查是否为模型 + try: + if hasattr(self, "_resolve_animation_owner_model"): + owner = self._resolve_animation_owner_model(node) + if owner and not owner.isEmpty() and owner != node: + return "模型" + except Exception: + pass + + try: + if hasattr(self, "animation_tools") and hasattr(self.animation_tools, "_resolve_ssbo_source_owner"): + source_owner = self.animation_tools._resolve_ssbo_source_owner(node) + if source_owner and not source_owner.isEmpty(): + return "模型" + except Exception: + pass + + try: + model_tag_names = ("model_path", "original_path", "saved_model_path") + if any(node.hasTag(tag_name) and bool(node.getTag(tag_name)) for tag_name in model_tag_names): + return "模型" + if node.hasTag("is_model_root") and node.getTag("is_model_root") == "1": + return "模型" + if node.hasTag("tree_item_type") and node.getTag("tree_item_type") == "IMPORTED_MODEL_NODE": + return "模型" + if node.hasTag("has_animations") and node.getTag("has_animations").lower() == "true": + return "模型" + cached_anim = node.getPythonTag("animation") if hasattr(node, "getPythonTag") else None + if cached_anim is True: + return "模型" + except Exception: + pass + if hasattr(self, 'scene_manager') and self.scene_manager: if hasattr(self.scene_manager, 'models') and node in self.scene_manager.models: return "模型"