diff --git a/imgui.ini b/imgui.ini index 84eb5748..59fef0d2 100644 --- a/imgui.ini +++ b/imgui.ini @@ -210,7 +210,7 @@ Size=460,240 Collapsed=0 [Window][打包项目] -Pos=754,392 +Pos=681,290 Size=540,320 Collapsed=0 diff --git a/project/asset_database.py b/project/asset_database.py index 99ae1ff0..fe705015 100644 --- a/project/asset_database.py +++ b/project/asset_database.py @@ -173,6 +173,39 @@ class AssetDatabase: with open(meta_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=4) + def _read_json_file(self, json_path: str) -> dict: + if not os.path.exists(json_path): + return {} + try: + with open(json_path, "r", encoding="utf-8") as f: + payload = json.load(f) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + def _build_model_import_cache_record(self, asset_record: dict, cache_dir: str, model_bam_path: str, hierarchy_path: str, materials_path: str, import_info_path: str) -> dict: + return { + "root": relative_project_path(self.layout.project_root, cache_dir), + "model_bam": relative_project_path(self.layout.project_root, model_bam_path), + "hierarchy": relative_project_path(self.layout.project_root, hierarchy_path), + "materials": relative_project_path(self.layout.project_root, materials_path), + "import_info": relative_project_path(self.layout.project_root, import_info_path), + } + + def _can_reuse_model_import_cache(self, asset_record: dict, *, model_bam_path: str, hierarchy_path: str, materials_path: str, import_info_path: str) -> bool: + if not all(os.path.exists(path) for path in (model_bam_path, hierarchy_path, materials_path, import_info_path)): + return False + + import_info = self._read_json_file(import_info_path) + if not import_info: + return False + if str(import_info.get("asset_guid", "") or "").strip() != str(asset_record.get("guid", "") or "").strip(): + return False + if str(import_info.get("source_hash", "") or "").strip() != str(asset_record.get("source_hash", "") or "").strip(): + return False + + return True + def _copy_into_assets(self, source_path: str, preferred_subdir: str = "") -> str: source_path = normalize_path(source_path) asset_type = detect_asset_type(source_path) @@ -207,6 +240,24 @@ class AssetDatabase: hierarchy_path = os.path.join(cache_dir, "hierarchy.json") materials_path = os.path.join(cache_dir, "materials.json") import_info_path = os.path.join(cache_dir, "import_info.json") + imported_cache = self._build_model_import_cache_record( + asset_record, + cache_dir, + model_bam_path, + hierarchy_path, + materials_path, + import_info_path, + ) + + if self._can_reuse_model_import_cache( + asset_record, + model_bam_path=model_bam_path, + hierarchy_path=hierarchy_path, + materials_path=materials_path, + import_info_path=import_info_path, + ): + asset_record["imported_cache"] = imported_cache + return try: model_np = self.world.loader.loadModel(Filename.fromOsSpecific(asset_path)) @@ -282,13 +333,7 @@ class AssetDatabase: indent=4, ) - asset_record["imported_cache"] = { - "root": relative_project_path(self.layout.project_root, cache_dir), - "model_bam": relative_project_path(self.layout.project_root, model_bam_path), - "hierarchy": relative_project_path(self.layout.project_root, hierarchy_path), - "materials": relative_project_path(self.layout.project_root, materials_path), - "import_info": relative_project_path(self.layout.project_root, import_info_path), - } + asset_record["imported_cache"] = imported_cache def register_asset(self, asset_path: str, preferred_subdir: str = "", copy_into_assets: bool = False) -> dict: asset_path = normalize_path(asset_path) diff --git a/project/project_manager.py b/project/project_manager.py index 4518975d..a2769067 100644 --- a/project/project_manager.py +++ b/project/project_manager.py @@ -1450,6 +1450,7 @@ class ProjectManager: return False refresh_runtime_fn = getattr(ssbo_editor, "refresh_runtime_from_source", None) + normalize_source_dirty_fn = getattr(ssbo_editor, "normalize_source_transform_dirty_tags", None) top_level_asset_nodes, node_lookup = self._iter_top_level_scene_asset_nodes(scene_description) if not top_level_asset_nodes: @@ -1540,6 +1541,11 @@ class ProjectManager: node_lookup, apply_material_state=False, ) + if callable(normalize_source_dirty_fn): + try: + normalize_source_dirty_fn(target_root) + except Exception: + pass # 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): diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index 6f7c7ef2..3eb4dc04 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -651,6 +651,7 @@ class ObjectController: static_np = chunk["dynamic_np"].copy_to(self.model) static_np.set_name(f"chunk_{chunk_id:04d}_static") static_np.unstash() + self._clear_string_tags_in_subtree(static_np) # Editor idle performance depends on the static chunk being actually batched. # The merged material/state preservation changes above keep per-geom render # state intact, so we can restore the pre-merge flattening behavior here. @@ -665,6 +666,58 @@ class ObjectController: else: static_np.unstash() + def _clear_string_tags_in_subtree(self, root_np): + """Static chunk copies do not need scene/editor tags; remove them before flatten.""" + if not root_np: + return 0 + + cleared = 0 + stack = [root_np] + while stack: + node = stack.pop() + if not node: + continue + try: + if node.is_empty(): + continue + except Exception: + try: + if node.isEmpty(): + continue + except Exception: + continue + + try: + tag_names = list(node.get_tag_keys()) + except Exception: + try: + tag_names = list(node.getTagKeys()) + except Exception: + tag_names = [] + + for tag_name in tag_names: + try: + node.clearTag(tag_name) + cleared += 1 + except Exception: + try: + node.setTag(tag_name, "") + cleared += 1 + except Exception: + continue + + try: + children = list(node.get_children()) + except Exception: + try: + children = list(node.getChildren()) + except Exception: + children = [] + for child in reversed(children): + stack.append(child) + + return cleared + def build_virtual_hierarchy(self): """Build a readonly virtual tree from node_list path keys.""" root = { diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index 9d93236f..34ac4d62 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -68,6 +68,7 @@ class SSBOEditor: self.source_model = None self.source_model_root = None self._source_child_base_mats = {} + self._source_transform_baseline_tag = "_ssbo_source_base_local_mat" self.last_import_tree_key = None self.last_import_root_name = None self.ssbo = None @@ -547,6 +548,52 @@ class SSBOEditor: except Exception: continue + def _set_source_node_transform_baseline(self, node): + """Attach the imported local transform baseline onto one source node.""" + if not self._node_is_valid(node): + return False + local_mat = self._get_node_local_mat_copy(node) + if local_mat is None: + return False + try: + node.setPythonTag(self._source_transform_baseline_tag, LMatrix4f(local_mat)) + return True + except Exception: + return False + + def _get_source_node_transform_baseline(self, node): + """Read the imported local transform baseline for one source node.""" + if not self._node_is_valid(node): + return None + try: + baseline_mat = node.getPythonTag(self._source_transform_baseline_tag) + except Exception: + baseline_mat = None + if baseline_mat is None: + return None + try: + return LMatrix4f(baseline_mat) + except Exception: + return None + + def _capture_source_transform_baselines(self, root_node=None): + """Snapshot imported local transforms so dirty tags can be normalized later.""" + start_node = root_node if self._node_is_valid(root_node) else self.source_model_root + if not self._node_is_valid(start_node): + return 0 + + captured = 0 + stack = [start_node] + while stack: + node = stack.pop() + if self._set_source_node_transform_baseline(node): + captured += 1 + children = list(self._iter_children(node)) + for child in reversed(children): + if self._node_is_valid(child): + stack.append(child) + return captured + def _get_top_level_group_keys(self): if not self.controller or not getattr(self.controller, "tree_root_key", None): return [] @@ -636,15 +683,101 @@ class SSBOEditor: continue composed_mat = LMatrix4f(base_mat) composed_mat *= model_root_mat + self._apply_source_local_mat_if_changed(source_child, composed_mat) + + def _get_node_local_mat_copy(self, node): + """Return a defensive copy of a node's current local matrix.""" + if not self._node_is_valid(node): + return None + try: + return LMatrix4f(node.get_mat()) + except Exception: try: - source_child.set_mat(composed_mat) - source_child.setTag("scene_transform_dirty", "true") + return LMatrix4f(node.getMat()) except Exception: + return None + + def _apply_source_local_mat_if_changed(self, source_node, local_mat): + """Write a local matrix back only when it materially differs.""" + if not self._node_is_valid(source_node) or local_mat is None: + return False + + current_local_mat = self._get_node_local_mat_copy(source_node) + dirty_tag_changed = self._update_source_transform_dirty_tag(source_node, local_mat) + if current_local_mat is not None and self._matrices_close(current_local_mat, local_mat): + return dirty_tag_changed + + try: + source_node.set_mat(local_mat) + except Exception: + try: + source_node.setMat(local_mat) + except Exception: + return False + dirty_tag_changed = self._update_source_transform_dirty_tag(source_node, local_mat) or dirty_tag_changed + return True + + def _update_source_transform_dirty_tag(self, source_node, local_mat): + """Keep scene_transform_dirty aligned with the imported baseline.""" + if not self._node_is_valid(source_node) or local_mat is None: + return False + + baseline_local_mat = self._get_source_node_transform_baseline(source_node) + if baseline_local_mat is None: + return False + + try: + has_dirty_tag = bool(source_node.hasTag("scene_transform_dirty")) + except Exception: + has_dirty_tag = False + + is_dirty = not self._matrices_close(local_mat, baseline_local_mat) + if is_dirty: + if has_dirty_tag: try: - source_child.setMat(composed_mat) - source_child.setTag("scene_transform_dirty", "true") + if str(source_node.getTag("scene_transform_dirty") or "").lower() == "true": + return False except Exception: - continue + pass + try: + source_node.setTag("scene_transform_dirty", "true") + return True + except Exception: + return False + + if not has_dirty_tag: + return False + try: + source_node.clearTag("scene_transform_dirty") + return True + except Exception: + try: + source_node.setTag("scene_transform_dirty", "") + return True + except Exception: + return False + + def normalize_source_transform_dirty_tags(self, root_node=None): + """Normalize transform dirty tags against import-time baselines.""" + start_node = root_node if self._node_is_valid(root_node) else self.source_model_root + if not self._node_is_valid(start_node): + return 0 + + normalized = 0 + stack = [start_node] + while stack: + node = stack.pop() + local_mat = self._get_node_local_mat_copy(node) + if local_mat is not None and self._update_source_transform_dirty_tag(node, local_mat): + normalized += 1 + children = list(self._iter_children(node)) + for child in reversed(children): + if self._node_is_valid(child): + stack.append(child) + + if normalized: + print(f"[SSBOEditor] Normalized transform dirty tags: {normalized}") + return normalized def _snapshot_runtime_node_transform_to_source_node(self, scene_node, source_node): """Persist one runtime node transform back to its matching source-tree node.""" @@ -689,16 +822,7 @@ class SSBOEditor: 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 + return self._apply_source_local_mat_if_changed(source_node, local_mat) def _snapshot_runtime_object_transforms_to_source_root(self): """ @@ -1616,7 +1740,11 @@ class SSBOEditor: self._set_node_name(imported_root, unique_root_name) if not skip_heavy_material_processing: self._detach_shared_materials_in_subtree(imported_root) + self._capture_source_transform_baselines(imported_root) imported_roots = [imported_root] + else: + for imported_root in imported_roots: + self._capture_source_transform_baselines(imported_root) self.source_model = source_root self._restore_saved_material_bindings_from_tags(source_root) @@ -1633,6 +1761,7 @@ class SSBOEditor: self._set_node_name(imported_root, unique_root_name) if not skip_heavy_material_processing: self._detach_shared_materials_in_subtree(imported_root) + self._capture_source_transform_baselines(imported_root) if keep_source_model and not append: self.source_model = imported_root @@ -2959,6 +3088,23 @@ class SSBOEditor: rebuilt = 0 for chunk_id in sorted(chunks): + chunk = chunks.get(chunk_id) + if not isinstance(chunk, dict): + continue + + needs_rebuild = bool(chunk.get("dynamic_enabled") or chunk.get("dirty")) + static_np = chunk.get("static_np") + if not needs_rebuild: + try: + needs_rebuild = not (static_np and not static_np.is_empty()) + except Exception: + try: + needs_rebuild = not (static_np and not static_np.isEmpty()) + except Exception: + needs_rebuild = True + if not needs_rebuild: + continue + try: controller._rebuild_static_chunk(chunk_id) controller._set_chunk_dynamic(chunk_id, False)