diff --git a/core/event_handler.py b/core/event_handler.py index 6f01957f..26a39962 100644 --- a/core/event_handler.py +++ b/core/event_handler.py @@ -413,6 +413,44 @@ class EventHandler: or lowered.startswith("gizmo") ) + def _resolve_model_root(node): + current = node + model_list = self.world.models if hasattr(self.world, "models") else [] + while _is_valid_node(current) and current != self.world.render: + try: + if current in model_list or current.hasTag("is_model_root"): + return current + current = current.getParent() + except Exception: + break + return None + + # In SSBO mode, animated/legacy models still rely on modelCollision_* for picking, + # so we cannot ignore those hits unconditionally. + # Only treat a hit on the *currently selected same model root* as blank-space clear. + if getattr(self.world, "use_ssbo_mouse_picking", False): + try: + hit_name = hitNode.getName() or "" + except Exception: + hit_name = "" + if hit_name.lower().startswith("modelcollision_"): + current_selected = None + try: + current_selected = self.world.selection.getSelectedNode() + except Exception: + current_selected = getattr(getattr(self, "world", None), "selection", None) + owner_root = _resolve_model_root(hitNode) + try: + same_root = bool( + current_selected and owner_root and current_selected == owner_root + ) + except Exception: + same_root = False + if same_root: + print("SSBO 模式下命中当前选中模型的辅助碰撞壳,按空白区域清除选择") + self.world.selection.updateSelection(None) + return + def _is_selectable_scene_node(node): if not _is_valid_node(node) or node == self.world.render: return False diff --git a/imgui.ini b/imgui.ini index 9f61f3e9..e70ac04f 100644 --- a/imgui.ini +++ b/imgui.ini @@ -78,7 +78,7 @@ Size=93,65 Collapsed=0 [Window][新建项目] -Pos=760,366 +Pos=760,354 Size=400,300 Collapsed=0 @@ -206,7 +206,7 @@ Collapsed=0 DockId=0x00000005,2 [Window][项目另存为] -Pos=730,396 +Pos=730,384 Size=460,240 Collapsed=0 diff --git a/project/asset_database.py b/project/asset_database.py index 99ae1ff0..34b558aa 100644 --- a/project/asset_database.py +++ b/project/asset_database.py @@ -87,9 +87,6 @@ class AssetDatabase: } ) - if asset_type == "model": - self._build_model_import_cache(record) - assets[asset_guid] = record if previous_asset_path != relative_asset_path: changed = True @@ -290,7 +287,13 @@ class AssetDatabase: "import_info": relative_project_path(self.layout.project_root, import_info_path), } - def register_asset(self, asset_path: str, preferred_subdir: str = "", copy_into_assets: bool = False) -> dict: + def register_asset( + self, + asset_path: str, + preferred_subdir: str = "", + copy_into_assets: bool = False, + build_import_cache: bool = False, + ) -> dict: asset_path = normalize_path(asset_path) if not os.path.exists(asset_path): return {} @@ -323,7 +326,7 @@ class AssetDatabase: } ) - if asset_type == "model": + if asset_type == "model" and build_import_cache: self._build_model_import_cache(record) meta_payload = { @@ -341,8 +344,13 @@ class AssetDatabase: self.save() return dict(record) - def import_asset(self, source_path: str, preferred_subdir: str = "") -> dict: - return self.register_asset(source_path, preferred_subdir=preferred_subdir, copy_into_assets=True) + def import_asset(self, source_path: str, preferred_subdir: str = "", build_import_cache: bool = False) -> dict: + return self.register_asset( + source_path, + preferred_subdir=preferred_subdir, + copy_into_assets=True, + build_import_cache=build_import_cache, + ) def ensure_project_assets_registered(self): self._sync_assets_from_meta_scan() @@ -351,4 +359,4 @@ class AssetDatabase: if file_name.endswith(".meta"): continue asset_path = os.path.join(root, file_name) - self.register_asset(asset_path, copy_into_assets=False) + self.register_asset(asset_path, copy_into_assets=False, build_import_cache=False) diff --git a/scene/gltf_support.py b/scene/gltf_support.py index b8437a85..0745b624 100644 --- a/scene/gltf_support.py +++ b/scene/gltf_support.py @@ -9,6 +9,9 @@ import struct import tempfile +_GLTF_METADATA_CACHE = {} + + def is_gltf_path(file_path: str) -> bool: ext = os.path.splitext(str(file_path or ""))[1].lower() return ext in {".gltf", ".glb"} @@ -88,20 +91,49 @@ def _load_gltf_json_payload(file_path: str): def probe_gltf_metadata(file_path: str) -> dict: - payload = _load_gltf_json_payload(file_path) - if not payload: + file_path = _to_os_specific_path(file_path) + if not file_path or not os.path.exists(file_path): return { "is_gltf": False, "has_animations": False, "animation_count": 0, } + try: + stat_info = os.stat(file_path) + cache_key = ( + os.path.abspath(file_path), + int(stat_info.st_mtime_ns), + int(stat_info.st_size), + ) + cached = _GLTF_METADATA_CACHE.get(cache_key) + if cached is not None: + return dict(cached) + except Exception: + cache_key = None + + payload = _load_gltf_json_payload(file_path) + if not payload: + result = { + "is_gltf": False, + "has_animations": False, + "animation_count": 0, + } + if cache_key is not None: + _GLTF_METADATA_CACHE.clear() + _GLTF_METADATA_CACHE[cache_key] = dict(result) + return result + animations = payload.get("animations") or [] - return { + result = { "is_gltf": True, "has_animations": bool(animations), "animation_count": len(animations), } + if cache_key is not None: + _GLTF_METADATA_CACHE.clear() + _GLTF_METADATA_CACHE[cache_key] = dict(result) + return result def _resolve_cache_root(project_root: str = "") -> str: diff --git a/scene/scene_manager_model_mixin.py b/scene/scene_manager_model_mixin.py index 32f36c35..f9d88d87 100644 --- a/scene/scene_manager_model_mixin.py +++ b/scene/scene_manager_model_mixin.py @@ -87,27 +87,30 @@ class SceneManagerModelMixin: gltf_meta = None try: - from scene.gltf_support import ensure_gltf_visual_bam, probe_gltf_metadata + from scene.gltf_support import get_gltf_visual_bam_path, probe_gltf_metadata gltf_meta = probe_gltf_metadata(filepath) if gltf_meta.get("is_gltf"): has_anim = gltf_meta.get("has_animations", False) # 智能加载策略: - # 1. 如果模型有动画,则强制使用 panda3d-gltf 构建的可见性缓存(BAM),以保证动画支持。 + # 1. 如果模型有动画,仅在缓存已存在时使用缓存;首次导入直接加载原始 glTF, + # 避免同步构建 BAM 导致首次导入被完整解析两次。 # 2. 如果模型是纯静态场景,则跳过缓存,使用原生加载器(如 Assimp),这样在大场景下更流畅。 if has_anim: project_manager = getattr(getattr(self, "world", None), "project_manager", None) project_root = getattr(project_manager, "current_project_path", "") if project_manager else "" - - cached_visual_path = ensure_gltf_visual_bam( + + cached_visual_path = get_gltf_visual_bam_path( filepath, project_root=project_root, skip_animations=False, # 有动画的模型不应跳过动画 flatten_nodes=False, ) - if cached_visual_path and cached_visual_path != filepath: + if cached_visual_path and cached_visual_path != filepath and os.path.exists(cached_visual_path): visual_load_path = cached_visual_path print(f"[GLTF智能加载] 检测到动画,使用 panda3d-gltf 缓存: {cached_visual_path}") + else: + print(f"[GLTF智能加载] 检测到动画,首次导入跳过同步BAM构建: {filepath}") else: print(f"[GLTF智能加载] 纯静态模型,跳过缓存以开启流畅模式: {filepath}") except Exception as e: @@ -844,33 +847,29 @@ class SceneManagerModelMixin: cNode.setIntoCollideMask(current_mask | model_collision_mask) print(f"为 {model.getName()} 启用模型间碰撞检测") - # 获取模型的边界信息,使用与选择框相同的计算方法 - minPoint = Point3() - maxPoint = Point3() - - # 使用与选择框相同的calcTightBounds方法获取边界,但是在局部坐标系中进行计算 - # 这样计算出的包围盒直接贴合几何体,并且无论模型自身受到什么平移/缩放/旋转都不会发生两次形变! - if model.calcTightBounds(minPoint, maxPoint, model): - # 检查边界框的有效性 - if (abs(minPoint.x) < 1e10 and abs(minPoint.y) < 1e10 and abs(minPoint.z) < 1e10 and - abs(maxPoint.x) < 1e10 and abs(maxPoint.y) < 1e10 and abs(maxPoint.z) < 1e10): - - # 我们现在获取的是纯局部几何数据,因此不再需要手动乘以100或应用旋转来抵消FBX形变 - - # 创建与选择框完全一致的碰撞体 - cBox = CollisionBox(minPoint, maxPoint) - cNode.addSolid(cBox) - radius = max(maxPoint.x - minPoint.x, maxPoint.y - minPoint.y, maxPoint.z - minPoint.z) / 2 - else: - # 使用默认球体 - radius = 1.0 - cSphere = CollisionSphere(Point3(0, 0, 0), radius) - cNode.addSolid(cSphere) - else: - # 使用默认球体 + # 导入阶段避免使用 calcTightBounds 扫描全部几何体。 + # 这里优先使用 Panda 已有包围体快速生成一个可用于选择/碰撞的近似球体。 + radius = 1.0 + center = Point3(0, 0, 0) + try: + bounds = model.getBounds() + if bounds and not bounds.isEmpty(): + try: + center = bounds.getApproxCenter() + except Exception: + center = bounds.getCenter() + try: + radius = float(bounds.getRadius()) + except Exception: + radius = 1.0 + except Exception: + pass + + if not (radius > 0.0 and radius < 1e10): radius = 1.0 - cSphere = CollisionSphere(Point3(0, 0, 0), radius) - cNode.addSolid(cSphere) + + cSphere = CollisionSphere(center, radius) + cNode.addSolid(cSphere) # 将碰撞节点附加到模型上 cNodePath = model.attachNewNode(cNode) diff --git a/scene/util.py b/scene/util.py index 7b42605f..66d45cef 100644 --- a/scene/util.py +++ b/scene/util.py @@ -22,16 +22,17 @@ class CrossPlatformPathHandler: print(f"路径处理器初始化 - 系统: {self.system}") - def normalize_model_path(self, filepath): - """标准化模型文件路径""" - try: + def normalize_model_path(self, filepath): + """标准化模型文件路径""" + try: #print(f"\n=== 路径标准化处理 ===") #print(f"原始路径: {filepath}") #print(f"当前系统: {self.system}") - # 步骤1: 检查原始路径是否存在 - if self._check_file_exists(filepath): - return self._panda3d_normalize(filepath) + # 步骤1: 检查原始路径是否存在 + if self._check_file_exists(filepath): + existing_path = self._to_os_specific_existing_path(filepath) or filepath + return self._panda3d_normalize(existing_path) # 步骤2: 路径修复尝试 fixed_path = self._attempt_path_fixes(filepath) @@ -51,10 +52,54 @@ class CrossPlatformPathHandler: print(f"❌ 路径标准化失败: {e}") return filepath - def _check_file_exists(self, filepath): - """检查文件是否存在""" - exists = os.path.exists(filepath) - return exists + def _check_file_exists(self, filepath): + """检查文件是否存在""" + try: + if filepath and os.path.exists(filepath): + return True + except Exception: + pass + + try: + os_path = self._to_os_specific_existing_path(filepath) + if os_path and os.path.exists(os_path): + return True + except Exception: + pass + return False + + def _to_os_specific_existing_path(self, filepath): + """将 Panda 风格路径转换为当前系统下真实存在的路径。""" + path_text = os.fspath(filepath or "") + if not path_text: + return "" + if os.path.exists(path_text): + return os.path.normpath(path_text) + + try: + 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(path_text).to_os_specific() + if candidate and os.path.exists(candidate): + return os.path.normpath(candidate) + except Exception: + continue + candidate = Filename(path_text).to_os_specific() + if candidate and os.path.exists(candidate): + return os.path.normpath(candidate) + except Exception: + pass + + if len(path_text) >= 3 and path_text[0] in ("/", "\\") and path_text[1].isalpha() and path_text[2] in ("/", "\\"): + drive_path = f"{path_text[1].upper()}:{path_text[2:]}" + drive_path = os.path.normpath(drive_path) + if os.path.exists(drive_path): + return drive_path + + return "" def _panda3d_normalize(self, filepath): """使用Panda3D标准化路径""" diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index da80b79d..e18f63f7 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -2875,6 +2875,14 @@ class SSBOEditor: if self.pick_object(mpos.x, mpos.y): return + # In SSBO picking mode, a miss should clear the current SSBO selection. + # Falling back to legacy collision picking here tends to immediately + # re-hit broad helper/collision shells from the selected root model, + # which makes "click blank space to deselect" fail for top-level nodes. + if self.has_active_selection(): + self.clear_selection() + return + try: win_width, win_height = self.base.win.getSize() window_x = (float(mpos.x) + 1.0) * 0.5 * float(win_width) diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index a700d830..16520346 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -1095,7 +1095,9 @@ class AnimationTools: except Exception as e: print(f"[Actor加载调试] 获取 tags 异常: {e}") - filepath = owner_model.getTag("model_path") if owner_model.hasTag("model_path") else "" + 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") @@ -1138,12 +1140,16 @@ class AnimationTools: candidate_paths.append(win_path) candidate_paths.append(os.path.normpath(win_path)) - # 尝试 Panda3D 路径标准化 - try: - from scene import util - candidate_paths.append(util.normalize_model_path(filepath)) - except Exception: - pass + # 仅在当前路径和基础变体都不可直接访问时,才走较重的路径修复/搜索。 + 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) @@ -1172,6 +1178,7 @@ class AnimationTools: 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 @@ -1180,6 +1187,7 @@ class AnimationTools: 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._actor_cache[owner_model] = actor return actor @@ -1196,6 +1204,7 @@ class AnimationTools: 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._actor_cache[owner_model] = proxy return proxy diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index b7dfb7d4..09fbd9ae 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -46,12 +46,32 @@ class AppActions: try: if not file_path or not os.path.exists(file_path): return False + try: + stat_info = os.stat(file_path) + cache_key = ( + os.path.abspath(file_path), + int(stat_info.st_mtime_ns), + int(stat_info.st_size), + ) + animation_cache = getattr(self, "_model_animation_probe_cache", None) + if animation_cache is None: + animation_cache = {} + self._model_animation_probe_cache = animation_cache + cached = animation_cache.get(cache_key) + if cached is not None: + return bool(cached) + except Exception: + cache_key = None try: from scene.gltf_support import probe_gltf_metadata gltf_meta = probe_gltf_metadata(file_path) if gltf_meta.get("is_gltf"): - return bool(gltf_meta.get("has_animations")) + result = bool(gltf_meta.get("has_animations")) + if cache_key is not None: + animation_cache.clear() + animation_cache[cache_key] = result + return result except Exception: pass loader = getattr(self, "loader", None) @@ -76,7 +96,11 @@ class AppActions: model.removeNode() except Exception: pass - return bool(has_animation) + result = bool(has_animation) + if cache_key is not None: + animation_cache.clear() + animation_cache[cache_key] = result + return result except Exception: return False diff --git a/ui/panels/editor_panels_right_material.py b/ui/panels/editor_panels_right_material.py index e40b7d7a..83068fc2 100644 --- a/ui/panels/editor_panels_right_material.py +++ b/ui/panels/editor_panels_right_material.py @@ -1,8 +1,69 @@ from imgui_bundle import imgui, imgui_ctx +import re class EditorPanelsRightMaterialMixin: """Auto-split mixin from editor_panels_right.py.""" + def _get_material_display_name(self, material, index): + name = "" + try: + if hasattr(material, "get_name"): + name = material.get_name() + if name: + name = str(name) + except Exception: + pass + if not name: + try: + if hasattr(material, "getName"): + name = material.getName() + if name: + name = str(name) + except Exception: + pass + if not name: + return f"材质{index + 1}" + clean_name = re.sub(r"__editable__.*$", "", name).strip() + return clean_name or f"材质{index + 1}" + + def _get_material_stable_key(self, material, index): + identity_fn = getattr(self.app, "_get_material_identity_key", None) + if callable(identity_fn): + try: + return str(identity_fn(material)) + except Exception: + pass + try: + return str(getattr(material, "this", None) or id(material)) + except Exception: + return f"material_{index}" + + def _get_materials_for_panel_display(self, node): + """Read-only material lookup for panel rendering; avoid mutating scene state every frame.""" + self._sync_material_panel_target(node) + panel_materials_fn = getattr(self.app, "_get_panel_edit_materials_for_node", None) + if callable(panel_materials_fn): + try: + materials = panel_materials_fn(node) + if materials: + return sorted( + materials, + key=lambda material: ( + self._get_material_display_name(material, 0).lower(), + self._get_material_stable_key(material, 0), + ), + ) + except Exception: + pass + materials = self.app._get_node_materials(node) + return sorted( + materials, + key=lambda material: ( + self._get_material_display_name(material, 0).lower(), + self._get_material_stable_key(material, 0), + ), + ) + def _ensure_material_edit_sessions(self): if not hasattr(self, "_material_edit_sessions"): self._material_edit_sessions = {} @@ -112,7 +173,7 @@ class EditorPanelsRightMaterialMixin: def _draw_appearance_properties(self, node): """绘制材质属性(Unity风格主材质入口)。""" - materials = self._ensure_node_materials_are_editable(node) + materials = self._get_materials_for_panel_display(node) if not materials: fallback_material = self.app._ensure_material_for_node(node) materials = [fallback_material] if fallback_material else [] @@ -222,16 +283,17 @@ class EditorPanelsRightMaterialMixin: def _draw_material_properties(self, node): """绘制材质属性""" - materials = self._ensure_node_materials_are_editable(node) + materials = self._get_materials_for_panel_display(node) if not materials: imgui.text_colored((0.5, 0.5, 0.5, 1.0), "无材质") return for i, material in enumerate(materials): - material_name = material.get_name() if hasattr(material, 'get_name') and material.get_name() else f"材质{i + 1}" + material_name = self._get_material_display_name(material, i) + material_key = self._get_material_stable_key(material, i) - if imgui.collapsing_header(f"材质: {material_name}"): + if imgui.collapsing_header(f"材质: {material_name}##{material_key}"): # PBR属性 imgui.text("PBR") if hasattr(material, 'roughness') and material.roughness is not None: diff --git a/ui/panels/property_helpers.py b/ui/panels/property_helpers.py index 79b7397d..cb222d76 100644 --- a/ui/panels/property_helpers.py +++ b/ui/panels/property_helpers.py @@ -1,4 +1,5 @@ import os +import re from pathlib import Path from imgui_bundle import imgui, imgui_ctx @@ -1358,6 +1359,7 @@ class PropertyHelpers: except Exception: source_name = "" + source_name = re.sub(r"__editable__.*$", "", str(source_name or "")).strip() node_name = self._get_node_name(node, "node") if hasattr(self, "_get_node_name") else "node" clone_name = f"{source_name or 'material'}__editable__{node_name}" try: