diff --git a/Assets/Models/dance.glb b/Assets/Models/dance.glb new file mode 100644 index 00000000..77ba40f8 Binary files /dev/null and b/Assets/Models/dance.glb differ diff --git a/Assets/Models/dance.glb.meta b/Assets/Models/dance.glb.meta new file mode 100644 index 00000000..5158c1ba --- /dev/null +++ b/Assets/Models/dance.glb.meta @@ -0,0 +1,8 @@ +{ + "guid": "ffc57139207e4c6a9677f6c29c90baed", + "asset_type": "model", + "source_hash": "facc0e3a9d60eda47d2a42e34922cbf09173479704536cde6f1bca4e90f5f431", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [] +} \ No newline at end of file diff --git a/Assets/Models/jxb.glb b/Assets/Models/jxb.glb new file mode 100644 index 00000000..2546206f Binary files /dev/null and b/Assets/Models/jxb.glb differ diff --git a/Assets/Models/jxb.glb.meta b/Assets/Models/jxb.glb.meta new file mode 100644 index 00000000..a300a4b6 --- /dev/null +++ b/Assets/Models/jxb.glb.meta @@ -0,0 +1,8 @@ +{ + "guid": "36dd2eccd8314fb6a0bd8a5090bad6b1", + "asset_type": "model", + "source_hash": "7916e67bf644e61e2d7b7776ef8df40ef8623aaceda16b873c64d0a3f1a4faba", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [] +} \ No newline at end of file diff --git a/Assets/Models/jyc.glb.meta b/Assets/Models/jyc.glb.meta new file mode 100644 index 00000000..c1f6c259 --- /dev/null +++ b/Assets/Models/jyc.glb.meta @@ -0,0 +1,8 @@ +{ + "guid": "7d4bce696eb848338bdcaffbacdbc4b1", + "asset_type": "model", + "source_hash": "46fc525a88f6d16eed0bac714a14692b68818d36a00065690b5877d878788948", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [] +} \ No newline at end of file diff --git a/Assets/Models/气体检测报警仪_跑动.fbx b/Assets/Models/气体检测报警仪_跑动.fbx new file mode 100644 index 00000000..3f9e420c Binary files /dev/null and b/Assets/Models/气体检测报警仪_跑动.fbx differ diff --git a/Assets/Models/气体检测报警仪_跑动.fbx.meta b/Assets/Models/气体检测报警仪_跑动.fbx.meta new file mode 100644 index 00000000..e44a284c --- /dev/null +++ b/Assets/Models/气体检测报警仪_跑动.fbx.meta @@ -0,0 +1,8 @@ +{ + "guid": "98fc2abf779348b6b78d0be52d79993e", + "asset_type": "model", + "source_hash": "c57c36f52fca892855f8d8f0e70a2ceb8889b159f1c40fbbbcddc7d874682353", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [] +} \ No newline at end of file diff --git a/Library/AssetDB.json b/Library/AssetDB.json index 43e9e982..14801879 100644 --- a/Library/AssetDB.json +++ b/Library/AssetDB.json @@ -10,7 +10,7 @@ "importer": "model_importer", "import_settings": {}, "dependency_guids": [], - "updated_at": "2026-03-19 17:17:48", + "updated_at": "2026-03-23 14:37:54", "imported_cache": { "root": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d", "model_bam": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/model.bam", @@ -18,9 +18,85 @@ "materials": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/materials.json", "import_info": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json" } + }, + "36dd2eccd8314fb6a0bd8a5090bad6b1": { + "guid": "36dd2eccd8314fb6a0bd8a5090bad6b1", + "asset_path": "Assets/Models/jxb.glb", + "asset_type": "model", + "meta_path": "Assets/Models/jxb.glb.meta", + "source_hash": "7916e67bf644e61e2d7b7776ef8df40ef8623aaceda16b873c64d0a3f1a4faba", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [], + "updated_at": "2026-03-23 14:34:07", + "imported_cache": { + "root": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1", + "model_bam": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/model.bam", + "hierarchy": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/hierarchy.json", + "materials": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/materials.json", + "import_info": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/import_info.json" + } + }, + "98fc2abf779348b6b78d0be52d79993e": { + "guid": "98fc2abf779348b6b78d0be52d79993e", + "asset_path": "Assets/Models/气体检测报警仪_跑动.fbx", + "asset_type": "model", + "meta_path": "Assets/Models/气体检测报警仪_跑动.fbx.meta", + "source_hash": "c57c36f52fca892855f8d8f0e70a2ceb8889b159f1c40fbbbcddc7d874682353", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [], + "updated_at": "2026-03-23 14:34:08", + "imported_cache": { + "root": "Library/Imported/98fc2abf779348b6b78d0be52d79993e", + "model_bam": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/model.bam", + "hierarchy": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/hierarchy.json", + "materials": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/materials.json", + "import_info": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/import_info.json" + } + }, + "ffc57139207e4c6a9677f6c29c90baed": { + "guid": "ffc57139207e4c6a9677f6c29c90baed", + "asset_path": "Assets/Models/dance.glb", + "asset_type": "model", + "meta_path": "Assets/Models/dance.glb.meta", + "source_hash": "facc0e3a9d60eda47d2a42e34922cbf09173479704536cde6f1bca4e90f5f431", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [], + "updated_at": "2026-03-23 14:34:07", + "imported_cache": { + "root": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed", + "model_bam": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/model.bam", + "hierarchy": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/hierarchy.json", + "materials": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/materials.json", + "import_info": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/import_info.json" + } + }, + "7d4bce696eb848338bdcaffbacdbc4b1": { + "guid": "7d4bce696eb848338bdcaffbacdbc4b1", + "asset_path": "Assets/Models/jyc.glb", + "asset_type": "model", + "meta_path": "Assets/Models/jyc.glb.meta", + "source_hash": "46fc525a88f6d16eed0bac714a14692b68818d36a00065690b5877d878788948", + "importer": "model_importer", + "import_settings": {}, + "dependency_guids": [], + "updated_at": "2026-03-23 14:35:25", + "imported_cache": { + "root": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1", + "model_bam": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/model.bam", + "hierarchy": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/hierarchy.json", + "materials": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/materials.json", + "import_info": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/import_info.json" + } } }, "path_to_guid": { - "Assets/Models/box1.glb": "90ece77e67b54ccda38d2de71cb4694d" + "Assets/Models/box1.glb": "90ece77e67b54ccda38d2de71cb4694d", + "Assets/Models/jxb.glb": "36dd2eccd8314fb6a0bd8a5090bad6b1", + "Assets/Models/气体检测报警仪_跑动.fbx": "98fc2abf779348b6b78d0be52d79993e", + "Assets/Models/dance.glb": "ffc57139207e4c6a9677f6c29c90baed", + "Assets/Models/jyc.glb": "7d4bce696eb848338bdcaffbacdbc4b1" } } \ No newline at end of file diff --git a/Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json b/Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json index 2d325fcf..06bec765 100644 --- a/Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json +++ b/Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json @@ -3,5 +3,5 @@ "asset_path": "Assets/Models/box1.glb", "asset_type": "model", "source_hash": "fc694905c47a9b0005d77b701cc41852b56ef08c7406829a306e98f3ce158a64", - "generated_at": "2026-03-19 17:17:48" + "generated_at": "2026-03-23 14:37:54" } \ No newline at end of file diff --git a/Panda3D-1.10/618a39808b4eb20ba86a9f6cabe5b91e.bam b/Panda3D-1.10/618a39808b4eb20ba86a9f6cabe5b91e.bam new file mode 100644 index 00000000..ad600d1a Binary files /dev/null and b/Panda3D-1.10/618a39808b4eb20ba86a9f6cabe5b91e.bam differ diff --git a/Panda3D-1.10/index-aa08c2.boo b/Panda3D-1.10/index-aa08c2.boo new file mode 100644 index 00000000..b4d97733 Binary files /dev/null and b/Panda3D-1.10/index-aa08c2.boo differ diff --git a/Panda3D-1.10/index_name.txt b/Panda3D-1.10/index_name.txt new file mode 100644 index 00000000..b19e0049 --- /dev/null +++ b/Panda3D-1.10/index_name.txt @@ -0,0 +1 @@ +index-aa08c2.boo diff --git a/core/world.py b/core/world.py index 6179337d..a674153a 100644 --- a/core/world.py +++ b/core/world.py @@ -51,6 +51,7 @@ class CoreWorld(ShowBase): # 设置基本配置 loadPrcFileData("", "show-frame-rate-meter 0") loadPrcFileData("", "window-type onscreen") + loadPrcFileData("", "window-title MetaCore") loadPrcFileData("", f"win-size {width} {height}") loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小 @@ -81,6 +82,13 @@ class CoreWorld(ShowBase): # 初始化 ShowBase ShowBase.__init__(self) + try: + props = WindowProperties() + props.setTitle("MetaCore") + self.win.requestProperties(props) + except Exception: + pass + # 创建渲染管线 self.render_pipeline.create(self) install_editor_tag_state_manager(self.render_pipeline, self) diff --git a/imgui.ini b/imgui.ini index 4d4334e9..c78b5138 100644 --- a/imgui.ini +++ b/imgui.ini @@ -31,19 +31,19 @@ DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=274,1331 +Size=339,1084 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=2167,20 -Size=393,1331 +Pos=1655,20 +Size=393,1084 Collapsed=0 DockId=0x00000002,0 [Window][控制台] -Pos=276,952 -Size=1889,399 +Pos=341,705 +Size=1312,399 Collapsed=0 DockId=0x00000006,1 @@ -59,7 +59,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=2560,1331 +Size=2048,1084 Collapsed=0 [Window][测试窗口1] @@ -98,8 +98,8 @@ Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=276,952 -Size=1889,399 +Pos=341,705 +Size=1312,399 Collapsed=0 DockId=0x00000006,0 @@ -134,7 +134,7 @@ Size=89,250 Collapsed=0 [Window][颜色选择器] -Pos=874,352 +Pos=878,354 Size=300,400 Collapsed=0 @@ -214,11 +214,21 @@ Pos=1010,515 Size=540,320 Collapsed=0 +[Window][关于 EG] +Pos=794,422 +Size=460,260 +Collapsed=0 + +[Window][关于 MetaCore] +Pos=794,422 +Size=460,260 +Collapsed=0 + [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2560,1331 Split=X +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1525,1012 Split=X - DockNode ID=0x00000007 Parent=0x00000001 SizeRef=274,1084 Selected=0xE0015051 - DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1249,1084 Split=Y + DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051 + DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1312,1084 Split=Y DockNode ID=0x00000005 Parent=0x00000008 SizeRef=2048,683 Split=Y DockNode ID=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006 DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051 diff --git a/project/project_manager.py b/project/project_manager.py index c039b7b1..ed4f1a48 100644 --- a/project/project_manager.py +++ b/project/project_manager.py @@ -1008,7 +1008,7 @@ class ProjectManager: load_lui_fn(temp_stub) except Exception: pass - return bool(ssbo_loaded or built_nodes or built_spot_lights or built_point_lights or scene_components) + return bool(ssbo_loaded or built_model_nodes or built_spot_lights or built_point_lights or scene_components) def _iter_top_level_scene_asset_nodes(self, scene_description): nodes = list(scene_description.get("nodes", []) or []) @@ -1089,18 +1089,176 @@ class ProjectManager: target_np.setTag("file", os.path.basename(asset_abs_path)) if imported_node_key: target_np.setTag("imported_node_key", imported_node_key) - target_np.setTag("is_model_root", "1") - target_np.setTag("is_scene_element", "1") + if asset_guid: + target_np.setTag("is_model_root", "1") + target_np.setTag("is_scene_element", "1") runtime_interactive = bool(metadata_component.get("runtime_interactive", node.get("runtime_interactive", False))) if runtime_interactive: target_np.setTag("runtime_interactive", "true") + for tag_name in ( + "has_animations", + "has_animations_checked", + "can_create_actor_from_memory", + "saved_has_animations", + "saved_can_create_actor_from_memory", + ): + tag_value = str( + metadata_component.get(tag_name, (node.get("tags", {}) or {}).get(tag_name, "")) + or "" + ).strip() + if tag_value: + target_np.setTag(tag_name, tag_value) + if asset_path: + asset_abs_path = os.path.join(project_path, asset_path.replace("/", os.sep)) + if not target_np.hasTag("saved_model_path"): + target_np.setTag("saved_model_path", asset_abs_path) + scripts = list(scripts_component.get("entries", []) or node.get("scripts", []) or []) if scripts: target_np.setTag("has_scripts", "true") target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False)) + self._apply_saved_material_tags_to_node(target_np) + + def _apply_saved_material_tags_to_node(self, target_np): + """Rebuild runtime material state from serialized material_* tags.""" + if not target_np or target_np.isEmpty(): + return + + property_helpers = getattr(self.world, "property_helpers", None) + if not property_helpers: + return + + material_tag_names = ( + "material_basecolor", + "material_emission", + "material_roughness", + "material_metallic", + "material_ior", + ) + if not any(target_np.hasTag(tag_name) for tag_name in material_tag_names): + return + + try: + from panda3d.core import Vec4 + except Exception: + Vec4 = None + + def _parse_vec4(raw_value): + try: + cleaned = str(raw_value or "").strip() + for prefix in ("LVecBase4f", "Vec4", "LColor"): + cleaned = cleaned.replace(prefix, "") + cleaned = cleaned.strip("() ") + values = [float(part.strip()) for part in cleaned.split(",") if part.strip()] + if len(values) == 3: + values.append(1.0) + if len(values) >= 4: + return tuple(values[:4]) + except Exception: + pass + return None + + def _parse_float(tag_name): + try: + return float(target_np.getTag(tag_name)) + except Exception: + return None + + ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None) + sync_runtime_fn = getattr(property_helpers, "_sync_material_node_runtime", None) + set_base_color_fn = getattr(property_helpers, "_set_material_base_color", None) + if not callable(ensure_material_fn): + return + + try: + material = ensure_material_fn(target_np) + except Exception: + material = None + if material is None: + return + + base_color = _parse_vec4(target_np.getTag("material_basecolor")) if target_np.hasTag("material_basecolor") else None + if base_color is not None and callable(set_base_color_fn): + try: + set_base_color_fn(material, base_color) + except Exception: + pass + + emission = _parse_vec4(target_np.getTag("material_emission")) if target_np.hasTag("material_emission") else None + if emission is not None and Vec4 is not None and hasattr(material, "set_emission"): + try: + material.set_emission(Vec4(*emission)) + except Exception: + pass + + roughness = _parse_float("material_roughness") + if roughness is not None and hasattr(material, "set_roughness"): + try: + material.set_roughness(roughness) + except Exception: + pass + + metallic = _parse_float("material_metallic") + if metallic is not None and hasattr(material, "set_metallic"): + try: + material.set_metallic(metallic) + except Exception: + pass + + ior = _parse_float("material_ior") + if ior is not None and hasattr(material, "set_refractive_index"): + try: + material.set_refractive_index(ior) + except Exception: + pass + + if callable(sync_runtime_fn): + try: + sync_runtime_fn(target_np, material, refresh_ssbo_runtime=False) + except Exception: + pass + + def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup): + """Apply saved state to one imported node and its serialized descendants by child order.""" + if not target_np or target_np.isEmpty() or not isinstance(node, dict): + return + + self._apply_scene_description_state_to_node(target_np, node, project_path) + + node_id = str(node.get("node_id", "") or "").strip() + if not node_id: + return + + child_nodes = [] + for candidate in list((node_lookup or {}).values()): + if str(candidate.get("parent_id", "") or "").strip() != node_id: + continue + child_nodes.append(candidate) + + def _node_index(candidate): + candidate_id = str(candidate.get("node_id", "") or "") + try: + return int(candidate_id.rsplit("/", 1)[-1]) + except Exception: + return 0 + + child_nodes.sort(key=_node_index) + + runtime_children = [] + try: + runtime_children = [child for child in target_np.getChildren() if child and not child.isEmpty()] + except Exception: + runtime_children = [] + + for child_entry in child_nodes: + child_index = _node_index(child_entry) + if child_index < 0 or child_index >= len(runtime_children): + continue + self._apply_scene_description_state_to_subtree(runtime_children[child_index], child_entry, project_path, node_lookup) + def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database): if not getattr(self.world, "use_ssbo_mouse_picking", False): return False @@ -1109,6 +1267,8 @@ class ProjectManager: if not ssbo_editor: return False + refresh_runtime_fn = getattr(ssbo_editor, "refresh_runtime_from_source", None) + top_level_asset_nodes, node_lookup = self._iter_top_level_scene_asset_nodes(scene_description) if not top_level_asset_nodes: return False @@ -1133,6 +1293,33 @@ class ProjectManager: if not os.path.exists(asset_abs): continue + has_saved_animation = False + try: + metadata_component = dict(components.get("metadata", {}) or {}) + tags = dict(node.get("tags", {}) or {}) + has_saved_animation = ( + str(tags.get("has_animations", "")).lower() == "true" + or str(tags.get("saved_has_animations", "")).lower() == "true" + or str(metadata_component.get("has_animations", "")).lower() == "true" + or str(metadata_component.get("saved_has_animations", "")).lower() == "true" + or str(metadata_component.get("can_create_actor_from_memory", "")).lower() == "true" + or str(metadata_component.get("saved_can_create_actor_from_memory", "")).lower() == "true" + ) + except Exception: + has_saved_animation = False + + if has_saved_animation: + scene_manager = getattr(self.world, "scene_manager", None) + if scene_manager and hasattr(scene_manager, "importModel"): + try: + imported_np = scene_manager.importModel(asset_abs) + except Exception: + imported_np = None + if imported_np and not imported_np.isEmpty(): + self._apply_scene_description_state_to_node(imported_np, node, project_path) + loaded_any = True + continue + try: imported_root = ssbo_editor.load_model( asset_abs, @@ -1148,7 +1335,15 @@ class ProjectManager: if target_root is None: continue - self._apply_scene_description_state_to_node(target_root, node, project_path) + self._apply_scene_description_state_to_subtree(target_root, node, project_path, node_lookup) + # Keep runtime aligned with source after restoring per-node transforms. + # Otherwise the next append=True import snapshots stale runtime poses + # back into source_model_root and overwrites the restored transform. + if callable(refresh_runtime_fn): + try: + refresh_runtime_fn(preserve_selection=False) + except Exception: + pass loaded_any = True if not loaded_any: @@ -1494,6 +1689,14 @@ class ProjectManager: print("错误: 请先创建或打开一个项目!") return False + ssbo_editor = getattr(self.world, "ssbo_editor", None) + snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None) if ssbo_editor else None + if callable(snapshot_material_fn): + try: + snapshot_material_fn() + except Exception as e: + print(f"⚠️ 保存前同步 SSBO 材质到源场景失败: {e}") + project_path = self.current_project_path ensure_project_directories(ProjectLayout(project_path)) project_config = self._ensure_v2_project_defaults(project_path, dict(self.project_config or {})) @@ -2213,8 +2416,11 @@ class ProjectManager: for model in list(getattr(scene_manager, "models", []) or []): try: if tree_widget: - tree_widget.delete_item(model) - elif model and not model.isEmpty(): + try: + tree_widget.delete_item(model) + except Exception: + pass + if model and not model.isEmpty(): model.removeNode() except Exception: pass @@ -2225,8 +2431,11 @@ class ProjectManager: for light_node in collection: try: if tree_widget: - tree_widget.delete_item(light_node) - elif light_node and not light_node.isEmpty(): + try: + tree_widget.delete_item(light_node) + except Exception: + pass + if light_node and not light_node.isEmpty(): light_node.removeNode() except Exception: pass @@ -2237,8 +2446,11 @@ class ProjectManager: for terrain in terrains: try: if tree_widget: - tree_widget.delete_item(terrain) - elif terrain and not terrain.isEmpty(): + try: + tree_widget.delete_item(terrain) + except Exception: + pass + if terrain and not terrain.isEmpty(): terrain.removeNode() except Exception: pass diff --git a/project/scene_description.py b/project/scene_description.py index 62ef93bd..261b041c 100644 --- a/project/scene_description.py +++ b/project/scene_description.py @@ -174,10 +174,20 @@ def _collect_script_component(scripts: list[dict]) -> dict: def _collect_node_metadata_component(node, runtime_interactive: bool) -> dict: - return { + metadata = { "node_class": node.node().getClassType().getName() if node.node() else "", "runtime_interactive": runtime_interactive, } + for tag_name in ( + "has_animations", + "has_animations_checked", + "can_create_actor_from_memory", + "saved_has_animations", + "saved_can_create_actor_from_memory", + ): + if node.hasTag(tag_name): + metadata[tag_name] = node.getTag(tag_name) + return metadata def _collect_material_override_component(node): @@ -232,6 +242,10 @@ def _resolve_imported_node_key(node, fallback_key: str) -> str: def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool: if not node: return False + # Imported model roots and their descendants must keep hierarchy state so + # per-child transforms/material overrides survive save/load. + if asset_guid: + return True if not asset_guid: return True if scripts: @@ -447,6 +461,37 @@ def build_runtime_scene(scene_description: dict): for node in nodes: if not node.get("asset_guid"): continue + metadata_component = dict((node.get("components", {}) or {}).get("metadata", {}) or {}) + has_animations = str( + metadata_component.get( + "has_animations", + metadata_component.get( + "saved_has_animations", + (node.get("tags", {}) or {}).get( + "has_animations", + (node.get("tags", {}) or {}).get("saved_has_animations", ""), + ), + ), + ) + or "" + ).lower() == "true" + can_create_actor = str( + metadata_component.get( + "can_create_actor_from_memory", + metadata_component.get( + "saved_can_create_actor_from_memory", + (node.get("tags", {}) or {}).get( + "can_create_actor_from_memory", + (node.get("tags", {}) or {}).get("saved_can_create_actor_from_memory", ""), + ), + ), + ) + or "" + ).lower() == "true" + if has_animations or can_create_actor: + interactive_node_ids.append(node.get("node_id", "")) + interactive_model_names.append(node.get("name", "")) + continue if node.get("runtime_interactive"): interactive_node_ids.append(node.get("node_id", "")) interactive_model_names.append(node.get("name", "")) diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index a43c140e..4196818e 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -242,6 +242,20 @@ class ObjectController: return False + def get_preferred_selection_ids(self, key): + """Return the IDs that should be selected when a tree node is clicked. + + Prefer the node's own local geometry so parent transforms remain directly + selectable. Only fall back to the aggregated subtree when the node itself + has no local renderable geometry. + """ + node = self.tree_nodes.get(key) + if node: + local_ids = list(node.get("local_ids", []) or []) + if local_ids: + return local_ids + return list(self.name_to_ids.get(key, []) or []) + def _encode_id_color(self, vdata, object_id): if not vdata.has_column("color"): new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4()) diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 6978d24e..1840a625 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -79,6 +79,7 @@ class SSBOEditor: # Internal State self.selected_name = None self.selected_ids = [] + self.transform_ids = [] self.search_text = "" self.last_search_text = None self.filtered_nodes = [] @@ -666,22 +667,37 @@ class SSBOEditor: if not self._node_is_valid(source_node): continue + runtime_snapshot = None + if callable(capture_snapshot_fn): + try: + runtime_snapshot = capture_snapshot_fn(obj_np) + except Exception: + runtime_snapshot = None + source_snapshot = None if callable(capture_snapshot_fn): try: - source_snapshot = capture_snapshot_fn(obj_np) + source_snapshot = capture_snapshot_fn(source_node) except Exception: source_snapshot = None source_node_key = id(source_node) entry = grouped_entries.get(source_node_key) - candidate_score = _snapshot_score(source_snapshot, obj_np) + runtime_score = _snapshot_score(runtime_snapshot, obj_np) + source_score = _snapshot_score(source_snapshot, source_node) + # Saving should prefer the visible runtime state on ties; otherwise a + # stale source snapshot can overwrite a freshly edited child material. + if runtime_snapshot is not None and runtime_score >= source_score: + chosen_snapshot = runtime_snapshot + else: + chosen_snapshot = source_snapshot + candidate_score = max(runtime_score, source_score) if entry is None or candidate_score > entry["score"]: grouped_entries[source_node_key] = { "gid": gid, "obj_np": obj_np, "source_node": source_node, - "snapshot": source_snapshot, + "snapshot": chosen_snapshot, "score": candidate_score, } @@ -870,6 +886,7 @@ class SSBOEditor: self.model = None self.selected_name = None self.selected_ids = [] + self.transform_ids = [] self.last_import_tree_key = None self.last_import_root_name = None if not preserve_source_models: @@ -1957,13 +1974,14 @@ class SSBOEditor: if not self.controller: return self._sync_pick_root_transform() - if not self.selected_ids: + transform_ids = self.get_selection_transform_ids() + if not transform_ids: return # Group selection can contain thousands of objects. # Only resync when proxy transform has changed. proxy = getattr(self, "_group_proxy", None) - if proxy and not proxy.is_empty() and len(self.selected_ids) > 1: + if proxy and not proxy.is_empty() and len(transform_ids) > 1: proxy_world = proxy.get_mat(self.base.render) if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat): return @@ -1971,8 +1989,8 @@ class SSBOEditor: else: self._last_group_sync_mat = None - if len(self.selected_ids) == 1: - gid = self.selected_ids[0] + if len(transform_ids) == 1: + gid = transform_ids[0] obj_np = self.controller.id_to_object_np.get(gid) pick_np = self.controller.id_to_pick_np.get(gid) if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()): @@ -1996,7 +2014,7 @@ class SSBOEditor: self._last_single_sync_gid = None self._last_single_sync_mat = None - for gid in self.selected_ids: + for gid in transform_ids: obj_np = self.controller.id_to_object_np.get(gid) pick_np = self.controller.id_to_pick_np.get(gid) if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty(): @@ -2112,7 +2130,8 @@ class SSBOEditor: def _update_outline_for_selection(self): if not self._outline_manager: return - if not self.controller or not self.selected_ids: + transform_ids = self.get_selection_transform_ids() + if not self.controller or not transform_ids: self._outline_manager.clear() return @@ -2126,7 +2145,7 @@ class SSBOEditor: targets = [] target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64))) - for gid in self.selected_ids: + for gid in transform_ids: obj_np = self.controller.id_to_object_np.get(gid) if obj_np and not obj_np.is_empty(): targets.append(obj_np) @@ -2149,6 +2168,11 @@ class SSBOEditor: def has_active_selection(self): return bool(self.controller and self.selected_name is not None) + def get_selection_transform_ids(self): + if not self.controller or self.selected_name is None: + return [] + return list(getattr(self, "transform_ids", []) or []) + def _is_root_selection(self): return bool( self.controller and @@ -2161,6 +2185,7 @@ class SSBOEditor: return None has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {}) + transform_ids = self.get_selection_transform_ids() if self._is_root_selection(): return self.model if self._node_is_valid(self.model) else None @@ -2168,7 +2193,7 @@ class SSBOEditor: if not has_dynamic_objects: return self.model if self._node_is_valid(self.model) else None - if len(self.selected_ids) > 1: + if len(transform_ids) > 1: proxy = getattr(self, "_group_proxy", None) if proxy and self._node_is_valid(proxy): # Apply the original first node's material/tags to proxy transparently? @@ -2182,15 +2207,36 @@ class SSBOEditor: return None + def get_selection_runtime_material_node(self): + """Return the runtime node that material editing should target.""" + if not self.controller or not self.selected_ids: + return None + obj_np = self.controller.id_to_object_np.get(self.selected_ids[0]) + if self._node_is_valid(obj_np): + return obj_np + return None + + def get_selection_source_material_node(self): + """Return the exact source-tree node for the currently edited material target.""" + if not self.controller or not self.selected_ids: + return None + owner_key = self.controller.id_to_name.get(self.selected_ids[0]) + if not owner_key: + return None + source_node = self._resolve_source_node_by_tree_key(owner_key) + if self._node_is_valid(source_node): + return source_node + return None + def get_selection_summary(self): if not self.controller or self.selected_name is None: return None return { "key": self.selected_name, "display_name": self.controller.display_names.get(self.selected_name, self.selected_name), - "object_count": len(self.selected_ids), + "object_count": len(self.get_selection_transform_ids()), "is_root": self._is_root_selection(), - "is_group": len(self.selected_ids) > 1 and not self._is_root_selection(), + "is_group": len(self.get_selection_transform_ids()) > 1 and not self._is_root_selection(), } def get_selection_key(self): @@ -2298,6 +2344,7 @@ class SSBOEditor: self._cleanup_group_proxy() self.selected_name = None self.selected_ids = [] + self.transform_ids = [] if self._outline_manager: self._outline_manager.clear() if self.controller: @@ -2355,7 +2402,12 @@ class SSBOEditor: self._reset_pick_sync_cache() self.selected_name = key - self.selected_ids = self.controller.name_to_ids.get(key, []) + preferred_ids_fn = getattr(self.controller, "get_preferred_selection_ids", None) + if callable(preferred_ids_fn): + self.selected_ids = preferred_ids_fn(key) + else: + self.selected_ids = self.controller.name_to_ids.get(key, []) + self.transform_ids = list(self.controller.name_to_ids.get(key, []) or []) has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {}) is_root_selection = ( self.controller and @@ -2367,7 +2419,7 @@ class SSBOEditor: root_key = getattr(self.controller, "tree_root_key", None) root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {} top_level_children = set(root_node.get("children", []) or []) - is_top_level_heavy_selection = key in top_level_children and len(self.selected_ids) >= 256 + is_top_level_heavy_selection = key in top_level_children and len(self.transform_ids) >= 256 except Exception: is_top_level_heavy_selection = False if sync_world_selection: @@ -2388,17 +2440,17 @@ class SSBOEditor: self._stop_pick_sync_task() return - self.controller.set_active_ids(self.selected_ids) + self.controller.set_active_ids(self.transform_ids) self._update_outline_for_selection() - if not self._transform_gizmo or not self.selected_ids: + if not self._transform_gizmo or not self.transform_ids: if self._transform_gizmo: self._transform_gizmo.detach() return - if len(self.selected_ids) == 1: + if len(self.transform_ids) == 1: # Single object: attach gizmo directly - obj_np = self.controller.id_to_object_np.get(self.selected_ids[0]) + obj_np = self.controller.id_to_object_np.get(self.transform_ids[0]) if obj_np and not obj_np.is_empty(): self._transform_gizmo.attach(obj_np) self._start_pick_sync_task() @@ -2416,7 +2468,7 @@ class SSBOEditor: pass center = Vec3(0, 0, 0) valid = [] - for gid in self.selected_ids: + for gid in self.transform_ids: obj_np = self.controller.id_to_object_np.get(gid) if obj_np and not obj_np.is_empty(): center += obj_np.get_pos(self.base.render) @@ -2494,8 +2546,9 @@ class SSBOEditor: self.filtered_nodes = rows def focus_on_selected(self): - if self.selected_name and self.selected_ids: - first_id = self.selected_ids[0] + transform_ids = self.get_selection_transform_ids() + if self.selected_name and transform_ids: + first_id = transform_ids[0] pos = self.controller.get_world_pos(first_id) dist = 100 self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5) @@ -2552,7 +2605,8 @@ class SSBOEditor: if io.want_capture_keyboard: return task.cont - if self.selected_ids and self.controller: + transform_ids = self.get_selection_transform_ids() + if transform_ids and self.controller: speed = 50 * dt acc = Vec3(0, 0, 0) if self.keys.get('arrow_up'): acc.z += speed @@ -2574,7 +2628,7 @@ class SSBOEditor: self.model.set_pos(next_pos) self._sync_pick_root_transform() else: - for idx in self.selected_ids: + for idx in transform_ids: self.controller.move_object(idx, acc) return task.cont diff --git a/templates/main_template.py b/templates/main_template.py index d4fcf56e..05ed1ced 100644 --- a/templates/main_template.py +++ b/templates/main_template.py @@ -355,7 +355,7 @@ class MainApp(ShowBase): imported_node_key = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "") if asset_guid: - loaded_np = self._load_runtime_asset_node(asset_guid, imported_node_key, node_name) + loaded_np = self._load_runtime_asset_node(asset_guid, imported_node_key, node_name, node) rebuilt_np = loaded_np if loaded_np and not loaded_np.isEmpty() else parent_np.attachNewNode(node_name) if rebuilt_np.getParent() != parent_np: rebuilt_np.reparentTo(parent_np) @@ -394,27 +394,55 @@ class MainApp(ShowBase): return rebuilt_np - def _load_runtime_asset_node(self, asset_guid, imported_node_key="", node_name=""): + def _load_runtime_asset_node(self, asset_guid, imported_node_key="", node_name="", node_data=None): asset_record = dict(self._asset_index.get(str(asset_guid or ""), {}) or {}) if not asset_record: return None candidate_paths = [] - imported_cache = dict(asset_record.get("imported_cache", {}) or {}) - imported_model_rel = str(imported_cache.get("model_bam", "") or "").replace("\\", "/").strip() - if imported_model_rel: - candidate_paths.append(os.path.join(DATA_ROOT, imported_model_rel.replace("/", os.sep))) + node_data = dict(node_data or {}) + components = dict(node_data.get("components", {}) or {}) + metadata_component = dict(components.get("metadata", {}) or {}) + node_tags = dict(node_data.get("tags", {}) or {}) + has_animations = str( + metadata_component.get( + "has_animations", + metadata_component.get( + "saved_has_animations", + node_tags.get("has_animations", node_tags.get("saved_has_animations", "")), + ), + ) + or "" + ).lower() == "true" + can_create_actor = str( + metadata_component.get( + "can_create_actor_from_memory", + metadata_component.get( + "saved_can_create_actor_from_memory", + node_tags.get( + "can_create_actor_from_memory", + node_tags.get("saved_can_create_actor_from_memory", ""), + ), + ), + ) + or "" + ).lower() == "true" asset_dir = os.path.join(DATA_ROOT, "assets", asset_guid) - imported_model_path = os.path.join(asset_dir, "imported", "model.bam") - if os.path.exists(imported_model_path): - candidate_paths.append(imported_model_path) - asset_path = str(asset_record.get("asset_path", "") or "").replace("\\", "/").strip() if asset_path: candidate_paths.append(os.path.join(DATA_ROOT, asset_path.replace("/", os.sep))) candidate_paths.append(os.path.join(asset_dir, os.path.basename(asset_path))) + imported_cache = dict(asset_record.get("imported_cache", {}) or {}) + imported_model_rel = str(imported_cache.get("model_bam", "") or "").replace("\\", "/").strip() + if imported_model_rel and not (has_animations or can_create_actor): + candidate_paths.append(os.path.join(DATA_ROOT, imported_model_rel.replace("/", os.sep))) + + imported_model_path = os.path.join(asset_dir, "imported", "model.bam") + if os.path.exists(imported_model_path) and not (has_animations or can_create_actor): + candidate_paths.append(imported_model_path) + for candidate_path in candidate_paths: if not candidate_path or not os.path.exists(candidate_path): continue diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index dda9678b..a700d830 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -948,7 +948,7 @@ class AnimationTools: except Exception: has_animation_nodes = False - def _try_memory_fallback(): + def _try_memory_fallback(prefer_scene_proxy=False): def _collect_autobind_source_candidates(): candidates = [] @@ -1032,6 +1032,22 @@ class AnimationTools: can_create_from_memory = True if can_create_from_memory: + autobind_candidates = _collect_autobind_source_candidates() + + if prefer_scene_proxy: + for source_node in autobind_candidates: + mem_proxy = _try_create_autobind_proxy( + source_node, + f"内存模型({source_node.getName()})", + owns_node=False + ) + if mem_proxy: + self._actor_cache[owner_model] = mem_proxy + owner_model.setTag("has_animations", "true") + if source_node != owner_model: + print(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}") + return mem_proxy + # 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。 # 先用副本创建真实 Actor,只有失败时才退回 autoBind 代理。 clone_parent = self._get_owner_parent_node(owner_model) @@ -1055,7 +1071,7 @@ class AnimationTools: pass # Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源 - for source_node in _collect_autobind_source_candidates(): + for source_node in autobind_candidates: mem_proxy = _try_create_autobind_proxy( source_node, f"内存模型({source_node.getName()})", @@ -1084,9 +1100,25 @@ class AnimationTools: filepath = owner_model.getTag("original_path") print(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: print(f"[Actor加载调试] filepath为空,触发 _try_memory_fallback()") - return _try_memory_fallback() + return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy) + + if prefer_scene_proxy: + memory_actor = _try_memory_fallback(prefer_scene_proxy=True) + if memory_actor: + print(f"[Actor加载] {owner_model.getName()} 使用场景内 autoBind 代理优先路径") + return memory_actor # 针对Actor加载,必须使用Panda3D规范的Unix风格路径,否则Windows绝对路径会导致加载彻底崩溃并返回空节点 panda_specific_path = "" @@ -1172,7 +1204,7 @@ class AnimationTools: pass # 所有创建路径失败时由内存加载进行兜底 - return _try_memory_fallback() + return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy) def _getModelFormat(self, origin_model): diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index a40fe287..e7e1f6b4 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -41,6 +41,37 @@ class AppActions: return node + def _model_file_has_animation(self, file_path): + """Detect whether a model file contains animation-related structures.""" + try: + if not file_path or not os.path.exists(file_path): + return False + loader = getattr(self, "loader", None) + if not loader: + return False + normalized_path = file_path + try: + from scene import util as scene_util + normalized_path = scene_util.normalize_model_path(file_path) + except Exception: + normalized_path = file_path + model = loader.loadModel(normalized_path) + if not model or model.isEmpty(): + return False + try: + has_animation = ( + model.findAllMatches("**/+Character").getNumPaths() > 0 + or model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0 + ) + finally: + try: + model.removeNode() + except Exception: + pass + return bool(has_animation) + except Exception: + return False + def _toggle_hot_reload(self): """切换热重载状态""" @@ -1485,11 +1516,13 @@ class AppActions: except Exception as e: print(f"项目资源导入失败,回退直接导入: {e}") + animated_model = bool(file_path and self._model_file_has_animation(file_path)) + command_manager = getattr(self, 'command_manager', None) if scene_package_import or not command_manager: return self._import_model_for_runtime(file_path, scene_package_import=scene_package_import) - if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): + if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None) and not animated_model: return self._execute_ssbo_import_command(file_path) from core.Command_System import CreateNodeCommand @@ -1507,31 +1540,44 @@ class AppActions: SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager). Legacy mode: load via SceneManager. """ - if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): - try: - # Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes. - if hasattr(self, 'selection') and self.selection: - try: - self.selection.clearSelection() - except Exception: - try: - self.selection.updateSelection(None) - except Exception: - pass + animated_model = bool(file_path and self._model_file_has_animation(file_path)) + if animated_model: + prefer_scene_manager = True + ssbo_editor = getattr(self, 'ssbo_editor', None) + if ssbo_editor and hasattr(ssbo_editor, "clear_selection"): + try: + ssbo_editor.clear_selection(sync_world_selection=False) + except Exception: + pass - self.ssbo_editor.load_model( - file_path, - keep_source_model=scene_package_import, - append=not scene_package_import, - scene_package_import=scene_package_import, - ) - return self._refresh_ssbo_runtime_import_bindings( - file_path=file_path, - scene_package_import=scene_package_import, - ) - except Exception as e: - print(f"[SSBO] load_model failed: {e}") - return None + if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): + if animated_model: + print(f"[AnimationImport] 检测到动画模型,跳过SSBO导入: {file_path}") + else: + try: + # Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes. + if hasattr(self, 'selection') and self.selection: + try: + self.selection.clearSelection() + except Exception: + try: + self.selection.updateSelection(None) + except Exception: + pass + + self.ssbo_editor.load_model( + file_path, + keep_source_model=scene_package_import, + append=not scene_package_import, + scene_package_import=scene_package_import, + ) + return self._refresh_ssbo_runtime_import_bindings( + file_path=file_path, + scene_package_import=scene_package_import, + ) + except Exception as e: + print(f"[SSBO] load_model failed: {e}") + return None # Legacy fallback if hasattr(self, 'scene_manager') and self.scene_manager: diff --git a/ui/panels/dialog_panels.py b/ui/panels/dialog_panels.py index 6f958583..8e32a978 100644 --- a/ui/panels/dialog_panels.py +++ b/ui/panels/dialog_panels.py @@ -647,17 +647,16 @@ class DialogPanels: ) self.style_manager.prepare_centered_dialog(460, 260) - with imgui_ctx.begin("关于 EG", True, flags) as window: + with imgui_ctx.begin("关于 MetaCore", True, flags) as window: if not window.opened: self.show_about_dialog = False return current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) or "未打开项目" - imgui.text("EG 编辑器") + imgui.text("元泰引擎 MetaCore V1.0") imgui.separator() - imgui.text(f"Python: {platform.python_version()}") imgui.text(f"平台: {platform.system()} {platform.release()}") - imgui.text(f"可执行文件: {os.path.basename(sys.executable)}") + imgui.text("可执行文件: MetaCore.exe") imgui.separator() imgui.text("当前项目:") imgui.text_colored((0.7, 0.7, 0.7, 1.0), current_project_path) diff --git a/ui/panels/editor_panels_left.py b/ui/panels/editor_panels_left.py index ae41e10f..402a51e2 100644 --- a/ui/panels/editor_panels_left.py +++ b/ui/panels/editor_panels_left.py @@ -439,7 +439,11 @@ class EditorPanelsLeftMixin: return display = controller.display_names.get(key, key) - obj_count = len(controller.name_to_ids.get(key, [])) + preferred_ids_fn = getattr(controller, "get_preferred_selection_ids", None) + if callable(preferred_ids_fn): + obj_count = len(preferred_ids_fn(key)) + else: + obj_count = len(controller.name_to_ids.get(key, [])) children = node_data["children"] is_selected = (getattr(ssbo_editor, "selected_name", None) == key) @@ -484,7 +488,11 @@ class EditorPanelsLeftMixin: return display = str(virtual_node.get("display_name", virtual_node.get("name", key)) or key) - obj_count = len(controller.name_to_ids.get(key, [])) + preferred_ids_fn = getattr(controller, "get_preferred_selection_ids", None) + if callable(preferred_ids_fn): + obj_count = len(preferred_ids_fn(key)) + else: + obj_count = len(controller.name_to_ids.get(key, [])) is_selected = (getattr(ssbo_editor, "selected_name", None) == key) children = list((virtual_node.get("children", {}) or {}).values()) diff --git a/ui/panels/editor_panels_right.py b/ui/panels/editor_panels_right.py index 2c55c364..15fd0ceb 100644 --- a/ui/panels/editor_panels_right.py +++ b/ui/panels/editor_panels_right.py @@ -235,10 +235,21 @@ class EditorPanelsRightMixin( elif node_type == "光源": self._draw_property_section("光源", lambda: self.app._draw_light_properties(node), default_open=True) elif node_type == "模型": + has_animation = False + try: + if node.hasTag("has_animations"): + has_animation = node.getTag("has_animations").lower() == "true" + if not has_animation: + has_animation = node.getPythonTag("animation") is True + except Exception: + has_animation = False self._draw_property_section("模型", lambda: self.app._draw_model_properties(node), default_open=True) - self._draw_property_section("动画", lambda: self.app._draw_animation_properties(node), default_open=False) + self._draw_property_section("动画", lambda: self.app._draw_animation_properties(node), default_open=has_animation) - self._draw_property_section("材质", lambda: self.app._draw_appearance_properties(node), default_open=True) + material_target = self.app._get_selection_material_node() + if not material_target or material_target.isEmpty(): + material_target = node + self._draw_property_section("材质", lambda target=material_target: self.app._draw_appearance_properties(target), default_open=True) self._draw_property_section("碰撞", lambda: self.app._draw_collision_properties(node), default_open=False) self._draw_property_section("操作", lambda: self.app._draw_property_actions(node), default_open=False) diff --git a/ui/panels/editor_panels_right_material.py b/ui/panels/editor_panels_right_material.py index 77f4bac0..e40b7d7a 100644 --- a/ui/panels/editor_panels_right_material.py +++ b/ui/panels/editor_panels_right_material.py @@ -8,11 +8,36 @@ class EditorPanelsRightMaterialMixin: self._material_edit_sessions = {} return self._material_edit_sessions + def _sync_material_panel_target(self, node): + """Reset per-node material UI state when selection changes.""" + target_key = None + try: + target_key = getattr(node, "this", None) or id(node) + except Exception: + target_key = id(node) + + if getattr(self, "_material_panel_target_key", None) == target_key: + return + + self._material_panel_target_key = target_key + self._material_edit_sessions = {} + property_helpers = getattr(self.app, "property_helpers", None) + ensure_unique_fn = getattr(property_helpers, "_ensure_unique_materials_for_node", None) + if callable(ensure_unique_fn): + ensure_unique_fn(node) + def _ensure_node_materials_are_editable(self, node): + self._sync_material_panel_target(node) ensure_unique_fn = getattr(self.app, "_ensure_unique_materials_for_node", None) if callable(ensure_unique_fn): try: - materials = ensure_unique_fn(node) + ensure_unique_fn(node) + except Exception: + pass + 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 materials except Exception: @@ -110,11 +135,11 @@ class EditorPanelsRightMaterialMixin: def apply_primary_color(color): for current_material in materials: self.app._set_material_base_color(current_material, color) - self.app._apply_material_to_geom_states(node, current_material) if self.app._get_material_surface_type(current_material) == 3: self.app._set_material_opacity(node, current_material, color[3]) else: - self.app._apply_material_surface_state(node, current_material) + self.app._sync_material_node_runtime(node, current_material, refresh_ssbo_runtime=False) + self._refresh_ssbo_runtime_for_material_node(node) def apply_surface_type(surface_type): for current_material in materials: diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py index de2cd9e3..16bc144e 100644 --- a/ui/panels/panel_delegates.py +++ b/ui/panels/panel_delegates.py @@ -50,6 +50,18 @@ class PanelDelegates: return source_node return self._get_selection_node() + def _get_selection_material_node(self): + """Return the node that material editing should target.""" + ssbo_editor = getattr(self, "ssbo_editor", None) + if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): + source_node = getattr(ssbo_editor, "get_selection_source_material_node", lambda: None)() + if source_node and self._node_is_valid(source_node, require_attached=False): + return source_node + runtime_node = getattr(ssbo_editor, "get_selection_runtime_material_node", lambda: None)() + if runtime_node and self._node_is_valid(runtime_node, require_attached=False): + return runtime_node + return self._get_selection_source_node() + def _get_selection_key(self): ssbo_editor = getattr(self, "ssbo_editor", None) if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): @@ -339,6 +351,9 @@ class PanelDelegates: def _apply_material_to_geom_states(self, *args, **kwargs): return self.property_helpers._apply_material_to_geom_states(*args, **kwargs) + def _sync_material_node_runtime(self, *args, **kwargs): + return self.property_helpers._sync_material_node_runtime(*args, **kwargs) + def _update_material_base_color(self, *args, **kwargs): return self.property_helpers._update_material_base_color(*args, **kwargs) diff --git a/ui/panels/property_helpers.py b/ui/panels/property_helpers.py index 75cf4b4e..1b1777ae 100644 --- a/ui/panels/property_helpers.py +++ b/ui/panels/property_helpers.py @@ -1101,6 +1101,7 @@ class PropertyHelpers: self._apply_material_to_geom_states(node, material) self._apply_material_surface_state(node, material) + self._persist_material_tags(node, material) self._clear_all_textures(node) texture_tags = node_state.get("textures", {}) or {} @@ -1148,31 +1149,68 @@ class PropertyHelpers: if not node or node.isEmpty() or material is None: return - target_geom_paths = self._get_geom_paths_for_material(node, material) - if not target_geom_paths: + target_geom_paths = [] + force_subtree_override = False + direct_geom_target = self._is_geom_node_path(node) + + try: + if node.hasMaterial(): + node_material = node.getMaterial() + if self._get_material_identity_key(node_material) == self._get_material_identity_key(material): + force_subtree_override = True + except Exception: + force_subtree_override = False + + if direct_geom_target: + target_geom_paths = [node] + elif force_subtree_override: try: - if node.hasMaterial(): - node.setMaterial(material, 1) + node.setMaterial(material, 1) except Exception: pass try: target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")] except Exception: target_geom_paths = [] + else: + target_geom_paths = self._get_geom_paths_for_material(node, material) if not target_geom_paths: - self._invalidate_material_render_cache() - return + try: + if node.hasMaterial(): + node.setMaterial(material, 1) + except Exception: + pass + try: + target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")] + except Exception: + target_geom_paths = [] + + if not target_geom_paths: + self._invalidate_material_render_cache() + return + + if direct_geom_target: + try: + if node.hasMaterial(): + node.clearMaterial() + except Exception: + pass + try: + node.clearAttrib(MaterialAttrib.getClassType()) + except Exception: + pass for geom_path in target_geom_paths: - try: - geom_path.setMaterial(material, 1) - except Exception: - pass + if not direct_geom_target: + try: + geom_path.setMaterial(material, 1) + except Exception: + pass - try: - geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material))) - except Exception: - pass + try: + geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material))) + except Exception: + pass geom_node = geom_path.node() for i in range(geom_node.getNumGeoms()): @@ -1189,6 +1227,8 @@ class PropertyHelpers: """If editing a SSBO source node, rebuild the runtime proxy immediately.""" try: ssbo_editor = getattr(self, "ssbo_editor", None) + if ssbo_editor is None: + ssbo_editor = getattr(getattr(self, "app", None), "ssbo_editor", None) if not ssbo_editor or not node or node.isEmpty(): return if not hasattr(ssbo_editor, "is_source_tree_node") or not ssbo_editor.is_source_tree_node(node): @@ -1210,6 +1250,7 @@ class PropertyHelpers: continue self._apply_material_to_geom_states(node, current_material) self._apply_material_surface_state(node, current_material) + self._persist_material_tags(node, current_material) refresh_pipeline = getattr(self, "_refresh_pipeline_material_mode", None) if callable(refresh_pipeline): try: @@ -1222,21 +1263,72 @@ class PropertyHelpers: except Exception as e: print(f"同步材质显示状态失败: {e}") + def _persist_material_tags(self, node, material): + """Mirror editable material values into node tags for project serialization.""" + try: + if not node or node.isEmpty() or material is None: + return + + emission = self._get_material_emission(material) + if emission is not None: + node.setTag("material_emission", str(tuple(float(v) for v in emission))) + + base_color = self._get_material_base_color(material) + if base_color is not None: + node.setTag("material_basecolor", str(tuple(float(v) for v in base_color))) + + scalar_values = { + "material_roughness": float(material.roughness) if hasattr(material, "roughness") and material.roughness is not None else None, + "material_metallic": float(material.metallic) if hasattr(material, "metallic") and material.metallic is not None else None, + "material_ior": float(material.refractive_index) if hasattr(material, "refractive_index") and material.refractive_index is not None else None, + } + for tag_name, value in scalar_values.items(): + if value is not None: + node.setTag(tag_name, str(float(value))) + except Exception: + pass + def _get_node_materials(self, node): """Return the editable materials currently used by a node.""" if not node or node.isEmpty(): return [] + materials = [] + try: if node.hasMaterial(): - return [node.getMaterial()] + node_material = node.getMaterial() + if node_material is not None: + materials.append(node_material) except Exception: pass try: - materials = list(node.find_all_materials()) + materials.extend(list(node.find_all_materials())) except Exception: - materials = [] + pass + + # `find_all_materials()` can miss effective inherited material state on some + # wrapper/transform nodes, so inspect renderable children as a fallback. + try: + from panda3d.core import MaterialAttrib + + for geom_path in node.find_all_matches("**/+GeomNode"): + try: + if geom_path.hasMaterial(): + geom_material = geom_path.getMaterial() + if geom_material is not None: + materials.append(geom_material) + net_state = geom_path.getNetState() + if net_state.hasAttrib(MaterialAttrib): + material_attrib = net_state.getAttrib(MaterialAttrib) + geom_material = material_attrib.getMaterial() if material_attrib else None + if geom_material is not None: + materials.append(geom_material) + except Exception: + continue + except Exception: + pass unique_materials = [] seen_keys = set() @@ -1284,7 +1376,10 @@ class PropertyHelpers: if not node or node.isEmpty(): return [] - materials = self._get_node_materials(node) + if self._is_geom_node_path(node): + materials = self._get_panel_edit_materials_for_node(node) + else: + materials = self._get_node_materials(node) if not materials: return [] @@ -1298,8 +1393,12 @@ class PropertyHelpers: stored_signature = tuple(node.getPythonTag("_editable_material_signature")) except Exception: stored_signature = None + try: + stored_isolated = bool(node.getPythonTag("_editable_material_isolated")) + except Exception: + stored_isolated = False - if stored_signature == current_signature: + if stored_signature == current_signature and stored_isolated: return materials node_material_key = None @@ -1311,6 +1410,7 @@ class PropertyHelpers: changed = False cloned_materials = [] + direct_geom_target = self._is_geom_node_path(node) for material in materials: if material is None: @@ -1324,31 +1424,54 @@ class PropertyHelpers: continue try: - if node_material_key is not None and source_key == node_material_key: + if node_material_key is not None and source_key == node_material_key and not direct_geom_target: node.setMaterial(cloned_material, 1) changed = True except Exception: pass - geom_paths = self._get_geom_paths_for_material(node, material) + if direct_geom_target: + geom_paths = [node] + try: + if node.hasMaterial(): + node.clearMaterial() + except Exception: + pass + try: + node.clearAttrib(MaterialAttrib.getClassType()) + except Exception: + pass + else: + geom_paths = self._get_geom_paths_for_material(node, material) if not geom_paths and node_material_key is None: try: - node.setMaterial(cloned_material, 1) - changed = True + has_geom_descendants = bool(node.find_all_matches("**/+GeomNode")) except Exception: - pass + has_geom_descendants = False + + # Only fall back to applying on the wrapper node when it is the + # actual render target. For transform parents, a blind node-level + # override creates an editable material that does not affect the + # visible child geom states. + if not has_geom_descendants: + try: + node.setMaterial(cloned_material, 1) + changed = True + except Exception: + pass for geom_path in geom_paths: - try: - geom_path.setMaterial(cloned_material, 1) - changed = True - except Exception: - pass + if not direct_geom_target: + try: + geom_path.setMaterial(cloned_material, 1) + changed = True + except Exception: + pass - try: - geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material))) - except Exception: - pass + try: + geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material))) + except Exception: + pass try: geom_node = geom_path.node() @@ -1378,6 +1501,10 @@ class PropertyHelpers: node.setPythonTag("_editable_material_signature", latest_signature) except Exception: pass + try: + node.setPythonTag("_editable_material_isolated", bool(changed)) + except Exception: + pass return latest_materials except Exception as e: @@ -1390,6 +1517,27 @@ class PropertyHelpers: except Exception: return id(material) + def _is_geom_node_path(self, node): + """Return whether the given NodePath directly wraps a GeomNode.""" + try: + if not node or node.isEmpty(): + return False + panda_node = node.node() + if panda_node is None: + return False + if hasattr(panda_node, "isGeomNode"): + try: + return bool(panda_node.isGeomNode()) + except Exception: + pass + from panda3d.core import GeomNode + try: + return panda_node.is_of_type(GeomNode.get_class_type()) + except Exception: + return isinstance(panda_node, GeomNode) + except Exception: + return False + def _geom_uses_material(self, geom_path, material): try: from panda3d.core import MaterialAttrib @@ -1474,6 +1622,34 @@ class PropertyHelpers: print(f"创建默认材质失败: {e}") return None + def _get_panel_edit_materials_for_node(self, node): + """Return the material instances the property panel should edit for this node. + + Parent/group nodes should edit a single node-level override material instead + of mutating every descendant material individually. + """ + if not node or node.isEmpty(): + return [] + + if self._is_geom_node_path(node): + material = self._get_renderable_node_material(node) + if material is None: + material = self._ensure_material_for_node(node) + return [material] if material is not None else [] + + try: + geom_descendants = list(node.find_all_matches("**/+GeomNode")) + except Exception: + geom_descendants = [] + + if node.hasMaterial(): + return self._get_node_materials(node) + + if geom_descendants: + return [] + + return self._get_node_materials(node) + def _get_material_surface_type(self, material): """Return the RenderPipeline shading model value used by this material.""" try: @@ -1800,6 +1976,8 @@ class PropertyHelpers: try: if not node or node.isEmpty(): return [] + if self._is_geom_node_path(node): + return [node] renderable_nodes = [match for match in node.find_all_matches("**/+GeomNode")] if renderable_nodes: return renderable_nodes @@ -1899,13 +2077,28 @@ class PropertyHelpers: opacity = self._get_material_opacity(material) if is_transparent else 1.0 base_color = self._get_material_transparent_base_color(material) - target_nodes = self._get_geom_paths_for_material(node, material) - if not target_nodes: + force_subtree_override = False + direct_geom_target = self._is_geom_node_path(node) + try: + if node.hasMaterial(): + node_material = node.getMaterial() + if self._get_material_identity_key(node_material) == self._get_material_identity_key(material): + force_subtree_override = True + except Exception: + force_subtree_override = False + + if direct_geom_target: + target_nodes = [node] + elif force_subtree_override: target_nodes = self._iter_material_state_nodes(node) + else: + target_nodes = self._get_geom_paths_for_material(node, material) + if not target_nodes: + target_nodes = self._iter_material_state_nodes(node) for target_node in target_nodes: target_material = material - if target_material is not None: + if target_material is not None and not direct_geom_target: try: target_node.setMaterial(target_material, 1) except Exception: