diff --git a/imgui.ini b/imgui.ini index 854da81c..8d1592d0 100644 --- a/imgui.ini +++ b/imgui.ini @@ -84,12 +84,12 @@ Size=400,300 Collapsed=0 [Window][选择路径] -Pos=390,125 +Pos=625,258 Size=600,500 Collapsed=0 [Window][打开项目] -Pos=440,175 +Pos=675,308 Size=500,400 Collapsed=0 diff --git a/scene/scene_manager_io_mixin.py b/scene/scene_manager_io_mixin.py index fb5a9acf..b91539b9 100644 --- a/scene/scene_manager_io_mixin.py +++ b/scene/scene_manager_io_mixin.py @@ -12,7 +12,7 @@ from pathlib import Path from panda3d.core import ( ModelPool, ModelRoot, Filename, NodePath, GeomNode, Material, Vec4, Vec3, MaterialAttrib, ColorAttrib, Point3, CollisionNode, CollisionSphere, CollisionBox, - BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib + BitMask32, TransparencyAttrib, LColor, TransformState, RenderModeAttrib, LMatrix4f ) from panda3d.egg import EggData, EggVertexPool from direct.actor.Actor import Actor @@ -251,6 +251,392 @@ class SceneManagerIOMixin: except Exception: return None + def _collect_local_transform_snapshot(self, root_node): + """采集根节点下所有子节点的局部变换快照(不含根节点自身)。""" + snapshot = {"by_index": {}, "by_name": {}} + try: + if not root_node or root_node.isEmpty(): + return snapshot + + def walk(node, index_path, name_path): + if index_path: + snapshot["by_index"][index_path] = { + "pos": tuple(node.getPos()), + "hpr": tuple(node.getHpr()), + "scale": tuple(node.getScale()), + } + snapshot["by_name"][name_path] = snapshot["by_index"][index_path] + + name_count = {} + for child_index, child in enumerate(node.getChildren()): + child_name = child.getName() or "" + occur = name_count.get(child_name, 0) + name_count[child_name] = occur + 1 + walk( + child, + index_path + (child_index,), + name_path + (f"{child_name}#{occur}",), + ) + + walk(root_node, tuple(), tuple()) + except Exception: + pass + return snapshot + + def _apply_local_transform_snapshot(self, root_node, snapshot): + """将保存的子节点局部变换回写到新导入模型。""" + try: + if (not root_node) or root_node.isEmpty() or (not snapshot): + return 0 + by_index = snapshot.get("by_index", {}) + by_name = snapshot.get("by_name", {}) + if (not by_index) and (not by_name): + return 0 + + applied_count = 0 + + def walk(node, index_path, name_path): + nonlocal applied_count + if index_path: + data = by_index.get(index_path) or by_name.get(name_path) + if data: + try: + node.setPos(*data["pos"]) + node.setHpr(*data["hpr"]) + node.setScale(*data["scale"]) + applied_count += 1 + except Exception: + pass + + name_count = {} + for child_index, child in enumerate(node.getChildren()): + child_name = child.getName() or "" + occur = name_count.get(child_name, 0) + name_count[child_name] = occur + 1 + walk( + child, + index_path + (child_index,), + name_path + (f"{child_name}#{occur}",), + ) + + walk(root_node, tuple(), tuple()) + return applied_count + except Exception: + return 0 + + def _matrices_close(self, a, b, eps=1e-6): + """比较两个4x4矩阵是否近似相等。""" + try: + if (a is None) or (b is None): + return False + for r in range(4): + ra = a.get_row(r) + rb = b.get_row(r) + if ( + abs(float(ra[0]) - float(rb[0])) > eps or + abs(float(ra[1]) - float(rb[1])) > eps or + abs(float(ra[2]) - float(rb[2])) > eps or + abs(float(ra[3]) - float(rb[3])) > eps + ): + return False + return True + except Exception: + return False + + def _matrix_to_list(self, mat): + """把 Panda3D 矩阵转为长度16的列表(行主序)。""" + values = [] + try: + for r in range(4): + for c in range(4): + values.append(float(mat.getCell(r, c))) + except Exception: + values = [] + return values + + def _list_to_matrix(self, values): + """把长度16列表还原为 Panda3D 矩阵。""" + try: + if not isinstance(values, (list, tuple)) or len(values) != 16: + return None + + # LMatrix4f.identMat() 在部分绑定里是只读对象,不能 setCell。 + numeric = [float(v) for v in values] + try: + return LMatrix4f(*numeric) + except Exception: + mat = LMatrix4f() + idx = 0 + for r in range(4): + for c in range(4): + try: + mat.setCell(r, c, numeric[idx]) + except Exception: + mat.set_cell(r, c, numeric[idx]) + idx += 1 + return mat + except Exception: + return None + + def _serialize_ssbo_subobject_transforms(self, model_root): + """序列化当前 SSBO 模型的子物体变换(按 global_id 记录矩阵)。""" + try: + ssbo_editor = getattr(self.world, "ssbo_editor", None) + if not ssbo_editor: + return "" + + cleanup_group_proxy = getattr(ssbo_editor, "_cleanup_group_proxy", None) + if callable(cleanup_group_proxy): + cleanup_group_proxy() + + controller = getattr(ssbo_editor, "controller", None) + ssbo_model = getattr(ssbo_editor, "model", None) + if not controller or not ssbo_model or ssbo_model != model_root: + return "" + + gid_mats = {} + changed_count = 0 + + for gid, obj_np in getattr(controller, "id_to_object_np", {}).items(): + if not obj_np: + continue + try: + if obj_np.is_empty(): + continue + except Exception: + if obj_np.isEmpty(): + continue + + try: + cur_mat = obj_np.get_mat(controller.model) + except Exception: + cur_mat = obj_np.getMat(controller.model) + + base_mat = None + try: + if gid < len(controller.global_transforms): + base_mat = controller.global_transforms[gid] + except Exception: + base_mat = None + + if base_mat is not None and self._matrices_close(cur_mat, base_mat): + continue + + mat_values = self._matrix_to_list(cur_mat) + if not mat_values: + continue + gid_mats[str(int(gid))] = mat_values + changed_count += 1 + + if changed_count <= 0: + return "" + + payload = { + "version": 1, + "gid_mats": gid_mats, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + except Exception as e: + print(f"[SSBO] 序列化子物体变换失败: {e}") + return "" + + def _restore_ssbo_subobject_transforms(self, imported_node, saved_node): + """把已保存的 SSBO 子物体变换恢复到当前导入模型。""" + try: + def _is_empty_np(np): + if not np: + return True + try: + return np.is_empty() + except Exception: + try: + return np.isEmpty() + except Exception: + return True + + def _first_path_tag(np): + if _is_empty_np(np): + return "" + for tag_name in ("saved_model_path", "model_path", "original_path", "file"): + try: + if np.hasTag(tag_name): + value = np.getTag(tag_name).strip() + if value: + return value.replace("\\", "/").lower() + except Exception: + continue + return "" + + if _is_empty_np(imported_node) or _is_empty_np(saved_node): + print("[SSBO] 子物体恢复跳过: 模型节点无效") + return 0 + if not saved_node.hasTag("ssbo_gid_mats"): + return 0 + + raw_text = saved_node.getTag("ssbo_gid_mats").strip() + if not raw_text: + print(f"[SSBO] 子物体恢复跳过: ssbo_gid_mats 为空 ({saved_node.getName()})") + return 0 + + try: + payload = json.loads(raw_text) + except Exception as e: + print(f"[SSBO] 子物体恢复失败: ssbo_gid_mats 非法 JSON: {e}") + return 0 + + gid_mats = payload.get("gid_mats", {}) if isinstance(payload, dict) else {} + if not isinstance(gid_mats, dict) or not gid_mats: + print(f"[SSBO] 子物体恢复跳过: 无 gid 数据 ({saved_node.getName()})") + return 0 + + ssbo_editor = getattr(self.world, "ssbo_editor", None) + controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None + model_root = getattr(controller, "model", None) if controller else None + if not controller or _is_empty_np(model_root): + print("[SSBO] 子物体恢复跳过: controller/model 不可用") + return 0 + + same_target = False + try: + same_target = (model_root == imported_node) + except Exception: + same_target = False + if not same_target: + try: + same_target = (model_root.node() == imported_node.node()) + except Exception: + same_target = False + if not same_target: + model_path = _first_path_tag(model_root) + imported_path = _first_path_tag(imported_node) + if model_path and imported_path and model_path == imported_path: + same_target = True + if not same_target: + try: + same_target = (model_root.getName() == imported_node.getName()) + except Exception: + same_target = False + if not same_target: + print( + f"[SSBO] 子物体恢复提示: controller/model 与导入节点句柄不一致," + f"继续按当前 controller 应用 ({imported_node.getName()})" + ) + + id_to_object_np = getattr(controller, "id_to_object_np", {}) + id_to_pick_np = getattr(controller, "id_to_pick_np", {}) + id_to_chunk = getattr(controller, "id_to_chunk", {}) + pick_model = getattr(controller, "pick_model", None) + + print( + f"[SSBO] 子物体恢复准备: model={model_root.getName()} " + f"saved={saved_node.getName()} gids={len(gid_mats)} " + f"controller_objs={len(id_to_object_np)}" + ) + + applied = 0 + fallback_hits = 0 + dirty_chunks = set() + missing_ids = [] + + for gid_text, mat_values in gid_mats.items(): + try: + gid = int(gid_text) + except Exception: + continue + + mat = self._list_to_matrix(mat_values) + if mat is None: + continue + + obj_np = id_to_object_np.get(gid) if isinstance(id_to_object_np, dict) else None + if _is_empty_np(obj_np): + try: + found = model_root.find(f"**/obj_{gid}") + if not _is_empty_np(found): + obj_np = found + fallback_hits += 1 + if isinstance(id_to_object_np, dict): + id_to_object_np[gid] = found + except Exception: + obj_np = None + + if _is_empty_np(obj_np): + missing_ids.append(gid) + continue + + try: + obj_np.set_mat(model_root, mat) + except Exception: + obj_np.setMat(model_root, mat) + + pick_np = id_to_pick_np.get(gid) if isinstance(id_to_pick_np, dict) else None + if _is_empty_np(pick_np) and not _is_empty_np(pick_model): + try: + found_pick = pick_model.find(f"**/pick_{gid}") + if not _is_empty_np(found_pick): + pick_np = found_pick + if isinstance(id_to_pick_np, dict): + id_to_pick_np[gid] = found_pick + except Exception: + pick_np = None + + if not _is_empty_np(pick_np): + pick_ref = pick_model if not _is_empty_np(pick_model) else model_root + try: + pick_np.set_mat(pick_ref, mat) + except Exception: + pick_np.setMat(pick_ref, mat) + + chunk_mapping = id_to_chunk.get(gid) if isinstance(id_to_chunk, dict) else None + chunk_id = None + if isinstance(chunk_mapping, (tuple, list)): + chunk_id = chunk_mapping[0] if chunk_mapping else None + else: + chunk_id = chunk_mapping + + if chunk_id is None: + try: + obj_parent = obj_np.getParent() + for cid, chunk_data in getattr(controller, "chunks", {}).items(): + chunk_dynamic = chunk_data.get("dynamic_np") + if _is_empty_np(chunk_dynamic): + continue + if obj_parent == chunk_dynamic: + chunk_id = cid + break + except Exception: + chunk_id = None + + if chunk_id is not None: + dirty_chunks.add(chunk_id) + applied += 1 + + rebuild_fn = getattr(controller, "_rebuild_static_chunk", None) + set_dynamic_fn = getattr(controller, "_set_chunk_dynamic", None) + for chunk_id in sorted(dirty_chunks): + if chunk_id not in controller.chunks: + continue + controller.chunks[chunk_id]["dirty"] = True + if callable(rebuild_fn): + rebuild_fn(chunk_id) + if callable(set_dynamic_fn): + set_dynamic_fn(chunk_id, False) + + if missing_ids: + show_ids = ",".join(str(v) for v in missing_ids[:12]) + if len(missing_ids) > 12: + show_ids += ",..." + print(f"[SSBO] 子物体恢复未命中 gid: {show_ids}") + print( + f"[SSBO] 子物体恢复结果: 应用 {applied}/{len(gid_mats)}," + f"fallback={fallback_hits},dirty_chunks={len(dirty_chunks)}" + ) + + return applied + except Exception as e: + print(f"[SSBO] 恢复子物体变换失败: {e}") + return 0 + def _reimport_saved_models_for_ssbo(self, scene, scene_filename, loaded_nodes): """在SSBO模式下,按保存的源模型路径重建模型根节点,避免加载烘焙后结构。""" replaced_model_names = set() @@ -278,6 +664,8 @@ class SceneManagerIOMixin: print(f"[SSBO] 跳过模型 {node_name}: 无法解析源文件路径") continue + saved_snapshot = self._collect_local_transform_snapshot(saved_node) + try: imported_node = runtime_import(source_path) except Exception as import_e: @@ -334,6 +722,16 @@ class SceneManagerIOMixin: if saved_node.hasTag("parent_name"): imported_node.setTag("parent_name", saved_node.getTag("parent_name")) + restored_ssbo_ids = self._restore_ssbo_subobject_transforms(imported_node, saved_node) + if restored_ssbo_ids > 0: + print(f"[SSBO] 已恢复模型 {node_name} 的子物体变换: {restored_ssbo_ids} 个") + + restored_children = 0 + if restored_ssbo_ids <= 0: + restored_children = self._apply_local_transform_snapshot(imported_node, saved_snapshot) + if restored_children > 0: + print(f"[SSBO] 已恢复模型 {node_name} 的子节点变换: {restored_children} 个") + loaded_nodes[node_name] = imported_node replaced_model_names.add(node_name) if imported_node not in self.models: @@ -349,6 +747,13 @@ class SceneManagerIOMixin: try: print(f"\n=== 开始保存场景到: {filename} ===") + # 提交 SSBO 组选择代理,避免保存时子物体仍挂在临时 proxy 下。 + ssbo_editor = getattr(self.world, "ssbo_editor", None) + if ssbo_editor: + cleanup_group_proxy = getattr(ssbo_editor, "_cleanup_group_proxy", None) + if callable(cleanup_group_proxy): + cleanup_group_proxy() + # 确保文件路径是规范化的 filename = os.path.normpath(filename) @@ -485,6 +890,15 @@ class SceneManagerIOMixin: if node.hasTag("can_create_actor_from_memory"): node.setTag("saved_can_create_actor_from_memory", node.getTag("can_create_actor_from_memory")) + # 保存 SSBO 子物体变换(仅当前受 SSBOEditor 控制的模型根)。 + ssbo_subobject_payload = self._serialize_ssbo_subobject_transforms(node) + if ssbo_subobject_payload: + node.setTag("ssbo_gid_mats", ssbo_subobject_payload) + print(f"[SSBO] 保存模型 {node.getName()} 的子物体变换数据") + else: + if node.hasTag("ssbo_gid_mats"): + node.clearTag("ssbo_gid_mats") + if hasattr(self.world,'script_manager') and self.world.script_manager: script_manager = self.world.script_manager scripts = script_manager.get_scripts_on_object(node)