diff --git a/eg_smoke_x76dg4m2/eg_smoke_x76dg4m2.png b/eg_smoke_x76dg4m2/eg_smoke_x76dg4m2.png deleted file mode 100644 index 3d4374f4..00000000 Binary files a/eg_smoke_x76dg4m2/eg_smoke_x76dg4m2.png and /dev/null differ diff --git a/eg_smoke_x76dg4m2/smoke_project/project.json b/eg_smoke_x76dg4m2/smoke_project/project.json deleted file mode 100644 index 3fd3d26b..00000000 --- a/eg_smoke_x76dg4m2/smoke_project/project.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "smoke_project", - "path": "D:\\IMGUI\\EG\\eg_smoke_x76dg4m2\\smoke_project", - "created_at": "2026-03-15 17:36:38", - "last_modified": "2026-03-15 17:36:39", - "version": "1.0.0", - "engine_version": "1.0.0", - "scene_file": "scenes\\scene.bam" -} \ No newline at end of file diff --git a/eg_smoke_x76dg4m2/smoke_project/scenes/scene.bam b/eg_smoke_x76dg4m2/smoke_project/scenes/scene.bam deleted file mode 100644 index 95d4c16b..00000000 Binary files a/eg_smoke_x76dg4m2/smoke_project/scenes/scene.bam and /dev/null differ diff --git a/eg_smoke_x76dg4m2/smoke_project/smoke_project.png b/eg_smoke_x76dg4m2/smoke_project/smoke_project.png deleted file mode 100644 index 3d4374f4..00000000 Binary files a/eg_smoke_x76dg4m2/smoke_project/smoke_project.png and /dev/null differ diff --git a/imgui.ini b/imgui.ini index 1159ccb0..6424dec7 100644 --- a/imgui.ini +++ b/imgui.ini @@ -31,19 +31,19 @@ DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=339,1084 +Size=339,1008 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=1694,20 -Size=346,1084 +Pos=1506,20 +Size=346,1008 Collapsed=0 DockId=0x00000002,0 [Window][控制台] -Pos=341,705 -Size=1351,399 +Pos=341,629 +Size=1163,399 Collapsed=0 DockId=0x00000006,1 @@ -59,7 +59,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=2040,1084 +Size=1852,1008 Collapsed=0 [Window][测试窗口1] @@ -78,17 +78,17 @@ Size=93,65 Collapsed=0 [Window][新建项目] -Pos=1080,525 +Pos=824,402 Size=400,300 Collapsed=0 [Window][选择路径] -Pos=720,302 +Pos=626,264 Size=600,500 Collapsed=0 [Window][打开项目] -Pos=770,352 +Pos=676,314 Size=500,400 Collapsed=0 @@ -98,8 +98,8 @@ Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=341,705 -Size=1351,399 +Pos=341,629 +Size=1163,399 Collapsed=0 DockId=0x00000006,0 @@ -226,7 +226,7 @@ Size=460,260 Collapsed=0 [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2040,1084 Split=X +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1852,1008 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=2212,1012 Split=X DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051 DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1871,1084 Split=Y diff --git a/project/project_manager.py b/project/project_manager.py index ed4f1a48..465a52c3 100644 --- a/project/project_manager.py +++ b/project/project_manager.py @@ -47,6 +47,102 @@ class ProjectManager: def _get_repo_root(self): return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + def _vector_differs(self, current_value, target_values, tolerance=1e-4): + try: + current_list = [float(current_value[i]) for i in range(3)] + except Exception: + try: + current_list = [float(current_value.x), float(current_value.y), float(current_value.z)] + except Exception: + current_list = [] + if len(current_list) < 3: + return True + try: + target_list = [float(target_values[0]), float(target_values[1]), float(target_values[2])] + except Exception: + return True + return any(abs(current_list[i] - target_list[i]) > tolerance for i in range(3)) + + def _log_render_runtime_stats(self, label): + """Print renderer/task stats to compare manual import vs project-open paths.""" + world = getattr(self, "world", None) + if not world: + return + + try: + win = getattr(world, "win", None) + num_regions = win.getNumDisplayRegions() if win else 0 + region_lines = [] + if win: + for index in range(num_regions): + try: + dr = win.getDisplayRegion(index) + camera = dr.getCamera() + camera_name = "None" + if camera and not camera.isEmpty(): + camera_name = camera.getName() + region_lines.append( + f"{index}:{dr.getSort()}:{int(bool(dr.isActive()))}:{camera_name}" + ) + except Exception: + continue + + graphics_engine = getattr(world, "graphicsEngine", None) + num_windows = graphics_engine.getNumWindows() if graphics_engine else 0 + + render = getattr(world, "render", None) + camera_count = 0 + special_camera_counts = {} + if render and not render.isEmpty(): + for pattern in ("**/+Camera",): + try: + camera_count += render.find_all_matches(pattern).get_num_paths() + except Exception: + pass + for camera_name in ( + "gizmo_overlay_cam", + "pick_camera", + "selection_outline_mask_camera", + ): + try: + special_camera_counts[camera_name] = render.find_all_matches( + f"**/{camera_name}" + ).get_num_paths() + except Exception: + special_camera_counts[camera_name] = 0 + + task_mgr = getattr(world, "taskMgr", None) or getattr(world, "task_mgr", None) + task_names = [] + if task_mgr: + try: + for task in list(task_mgr.getTasks()): + try: + task_names.append(task.name) + except Exception: + continue + except Exception: + task_names = [] + interesting_tasks = [ + name for name in task_names + if ( + "gizmo" in str(name).lower() + or "outline" in str(name).lower() + or "pick" in str(name).lower() + or "lui" in str(name).lower() + or "canvas" in str(name).lower() + ) + ] + + print( + f"[RenderStats:{label}] regions={num_regions} windows={num_windows} " + f"render_cameras={camera_count} special={special_camera_counts} " + f"interesting_tasks={interesting_tasks}" + ) + if region_lines: + print(f"[RenderStats:{label}] region_detail={' | '.join(region_lines)}") + except Exception: + pass + def get_project_scripts_dir(self, project_path=None): project_path = os.path.normpath(project_path or self.current_project_path or "") if not project_path: @@ -689,6 +785,10 @@ class ProjectManager: """ # print(f"\n[DEBUG] ===== 开始打开项目: {project_path} =====") try: + try: + self.world._scene_tree_epoch = int(getattr(self.world, "_scene_tree_epoch", 0) or 0) + 1 + except Exception: + self.world._scene_tree_epoch = 1 if not project_path: print("错误: 项目路径不能为空") return False @@ -913,6 +1013,14 @@ class ProjectManager: scene_file_path = os.path.join(project_path, scene_entry["path"].replace("/", os.sep)) save_json(scene_file_path, scene_description) self._write_scene_sidecars(scene_paths, gui_snapshot, lui_snapshot) + try: + print( + f"[SceneSave] roots={len(root_nodes)} nodes={len(scene_description.get('nodes', []) or [])} " + f"assets={len(scene_description.get('referenced_asset_guids', []) or [])} " + f"scripts={len(scene_description.get('referenced_script_guids', []) or [])}" + ) + except Exception: + pass return scene_description def _load_scene_description_into_editor(self, scene_description, project_path, scene_entry=None): @@ -921,6 +1029,14 @@ class ProjectManager: return False self._clearCurrentScene() + self._log_render_runtime_stats("after_clear") + try: + print( + f"[SceneOpen] scene_nodes={len(scene_description.get('nodes', []) or [])} " + f"assets={len(scene_description.get('referenced_asset_guids', []) or [])}" + ) + except Exception: + pass ssbo_loaded = self._load_scene_description_via_ssbo(scene_description, project_path, asset_database) scene_root, keep_nodes = self._build_scene_root_from_description( @@ -945,6 +1061,22 @@ class ProjectManager: except Exception: root_children = [] + try: + ssbo_editor = getattr(self.world, "ssbo_editor", None) + runtime_model = getattr(ssbo_editor, "model", None) if ssbo_editor else None + runtime_desc = 0 + if runtime_model and not runtime_model.isEmpty(): + try: + runtime_desc = runtime_model.find_all_matches("**").get_num_paths() + except Exception: + runtime_desc = 0 + print( + f"[SceneOpen] ssbo_loaded={ssbo_loaded} keep_nodes={len(keep_nodes)} " + f"scene_root_children={len(root_children)} runtime_desc={runtime_desc}" + ) + except Exception: + pass + for child in root_children: child.reparentTo(self.world.render) if child.hasTag("is_model_root") or child.hasTag("asset_guid"): @@ -981,6 +1113,28 @@ class ProjectManager: except Exception: pass + try: + render_desc = self.world.render.find_all_matches("**").get_num_paths() + print( + f"[SceneOpen] render_desc={render_desc} built_models={len(built_model_nodes)} " + f"spot={len(built_spot_lights)} point={len(built_point_lights)}" + ) + except Exception: + pass + + try: + controller = getattr(ssbo_editor, "controller", None) if ssbo_loaded else None + if controller and hasattr(controller, "get_runtime_structure_stats"): + print(f"[SceneOpen] runtime_structure={controller.get_runtime_structure_stats()}") + except Exception: + pass + try: + if ssbo_loaded and ssbo_editor and hasattr(ssbo_editor, "get_source_tree_stats"): + print(f"[SceneOpen] source_tree={ssbo_editor.get_source_tree_stats()}") + except Exception: + pass + + self._log_render_runtime_stats("after_scene_rebuild") scene_components = dict(scene_description.get("scene_components", {}) or {}) camera_state = dict(scene_components.get("camera", {}) or scene_description.get("camera", {}) or {}) camera = getattr(self.world, "camera", None) or getattr(self.world, "cam", None) @@ -1008,6 +1162,7 @@ class ProjectManager: load_lui_fn(temp_stub) except Exception: pass + self._log_render_runtime_stats("after_lui_restore") 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): @@ -1038,7 +1193,7 @@ class ProjectManager: top_level_nodes.append(node) return top_level_nodes, node_lookup - def _apply_scene_description_state_to_node(self, target_np, node, project_path): + def _apply_scene_description_state_to_node(self, target_np, node, project_path, apply_material_state=True): if not target_np or target_np.isEmpty() or not isinstance(node, dict): return @@ -1049,11 +1204,24 @@ class ProjectManager: position = list(transform.get("position", [0, 0, 0]) or [0, 0, 0]) rotation = list(transform.get("rotation", [0, 0, 0]) or [0, 0, 0]) scale = list(transform.get("scale", [1, 1, 1]) or [1, 1, 1]) - if len(position) >= 3: + try: + current_pos = target_np.getPos() + except Exception: + current_pos = None + try: + current_hpr = target_np.getHpr() + except Exception: + current_hpr = None + try: + current_scale = target_np.getScale() + except Exception: + current_scale = None + + if len(position) >= 3 and self._vector_differs(current_pos, position): target_np.setPos(float(position[0]), float(position[1]), float(position[2])) - if len(rotation) >= 3: + if len(rotation) >= 3 and self._vector_differs(current_hpr, rotation): target_np.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2])) - if len(scale) >= 3: + if len(scale) >= 3 and self._vector_differs(current_scale, scale): target_np.setScale(float(scale[0]), float(scale[1]), float(scale[2])) visibility = dict(node.get("visibility", {}) or {}) @@ -1120,7 +1288,8 @@ class ProjectManager: 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) + if apply_material_state: + 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.""" @@ -1221,12 +1390,17 @@ class ProjectManager: except Exception: pass - def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup): + def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup, apply_material_state=True): """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) + self._apply_scene_description_state_to_node( + target_np, + node, + project_path, + apply_material_state=apply_material_state, + ) node_id = str(node.get("node_id", "") or "").strip() if not node_id: @@ -1257,7 +1431,13 @@ class ProjectManager: 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) + self._apply_scene_description_state_to_subtree( + runtime_children[child_index], + child_entry, + project_path, + node_lookup, + apply_material_state=apply_material_state, + ) def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database): if not getattr(self.world, "use_ssbo_mouse_picking", False): @@ -1273,7 +1453,7 @@ class ProjectManager: if not top_level_asset_nodes: return False - loaded_any = False + candidate_nodes = [] for node in top_level_asset_nodes: components = dict(node.get("components", {}) or {}) model_component = dict(components.get("model", {}) or {}) @@ -1293,6 +1473,13 @@ class ProjectManager: if not os.path.exists(asset_abs): continue + candidate_nodes.append((node, components, model_component, asset_abs)) + + loaded_any = False + total_candidates = len(candidate_nodes) + for index, (node, components, model_component, asset_abs) in enumerate(candidate_nodes): + has_more_assets = index < (total_candidates - 1) + has_saved_animation = False try: metadata_component = dict(components.get("metadata", {}) or {}) @@ -1326,6 +1513,7 @@ class ProjectManager: keep_source_model=False, append=loaded_any, scene_package_import=False, + rebuild_runtime=False, ) except Exception as e: print(f"⚠️ SSBO 恢复场景模型失败 {asset_abs}: {e}") @@ -1335,11 +1523,16 @@ class ProjectManager: if target_root is None: continue - 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): + self._apply_scene_description_state_to_subtree( + target_root, + node, + project_path, + node_lookup, + apply_material_state=False, + ) + # Only rebuild between imports, or once after the whole batch below. + # Otherwise reopening a project pays one extra full SSBO rebuild per model. + if has_more_assets and callable(refresh_runtime_fn): try: refresh_runtime_fn(preserve_selection=False) except Exception: @@ -1349,13 +1542,16 @@ class ProjectManager: if not loaded_any: return False - clear_runtime_fn = getattr(ssbo_editor, "_clear_runtime_state", None) - rebuild_fn = getattr(ssbo_editor, "_rebuild_runtime_from_source_root", None) - if callable(rebuild_fn): + if callable(refresh_runtime_fn): try: - if callable(clear_runtime_fn): - clear_runtime_fn(preserve_source_models=True) - rebuild_fn(highlight_root_name=None) + refresh_runtime_fn(preserve_selection=False) + except Exception: + pass + + force_static_idle_fn = getattr(ssbo_editor, "force_static_chunk_idle_state", None) + if callable(force_static_idle_fn): + try: + force_static_idle_fn() except Exception: pass @@ -1660,6 +1856,24 @@ class ProjectManager: if skip_asset_nodes: if self._node_belongs_to_asset_hierarchy(node, node_lookup): continue + imported_node_key = str( + node.get("imported_node_key", "") + or ((node.get("components", {}) or {}).get("model", {}) or {}).get("imported_node_key", "") + or "" + ).strip() + if imported_node_key: + continue + if not self._is_scene_description_node_interactive(node): + components = dict(node.get("components", {}) or {}) + tags = dict(node.get("tags", {}) or {}) + has_light = bool(components.get("light")) + has_gui_tag = ( + str(tags.get("is_gui_element", "")).lower() == "true" + or str(tags.get("gui_type", "")).strip() != "" + ) + has_scene_element_tag = str(tags.get("is_scene_element", "")).lower() == "true" + if not has_light and not has_gui_tag and not has_scene_element_tag: + continue is_interactive = self._is_scene_description_node_interactive(node) if include_mode == "interactive" and not is_interactive: continue diff --git a/project/scene_description.py b/project/scene_description.py index 261b041c..20d72a4c 100644 --- a/project/scene_description.py +++ b/project/scene_description.py @@ -242,12 +242,8 @@ 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: return True if runtime_interactive: @@ -256,6 +252,12 @@ def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], ru return True if material_component: return True + if node.hasTag("scene_transform_dirty") and node.getTag("scene_transform_dirty").lower() == "true": + return True + if node.hasTag("scene_material_dirty") and node.getTag("scene_material_dirty").lower() == "true": + return True + if node.hasTag("user_visible") and node.getTag("user_visible").lower() != "true": + return True return False @@ -290,11 +292,61 @@ def _build_scene_description_payload( referenced_asset_guids = set() referenced_script_guids = set() + subtree_serializable_cache = {} + + def _node_is_serializable(node) -> bool: + if not node or node.isEmpty(): + return False + + cache_key = id(node) + if cache_key in subtree_serializable_cache: + return subtree_serializable_cache[cache_key] + + runtime_interactive = ( + node.hasTag("runtime_interactive") + and node.getTag("runtime_interactive").lower() == "true" + ) + scripts = [] + if node.hasTag("scripts_info"): + try: + scripts = json.loads(node.getTag("scripts_info")) + except Exception: + scripts = [] + + asset_guid = "" + if node.hasTag("is_model_root"): + asset_guid = "root" + + light_component = _collect_light_component(node) + material_component = _collect_material_override_component(node) + + serializable_here = _should_serialize_child_nodes( + node, + asset_guid, + scripts, + runtime_interactive, + light_component, + material_component, + ) + if serializable_here: + subtree_serializable_cache[cache_key] = True + return True + + try: + children = list(node.getChildren()) + except Exception: + children = [] + result = any(_node_is_serializable(child) for child in children if child and not child.isEmpty()) + subtree_serializable_cache[cache_key] = result + return result + def walk_nodes(children, parent_id=""): for index, child in enumerate(list(children or [])): if not child or child.isEmpty(): continue node_id = _node_path_id(parent_id, index) + if not _node_is_serializable(child): + continue node_name = child.getName() imported_node_key = _resolve_imported_node_key(child, node_id) @@ -387,6 +439,9 @@ def _build_scene_description_payload( runtime_interactive, light_component, material_component, + ) or any( + _node_is_serializable(grandchild) + for grandchild in list(child.getChildren()) if grandchild and not grandchild.isEmpty() ): try: child_nodes = list(child.getChildren()) diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index 92221da7..6f7c7ef2 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -242,6 +242,81 @@ class ObjectController: return False + def get_runtime_structure_stats(self): + """Summarize hybrid runtime structure to diagnose idle-state regressions.""" + stats = { + "chunks_total": 0, + "chunks_dynamic_enabled": 0, + "chunk_static_nodes": 0, + "chunk_dynamic_nodes": 0, + "chunk_static_visible": 0, + "chunk_static_stashed": 0, + "chunk_dynamic_visible": 0, + "chunk_dynamic_stashed": 0, + "dynamic_object_nodes": 0, + "pick_nodes": 0, + "model_descendants": 0, + "pick_descendants": 0, + "model_geom_nodes": 0, + "pick_geom_nodes": 0, + } + + chunks = getattr(self, "chunks", {}) or {} + stats["chunks_total"] = len(chunks) + for chunk in chunks.values(): + if not isinstance(chunk, dict): + continue + if chunk.get("dynamic_enabled"): + stats["chunks_dynamic_enabled"] += 1 + dynamic_np = chunk.get("dynamic_np") + static_np = chunk.get("static_np") + try: + if dynamic_np and not dynamic_np.is_empty(): + stats["chunk_dynamic_nodes"] += 1 + try: + if dynamic_np.is_stashed(): + stats["chunk_dynamic_stashed"] += 1 + elif not dynamic_np.is_hidden(): + stats["chunk_dynamic_visible"] += 1 + except Exception: + pass + stats["dynamic_object_nodes"] += len(list(dynamic_np.get_children())) + except Exception: + pass + try: + if static_np and not static_np.is_empty(): + stats["chunk_static_nodes"] += 1 + try: + if static_np.is_stashed(): + stats["chunk_static_stashed"] += 1 + elif not static_np.is_hidden(): + stats["chunk_static_visible"] += 1 + except Exception: + pass + except Exception: + pass + + model = getattr(self, "model", None) + if model: + try: + if not model.is_empty(): + stats["model_descendants"] = model.find_all_matches("**").get_num_paths() + stats["model_geom_nodes"] = model.find_all_matches("**/+GeomNode").get_num_paths() + except Exception: + pass + + pick_model = getattr(self, "pick_model", None) + if pick_model: + try: + if not pick_model.is_empty(): + stats["pick_descendants"] = pick_model.find_all_matches("**").get_num_paths() + stats["pick_geom_nodes"] = pick_model.find_all_matches("**/+GeomNode").get_num_paths() + stats["pick_nodes"] = len(list(pick_model.get_children())) + except Exception: + pass + + return stats + def get_preferred_selection_ids(self, key): """Return the IDs that should be selected when a tree node is clicked. diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 46435dc3..24d7231a 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -561,101 +561,117 @@ class SSBOEditor: if not self.controller or not self.model or not self.source_model_root: return - proxy = getattr(self, "_group_proxy", None) - if self._node_is_valid(proxy): + self._snapshot_active_selection_transform_to_source() + + # Persist whole-model transforms that live on the aggregated runtime root. + # Child/object sync above only captures transforms relative to self.model, + # so moving the whole imported model root would otherwise be lost on save. + model_root_mat = None + try: + model_root_mat = LMatrix4f(self.model.get_mat(self.base.render)) + except Exception: try: - selection_key = str(proxy.getTag("ssbo_selection_key") or "").strip() + model_root_mat = LMatrix4f(self.model.getMat(self.base.render)) except Exception: - selection_key = "" - if selection_key and selection_key != getattr(self.controller, "tree_root_key", None): - source_group_node = self._resolve_source_node_by_tree_key(selection_key) - if self._node_is_valid(source_group_node): - try: - group_mat = LMatrix4f(proxy.get_mat(self.base.render)) - except Exception: - try: - group_mat = LMatrix4f(proxy.getMat(self.base.render)) - except Exception: - group_mat = None - if group_mat is not None: - try: - source_group_node.set_mat(group_mat) - except Exception: - try: - source_group_node.setMat(group_mat) - except Exception: - pass + model_root_mat = None - grouped_entries = {} - for gid, obj_np in self.controller.id_to_object_np.items(): - if not self._node_is_valid(obj_np): + if model_root_mat is None: + return + + source_children = self._get_source_root_children() + if not source_children: + return + + for source_child in source_children: + if not self._node_is_valid(source_child): continue - - owner_key = self.controller.id_to_name.get(gid) - if not owner_key or owner_key == getattr(self.controller, "tree_root_key", None): + child_name = self._get_node_name(source_child, None) + if not child_name: continue - - source_node = self._resolve_source_node_by_tree_key(owner_key) - if not self._node_is_valid(source_node): - continue - - try: - current_net_mat = LMatrix4f(obj_np.get_mat(self.model)) - except Exception: + base_mat = self._source_child_base_mats.get(child_name) + if base_mat is None: try: - current_net_mat = LMatrix4f(obj_np.getMat(self.model)) - except Exception: - continue - - existing_entry = grouped_entries.get(owner_key) - if existing_entry is None: - grouped_entries[owner_key] = { - "source_node": source_node, - "current_net_mat": current_net_mat, - } - - for owner_key in sorted(grouped_entries.keys(), key=lambda key: str(key).count("/")): - entry = grouped_entries.get(owner_key) or {} - source_node = entry.get("source_node") - current_net_mat = entry.get("current_net_mat") - if not self._node_is_valid(source_node) or current_net_mat is None: - continue - - try: - source_parent = source_node.get_parent() - except Exception: - try: - source_parent = source_node.getParent() - except Exception: - source_parent = None - - parent_net_mat = LMatrix4f.ident_mat() - if self._node_is_valid(source_parent) and source_parent != self.source_model_root: - try: - parent_net_mat = LMatrix4f(source_parent.get_mat(self.source_model_root)) + base_mat = LMatrix4f(source_child.get_mat()) except Exception: try: - parent_net_mat = LMatrix4f(source_parent.getMat(self.source_model_root)) + base_mat = LMatrix4f(source_child.getMat()) except Exception: - parent_net_mat = LMatrix4f.ident_mat() - - inv_parent_mat = LMatrix4f(parent_net_mat) + continue + composed_mat = LMatrix4f(base_mat) + composed_mat *= model_root_mat try: - inv_parent_mat.invertInPlace() + source_child.set_mat(composed_mat) + source_child.setTag("scene_transform_dirty", "true") except Exception: try: - inv_parent_mat.invert_in_place() + source_child.setMat(composed_mat) + source_child.setTag("scene_transform_dirty", "true") except Exception: continue - local_mat = current_net_mat * inv_parent_mat + def _snapshot_active_selection_transform_to_source(self): + """Persist only the actively edited selection back into the source tree.""" + if not self.controller or not self.model or not self.source_model_root: + return False + + selected_key = self.selected_name + root_key = getattr(self.controller, "tree_root_key", None) + if not selected_key or selected_key == root_key: + return False + + scene_node = self.get_selection_scene_node() + source_node = self.get_selection_source_node() + if not self._node_is_valid(scene_node) or not self._node_is_valid(source_node): + return False + + try: + current_net_mat = LMatrix4f(scene_node.get_mat(self.model)) + except Exception: try: - source_node.set_mat(local_mat) + current_net_mat = LMatrix4f(scene_node.getMat(self.model)) + except Exception: + current_net_mat = None + if current_net_mat is None: + return False + + try: + source_parent = source_node.get_parent() + except Exception: + try: + source_parent = source_node.getParent() + except Exception: + source_parent = None + + parent_net_mat = LMatrix4f.ident_mat() + if self._node_is_valid(source_parent) and source_parent != self.source_model_root: + try: + parent_net_mat = LMatrix4f(source_parent.get_mat(self.source_model_root)) except Exception: try: - source_node.setMat(local_mat) + parent_net_mat = LMatrix4f(source_parent.getMat(self.source_model_root)) except Exception: - continue + parent_net_mat = LMatrix4f.ident_mat() + + inv_parent_mat = LMatrix4f(parent_net_mat) + try: + inv_parent_mat.invertInPlace() + except Exception: + try: + inv_parent_mat.invert_in_place() + except Exception: + return False + + local_mat = current_net_mat * inv_parent_mat + try: + source_node.set_mat(local_mat) + source_node.setTag("scene_transform_dirty", "true") + except Exception: + try: + source_node.setMat(local_mat) + source_node.setTag("scene_transform_dirty", "true") + except Exception: + return False + return True def _resolve_source_node_by_tree_key(self, tree_key): """Resolve controller tree key (e.g. 0/1/2) to source_model_root node.""" @@ -1115,6 +1131,7 @@ class SSBOEditor: target_snapshot = _clone_snapshot_for_target(source_snapshot, source_node) if target_snapshot is not None: apply_snapshot_fn(source_node, target_snapshot) + source_node.setTag("scene_material_dirty", "true") synced += 1 continue except Exception: @@ -1145,6 +1162,7 @@ class SSBOEditor: try: if callable(set_src_state): set_src_state(src_geom_index, runtime_geom_state) + source_node.setTag("scene_material_dirty", "true") synced += 1 except Exception: continue @@ -1223,6 +1241,7 @@ class SSBOEditor: self.clear_selection() self._cleanup_group_proxy() self._reset_pick_sync_cache() + self._teardown_gpu_picking() controller = self.controller pick_model = getattr(controller, "pick_model", None) if controller else None @@ -1440,6 +1459,7 @@ class SSBOEditor: keep_source_model=False, append=False, scene_package_import=False, + rebuild_runtime=True, ): """Load and process one model into the aggregated SSBO scene.""" print(f"[SSBOEditor] Loading model: {model_path}") @@ -1511,7 +1531,8 @@ class SSBOEditor: self.source_model = source_root self._restore_saved_material_bindings_from_tags(source_root) self._capture_source_child_base_mats() - self._rebuild_runtime_from_source_root(highlight_root_name=None) + if rebuild_runtime: + self._rebuild_runtime_from_source_root(highlight_root_name=None) if len(imported_roots) == 1: return imported_roots[0] return source_root @@ -1529,7 +1550,8 @@ class SSBOEditor: self.source_model = source_root self._capture_source_child_base_mats() - self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name) + if rebuild_runtime: + self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name) return imported_root def _build_filename_candidates(self, path_text): @@ -2106,7 +2128,8 @@ class SSBOEditor: def setup_gpu_picking(self): """Setup GPU Picking (Basic implementation)""" - # ... (Buffer setup code remains same) ... + self._teardown_gpu_picking() + win_props = WindowProperties() win_props.set_size(1, 1) fb_props = FrameBufferProperties() @@ -2169,6 +2192,35 @@ class SSBOEditor: self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0)) self.pick_buffer.set_clear_color_active(True) + def _teardown_gpu_picking(self): + """Release old GPU picking resources before rebuilding them.""" + pick_cam_np = getattr(self, "pick_cam_np", None) + if pick_cam_np is not None: + try: + if not pick_cam_np.is_empty(): + pick_cam_np.remove_node() + except Exception: + try: + if not pick_cam_np.isEmpty(): + pick_cam_np.removeNode() + except Exception: + pass + self.pick_cam_np = None + self.pick_cam = None + self.pick_lens = None + + pick_buffer = getattr(self, "pick_buffer", None) + if pick_buffer is not None: + try: + self.base.graphicsEngine.remove_window(pick_buffer) + except Exception: + try: + self.base.graphicsEngine.removeWindow(pick_buffer) + except Exception: + pass + self.pick_buffer = None + self.pick_texture = None + def _sync_pick_model_transform(self): """Sync pick-scene clone to current source model world transform.""" if not self.controller or not self.model: @@ -2744,6 +2796,7 @@ class SSBOEditor: try: self._clear_runtime_state(preserve_source_models=True) self._rebuild_runtime_from_source_root(highlight_root_name=None) + self.force_static_chunk_idle_state() if preserve_selection and selected_key and self.controller: try: if ( @@ -2759,6 +2812,74 @@ class SSBOEditor: print(f"[SSBOEditor] 从 source 刷新 runtime 失败: {e}") return False + def get_source_tree_stats(self): + root = getattr(self, "source_model_root", None) + stats = { + "valid": False, + "has_parent": False, + "hidden": False, + "stashed": False, + "descendants": 0, + "geom_nodes": 0, + "top_children": 0, + "parent_name": "", + } + if not self._node_is_valid(root): + return stats + + stats["valid"] = True + try: + stats["has_parent"] = bool(root.has_parent()) + except Exception: + pass + try: + stats["hidden"] = bool(root.is_hidden()) + except Exception: + pass + try: + stats["stashed"] = bool(root.is_stashed()) + except Exception: + pass + try: + parent = root.get_parent() + if parent and not parent.is_empty(): + stats["parent_name"] = parent.get_name() or "" + except Exception: + pass + try: + stats["descendants"] = root.find_all_matches("**").get_num_paths() + except Exception: + pass + try: + stats["geom_nodes"] = len(list(root.find_all_matches("**/+GeomNode"))) + except Exception: + pass + try: + stats["top_children"] = len([child for child in root.get_children() if not child.is_empty()]) + except Exception: + pass + return stats + + def force_static_chunk_idle_state(self): + """Force hybrid chunk runtime back to fully static idle mode.""" + controller = getattr(self, "controller", None) + chunks = getattr(controller, "chunks", None) if controller else None + if not controller or not isinstance(chunks, dict) or not chunks: + return False + + rebuilt = 0 + for chunk_id in sorted(chunks): + try: + controller._rebuild_static_chunk(chunk_id) + controller._set_chunk_dynamic(chunk_id, False) + rebuilt += 1 + except Exception: + continue + + if rebuilt: + print(f"[SSBOEditor] Forced static idle chunks: {rebuilt}") + return rebuilt > 0 + def _sync_editor_selection_reference(self, node): selection = getattr(self.base, "selection", None) if not selection: @@ -2819,6 +2940,7 @@ class SSBOEditor: self.clear_selection(sync_world_selection=False) def clear_selection(self, sync_world_selection=True): + self._snapshot_active_selection_transform_to_source() self._stop_pick_sync_task() self._reset_pick_sync_cache() self._cleanup_group_proxy() @@ -2877,6 +2999,7 @@ class SSBOEditor: pass # No selection mask texture needed without custom shader def select_node(self, key, sync_world_selection=True): + self._snapshot_active_selection_transform_to_source() # Clean up previous group proxy before changing selection self._cleanup_group_proxy() self._reset_pick_sync_cache() diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index e7e1f6b4..91ddb425 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -1540,6 +1540,10 @@ class AppActions: SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager). Legacy mode: load via SceneManager. """ + try: + self._scene_tree_epoch = int(getattr(self, "_scene_tree_epoch", 0) or 0) + 1 + except Exception: + self._scene_tree_epoch = 1 animated_model = bool(file_path and self._model_file_has_animation(file_path)) if animated_model: prefer_scene_manager = True @@ -1584,6 +1588,77 @@ class AppActions: return self.scene_manager.importModel(file_path) return None + def _log_render_runtime_stats(self, label): + """Print renderer/task stats for import/open path comparison.""" + try: + win = getattr(self, "win", None) + num_regions = win.getNumDisplayRegions() if win else 0 + region_lines = [] + if win: + for index in range(num_regions): + try: + dr = win.getDisplayRegion(index) + camera = dr.getCamera() + camera_name = "None" + if camera and not camera.isEmpty(): + camera_name = camera.getName() + region_lines.append( + f"{index}:{dr.getSort()}:{int(bool(dr.isActive()))}:{camera_name}" + ) + except Exception: + continue + + graphics_engine = getattr(self, "graphicsEngine", None) + num_windows = graphics_engine.getNumWindows() if graphics_engine else 0 + + render = getattr(self, "render", None) + camera_count = 0 + special_camera_counts = {} + if render and not render.isEmpty(): + try: + camera_count = render.find_all_matches("**/+Camera").get_num_paths() + except Exception: + camera_count = 0 + for camera_name in ( + "gizmo_overlay_cam", + "pick_camera", + "selection_outline_mask_camera", + ): + try: + special_camera_counts[camera_name] = render.find_all_matches( + f"**/{camera_name}" + ).get_num_paths() + except Exception: + special_camera_counts[camera_name] = 0 + + task_mgr = getattr(self, "taskMgr", None) or getattr(self, "task_mgr", None) + task_names = [] + if task_mgr: + try: + task_names = [task.name for task in list(task_mgr.getTasks())] + except Exception: + task_names = [] + interesting_tasks = [ + name for name in task_names + if ( + "gizmo" in str(name).lower() + or "outline" in str(name).lower() + or "pick" in str(name).lower() + or "lui" in str(name).lower() + or "canvas" in str(name).lower() + ) + ] + + print( + f"[RenderStats:{label}] regions={num_regions} windows={num_windows} " + f"render_cameras={camera_count} special={special_camera_counts} " + f"interesting_tasks={interesting_tasks}" + ) + if region_lines: + print(f"[RenderStats:{label}] region_detail={' | '.join(region_lines)}") + except Exception: + pass + def _import_model_with_menu_logic( self, file_path, @@ -1667,6 +1742,16 @@ class AppActions: if show_success_message: self.add_success_message(f"模型导入成功: {file_name}") + try: + ssbo_editor = getattr(self, "ssbo_editor", None) + controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None + if controller and hasattr(controller, "get_runtime_structure_stats"): + print(f"[ManualImport] runtime_structure={controller.get_runtime_structure_stats()}") + if ssbo_editor and hasattr(ssbo_editor, "get_source_tree_stats"): + print(f"[ManualImport] source_tree={ssbo_editor.get_source_tree_stats()}") + except Exception: + pass + self._log_render_runtime_stats("manual_import") return model_node except Exception as e: self.add_error_message(f"导入模型失败: {e}") diff --git a/ui/panels/editor_panels_left.py b/ui/panels/editor_panels_left.py index 402a51e2..5d756f5c 100644 --- a/ui/panels/editor_panels_left.py +++ b/ui/panels/editor_panels_left.py @@ -155,6 +155,12 @@ class EditorPanelsLeftMixin: count += 1 return count + def _get_scene_tree_epoch(self): + try: + return int(getattr(self.app, "_scene_tree_epoch", 0) or 0) + except Exception: + return 0 + def _ssbo_key_matches_scene_filter(self, controller, key): filter_text = self._get_scene_tree_filter() if not filter_text: @@ -446,10 +452,11 @@ class EditorPanelsLeftMixin: obj_count = len(controller.name_to_ids.get(key, [])) children = node_data["children"] is_selected = (getattr(ssbo_editor, "selected_name", None) == key) + tree_epoch = self._get_scene_tree_epoch() if not children: # Leaf node: selectable - label = f"{display} ({obj_count})##{key}" + label = f"{display} ({obj_count})##{tree_epoch}:{key}" if imgui.selectable(label, is_selected)[0]: ssbo_editor.select_node(key) if hasattr(self.app, "lui_manager"): @@ -465,7 +472,7 @@ class EditorPanelsLeftMixin: flags = imgui.TreeNodeFlags_.open_on_arrow if is_selected: flags |= imgui.TreeNodeFlags_.selected - label = f"{display} ({obj_count})##{key}" + label = f"{display} ({obj_count})##{tree_epoch}:{key}" opened = imgui.tree_node_ex(label, flags) if imgui.is_item_clicked(0): ssbo_editor.select_node(key) @@ -495,6 +502,7 @@ class EditorPanelsLeftMixin: 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()) + tree_epoch = self._get_scene_tree_epoch() if display.strip().lower() == "root" and children: for child in children: @@ -504,7 +512,7 @@ class EditorPanelsLeftMixin: return if not children: - label = f"{display} ({obj_count})##{key}" + label = f"{display} ({obj_count})##{tree_epoch}:{key}" if imgui.selectable(label, is_selected)[0]: ssbo_editor.select_node(key) if hasattr(self.app, "lui_manager"): @@ -520,7 +528,7 @@ class EditorPanelsLeftMixin: flags = imgui.TreeNodeFlags_.open_on_arrow if is_selected: flags |= imgui.TreeNodeFlags_.selected - label = f"{display} ({obj_count})##{key}" + label = f"{display} ({obj_count})##{tree_epoch}:{key}" opened = imgui.tree_node_ex(label, flags) if imgui.is_item_clicked(0): ssbo_editor.select_node(key)