保存、打开项目优化

This commit is contained in:
ayuan9957 2026-03-24 20:51:39 +08:00
parent 062bd4e720
commit 5c0a61d253
11 changed files with 679 additions and 128 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

View File

@ -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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

View File

@ -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

View File

@ -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,6 +1288,7 @@ class ProjectManager:
target_np.setTag("has_scripts", "true")
target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False))
if apply_material_state:
self._apply_saved_material_tags_to_node(target_np)
def _apply_saved_material_tags_to_node(self, target_np):
@ -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

View File

@ -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())

View File

@ -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.

View File

@ -561,65 +561,78 @@ 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):
try:
selection_key = str(proxy.getTag("ssbo_selection_key") or "").strip()
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
self._snapshot_active_selection_transform_to_source()
grouped_entries = {}
for gid, obj_np in self.controller.id_to_object_np.items():
if not self._node_is_valid(obj_np):
# 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:
model_root_mat = LMatrix4f(self.model.getMat(self.base.render))
except Exception:
model_root_mat = None
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
base_mat = self._source_child_base_mats.get(child_name)
if base_mat is None:
try:
current_net_mat = LMatrix4f(obj_np.get_mat(self.model))
base_mat = LMatrix4f(source_child.get_mat())
except Exception:
try:
current_net_mat = LMatrix4f(obj_np.getMat(self.model))
base_mat = LMatrix4f(source_child.getMat())
except Exception:
continue
composed_mat = LMatrix4f(base_mat)
composed_mat *= model_root_mat
try:
source_child.set_mat(composed_mat)
source_child.setTag("scene_transform_dirty", "true")
except Exception:
try:
source_child.setMat(composed_mat)
source_child.setTag("scene_transform_dirty", "true")
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,
}
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
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
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:
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()
@ -646,16 +659,19 @@ class SSBOEditor:
try:
inv_parent_mat.invert_in_place()
except Exception:
continue
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:
continue
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,6 +1531,7 @@ class SSBOEditor:
self.source_model = source_root
self._restore_saved_material_bindings_from_tags(source_root)
self._capture_source_child_base_mats()
if rebuild_runtime:
self._rebuild_runtime_from_source_root(highlight_root_name=None)
if len(imported_roots) == 1:
return imported_roots[0]
@ -1529,6 +1550,7 @@ class SSBOEditor:
self.source_model = source_root
self._capture_source_child_base_mats()
if rebuild_runtime:
self._rebuild_runtime_from_source_root(highlight_root_name=unique_root_name)
return imported_root
@ -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()

View File

@ -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}")

View File

@ -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)