2761 lines
121 KiB
Python
2761 lines
121 KiB
Python
import os
|
||
import sys
|
||
import json
|
||
import copy
|
||
import datetime
|
||
import subprocess
|
||
import shutil
|
||
import importlib.util
|
||
import py_compile
|
||
import uuid
|
||
|
||
from panda3d.core import Filename, ModelRoot, NodePath
|
||
|
||
from project.asset_database import AssetDatabase
|
||
from project.project_schema import (
|
||
ENGINE_NAME,
|
||
SCENE_DESCRIPTION_EXTENSION,
|
||
PROJECT_SCHEMA_VERSION,
|
||
ProjectLayout,
|
||
default_build_settings,
|
||
ensure_project_directories,
|
||
generate_guid,
|
||
normalize_path,
|
||
relative_project_path,
|
||
)
|
||
from project.scene_description import (
|
||
build_scene_cook_manifest,
|
||
build_scene_description_from_world,
|
||
build_runtime_manifest,
|
||
build_runtime_scene,
|
||
load_json,
|
||
normalize_scene_description,
|
||
save_json,
|
||
)
|
||
from project.webgl_packager import WebGLPackager
|
||
|
||
class ProjectManager:
|
||
|
||
def __init__(self, world):
|
||
self.world = world
|
||
self.current_project_path = None
|
||
self.project_config = None
|
||
self.current_scene_guid = None
|
||
self._asset_database = None
|
||
self.last_webgl_export_report = None
|
||
|
||
print("✓ 项目管理系统初始化完成")
|
||
|
||
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:
|
||
return ""
|
||
return os.path.join(project_path, "Assets", "Scripts")
|
||
|
||
def get_project_layout(self, project_path=None):
|
||
project_path = os.path.normpath(project_path or self.current_project_path or "")
|
||
if not project_path:
|
||
return None
|
||
return ProjectLayout(project_path)
|
||
|
||
def get_asset_database(self, project_path=None, *, reload=False):
|
||
project_path = os.path.normpath(project_path or self.current_project_path or "")
|
||
if not project_path:
|
||
return None
|
||
if reload or self._asset_database is None or self._asset_database.layout.project_root != normalize_path(project_path):
|
||
self._asset_database = AssetDatabase(project_path, world=self.world)
|
||
return self._asset_database
|
||
|
||
def _sync_resource_manager_root(self, project_path=None):
|
||
resource_manager = getattr(self.world, "resource_manager", None)
|
||
if resource_manager and hasattr(resource_manager, "set_project_path"):
|
||
resource_manager.set_project_path(project_path or self.current_project_path)
|
||
|
||
def _build_scene_entry(self, scene_guid, scene_name):
|
||
return {
|
||
"guid": scene_guid,
|
||
"name": scene_name,
|
||
"path": f"Scenes/{scene_name}{SCENE_DESCRIPTION_EXTENSION}",
|
||
"enabled_in_build": True,
|
||
}
|
||
|
||
def _normalize_scene_entry_payload(self, scene_entry):
|
||
scene_entry = dict(scene_entry or {})
|
||
scene_guid = str(scene_entry.get("guid", "") or "").strip() or generate_guid()
|
||
scene_name = str(scene_entry.get("name", "") or "Main").strip() or "Main"
|
||
normalized_path = str(scene_entry.get("path", "") or "").replace("\\", "/").strip()
|
||
if not normalized_path:
|
||
normalized_path = f"Scenes/{scene_name}{SCENE_DESCRIPTION_EXTENSION}"
|
||
elif normalized_path.lower().endswith(".egscene"):
|
||
normalized_path = normalized_path[:-8] + SCENE_DESCRIPTION_EXTENSION
|
||
elif not normalized_path.lower().endswith(SCENE_DESCRIPTION_EXTENSION):
|
||
normalized_path = f"Scenes/{scene_name}{SCENE_DESCRIPTION_EXTENSION}"
|
||
|
||
normalized_entry = {
|
||
"guid": scene_guid,
|
||
"name": scene_name,
|
||
"path": normalized_path,
|
||
"enabled_in_build": bool(scene_entry.get("enabled_in_build", True)),
|
||
}
|
||
return normalized_entry
|
||
|
||
def _upgrade_scene_description_files(self, project_path, project_config):
|
||
changed = False
|
||
normalized_scenes = []
|
||
for raw_scene_entry in self._get_scene_entries(project_config):
|
||
original_entry = dict(raw_scene_entry or {})
|
||
normalized_entry = self._normalize_scene_entry_payload(original_entry)
|
||
|
||
original_rel = str(original_entry.get("path", "") or "").replace("\\", "/").strip()
|
||
normalized_rel = normalized_entry["path"]
|
||
if original_rel and original_rel != normalized_rel:
|
||
old_scene_file = os.path.join(project_path, original_rel.replace("/", os.sep))
|
||
new_scene_file = os.path.join(project_path, normalized_rel.replace("/", os.sep))
|
||
os.makedirs(os.path.dirname(new_scene_file), exist_ok=True)
|
||
if os.path.exists(old_scene_file) and not os.path.exists(new_scene_file):
|
||
shutil.move(old_scene_file, new_scene_file)
|
||
changed = True
|
||
|
||
if original_entry != normalized_entry:
|
||
changed = True
|
||
normalized_scenes.append(normalized_entry)
|
||
|
||
if normalized_scenes:
|
||
project_config["scenes"] = normalized_scenes
|
||
|
||
scene_file = str(project_config.get("scene_file", "") or "").replace("\\", "/").strip()
|
||
if scene_file.lower().endswith(".egscene"):
|
||
project_config["scene_file"] = scene_file[:-8] + SCENE_DESCRIPTION_EXTENSION
|
||
changed = True
|
||
elif not scene_file and normalized_scenes:
|
||
project_config["scene_file"] = normalized_scenes[0]["path"]
|
||
changed = True
|
||
|
||
return changed, project_config
|
||
|
||
def _get_scene_entries(self, project_config=None):
|
||
project_config = project_config or self.project_config or {}
|
||
scenes = project_config.get("scenes", [])
|
||
return list(scenes) if isinstance(scenes, list) else []
|
||
|
||
def _get_scene_entry(self, scene_guid=None, project_config=None):
|
||
project_config = project_config or self.project_config or {}
|
||
target_guid = scene_guid or self.current_scene_guid or project_config.get("last_open_scene_guid") or project_config.get("startup_scene_guid")
|
||
for scene_entry in self._get_scene_entries(project_config):
|
||
if scene_entry.get("guid") == target_guid:
|
||
return dict(scene_entry)
|
||
scenes = self._get_scene_entries(project_config)
|
||
return dict(scenes[0]) if scenes else {}
|
||
|
||
def _scene_paths(self, scene_entry=None, project_path=None):
|
||
layout = self.get_project_layout(project_path)
|
||
if not layout:
|
||
return {}
|
||
scene_entry = scene_entry or self._get_scene_entry(project_config=self.project_config)
|
||
if not scene_entry:
|
||
return {}
|
||
scene_rel = scene_entry.get("path", "")
|
||
scene_abs = os.path.join(layout.project_root, scene_rel.replace("/", os.sep)) if scene_rel else ""
|
||
scene_root, _ = os.path.splitext(scene_abs)
|
||
return {
|
||
"scene_file": scene_abs,
|
||
"scene_gui_file": f"{scene_root}_gui.json" if scene_root else "",
|
||
"scene_lui_file": f"{scene_root}_lui.json" if scene_root else "",
|
||
}
|
||
|
||
def _create_default_project_config(self, project_root, project_name):
|
||
scene_guid = generate_guid()
|
||
scene_name = "Main"
|
||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
return {
|
||
"schema_version": PROJECT_SCHEMA_VERSION,
|
||
"name": project_name,
|
||
"path": normalize_path(project_root),
|
||
"created_at": timestamp,
|
||
"last_modified": timestamp,
|
||
"version": "2.0.0",
|
||
"engine_name": ENGINE_NAME,
|
||
"engine_version": "2.0.0",
|
||
"scenes": [self._build_scene_entry(scene_guid, scene_name)],
|
||
"scene_file": f"Scenes/{scene_name}{SCENE_DESCRIPTION_EXTENSION}",
|
||
"startup_scene_guid": scene_guid,
|
||
"last_open_scene_guid": scene_guid,
|
||
"build_settings": {
|
||
**default_build_settings(),
|
||
"enabled_scene_guids": [scene_guid],
|
||
},
|
||
}
|
||
|
||
def _ensure_v2_project_defaults(self, project_path, project_config):
|
||
project_config = dict(project_config or {})
|
||
project_config["schema_version"] = PROJECT_SCHEMA_VERSION
|
||
project_config["path"] = normalize_path(project_path)
|
||
project_config["engine_name"] = ENGINE_NAME
|
||
project_config["engine_version"] = str(project_config.get("engine_version", "2.0.0") or "2.0.0")
|
||
|
||
scenes = self._get_scene_entries(project_config)
|
||
if not scenes:
|
||
fallback_guid = generate_guid()
|
||
fallback_name = "Main"
|
||
scenes = [self._build_scene_entry(fallback_guid, fallback_name)]
|
||
project_config["scenes"] = scenes
|
||
else:
|
||
project_config["scenes"] = [self._normalize_scene_entry_payload(scene) for scene in scenes]
|
||
scenes = self._get_scene_entries(project_config)
|
||
|
||
startup_scene_guid = project_config.get("startup_scene_guid") or scenes[0]["guid"]
|
||
last_open_scene_guid = project_config.get("last_open_scene_guid") or startup_scene_guid
|
||
project_config["startup_scene_guid"] = startup_scene_guid
|
||
project_config["last_open_scene_guid"] = last_open_scene_guid
|
||
current_scene_entry = self._get_scene_entry(scene_guid=last_open_scene_guid, project_config=project_config) or scenes[0]
|
||
project_config["scene_file"] = current_scene_entry.get("path", project_config.get("scene_file", ""))
|
||
|
||
build_settings = project_config.get("build_settings", {}) or {}
|
||
enabled_scene_guids = list(build_settings.get("enabled_scene_guids", []) or [])
|
||
if not enabled_scene_guids:
|
||
enabled_scene_guids = [scene["guid"] for scene in scenes]
|
||
build_settings["enabled_scene_guids"] = enabled_scene_guids
|
||
build_settings.setdefault("output_format", "directory")
|
||
build_settings.setdefault("windows_player", True)
|
||
|
||
profiles = build_settings.get("profiles", {}) or {}
|
||
active_profile = str(build_settings.get("active_profile", "") or "default")
|
||
default_profile = dict((profiles.get(active_profile) or profiles.get("default") or {}))
|
||
if not default_profile:
|
||
default_profile = {
|
||
"name": "Default",
|
||
"target_platform": "windows",
|
||
"output_format": build_settings.get("output_format", "directory"),
|
||
"output_dir": "Builds",
|
||
"windows": {
|
||
"enabled": bool(build_settings.get("windows_player", True)),
|
||
"exe_name": "",
|
||
"windowed": True,
|
||
},
|
||
"runtime": {
|
||
"startup_scene_guid": project_config.get("startup_scene_guid", ""),
|
||
"enabled_scene_guids": enabled_scene_guids,
|
||
"script_mode": "pyc",
|
||
},
|
||
}
|
||
|
||
default_profile = self._normalize_build_profile(
|
||
default_profile,
|
||
scenes=scenes,
|
||
project_config=project_config,
|
||
default_enabled_scene_guids=enabled_scene_guids,
|
||
fallback_profile_name="Default",
|
||
fallback_output_dir="Builds",
|
||
fallback_output_format=build_settings.get("output_format", "directory"),
|
||
fallback_windows_enabled=build_settings.get("windows_player", True),
|
||
)
|
||
|
||
build_settings["active_profile"] = active_profile or "default"
|
||
build_settings["profiles"] = {"default": default_profile, **{k: v for k, v in profiles.items() if k != "default"}}
|
||
project_config["build_settings"] = build_settings
|
||
return project_config
|
||
|
||
def _normalize_enabled_scene_guids(self, enabled_scene_guids, scenes):
|
||
valid_scene_guids = [str(scene.get("guid", "") or "") for scene in (scenes or []) if scene.get("guid")]
|
||
valid_scene_guid_set = set(valid_scene_guids)
|
||
normalized = []
|
||
for scene_guid in enabled_scene_guids or []:
|
||
normalized_guid = str(scene_guid or "").strip()
|
||
if normalized_guid and normalized_guid in valid_scene_guid_set and normalized_guid not in normalized:
|
||
normalized.append(normalized_guid)
|
||
return normalized or valid_scene_guids
|
||
|
||
def _normalize_build_profile(
|
||
self,
|
||
profile,
|
||
*,
|
||
scenes,
|
||
project_config,
|
||
default_enabled_scene_guids,
|
||
fallback_profile_name,
|
||
fallback_output_dir,
|
||
fallback_output_format,
|
||
fallback_windows_enabled,
|
||
):
|
||
profile = copy.deepcopy(profile or {})
|
||
normalized_output_dir = str(profile.get("output_dir", fallback_output_dir) or fallback_output_dir).strip() or fallback_output_dir
|
||
normalized_output_dir = normalized_output_dir.replace("\\", "/")
|
||
while normalized_output_dir.startswith("./"):
|
||
normalized_output_dir = normalized_output_dir[2:]
|
||
normalized_output_dir = normalized_output_dir.strip("/") or fallback_output_dir
|
||
|
||
profile["name"] = str(profile.get("name", fallback_profile_name) or fallback_profile_name)
|
||
profile["target_platform"] = str(profile.get("target_platform", "windows") or "windows")
|
||
profile["output_format"] = str(profile.get("output_format", fallback_output_format) or fallback_output_format)
|
||
profile["output_dir"] = normalized_output_dir
|
||
profile["windows"] = dict(profile.get("windows", {}) or {})
|
||
profile["windows"]["enabled"] = bool(profile["windows"].get("enabled", fallback_windows_enabled))
|
||
profile["windows"]["exe_name"] = str(profile["windows"].get("exe_name", "") or "").strip()
|
||
profile["windows"]["windowed"] = bool(profile["windows"].get("windowed", True))
|
||
profile["runtime"] = dict(profile.get("runtime", {}) or {})
|
||
|
||
normalized_enabled_scene_guids = self._normalize_enabled_scene_guids(
|
||
profile["runtime"].get("enabled_scene_guids", []) or default_enabled_scene_guids,
|
||
scenes,
|
||
)
|
||
startup_scene_guid = str(
|
||
profile["runtime"].get("startup_scene_guid", project_config.get("startup_scene_guid", "")) or project_config.get("startup_scene_guid", "")
|
||
).strip()
|
||
if startup_scene_guid not in normalized_enabled_scene_guids:
|
||
startup_scene_guid = normalized_enabled_scene_guids[0] if normalized_enabled_scene_guids else ""
|
||
|
||
profile["runtime"]["enabled_scene_guids"] = normalized_enabled_scene_guids
|
||
profile["runtime"]["startup_scene_guid"] = startup_scene_guid
|
||
profile["runtime"]["script_mode"] = str(profile["runtime"].get("script_mode", "pyc") or "pyc")
|
||
return profile
|
||
|
||
def _get_active_build_profile(self, project_config=None):
|
||
project_config = self._ensure_v2_project_defaults(
|
||
self.current_project_path or (project_config or {}).get("path") or "",
|
||
project_config or self.project_config or {},
|
||
)
|
||
build_settings = project_config.get("build_settings", {}) or {}
|
||
active_profile = str(build_settings.get("active_profile", "default") or "default")
|
||
profiles = build_settings.get("profiles", {}) or {}
|
||
profile = dict((profiles.get(active_profile) or profiles.get("default") or {}))
|
||
return active_profile, profile
|
||
|
||
def get_build_profile_options(self, project_config=None):
|
||
project_config = self._ensure_v2_project_defaults(
|
||
self.current_project_path or (project_config or {}).get("path") or "",
|
||
project_config or self.project_config or {},
|
||
)
|
||
active_profile_name, profile = self._get_active_build_profile(project_config)
|
||
build_settings = project_config.get("build_settings", {}) or {}
|
||
profiles = dict(build_settings.get("profiles", {}) or {})
|
||
runtime_settings = dict((profile or {}).get("runtime", {}) or {})
|
||
windows_settings = dict((profile or {}).get("windows", {}) or {})
|
||
scene_entries = self._get_scene_entries(project_config)
|
||
enabled_scene_guids = self._normalize_enabled_scene_guids(runtime_settings.get("enabled_scene_guids", []) or [], scene_entries)
|
||
startup_scene_guid = str(runtime_settings.get("startup_scene_guid", project_config.get("startup_scene_guid", "")) or "")
|
||
if startup_scene_guid not in enabled_scene_guids:
|
||
startup_scene_guid = enabled_scene_guids[0] if enabled_scene_guids else ""
|
||
return {
|
||
"active_profile": active_profile_name,
|
||
"profile_names": list(profiles.keys()) or ["default"],
|
||
"output_dir": str((profile or {}).get("output_dir", "Builds") or "Builds"),
|
||
"exe_name": str(windows_settings.get("exe_name", "") or ""),
|
||
"startup_scene_guid": startup_scene_guid,
|
||
"enabled_scene_guids": enabled_scene_guids,
|
||
"scenes": scene_entries,
|
||
}
|
||
|
||
def update_active_build_profile(self, *, output_dir=None, exe_name=None, startup_scene_guid=None, enabled_scene_guids=None, active_profile_name=None):
|
||
if not self.current_project_path:
|
||
return False
|
||
|
||
project_config = self._ensure_v2_project_defaults(self.current_project_path, self.project_config or {})
|
||
current_profile_name, active_profile = self._get_active_build_profile(project_config)
|
||
build_settings = dict(project_config.get("build_settings", {}) or {})
|
||
profiles = dict(build_settings.get("profiles", {}) or {})
|
||
target_profile_name = str(active_profile_name or current_profile_name or "default").strip() or "default"
|
||
scene_entries = self._get_scene_entries(project_config)
|
||
if target_profile_name != current_profile_name:
|
||
active_profile = dict((profiles.get(target_profile_name) or profiles.get("default") or active_profile or {}))
|
||
profile = dict(active_profile or {})
|
||
profile["windows"] = dict(profile.get("windows", {}) or {})
|
||
profile["runtime"] = dict(profile.get("runtime", {}) or {})
|
||
|
||
if output_dir is not None:
|
||
profile["output_dir"] = str(output_dir or "Builds").strip() or "Builds"
|
||
if exe_name is not None:
|
||
profile["windows"]["exe_name"] = str(exe_name or "").strip()
|
||
if startup_scene_guid is not None:
|
||
startup_scene_guid = str(startup_scene_guid or "").strip()
|
||
profile["runtime"]["startup_scene_guid"] = startup_scene_guid
|
||
if startup_scene_guid:
|
||
project_config["startup_scene_guid"] = startup_scene_guid
|
||
if enabled_scene_guids is not None:
|
||
normalized_scene_guids = [str(scene_guid).strip() for scene_guid in enabled_scene_guids if str(scene_guid).strip()]
|
||
profile["runtime"]["enabled_scene_guids"] = normalized_scene_guids
|
||
build_settings["enabled_scene_guids"] = normalized_scene_guids
|
||
|
||
profile = self._normalize_build_profile(
|
||
profile,
|
||
scenes=scene_entries,
|
||
project_config=project_config,
|
||
default_enabled_scene_guids=build_settings.get("enabled_scene_guids", []) or [scene.get("guid", "") for scene in scene_entries if scene.get("guid")],
|
||
fallback_profile_name=target_profile_name,
|
||
fallback_output_dir="Builds",
|
||
fallback_output_format=build_settings.get("output_format", "directory"),
|
||
fallback_windows_enabled=build_settings.get("windows_player", True),
|
||
)
|
||
build_settings["enabled_scene_guids"] = list(profile.get("runtime", {}).get("enabled_scene_guids", []) or [])
|
||
if profile.get("runtime", {}).get("startup_scene_guid"):
|
||
project_config["startup_scene_guid"] = profile["runtime"]["startup_scene_guid"]
|
||
|
||
profiles[target_profile_name] = profile
|
||
build_settings["active_profile"] = target_profile_name
|
||
build_settings["profiles"] = profiles
|
||
project_config["build_settings"] = build_settings
|
||
self.project_config = project_config
|
||
self._write_project_config(self.current_project_path, project_config)
|
||
return True
|
||
|
||
def create_build_profile(self, profile_name):
|
||
if not self.current_project_path:
|
||
return False
|
||
|
||
profile_name = str(profile_name or "").strip()
|
||
if not profile_name:
|
||
return False
|
||
|
||
project_config = self._ensure_v2_project_defaults(self.current_project_path, self.project_config or {})
|
||
active_profile_name, active_profile = self._get_active_build_profile(project_config)
|
||
build_settings = dict(project_config.get("build_settings", {}) or {})
|
||
profiles = dict(build_settings.get("profiles", {}) or {})
|
||
if profile_name in profiles:
|
||
build_settings["active_profile"] = profile_name
|
||
project_config["build_settings"] = build_settings
|
||
self.project_config = project_config
|
||
self._write_project_config(self.current_project_path, project_config)
|
||
return True
|
||
|
||
profiles[profile_name] = copy.deepcopy(active_profile or {})
|
||
profiles[profile_name]["name"] = profile_name
|
||
build_settings["active_profile"] = profile_name
|
||
build_settings["profiles"] = profiles
|
||
project_config["build_settings"] = build_settings
|
||
self.project_config = project_config
|
||
self._write_project_config(self.current_project_path, project_config)
|
||
return True
|
||
|
||
def _ensure_project_scripts_dir(self, project_path):
|
||
scripts_dir = self.get_project_scripts_dir(project_path)
|
||
if scripts_dir:
|
||
os.makedirs(scripts_dir, exist_ok=True)
|
||
return scripts_dir
|
||
|
||
def _sync_project_script_manager(self, project_path, reload_scripts=True):
|
||
script_manager = getattr(self.world, "script_manager", None)
|
||
if not script_manager:
|
||
return ""
|
||
|
||
scripts_dir = self._ensure_project_scripts_dir(project_path)
|
||
script_manager.set_scripts_directory(
|
||
scripts_dir,
|
||
create=True,
|
||
reload_scripts=reload_scripts,
|
||
)
|
||
return scripts_dir
|
||
|
||
def _restore_project_context(self, project_path, project_config, reload_scripts=True):
|
||
normalized_project_path = os.path.normpath(project_path) if project_path else None
|
||
self.current_project_path = normalized_project_path
|
||
self.project_config = project_config
|
||
self.current_scene_guid = (project_config or {}).get("last_open_scene_guid") if project_config else None
|
||
self._asset_database = None
|
||
|
||
script_manager = getattr(self.world, "script_manager", None)
|
||
if not script_manager:
|
||
self._sync_resource_manager_root(normalized_project_path)
|
||
return
|
||
|
||
if normalized_project_path:
|
||
self._sync_project_script_manager(normalized_project_path, reload_scripts=reload_scripts)
|
||
else:
|
||
script_manager.set_scripts_directory(
|
||
"scripts",
|
||
create=True,
|
||
reload_scripts=reload_scripts,
|
||
)
|
||
self.get_asset_database(normalized_project_path, reload=True) if normalized_project_path else None
|
||
self._sync_resource_manager_root(normalized_project_path)
|
||
|
||
def _sanitize_build_name(self, project_name):
|
||
invalid_chars = '<>:"/\\|?*'
|
||
safe_name = "".join("_" if char in invalid_chars else char for char in str(project_name or "").strip())
|
||
safe_name = safe_name.rstrip(". ")
|
||
return safe_name or f"{ENGINE_NAME}Project"
|
||
|
||
def _write_project_config(self, project_path, project_config):
|
||
config_file = os.path.join(project_path, "project.json")
|
||
with open(config_file, "w", encoding="utf-8") as f:
|
||
json.dump(project_config, f, ensure_ascii=False, indent=4)
|
||
|
||
def _copy_if_exists(self, source_path, target_path):
|
||
if not source_path or not os.path.exists(source_path):
|
||
return
|
||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||
if os.path.isdir(source_path):
|
||
if os.path.exists(target_path):
|
||
shutil.rmtree(target_path)
|
||
shutil.copytree(source_path, target_path)
|
||
else:
|
||
shutil.copy2(source_path, target_path)
|
||
|
||
def _create_legacy_backup(self, project_path):
|
||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
backup_root = os.path.join(project_path, "LegacyBackup", timestamp)
|
||
os.makedirs(backup_root, exist_ok=True)
|
||
for name in ("project.json", "models", "textures", "scenes", "Resources", "scripts"):
|
||
source_path = os.path.join(project_path, name)
|
||
if not os.path.exists(source_path):
|
||
continue
|
||
self._copy_if_exists(source_path, os.path.join(backup_root, name))
|
||
print(f"✓ 已创建旧项目备份: {backup_root}")
|
||
return backup_root
|
||
|
||
def _capture_camera_state(self):
|
||
camera = getattr(self.world, "camera", None) or getattr(self.world, "cam", None)
|
||
if not camera or camera.isEmpty():
|
||
return {}
|
||
return {
|
||
"position": list(camera.getPos()),
|
||
"rotation": list(camera.getHpr()),
|
||
"camera_control_enabled": bool(getattr(self.world, "camera_control_enabled", True)),
|
||
}
|
||
|
||
def import_asset_into_project(self, source_path, preferred_subdir=""):
|
||
asset_database = self.get_asset_database()
|
||
if not asset_database or not source_path:
|
||
return {}
|
||
return asset_database.import_asset(source_path, preferred_subdir=preferred_subdir)
|
||
|
||
def register_project_asset(self, asset_path):
|
||
asset_database = self.get_asset_database()
|
||
if not asset_database or not asset_path:
|
||
return {}
|
||
return asset_database.register_asset(asset_path, copy_into_assets=False)
|
||
|
||
def _retarget_live_scene_asset_paths(self):
|
||
asset_database = self.get_asset_database()
|
||
if not asset_database or not getattr(self.world, "render", None):
|
||
return
|
||
|
||
try:
|
||
for node in self.world.render.findAllMatches("**"):
|
||
if not node.hasTag("is_model_root"):
|
||
continue
|
||
|
||
source_path = ""
|
||
for tag_name in ("model_path", "saved_model_path", "original_path"):
|
||
if node.hasTag(tag_name) and node.getTag(tag_name):
|
||
source_path = node.getTag(tag_name)
|
||
break
|
||
if not source_path:
|
||
continue
|
||
|
||
asset_record = asset_database.register_asset(source_path, copy_into_assets=False)
|
||
if not asset_record:
|
||
continue
|
||
|
||
asset_abs_path = os.path.join(self.current_project_path, asset_record["asset_path"].replace("/", os.sep))
|
||
node.setTag("model_path", asset_abs_path)
|
||
node.setTag("saved_model_path", asset_abs_path)
|
||
node.setTag("asset_guid", asset_record.get("guid", ""))
|
||
node.setTag("asset_path", asset_record.get("asset_path", ""))
|
||
|
||
for gui_node in getattr(self.world, "gui_elements", []) or []:
|
||
for tag_name, preferred_subdir in (
|
||
("image_path", "UI"),
|
||
("gui_image_path", "UI"),
|
||
("video_path", "Video"),
|
||
):
|
||
if not gui_node or gui_node.isEmpty() or not gui_node.hasTag(tag_name):
|
||
continue
|
||
raw_value = gui_node.getTag(tag_name)
|
||
if not raw_value or raw_value.startswith(("http://", "https://")):
|
||
continue
|
||
asset_record = asset_database.register_asset(raw_value, preferred_subdir=preferred_subdir, copy_into_assets=False)
|
||
if not asset_record:
|
||
continue
|
||
asset_abs_path = os.path.join(self.current_project_path, asset_record["asset_path"].replace("/", os.sep))
|
||
gui_node.setTag(tag_name, asset_abs_path)
|
||
except Exception as e:
|
||
print(f"⚠️ 同步场景资产路径失败: {e}")
|
||
|
||
def _load_scene_entry_into_editor(self, scene_entry, project_path):
|
||
scene_description = self._load_scene_description(scene_entry, project_path)
|
||
if not scene_description:
|
||
print(f"⚠️ 场景描述不存在,已回退为空场景: {scene_entry.get('path', '')}")
|
||
self._clearCurrentScene()
|
||
return True
|
||
return self._load_scene_description_into_editor(scene_description, project_path, scene_entry)
|
||
|
||
def createScene(self, scene_name):
|
||
if not self.current_project_path or not scene_name:
|
||
return False
|
||
|
||
scene_name = str(scene_name).strip()
|
||
if not scene_name:
|
||
return False
|
||
|
||
project_config = dict(self.project_config or {})
|
||
if any(scene.get("name") == scene_name for scene in self._get_scene_entries(project_config)):
|
||
print(f"错误: 场景已存在: {scene_name}")
|
||
return False
|
||
|
||
scene_guid = generate_guid()
|
||
scene_entry = self._build_scene_entry(scene_guid, scene_name)
|
||
project_config.setdefault("scenes", []).append(scene_entry)
|
||
project_config["last_open_scene_guid"] = scene_guid
|
||
project_config["scene_file"] = scene_entry["path"]
|
||
build_settings = project_config.get("build_settings", {}) or {}
|
||
enabled_scene_guids = list(build_settings.get("enabled_scene_guids", []) or [])
|
||
enabled_scene_guids.append(scene_guid)
|
||
build_settings["enabled_scene_guids"] = enabled_scene_guids
|
||
project_config["build_settings"] = build_settings
|
||
|
||
self._clearCurrentScene()
|
||
self.project_config = project_config
|
||
self.current_scene_guid = scene_guid
|
||
self._write_project_config(self.current_project_path, project_config)
|
||
return self.saveProject()
|
||
|
||
def openScene(self, scene_guid):
|
||
if not self.current_project_path or not scene_guid:
|
||
return False
|
||
project_config = dict(self.project_config or {})
|
||
scene_entry = self._get_scene_entry(scene_guid=scene_guid, project_config=project_config)
|
||
if not scene_entry:
|
||
return False
|
||
if not self._load_scene_entry_into_editor(scene_entry, self.current_project_path):
|
||
return False
|
||
self.current_scene_guid = scene_entry["guid"]
|
||
project_config["last_open_scene_guid"] = scene_entry["guid"]
|
||
project_config["scene_file"] = scene_entry["path"]
|
||
self.project_config = project_config
|
||
self._write_project_config(self.current_project_path, project_config)
|
||
return True
|
||
|
||
# ==================== 项目生命周期管理 ====================
|
||
|
||
def createNewProject(self, project_path, project_name):
|
||
"""创建新项目
|
||
|
||
Args:
|
||
project_path: 项目路径
|
||
project_name: 项目名称
|
||
|
||
Returns:
|
||
bool: 创建是否成功
|
||
"""
|
||
if not project_path or not project_name:
|
||
print("错误: 项目路径和名称不能为空")
|
||
return False
|
||
|
||
full_project_path = os.path.normpath(os.path.join(project_path, project_name))
|
||
print(f"full_project_path: {full_project_path}")
|
||
|
||
try:
|
||
if os.path.exists(full_project_path):
|
||
if not os.path.isdir(full_project_path):
|
||
print("错误: 项目路径已存在且不是文件夹")
|
||
return False
|
||
if os.listdir(full_project_path):
|
||
print("错误: 项目目录已存在且非空")
|
||
return False
|
||
|
||
layout = ProjectLayout(full_project_path)
|
||
ensure_project_directories(layout)
|
||
project_config = self._create_default_project_config(full_project_path, project_name)
|
||
self._write_project_config(full_project_path, project_config)
|
||
print(f"项目配置文件已创建: {layout.project_file}")
|
||
|
||
self._clearCurrentScene()
|
||
self.current_project_path = full_project_path
|
||
self.project_config = project_config
|
||
self.current_scene_guid = project_config.get("startup_scene_guid")
|
||
self.get_asset_database(full_project_path, reload=True)
|
||
self._sync_project_script_manager(full_project_path, reload_scripts=True)
|
||
self._sync_resource_manager_root(full_project_path)
|
||
|
||
if not self.saveProject():
|
||
print("初始场景保存失败")
|
||
return False
|
||
|
||
print(f"项目 '{project_name}' 创建成功!")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"创建项目失败: {str(e)}")
|
||
return False
|
||
|
||
def openProject(self, project_path):
|
||
"""打开项目
|
||
|
||
Args:
|
||
project_path: 项目路径
|
||
|
||
Returns:
|
||
bool: 打开是否成功
|
||
"""
|
||
# 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
|
||
|
||
project_path = normalize_path(project_path)
|
||
config_file = os.path.join(project_path, "project.json")
|
||
if not os.path.exists(config_file):
|
||
print("错误: 选择的不是有效的项目文件夹!")
|
||
return False
|
||
|
||
with open(config_file, "r", encoding="utf-8") as f:
|
||
project_config = json.load(f)
|
||
|
||
previous_project_path = self.current_project_path
|
||
previous_project_config = self.project_config
|
||
|
||
ensure_project_directories(ProjectLayout(project_path))
|
||
project_config = self._ensure_v2_project_defaults(project_path, project_config)
|
||
_, project_config = self._upgrade_scene_description_files(project_path, project_config)
|
||
|
||
self.current_project_path = project_path
|
||
self.project_config = project_config
|
||
self.current_scene_guid = project_config.get("last_open_scene_guid") or project_config.get("startup_scene_guid")
|
||
self.get_asset_database(project_path, reload=True)
|
||
self._sync_project_script_manager(self.current_project_path, reload_scripts=True)
|
||
self._sync_resource_manager_root(project_path)
|
||
|
||
scene_entry = self._get_scene_entry(project_config=project_config)
|
||
if scene_entry:
|
||
if not self._load_scene_entry_into_editor(scene_entry, project_path):
|
||
self._restore_project_context(
|
||
previous_project_path,
|
||
previous_project_config,
|
||
reload_scripts=True,
|
||
)
|
||
return False
|
||
|
||
project_config["last_modified"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
project_config["last_open_scene_guid"] = self.current_scene_guid or project_config.get("startup_scene_guid")
|
||
current_scene_entry = self._get_scene_entry(project_config=project_config)
|
||
if current_scene_entry:
|
||
project_config["scene_file"] = current_scene_entry.get("path", project_config.get("scene_file", ""))
|
||
self._write_project_config(project_path, project_config)
|
||
|
||
self.current_project_path = project_path
|
||
self.project_config = project_config
|
||
|
||
print("项目加载成功!")
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._restore_project_context(
|
||
locals().get("previous_project_path"),
|
||
locals().get("previous_project_config"),
|
||
reload_scripts=True,
|
||
)
|
||
# print(f"[DEBUG] ===== 项目打开失败 =====")
|
||
print(f"加载项目时发生错误:{str(e)}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
def openProjectForPath(self, project_path):
|
||
"""通过路径打开项目
|
||
|
||
Args:
|
||
project_path: 项目路径
|
||
|
||
Returns:
|
||
bool: 打开是否成功
|
||
"""
|
||
return self.openProject(project_path)
|
||
|
||
def _capture_scene_sidecar_snapshots(self, scene_paths):
|
||
gui_snapshot = self._capture_gui_snapshot_from_world()
|
||
lui_snapshot = self._capture_lui_snapshot_from_world()
|
||
if not gui_snapshot:
|
||
gui_snapshot = load_json(scene_paths.get("scene_gui_file", ""), [])
|
||
if not lui_snapshot:
|
||
lui_snapshot = load_json(scene_paths.get("scene_lui_file", ""), {})
|
||
return gui_snapshot, lui_snapshot
|
||
|
||
def _capture_gui_snapshot_from_world(self):
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
collect_fn = getattr(scene_manager, "_collectGUIElementInfo", None) if scene_manager else None
|
||
if not callable(collect_fn):
|
||
return []
|
||
|
||
gui_snapshot = []
|
||
for gui_node in list(getattr(self.world, "gui_elements", []) or []):
|
||
if not gui_node or gui_node.isEmpty():
|
||
continue
|
||
try:
|
||
gui_info = collect_fn(gui_node)
|
||
except Exception:
|
||
gui_info = None
|
||
if gui_info:
|
||
gui_snapshot.append(gui_info)
|
||
return gui_snapshot
|
||
|
||
def _capture_lui_snapshot_from_world(self):
|
||
lui_manager = getattr(self.world, "lui_manager", None)
|
||
capture_fn = getattr(lui_manager, "_capture_lui_structure_snapshot", None) if lui_manager else None
|
||
if not callable(capture_fn):
|
||
return {}
|
||
try:
|
||
return dict(capture_fn() or {})
|
||
except Exception:
|
||
return {}
|
||
|
||
def _write_scene_sidecars(self, scene_paths, gui_snapshot, lui_snapshot):
|
||
gui_file = scene_paths.get("scene_gui_file", "")
|
||
lui_file = scene_paths.get("scene_lui_file", "")
|
||
if gui_file:
|
||
save_json(gui_file, list(gui_snapshot or []))
|
||
if lui_file:
|
||
save_json(lui_file, dict(lui_snapshot or {}))
|
||
|
||
def _cleanup_legacy_scene_artifacts(self, scene_entry, project_path):
|
||
scene_paths = self._scene_paths(scene_entry, project_path=project_path)
|
||
scene_file = scene_paths.get("scene_file", "")
|
||
legacy_candidates = [
|
||
os.path.join(project_path, "scenes", "scene.bam"),
|
||
os.path.join(project_path, "scenes", "scene_gui.json"),
|
||
os.path.join(project_path, "scenes", "scene_lui.json"),
|
||
]
|
||
if scene_file:
|
||
legacy_candidates.append(f"{os.path.splitext(scene_file)[0]}.bam")
|
||
|
||
removed_files = []
|
||
for legacy_path in legacy_candidates:
|
||
if not legacy_path:
|
||
continue
|
||
normalized_path = os.path.normpath(legacy_path)
|
||
if not os.path.exists(normalized_path):
|
||
continue
|
||
try:
|
||
os.remove(normalized_path)
|
||
removed_files.append(normalized_path)
|
||
except OSError:
|
||
continue
|
||
|
||
if removed_files:
|
||
print(f"✓ 已清理旧场景缓存文件: {len(removed_files)} 个")
|
||
|
||
def _collect_live_scene_root_nodes(self):
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
if not scene_manager:
|
||
return []
|
||
|
||
root_nodes = []
|
||
seen = set()
|
||
model_root_nodes = []
|
||
auxiliary_root_nodes = []
|
||
ssbo_editor = getattr(self.world, "ssbo_editor", None)
|
||
source_model_root = getattr(ssbo_editor, "source_model_root", None) if ssbo_editor else None
|
||
if source_model_root and not source_model_root.isEmpty():
|
||
snapshot_fn = getattr(ssbo_editor, "_snapshot_top_level_transforms_to_source_root", None)
|
||
snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None)
|
||
if callable(snapshot_fn):
|
||
try:
|
||
snapshot_fn()
|
||
except Exception:
|
||
pass
|
||
if callable(snapshot_material_fn):
|
||
try:
|
||
snapshot_material_fn()
|
||
except Exception:
|
||
pass
|
||
model_nodes = list(source_model_root.getChildren())
|
||
else:
|
||
model_nodes = list(getattr(scene_manager, "models", []) or [])
|
||
|
||
for node in model_nodes:
|
||
if not node or node.isEmpty():
|
||
continue
|
||
node_key = id(node)
|
||
if node_key in seen:
|
||
continue
|
||
seen.add(node_key)
|
||
model_root_nodes.append(node)
|
||
|
||
for collection_name in ("Spotlight", "Pointlight"):
|
||
for node in list(getattr(scene_manager, collection_name, []) or []):
|
||
if not node or node.isEmpty():
|
||
continue
|
||
node_key = id(node)
|
||
if node_key in seen:
|
||
continue
|
||
seen.add(node_key)
|
||
auxiliary_root_nodes.append(node)
|
||
|
||
root_nodes.extend(model_root_nodes)
|
||
root_nodes.extend(auxiliary_root_nodes)
|
||
return root_nodes
|
||
|
||
def _write_scene_description_from_live_scene(self, scene_entry, project_path):
|
||
scene_paths = self._scene_paths(scene_entry, project_path=project_path)
|
||
asset_database = self.get_asset_database(project_path, reload=True)
|
||
if asset_database:
|
||
asset_database.ensure_project_assets_registered()
|
||
|
||
gui_snapshot, lui_snapshot = self._capture_scene_sidecar_snapshots(scene_paths)
|
||
root_nodes = self._collect_live_scene_root_nodes()
|
||
if not root_nodes:
|
||
raise RuntimeError("当前场景没有可保存的根节点")
|
||
|
||
scene_description = build_scene_description_from_world(
|
||
asset_database=asset_database,
|
||
project_root=project_path,
|
||
scene_guid=scene_entry["guid"],
|
||
scene_name=scene_entry["name"],
|
||
scene_file_rel=scene_entry["path"],
|
||
root_nodes=root_nodes,
|
||
cache_bam_path="",
|
||
cache_gui_path=scene_paths["scene_gui_file"],
|
||
cache_lui_path=scene_paths["scene_lui_file"],
|
||
gui_elements=gui_snapshot,
|
||
lui_snapshot=lui_snapshot,
|
||
camera_state=self._capture_camera_state(),
|
||
)
|
||
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):
|
||
asset_database = self.get_asset_database(project_path, reload=True)
|
||
if not asset_database:
|
||
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(
|
||
scene_description,
|
||
project_path,
|
||
asset_database,
|
||
include_mode="all",
|
||
skip_asset_nodes=ssbo_loaded,
|
||
)
|
||
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
if scene_manager:
|
||
scene_manager.models = []
|
||
scene_manager.Spotlight = []
|
||
scene_manager.Pointlight = []
|
||
|
||
built_model_nodes = []
|
||
built_spot_lights = []
|
||
built_point_lights = []
|
||
try:
|
||
root_children = list(scene_root.getChildren())
|
||
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"):
|
||
built_model_nodes.append(child)
|
||
try:
|
||
if scene_manager and hasattr(scene_manager, "setupCollision"):
|
||
scene_manager.setupCollision(child)
|
||
if scene_manager and hasattr(scene_manager, "_processModelAnimations"):
|
||
scene_manager._processModelAnimations(child)
|
||
except Exception:
|
||
pass
|
||
elif child.hasTag("light_type"):
|
||
light_type = child.getTag("light_type")
|
||
if light_type == "spot_light":
|
||
built_spot_lights.append(child)
|
||
elif light_type == "point_light":
|
||
built_point_lights.append(child)
|
||
|
||
if scene_manager:
|
||
if not ssbo_loaded:
|
||
scene_manager.models = built_model_nodes
|
||
scene_manager.Spotlight = built_spot_lights
|
||
scene_manager.Pointlight = built_point_lights
|
||
update_tree_fn = getattr(scene_manager, "updateSceneTree", None)
|
||
if callable(update_tree_fn):
|
||
try:
|
||
update_tree_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
if not scene_root.isEmpty():
|
||
scene_root.removeNode()
|
||
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)
|
||
if camera and not camera.isEmpty():
|
||
try:
|
||
position = list(camera_state.get("position", []) or [])
|
||
rotation = list(camera_state.get("rotation", []) or [])
|
||
if len(position) >= 3:
|
||
camera.setPos(float(position[0]), float(position[1]), float(position[2]))
|
||
if len(rotation) >= 3:
|
||
camera.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2]))
|
||
if "camera_control_enabled" in camera_state:
|
||
self.world.camera_control_enabled = bool(camera_state.get("camera_control_enabled", True))
|
||
except Exception:
|
||
pass
|
||
gui_snapshot = list((scene_components.get("gui", {}) or {}).get("elements", []) or scene_description.get("gui", []) or [])
|
||
lui_snapshot = dict(scene_components.get("lui", {}) or scene_description.get("lui", {}) or {})
|
||
scene_paths = self._scene_paths(scene_entry or {}, project_path=project_path) if scene_entry else {}
|
||
self._write_scene_sidecars(scene_paths, gui_snapshot, lui_snapshot)
|
||
|
||
try:
|
||
load_lui_fn = getattr(scene_manager, "_load_lui_snapshot_file", None) if scene_manager else None
|
||
if callable(load_lui_fn) and scene_paths.get("scene_file"):
|
||
temp_stub = os.path.splitext(scene_paths["scene_file"])[0] + ".bam"
|
||
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):
|
||
nodes = list(scene_description.get("nodes", []) or [])
|
||
node_lookup = {
|
||
str(node.get("node_id", "") or ""): dict(node)
|
||
for node in nodes
|
||
if node.get("node_id") is not None
|
||
}
|
||
|
||
top_level_nodes = []
|
||
for node_id, node in node_lookup.items():
|
||
asset_guid = str(node.get("asset_guid", "") or (node.get("components", {}) or {}).get("model", {}).get("asset_guid", "") or "").strip()
|
||
if not asset_guid:
|
||
continue
|
||
parent_id = node.get("parent_id")
|
||
has_asset_parent = False
|
||
while parent_id:
|
||
parent_node = node_lookup.get(parent_id)
|
||
if not parent_node:
|
||
break
|
||
parent_asset_guid = str(parent_node.get("asset_guid", "") or (parent_node.get("components", {}) or {}).get("model", {}).get("asset_guid", "") or "").strip()
|
||
if parent_asset_guid:
|
||
has_asset_parent = True
|
||
break
|
||
parent_id = parent_node.get("parent_id")
|
||
if not has_asset_parent:
|
||
top_level_nodes.append(node)
|
||
return top_level_nodes, node_lookup
|
||
|
||
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
|
||
|
||
node_name = str(node.get("name", "") or target_np.getName() or "Node")
|
||
target_np.setName(node_name)
|
||
|
||
transform = dict(node.get("transform", {}) or {})
|
||
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])
|
||
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 and self._vector_differs(current_hpr, rotation):
|
||
target_np.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2]))
|
||
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 {})
|
||
user_visible = bool(visibility.get("user_visible", True))
|
||
target_np.setPythonTag("user_visible", user_visible)
|
||
target_np.setTag("user_visible", "true" if user_visible else "false")
|
||
if user_visible:
|
||
target_np.show()
|
||
else:
|
||
target_np.hide()
|
||
|
||
for tag_name, tag_value in dict(node.get("tags", {}) or {}).items():
|
||
if tag_value is None:
|
||
continue
|
||
target_np.setTag(str(tag_name), str(tag_value))
|
||
|
||
components = dict(node.get("components", {}) or {})
|
||
model_component = dict(components.get("model", {}) or {})
|
||
scripts_component = dict(components.get("scripts", {}) or {})
|
||
metadata_component = dict(components.get("metadata", {}) or {})
|
||
|
||
asset_guid = str(model_component.get("asset_guid", "") or node.get("asset_guid", "") or "").strip()
|
||
asset_path = str(model_component.get("asset_path", "") or node.get("asset_path", "") or "").strip()
|
||
imported_node_key = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "").strip()
|
||
if asset_guid:
|
||
target_np.setTag("asset_guid", asset_guid)
|
||
if asset_path:
|
||
asset_abs_path = os.path.join(project_path, asset_path.replace("/", os.sep))
|
||
target_np.setTag("asset_path", asset_path)
|
||
target_np.setTag("model_path", asset_abs_path)
|
||
target_np.setTag("saved_model_path", asset_abs_path)
|
||
target_np.setTag("original_path", asset_abs_path)
|
||
target_np.setTag("file", os.path.basename(asset_abs_path))
|
||
if imported_node_key:
|
||
target_np.setTag("imported_node_key", imported_node_key)
|
||
if asset_guid:
|
||
target_np.setTag("is_model_root", "1")
|
||
target_np.setTag("is_scene_element", "1")
|
||
|
||
runtime_interactive = bool(metadata_component.get("runtime_interactive", node.get("runtime_interactive", False)))
|
||
if runtime_interactive:
|
||
target_np.setTag("runtime_interactive", "true")
|
||
|
||
for tag_name in (
|
||
"has_animations",
|
||
"has_animations_checked",
|
||
"can_create_actor_from_memory",
|
||
"saved_has_animations",
|
||
"saved_can_create_actor_from_memory",
|
||
):
|
||
tag_value = str(
|
||
metadata_component.get(tag_name, (node.get("tags", {}) or {}).get(tag_name, ""))
|
||
or ""
|
||
).strip()
|
||
if tag_value:
|
||
target_np.setTag(tag_name, tag_value)
|
||
if asset_path:
|
||
asset_abs_path = os.path.join(project_path, asset_path.replace("/", os.sep))
|
||
if not target_np.hasTag("saved_model_path"):
|
||
target_np.setTag("saved_model_path", asset_abs_path)
|
||
|
||
scripts = list(scripts_component.get("entries", []) or node.get("scripts", []) or [])
|
||
if scripts:
|
||
target_np.setTag("has_scripts", "true")
|
||
target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False))
|
||
|
||
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."""
|
||
if not target_np or target_np.isEmpty():
|
||
return
|
||
|
||
property_helpers = getattr(self.world, "property_helpers", None)
|
||
if not property_helpers:
|
||
return
|
||
|
||
material_tag_names = (
|
||
"material_basecolor",
|
||
"material_emission",
|
||
"material_roughness",
|
||
"material_metallic",
|
||
"material_ior",
|
||
)
|
||
if not any(target_np.hasTag(tag_name) for tag_name in material_tag_names):
|
||
return
|
||
|
||
try:
|
||
from panda3d.core import Vec4
|
||
except Exception:
|
||
Vec4 = None
|
||
|
||
def _parse_vec4(raw_value):
|
||
try:
|
||
cleaned = str(raw_value or "").strip()
|
||
for prefix in ("LVecBase4f", "Vec4", "LColor"):
|
||
cleaned = cleaned.replace(prefix, "")
|
||
cleaned = cleaned.strip("() ")
|
||
values = [float(part.strip()) for part in cleaned.split(",") if part.strip()]
|
||
if len(values) == 3:
|
||
values.append(1.0)
|
||
if len(values) >= 4:
|
||
return tuple(values[:4])
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _parse_float(tag_name):
|
||
try:
|
||
return float(target_np.getTag(tag_name))
|
||
except Exception:
|
||
return None
|
||
|
||
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None)
|
||
sync_runtime_fn = getattr(property_helpers, "_sync_material_node_runtime", None)
|
||
set_base_color_fn = getattr(property_helpers, "_set_material_base_color", None)
|
||
if not callable(ensure_material_fn):
|
||
return
|
||
|
||
try:
|
||
material = ensure_material_fn(target_np)
|
||
except Exception:
|
||
material = None
|
||
if material is None:
|
||
return
|
||
|
||
base_color = _parse_vec4(target_np.getTag("material_basecolor")) if target_np.hasTag("material_basecolor") else None
|
||
if base_color is not None and callable(set_base_color_fn):
|
||
try:
|
||
set_base_color_fn(material, base_color)
|
||
except Exception:
|
||
pass
|
||
|
||
emission = _parse_vec4(target_np.getTag("material_emission")) if target_np.hasTag("material_emission") else None
|
||
if emission is not None and Vec4 is not None and hasattr(material, "set_emission"):
|
||
try:
|
||
material.set_emission(Vec4(*emission))
|
||
except Exception:
|
||
pass
|
||
|
||
roughness = _parse_float("material_roughness")
|
||
if roughness is not None and hasattr(material, "set_roughness"):
|
||
try:
|
||
material.set_roughness(roughness)
|
||
except Exception:
|
||
pass
|
||
|
||
metallic = _parse_float("material_metallic")
|
||
if metallic is not None and hasattr(material, "set_metallic"):
|
||
try:
|
||
material.set_metallic(metallic)
|
||
except Exception:
|
||
pass
|
||
|
||
ior = _parse_float("material_ior")
|
||
if ior is not None and hasattr(material, "set_refractive_index"):
|
||
try:
|
||
material.set_refractive_index(ior)
|
||
except Exception:
|
||
pass
|
||
|
||
if callable(sync_runtime_fn):
|
||
try:
|
||
sync_runtime_fn(target_np, material, refresh_ssbo_runtime=False)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup, apply_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,
|
||
apply_material_state=apply_material_state,
|
||
)
|
||
|
||
node_id = str(node.get("node_id", "") or "").strip()
|
||
if not node_id:
|
||
return
|
||
|
||
child_nodes = []
|
||
for candidate in list((node_lookup or {}).values()):
|
||
if str(candidate.get("parent_id", "") or "").strip() != node_id:
|
||
continue
|
||
child_nodes.append(candidate)
|
||
|
||
def _node_index(candidate):
|
||
candidate_id = str(candidate.get("node_id", "") or "")
|
||
try:
|
||
return int(candidate_id.rsplit("/", 1)[-1])
|
||
except Exception:
|
||
return 0
|
||
|
||
child_nodes.sort(key=_node_index)
|
||
|
||
runtime_children = []
|
||
try:
|
||
runtime_children = [child for child in target_np.getChildren() if child and not child.isEmpty()]
|
||
except Exception:
|
||
runtime_children = []
|
||
|
||
for child_entry in child_nodes:
|
||
child_index = _node_index(child_entry)
|
||
if child_index < 0 or child_index >= len(runtime_children):
|
||
continue
|
||
self._apply_scene_description_state_to_subtree(
|
||
runtime_children[child_index],
|
||
child_entry,
|
||
project_path,
|
||
node_lookup,
|
||
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):
|
||
return False
|
||
|
||
ssbo_editor = getattr(self.world, "ssbo_editor", None)
|
||
if not ssbo_editor:
|
||
return False
|
||
|
||
refresh_runtime_fn = getattr(ssbo_editor, "refresh_runtime_from_source", None)
|
||
|
||
top_level_asset_nodes, node_lookup = self._iter_top_level_scene_asset_nodes(scene_description)
|
||
if not top_level_asset_nodes:
|
||
return False
|
||
|
||
candidate_nodes = []
|
||
for node in top_level_asset_nodes:
|
||
components = dict(node.get("components", {}) or {})
|
||
model_component = dict(components.get("model", {}) or {})
|
||
asset_guid = str(model_component.get("asset_guid", "") or node.get("asset_guid", "") or "").strip()
|
||
if not asset_guid:
|
||
continue
|
||
|
||
asset_record = asset_database.get_asset(asset_guid)
|
||
if not asset_record:
|
||
continue
|
||
|
||
asset_rel = str(asset_record.get("asset_path", "") or model_component.get("asset_path", "") or node.get("asset_path", "") or "").strip()
|
||
if not asset_rel:
|
||
continue
|
||
|
||
asset_abs = os.path.join(project_path, asset_rel.replace("/", os.sep))
|
||
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 {})
|
||
tags = dict(node.get("tags", {}) or {})
|
||
has_saved_animation = (
|
||
str(tags.get("has_animations", "")).lower() == "true"
|
||
or str(tags.get("saved_has_animations", "")).lower() == "true"
|
||
or str(metadata_component.get("has_animations", "")).lower() == "true"
|
||
or str(metadata_component.get("saved_has_animations", "")).lower() == "true"
|
||
or str(metadata_component.get("can_create_actor_from_memory", "")).lower() == "true"
|
||
or str(metadata_component.get("saved_can_create_actor_from_memory", "")).lower() == "true"
|
||
)
|
||
except Exception:
|
||
has_saved_animation = False
|
||
|
||
if has_saved_animation:
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
if scene_manager and hasattr(scene_manager, "importModel"):
|
||
try:
|
||
imported_np = scene_manager.importModel(asset_abs)
|
||
except Exception:
|
||
imported_np = None
|
||
if imported_np and not imported_np.isEmpty():
|
||
self._apply_scene_description_state_to_node(imported_np, node, project_path)
|
||
loaded_any = True
|
||
continue
|
||
|
||
try:
|
||
imported_root = ssbo_editor.load_model(
|
||
asset_abs,
|
||
keep_source_model=False,
|
||
append=loaded_any,
|
||
scene_package_import=False,
|
||
rebuild_runtime=False,
|
||
)
|
||
except Exception as e:
|
||
print(f"⚠️ SSBO 恢复场景模型失败 {asset_abs}: {e}")
|
||
continue
|
||
|
||
target_root = imported_root if imported_root and not imported_root.isEmpty() else None
|
||
if target_root is None:
|
||
continue
|
||
|
||
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:
|
||
pass
|
||
loaded_any = True
|
||
|
||
if not loaded_any:
|
||
return False
|
||
|
||
if callable(refresh_runtime_fn):
|
||
try:
|
||
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
|
||
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
runtime_model = getattr(ssbo_editor, "model", None)
|
||
if scene_manager:
|
||
scene_manager.models = [runtime_model] if runtime_model and not runtime_model.isEmpty() else []
|
||
scene_manager.Spotlight = []
|
||
scene_manager.Pointlight = []
|
||
update_tree_fn = getattr(scene_manager, "updateSceneTree", None)
|
||
if callable(update_tree_fn):
|
||
try:
|
||
update_tree_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
return True
|
||
|
||
|
||
def _should_keep_scene_description_node(self, node_id, node, node_lookup):
|
||
if not node_id:
|
||
return False
|
||
if node.get("asset_guid"):
|
||
return True
|
||
components = node.get("components", {}) or {}
|
||
if components.get("light"):
|
||
return True
|
||
if node.get("scripts"):
|
||
return True
|
||
|
||
tags = node.get("tags", {}) or {}
|
||
if any(key in tags for key in ("is_scene_element", "runtime_interactive", "element_type", "has_scripts")):
|
||
return True
|
||
|
||
parent_id = node.get("parent_id")
|
||
while parent_id:
|
||
parent_node = node_lookup.get(parent_id)
|
||
if not parent_node:
|
||
break
|
||
if parent_node.get("asset_guid"):
|
||
return False
|
||
parent_id = parent_node.get("parent_id")
|
||
return True
|
||
|
||
def _resolve_scene_description_parent(self, node, keep_nodes, node_lookup):
|
||
parent_id = node.get("parent_id")
|
||
while parent_id:
|
||
if parent_id in keep_nodes:
|
||
return parent_id
|
||
parent_node = node_lookup.get(parent_id)
|
||
parent_id = parent_node.get("parent_id") if parent_node else None
|
||
return None
|
||
|
||
def _instantiate_scene_description_node(self, *, node, parent_np, project_path, asset_database):
|
||
node_name = str(node.get("name", "") or "Node")
|
||
tags = dict(node.get("tags", {}) or {})
|
||
components = dict(node.get("components", {}) or {})
|
||
model_component = dict(components.get("model", {}) or {})
|
||
scripts_component = dict(components.get("scripts", {}) or {})
|
||
metadata_component = dict(components.get("metadata", {}) or {})
|
||
|
||
asset_guid = str(model_component.get("asset_guid", "") or node.get("asset_guid", "") or "")
|
||
imported_node_key = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "").strip()
|
||
|
||
if asset_guid:
|
||
loaded_np = self._load_asset_node_for_scene_description(
|
||
asset_guid,
|
||
project_path,
|
||
asset_database,
|
||
imported_node_key=imported_node_key,
|
||
node_name=node_name,
|
||
)
|
||
if not loaded_np or loaded_np.isEmpty():
|
||
rebuilt_np = parent_np.attachNewNode(node_name)
|
||
else:
|
||
rebuilt_np = loaded_np
|
||
rebuilt_np.reparentTo(parent_np)
|
||
rebuilt_np.setName(node_name)
|
||
else:
|
||
rebuilt_np = parent_np.attachNewNode(node_name)
|
||
|
||
transform = node.get("transform", {}) or {}
|
||
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:
|
||
rebuilt_np.setPos(float(position[0]), float(position[1]), float(position[2]))
|
||
if len(rotation) >= 3:
|
||
rebuilt_np.setHpr(float(rotation[0]), float(rotation[1]), float(rotation[2]))
|
||
if len(scale) >= 3:
|
||
rebuilt_np.setScale(float(scale[0]), float(scale[1]), float(scale[2]))
|
||
|
||
visibility = node.get("visibility", {}) or {}
|
||
user_visible = bool(visibility.get("user_visible", True))
|
||
rebuilt_np.setPythonTag("user_visible", user_visible)
|
||
rebuilt_np.setTag("user_visible", "true" if user_visible else "false")
|
||
if user_visible:
|
||
rebuilt_np.show()
|
||
else:
|
||
rebuilt_np.hide()
|
||
|
||
for tag_name, tag_value in tags.items():
|
||
if tag_value is None:
|
||
continue
|
||
rebuilt_np.setTag(str(tag_name), str(tag_value))
|
||
|
||
runtime_interactive = bool(metadata_component.get("runtime_interactive", node.get("runtime_interactive", False)))
|
||
if runtime_interactive:
|
||
rebuilt_np.setTag("runtime_interactive", "true")
|
||
|
||
if asset_guid:
|
||
rebuilt_np.setTag("asset_guid", asset_guid)
|
||
asset_path = str(model_component.get("asset_path", "") or node.get("asset_path", "") or "")
|
||
if asset_path:
|
||
asset_abs_path = os.path.join(project_path, asset_path.replace("/", os.sep))
|
||
rebuilt_np.setTag("asset_path", asset_path)
|
||
rebuilt_np.setTag("model_path", asset_abs_path)
|
||
rebuilt_np.setTag("saved_model_path", asset_abs_path)
|
||
if imported_node_key:
|
||
rebuilt_np.setTag("imported_node_key", imported_node_key)
|
||
|
||
scripts = list(scripts_component.get("entries", []) or node.get("scripts", []) or [])
|
||
if scripts:
|
||
rebuilt_np.setTag("has_scripts", "true")
|
||
rebuilt_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False))
|
||
|
||
light_component = components.get("light", {}) or {}
|
||
if light_component.get("type"):
|
||
rebuilt_np.setTag("light_type", str(light_component.get("type")))
|
||
for input_name, tag_name in (
|
||
("energy", "light_energy"),
|
||
("radius", "light_radius"),
|
||
("fov", "light_fov"),
|
||
("stored_energy", "stored_energy"),
|
||
):
|
||
if input_name in light_component:
|
||
rebuilt_np.setTag(tag_name, str(light_component[input_name]))
|
||
|
||
return rebuilt_np
|
||
|
||
def _clone_imported_subnode(self, loaded_np, imported_node_key, node_name):
|
||
imported_node_key = str(imported_node_key or "").strip().strip("/")
|
||
if not imported_node_key:
|
||
return loaded_np
|
||
|
||
target_np = loaded_np
|
||
try:
|
||
for part in imported_node_key.split("/"):
|
||
if part == "":
|
||
continue
|
||
child_index = int(part)
|
||
children = list(target_np.getChildren())
|
||
if child_index < 0 or child_index >= len(children):
|
||
return loaded_np
|
||
target_np = children[child_index]
|
||
except Exception:
|
||
return loaded_np
|
||
|
||
try:
|
||
clone_root = NodePath(ModelRoot(node_name or target_np.getName() or "ImportedNode"))
|
||
cloned_child = target_np.copyTo(clone_root)
|
||
cloned_child.wrtReparentTo(clone_root)
|
||
cloned_child.setName(node_name or target_np.getName())
|
||
return cloned_child
|
||
except Exception:
|
||
return target_np
|
||
|
||
def _load_imported_hierarchy_index(self, asset_record, project_path):
|
||
imported_cache = asset_record.get("imported_cache", {}) or {}
|
||
hierarchy_rel = imported_cache.get("hierarchy", "")
|
||
if not hierarchy_rel:
|
||
return {}
|
||
|
||
hierarchy_path = os.path.join(project_path, hierarchy_rel.replace("/", os.sep))
|
||
if not os.path.exists(hierarchy_path):
|
||
return {}
|
||
|
||
try:
|
||
with open(hierarchy_path, "r", encoding="utf-8") as f:
|
||
payload = json.load(f)
|
||
index = {}
|
||
for item in payload if isinstance(payload, list) else []:
|
||
key = str((item or {}).get("key", "") or "").strip()
|
||
if key:
|
||
index[key] = dict(item or {})
|
||
return index
|
||
except Exception as e:
|
||
print(f"⚠️ 读取导入层级缓存失败: {hierarchy_path} ({e})")
|
||
return {}
|
||
|
||
def _find_subnode_by_name(self, loaded_np, expected_name):
|
||
expected_name = str(expected_name or "").strip()
|
||
if not expected_name:
|
||
return None
|
||
|
||
for candidate in loaded_np.findAllMatches("**"):
|
||
try:
|
||
if candidate.getName() == expected_name:
|
||
return candidate
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
def _load_asset_node_for_scene_description(self, asset_guid, project_path, asset_database, imported_node_key="", node_name=""):
|
||
asset_record = asset_database.get_asset(asset_guid)
|
||
if not asset_record:
|
||
return None
|
||
|
||
imported_cache = asset_record.get("imported_cache", {}) or {}
|
||
model_bam_rel = imported_cache.get("model_bam", "")
|
||
source_path_rel = asset_record.get("asset_path", "")
|
||
hierarchy_index = self._load_imported_hierarchy_index(asset_record, project_path)
|
||
|
||
candidate_paths = []
|
||
if model_bam_rel:
|
||
candidate_paths.append(os.path.join(project_path, model_bam_rel.replace("/", os.sep)))
|
||
if source_path_rel:
|
||
candidate_paths.append(os.path.join(project_path, source_path_rel.replace("/", os.sep)))
|
||
|
||
for candidate_path in candidate_paths:
|
||
if not candidate_path or not os.path.exists(candidate_path):
|
||
continue
|
||
try:
|
||
loaded_np = self.world.loader.loadModel(Filename.fromOsSpecific(candidate_path))
|
||
if loaded_np and not loaded_np.isEmpty():
|
||
if imported_node_key and candidate_path.endswith(".bam"):
|
||
expected_item = hierarchy_index.get(imported_node_key, {}) if hierarchy_index else {}
|
||
expected_name = str(expected_item.get("name", "") or node_name or "").strip()
|
||
cloned_np = self._clone_imported_subnode(loaded_np, imported_node_key, node_name)
|
||
cloned_name = ""
|
||
try:
|
||
cloned_name = cloned_np.getName()
|
||
except Exception:
|
||
cloned_name = ""
|
||
|
||
if expected_name and cloned_name and cloned_name != expected_name and cloned_name != node_name:
|
||
fallback_np = self._find_subnode_by_name(loaded_np, expected_name)
|
||
if fallback_np is not None and not fallback_np.isEmpty():
|
||
try:
|
||
clone_root = NodePath(ModelRoot(node_name or expected_name or "ImportedNode"))
|
||
fixed_np = fallback_np.copyTo(clone_root)
|
||
fixed_np.wrtReparentTo(clone_root)
|
||
fixed_np.setName(node_name or expected_name)
|
||
return fixed_np
|
||
except Exception:
|
||
pass
|
||
return cloned_np
|
||
return loaded_np
|
||
except Exception as e:
|
||
print(f"⚠️ 加载场景描述资产失败 {candidate_path}: {e}")
|
||
return None
|
||
|
||
def _is_scene_description_node_interactive(self, node):
|
||
if not isinstance(node, dict):
|
||
return False
|
||
if bool(node.get("runtime_interactive", False)):
|
||
return True
|
||
scripts = list(node.get("scripts", []) or [])
|
||
if scripts:
|
||
return True
|
||
components = dict(node.get("components", {}) or {})
|
||
scripts_component = dict(components.get("scripts", {}) or {})
|
||
if list(scripts_component.get("entries", []) or []):
|
||
return True
|
||
tags = dict(node.get("tags", {}) or {})
|
||
if str(tags.get("runtime_interactive", "")).lower() == "true":
|
||
return True
|
||
if str(tags.get("has_scripts", "")).lower() == "true":
|
||
return True
|
||
return False
|
||
|
||
def _node_belongs_to_asset_hierarchy(self, node, node_lookup):
|
||
if not isinstance(node, dict):
|
||
return False
|
||
|
||
asset_guid = str(node.get("asset_guid", "") or (node.get("components", {}) or {}).get("model", {}).get("asset_guid", "") or "").strip()
|
||
if asset_guid:
|
||
return True
|
||
|
||
parent_id = node.get("parent_id")
|
||
while parent_id:
|
||
parent_node = node_lookup.get(parent_id)
|
||
if not parent_node:
|
||
break
|
||
parent_asset_guid = str(parent_node.get("asset_guid", "") or (parent_node.get("components", {}) or {}).get("model", {}).get("asset_guid", "") or "").strip()
|
||
if parent_asset_guid:
|
||
return True
|
||
parent_id = parent_node.get("parent_id")
|
||
return False
|
||
|
||
def _build_scene_root_from_description(self, scene_description, project_path, asset_database, include_mode="all", skip_asset_nodes=False):
|
||
nodes = list(scene_description.get("nodes", []) or [])
|
||
node_lookup = {
|
||
str(node.get("node_id", "") or ""): dict(node)
|
||
for node in nodes
|
||
if node.get("node_id") is not None
|
||
}
|
||
keep_nodes = {}
|
||
for node_id, node in node_lookup.items():
|
||
if not self._should_keep_scene_description_node(node_id, node, node_lookup):
|
||
continue
|
||
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
|
||
if include_mode == "static" and is_interactive:
|
||
continue
|
||
keep_nodes[node_id] = node
|
||
|
||
scene_root = NodePath(ModelRoot("SceneRoot"))
|
||
built_nodes = {}
|
||
for node_id, node in keep_nodes.items():
|
||
parent_id = self._resolve_scene_description_parent(node, keep_nodes, node_lookup)
|
||
parent_np = built_nodes.get(parent_id, scene_root)
|
||
rebuilt_np = self._instantiate_scene_description_node(
|
||
node=node,
|
||
parent_np=parent_np,
|
||
project_path=project_path,
|
||
asset_database=asset_database,
|
||
)
|
||
if rebuilt_np:
|
||
built_nodes[node_id] = rebuilt_np
|
||
return scene_root, keep_nodes
|
||
|
||
def saveProject(self):
|
||
"""保存项目"""
|
||
try:
|
||
if not self.current_project_path:
|
||
print("错误: 请先创建或打开一个项目!")
|
||
return False
|
||
|
||
ssbo_editor = getattr(self.world, "ssbo_editor", None)
|
||
snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None) if ssbo_editor else None
|
||
if callable(snapshot_material_fn):
|
||
try:
|
||
snapshot_material_fn()
|
||
except Exception as e:
|
||
print(f"⚠️ 保存前同步 SSBO 材质到源场景失败: {e}")
|
||
|
||
project_path = self.current_project_path
|
||
ensure_project_directories(ProjectLayout(project_path))
|
||
project_config = self._ensure_v2_project_defaults(project_path, dict(self.project_config or {}))
|
||
_, project_config = self._upgrade_scene_description_files(project_path, project_config)
|
||
scene_entry = self._get_scene_entry(project_config=project_config)
|
||
if not scene_entry:
|
||
print("错误: 当前项目没有可保存的场景")
|
||
return False
|
||
|
||
self._retarget_live_scene_asset_paths()
|
||
self._write_scene_description_from_live_scene(scene_entry, project_path)
|
||
project_config["last_modified"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
project_config["last_open_scene_guid"] = scene_entry["guid"]
|
||
project_config["scene_file"] = scene_entry.get("path", project_config.get("scene_file", ""))
|
||
build_settings = project_config.get("build_settings", {}) or {}
|
||
enabled_scene_guids = list(build_settings.get("enabled_scene_guids", []) or [])
|
||
if scene_entry["guid"] not in enabled_scene_guids:
|
||
enabled_scene_guids.append(scene_entry["guid"])
|
||
build_settings["enabled_scene_guids"] = enabled_scene_guids
|
||
project_config["build_settings"] = build_settings
|
||
self._write_project_config(project_path, project_config)
|
||
self.project_config = project_config
|
||
self.current_scene_guid = scene_entry["guid"]
|
||
self._cleanup_legacy_scene_artifacts(scene_entry, project_path)
|
||
print("项目保存成功!")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"保存项目时发生错误:{str(e)}")
|
||
return False
|
||
|
||
# ==================== 项目打包功能 ====================
|
||
|
||
def _load_scene_description(self, scene_entry, project_path):
|
||
scene_file_path = os.path.join(project_path, scene_entry["path"].replace("/", os.sep))
|
||
return normalize_scene_description(load_json(scene_file_path, {}))
|
||
|
||
def _resolve_enabled_scene_entries(self, project_config):
|
||
_, active_profile = self._get_active_build_profile(project_config)
|
||
runtime_settings = dict((active_profile or {}).get("runtime", {}) or {})
|
||
enabled_guids = set((runtime_settings.get("enabled_scene_guids", []) or (project_config.get("build_settings") or {}).get("enabled_scene_guids", []) or []))
|
||
scene_entries = self._get_scene_entries(project_config)
|
||
if not enabled_guids:
|
||
return scene_entries
|
||
return [scene for scene in scene_entries if scene.get("guid") in enabled_guids]
|
||
|
||
def _copy_asset_record_to_runtime(self, asset_record, data_root, project_path):
|
||
if not asset_record:
|
||
return
|
||
asset_guid = asset_record.get("guid", "")
|
||
if not asset_guid:
|
||
return
|
||
|
||
source_root = os.path.join(project_path, asset_record.get("asset_path", "").replace("/", os.sep))
|
||
target_root = os.path.join(data_root, "assets", asset_guid)
|
||
os.makedirs(target_root, exist_ok=True)
|
||
|
||
if os.path.exists(source_root) and os.path.isfile(source_root):
|
||
shutil.copy2(source_root, os.path.join(target_root, os.path.basename(source_root)))
|
||
|
||
imported_cache = asset_record.get("imported_cache", {}) or {}
|
||
imported_root_rel = imported_cache.get("root", "")
|
||
if imported_root_rel:
|
||
imported_root = os.path.join(project_path, imported_root_rel.replace("/", os.sep))
|
||
if os.path.exists(imported_root):
|
||
runtime_imported_root = os.path.join(target_root, "imported")
|
||
if os.path.exists(runtime_imported_root):
|
||
shutil.rmtree(runtime_imported_root)
|
||
shutil.copytree(imported_root, runtime_imported_root)
|
||
|
||
def _stage_runtime_data(self, project_path, project_config, data_root):
|
||
layout = self.get_project_layout(project_path)
|
||
asset_database = self.get_asset_database(project_path, reload=True)
|
||
asset_database.ensure_project_assets_registered()
|
||
|
||
assets_runtime_root = os.path.join(data_root, "Assets")
|
||
if os.path.exists(layout.assets_root):
|
||
for root, dirs, files in os.walk(layout.assets_root):
|
||
rel_root = os.path.relpath(root, layout.assets_root)
|
||
normalized_rel_root = "" if rel_root == "." else rel_root
|
||
dirs[:] = [name for name in dirs if name != "Scripts"]
|
||
target_root = assets_runtime_root if not normalized_rel_root else os.path.join(assets_runtime_root, normalized_rel_root)
|
||
os.makedirs(target_root, exist_ok=True)
|
||
for file_name in files:
|
||
if file_name.endswith(".meta"):
|
||
continue
|
||
shutil.copy2(os.path.join(root, file_name), os.path.join(target_root, file_name))
|
||
|
||
runtime_scenes = []
|
||
referenced_asset_guids = set()
|
||
cook_manifests = []
|
||
|
||
def _rewrite_gui_media_paths(scene_description):
|
||
gui_items = list(scene_description.get("gui", []) or [])
|
||
for gui_info in gui_items:
|
||
for key_name, preferred_subdir in (
|
||
("image_path", "UI"),
|
||
("bg_image_path", "UI"),
|
||
("video_path", "Video"),
|
||
):
|
||
raw_value = str(gui_info.get(key_name, "") or "").strip()
|
||
if not raw_value or raw_value.startswith(("http://", "https://")):
|
||
continue
|
||
resolved_path = raw_value
|
||
if not os.path.isabs(resolved_path):
|
||
resolved_path = os.path.join(project_path, raw_value.replace("/", os.sep))
|
||
if not os.path.exists(resolved_path):
|
||
continue
|
||
asset_record = asset_database.register_asset(resolved_path, preferred_subdir=preferred_subdir, copy_into_assets=False)
|
||
if asset_record:
|
||
referenced_asset_guids.add(asset_record.get("guid", ""))
|
||
runtime_file = f"assets/{asset_record['guid']}/{os.path.basename(resolved_path)}"
|
||
gui_info[key_name] = runtime_file.replace("\\", "/")
|
||
scene_description["gui"] = gui_items
|
||
return scene_description
|
||
|
||
for scene_entry in self._resolve_enabled_scene_entries(project_config):
|
||
scene_description = self._load_scene_description(scene_entry, project_path)
|
||
if not scene_description:
|
||
continue
|
||
scene_description = _rewrite_gui_media_paths(dict(scene_description))
|
||
|
||
build_cache_dir = layout.build_cache_dir(scene_entry["guid"])
|
||
if os.path.exists(build_cache_dir):
|
||
shutil.rmtree(build_cache_dir)
|
||
os.makedirs(build_cache_dir, exist_ok=True)
|
||
|
||
runtime_scene = build_runtime_scene(scene_description)
|
||
cook_manifest = build_scene_cook_manifest(scene_description, runtime_scene)
|
||
runtime_scenes.append(runtime_scene)
|
||
cook_manifests.append(cook_manifest)
|
||
save_json(
|
||
os.path.join(data_root, "scenes", f"{scene_entry['guid']}.runtime.json"),
|
||
runtime_scene,
|
||
)
|
||
save_json(
|
||
os.path.join(build_cache_dir, "runtime_scene.json"),
|
||
runtime_scene,
|
||
)
|
||
save_json(
|
||
os.path.join(build_cache_dir, "scene_description.json"),
|
||
scene_description,
|
||
)
|
||
save_json(
|
||
os.path.join(build_cache_dir, "cook_manifest.json"),
|
||
cook_manifest,
|
||
)
|
||
|
||
referenced_asset_guids.update(scene_description.get("referenced_asset_guids", []) or [])
|
||
referenced_asset_guids.update(scene_description.get("referenced_script_guids", []) or [])
|
||
|
||
asset_records = []
|
||
for asset_guid in sorted(guid for guid in referenced_asset_guids if guid):
|
||
asset_record = asset_database.get_asset(asset_guid)
|
||
if not asset_record:
|
||
continue
|
||
asset_records.append(asset_record)
|
||
self._copy_asset_record_to_runtime(asset_record, data_root, project_path)
|
||
|
||
manifest = build_runtime_manifest(project_config, runtime_scenes, asset_records)
|
||
save_json(os.path.join(data_root, "manifest.json"), manifest)
|
||
save_json(
|
||
os.path.join(data_root, "cook_manifest.json"),
|
||
{
|
||
"schema_version": 1,
|
||
"project_name": project_config.get("name", f"{ENGINE_NAME}Project"),
|
||
"scene_guids": [scene.get("scene_guid", "") for scene in runtime_scenes if scene.get("scene_guid")],
|
||
"scenes": cook_manifests,
|
||
},
|
||
)
|
||
cook_summary = dict(manifest.get("cook_summary", {}) or {})
|
||
print(
|
||
"✓ Cook 构建统计: "
|
||
f"场景 {cook_summary.get('scene_count', 0)} 个, "
|
||
f"资源 {cook_summary.get('asset_count', 0)} 个, "
|
||
f"交互节点 {cook_summary.get('interactive_node_count', 0)} 个, "
|
||
f"静态节点 {cook_summary.get('static_node_count', 0)} 个"
|
||
)
|
||
return manifest
|
||
|
||
def buildWebGLPackage(self, output_dir):
|
||
"""将当前项目导出为 WebGL 静态站点目录。"""
|
||
try:
|
||
if not self.current_project_path:
|
||
print("错误: 请先创建或打开一个项目!")
|
||
return False
|
||
|
||
if not output_dir:
|
||
print("错误: 请指定 WebGL 打包输出目录!")
|
||
return False
|
||
|
||
project_path = normalize_path(self.current_project_path)
|
||
ensure_project_directories(ProjectLayout(project_path))
|
||
|
||
if hasattr(self.world, "selection") and self.world.selection:
|
||
self.world.selection.clearSelection()
|
||
print("已取消场景中的物体选中状态")
|
||
|
||
if not self.saveProject():
|
||
print("错误: WebGL 打包前保存场景失败!")
|
||
return False
|
||
|
||
output_dir = normalize_path(output_dir)
|
||
|
||
packager = WebGLPackager(self.world)
|
||
report = packager.package(project_path, output_dir)
|
||
self.last_webgl_export_report = report
|
||
|
||
status = str(report.get("status", "failed") or "failed")
|
||
report_path = os.path.join(
|
||
str(report.get("output_dir", "") or ""),
|
||
"reports",
|
||
"export_report.json",
|
||
)
|
||
|
||
if status in ("success", "partial"):
|
||
print(f"WebGL打包完成: {status}")
|
||
print(f"输出目录: {report.get('output_dir', '')}")
|
||
print(f"报告路径: {report_path}")
|
||
return True
|
||
|
||
print("WebGL打包失败")
|
||
if report_path:
|
||
print(f"报告路径: {report_path}")
|
||
return False
|
||
except Exception as exc:
|
||
print(f"WebGL打包过程出错: {exc}")
|
||
return False
|
||
|
||
def buildPackage(self, build_dir):
|
||
"""将当前项目打包为最终运行程序。"""
|
||
try:
|
||
if not self.current_project_path:
|
||
print("错误: 请先创建或打开一个项目!")
|
||
return False
|
||
|
||
project_path = normalize_path(self.current_project_path)
|
||
ensure_project_directories(ProjectLayout(project_path))
|
||
self.project_config = self._ensure_v2_project_defaults(project_path, self.project_config or {})
|
||
_, self.project_config = self._upgrade_scene_description_files(project_path, self.project_config)
|
||
_, active_profile = self._get_active_build_profile(self.project_config)
|
||
profile_runtime = dict((active_profile or {}).get("runtime", {}) or {})
|
||
profile_windows = dict((active_profile or {}).get("windows", {}) or {})
|
||
|
||
configured_startup_scene = str(profile_runtime.get("startup_scene_guid", "") or "").strip()
|
||
if configured_startup_scene:
|
||
self.project_config["startup_scene_guid"] = configured_startup_scene
|
||
|
||
project_display_name = (
|
||
(self.project_config or {}).get("name")
|
||
or os.path.basename(project_path)
|
||
or f"{ENGINE_NAME}Project"
|
||
)
|
||
configured_exe_name = str(profile_windows.get("exe_name", "") or "").strip()
|
||
project_name = self._sanitize_build_name(configured_exe_name or project_display_name)
|
||
|
||
if not build_dir:
|
||
print("错误: 请指定打包输出目录!")
|
||
return False
|
||
|
||
if hasattr(self.world, "selection") and self.world.selection:
|
||
self.world.selection.clearSelection()
|
||
print("已取消场景中的物体选中状态")
|
||
|
||
self._sync_project_script_manager(project_path, reload_scripts=True)
|
||
if not self.saveProject():
|
||
print("错误: 保存场景失败!")
|
||
return False
|
||
|
||
output_root = normalize_path(build_dir)
|
||
staging_root = os.path.join(output_root, f".{project_name}_staging")
|
||
source_root = os.path.join(staging_root, "source")
|
||
data_root = os.path.join(staging_root, "data")
|
||
dist_dir = os.path.join(output_root, project_name)
|
||
|
||
if os.path.exists(staging_root):
|
||
shutil.rmtree(staging_root)
|
||
os.makedirs(source_root, exist_ok=True)
|
||
os.makedirs(data_root, exist_ok=True)
|
||
|
||
if os.path.exists(dist_dir):
|
||
shutil.rmtree(dist_dir)
|
||
|
||
manifest = self._stage_runtime_data(project_path, self.project_config or {}, data_root)
|
||
if not manifest.get("scenes"):
|
||
print("错误: 当前项目没有可打包的场景")
|
||
return False
|
||
|
||
self._copyScriptsToBuild(data_root, project_path)
|
||
self._copyRenderPipelineRuntime(os.path.join(data_root, "pipeline"))
|
||
|
||
runtime_entry = self._createProjectRuntimeEntry(source_root, project_display_name)
|
||
if not runtime_entry:
|
||
print("错误: 生成项目运行时入口失败")
|
||
return False
|
||
|
||
success = self._executeProjectBuild(
|
||
source_root=source_root,
|
||
data_root=data_root,
|
||
build_root=output_root,
|
||
project_name=project_name,
|
||
)
|
||
if not success:
|
||
return False
|
||
|
||
if os.path.exists(staging_root):
|
||
shutil.rmtree(staging_root, ignore_errors=True)
|
||
|
||
exe_path = os.path.join(dist_dir, f"{project_name}.exe")
|
||
print(f"打包完成!项目运行程序: {exe_path}")
|
||
build_result = {
|
||
"success": True,
|
||
"project_name": project_name,
|
||
"output_dir": dist_dir,
|
||
"exe_path": exe_path,
|
||
"manifest": manifest,
|
||
"cook_summary": dict(manifest.get("cook_summary", {}) or {}),
|
||
}
|
||
build_result["validation"] = self._validate_build_output(dist_dir, build_result)
|
||
report_path = self._write_build_report(dist_dir, build_result)
|
||
if report_path:
|
||
build_result["report_path"] = report_path
|
||
print(f"✓ 构建报告已生成: {report_path}")
|
||
summary_path = self._write_build_summary(dist_dir, build_result)
|
||
if summary_path:
|
||
build_result["summary_path"] = summary_path
|
||
print(f"✓ 构建摘要已生成: {summary_path}")
|
||
return build_result
|
||
|
||
except Exception as e:
|
||
print(f"打包过程出错:{str(e)}")
|
||
return False
|
||
|
||
def _merge_folder_contents(self, source_folder, destination_folder):
|
||
"""将 source_folder 的内容合并到 destination_folder。"""
|
||
if not source_folder or not os.path.exists(source_folder):
|
||
return False
|
||
|
||
os.makedirs(destination_folder, exist_ok=True)
|
||
for root, _, files in os.walk(source_folder):
|
||
rel_root = os.path.relpath(root, source_folder)
|
||
target_root = destination_folder if rel_root == "." else os.path.join(destination_folder, rel_root)
|
||
os.makedirs(target_root, exist_ok=True)
|
||
for file_name in files:
|
||
source_file = os.path.join(root, file_name)
|
||
target_file = os.path.join(target_root, file_name)
|
||
shutil.copy2(source_file, target_file)
|
||
return True
|
||
|
||
def _collect_output_file_stats(self, root_path):
|
||
stats = {
|
||
"file_count": 0,
|
||
"dir_count": 0,
|
||
"total_size_bytes": 0,
|
||
}
|
||
if not root_path or not os.path.exists(root_path):
|
||
return stats
|
||
|
||
for root, dirs, files in os.walk(root_path):
|
||
stats["dir_count"] += len(dirs)
|
||
stats["file_count"] += len(files)
|
||
for file_name in files:
|
||
file_path = os.path.join(root, file_name)
|
||
try:
|
||
stats["total_size_bytes"] += os.path.getsize(file_path)
|
||
except OSError:
|
||
continue
|
||
return stats
|
||
|
||
def _validate_build_output(self, output_dir, build_result):
|
||
validation = {
|
||
"ok": True,
|
||
"checks": [],
|
||
}
|
||
if not output_dir or not isinstance(build_result, dict):
|
||
validation["ok"] = False
|
||
validation["checks"].append({"name": "build_result", "ok": False, "detail": "missing output_dir or build_result"})
|
||
return validation
|
||
|
||
def add_check(name, path_value, required=True):
|
||
exists = bool(path_value) and os.path.exists(path_value)
|
||
validation["checks"].append(
|
||
{
|
||
"name": name,
|
||
"path": path_value,
|
||
"ok": exists if required else True,
|
||
"required": required,
|
||
}
|
||
)
|
||
if required and not exists:
|
||
validation["ok"] = False
|
||
|
||
exe_path = str(build_result.get("exe_path", "") or "")
|
||
add_check("exe", exe_path)
|
||
|
||
data_root = os.path.join(output_dir, "_internal", "data")
|
||
if not os.path.exists(data_root):
|
||
data_root = os.path.join(output_dir, "data")
|
||
add_check("data_root", data_root)
|
||
|
||
manifest_path = os.path.join(data_root, "manifest.json") if data_root else ""
|
||
add_check("manifest", manifest_path)
|
||
|
||
manifest = dict(build_result.get("manifest", {}) or {})
|
||
scenes = list(manifest.get("scenes", []) or [])
|
||
for scene_entry in scenes:
|
||
runtime_rel = str(scene_entry.get("runtime_path", "") or "")
|
||
runtime_abs = os.path.join(data_root, runtime_rel.replace("/", os.sep)) if runtime_rel else ""
|
||
scene_guid = str(scene_entry.get("guid", "") or "")
|
||
add_check(f"runtime_scene:{scene_guid}", runtime_abs)
|
||
|
||
add_check("scripts_dir", os.path.join(data_root, "scripts"))
|
||
add_check("pipeline_dir", os.path.join(data_root, "pipeline"))
|
||
|
||
legacy_scene_bams = []
|
||
for root, _, files in os.walk(output_dir):
|
||
for file_name in files:
|
||
if file_name.lower() != "scene.bam":
|
||
continue
|
||
legacy_scene_bams.append(os.path.join(root, file_name))
|
||
validation["checks"].append(
|
||
{
|
||
"name": "no_scene_bam_in_scene_pipeline",
|
||
"path": ", ".join(legacy_scene_bams),
|
||
"ok": not legacy_scene_bams,
|
||
"required": True,
|
||
"detail": "scene.bam 仅允许作为模型资源/模型缓存存在,不允许进入项目场景与运行时场景链路",
|
||
}
|
||
)
|
||
if legacy_scene_bams:
|
||
validation["ok"] = False
|
||
return validation
|
||
|
||
def _write_build_report(self, output_dir, build_result):
|
||
if not output_dir or not isinstance(build_result, dict):
|
||
return ""
|
||
|
||
report_payload = {
|
||
"schema_version": 1,
|
||
"generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"engine_name": ENGINE_NAME,
|
||
"scene_format": SCENE_DESCRIPTION_EXTENSION.lstrip("."),
|
||
"project_name": build_result.get("project_name", ""),
|
||
"exe_path": build_result.get("exe_path", ""),
|
||
"output_dir": build_result.get("output_dir", ""),
|
||
"cook_summary": dict(build_result.get("cook_summary", {}) or {}),
|
||
"output_stats": self._collect_output_file_stats(output_dir),
|
||
"validation": self._validate_build_output(output_dir, build_result),
|
||
}
|
||
report_path = os.path.join(output_dir, "build_report.json")
|
||
save_json(report_path, report_payload)
|
||
return report_path
|
||
|
||
def _write_build_summary(self, output_dir, build_result):
|
||
if not output_dir or not isinstance(build_result, dict):
|
||
return ""
|
||
|
||
cook_summary = dict(build_result.get("cook_summary", {}) or {})
|
||
validation = dict(build_result.get("validation", {}) or {})
|
||
lines = [
|
||
f"Project: {build_result.get('project_name', '')}",
|
||
f"Output: {build_result.get('output_dir', '')}",
|
||
f"EXE: {build_result.get('exe_path', '')}",
|
||
f"Validation: {'OK' if validation.get('ok', False) else 'FAILED'}",
|
||
f"Scenes: {cook_summary.get('scene_count', 0)}",
|
||
f"Assets: {cook_summary.get('asset_count', 0)}",
|
||
f"Interactive Nodes: {cook_summary.get('interactive_node_count', 0)}",
|
||
f"Static Nodes: {cook_summary.get('static_node_count', 0)}",
|
||
f"Interactive Models: {cook_summary.get('interactive_model_count', 0)}",
|
||
f"Static Models: {cook_summary.get('static_model_count', 0)}",
|
||
"",
|
||
"Checks:",
|
||
]
|
||
for check in list(validation.get("checks", []) or []):
|
||
status = "OK" if check.get("ok") else "MISSING"
|
||
lines.append(f"- {status}: {check.get('name', '')} -> {check.get('path', '')}")
|
||
|
||
summary_path = os.path.join(output_dir, "build_summary.txt")
|
||
with open(summary_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(lines))
|
||
return summary_path
|
||
|
||
def _load_runtime_template(self):
|
||
template_path = os.path.join(self._get_repo_root(), "templates", "main_template.py")
|
||
with open(template_path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
def _createProjectRuntimeEntry(self, source_root, project_name):
|
||
try:
|
||
template_content = self._load_runtime_template()
|
||
safe_project_name = project_name.replace("\\", "\\\\").replace('"', '\\"')
|
||
runtime_source = template_content.replace("__EG_PROJECT_NAME__", safe_project_name)
|
||
runtime_entry = os.path.join(source_root, "main.py")
|
||
with open(runtime_entry, "w", encoding="utf-8") as f:
|
||
f.write(runtime_source)
|
||
return runtime_entry
|
||
except Exception as e:
|
||
print(f"创建项目运行时入口失败: {e}")
|
||
return ""
|
||
|
||
def _executeProjectBuild(self, source_root, data_root, build_root, project_name):
|
||
repo_root = self._get_repo_root()
|
||
work_root = os.path.join(build_root, f".{project_name}_work")
|
||
spec_root = os.path.join(build_root, f".{project_name}_spec")
|
||
|
||
for directory in (work_root, spec_root):
|
||
if os.path.exists(directory):
|
||
shutil.rmtree(directory)
|
||
os.makedirs(directory, exist_ok=True)
|
||
|
||
pyinstaller_runner = None
|
||
pyinstaller_candidates = [
|
||
[sys.executable, "-m", "PyInstaller"],
|
||
[sys.executable, "-m", "pyinstaller"],
|
||
["pyinstaller"],
|
||
]
|
||
for candidate in pyinstaller_candidates:
|
||
pyinstaller_probe = subprocess.run(
|
||
[*candidate, "--version"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
if pyinstaller_probe.returncode == 0:
|
||
pyinstaller_runner = candidate
|
||
break
|
||
|
||
if not pyinstaller_runner:
|
||
print("错误: 当前环境未安装 PyInstaller,无法执行项目打包。")
|
||
return False
|
||
|
||
sep = ";" if os.name == "nt" else ":"
|
||
command = [
|
||
*pyinstaller_runner,
|
||
"--noconfirm",
|
||
"--clean",
|
||
"--windowed",
|
||
"--name",
|
||
project_name,
|
||
"--distpath",
|
||
build_root,
|
||
"--workpath",
|
||
work_root,
|
||
"--specpath",
|
||
spec_root,
|
||
"--paths",
|
||
source_root,
|
||
"--paths",
|
||
repo_root,
|
||
"--paths",
|
||
os.path.join(repo_root, "RenderPipelineFile"),
|
||
"--exclude-module",
|
||
"imgui_bundle",
|
||
"--exclude-module",
|
||
"p3dimgui",
|
||
"--hidden-import",
|
||
"core",
|
||
"--hidden-import",
|
||
"core.script_system",
|
||
"--hidden-import",
|
||
"core.CustomMouseController",
|
||
"--hidden-import",
|
||
"ssbo_component.runtime_importer",
|
||
"--hidden-import",
|
||
"ssbo_component.ssbo_controller",
|
||
"--hidden-import",
|
||
"rpcore",
|
||
"--collect-submodules",
|
||
"rpcore",
|
||
"--collect-submodules",
|
||
"rpplugins",
|
||
"--collect-submodules",
|
||
"rplibs",
|
||
]
|
||
|
||
panda_binary_dir = self._get_panda3d_binary_dir()
|
||
panda_display_binaries = [
|
||
"libpandagl.dll",
|
||
"libp3windisplay.dll",
|
||
"libpandadx9.dll",
|
||
"libp3tinydisplay.dll",
|
||
"cgGL.dll",
|
||
"cgD3D9.dll",
|
||
"d3dx9_43.dll",
|
||
]
|
||
if panda_binary_dir:
|
||
for binary_name in panda_display_binaries:
|
||
binary_path = os.path.join(panda_binary_dir, binary_name)
|
||
if os.path.exists(binary_path):
|
||
command.extend(["--add-binary", f"{binary_path}{sep}."])
|
||
|
||
if os.path.exists(data_root):
|
||
command.extend(["--add-data", f"{data_root}{sep}data"])
|
||
|
||
command.append(os.path.join(source_root, "main.py"))
|
||
|
||
result = subprocess.run(
|
||
command,
|
||
cwd=source_root,
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
print("项目打包失败。")
|
||
output = (result.stdout or "") + "\n" + (result.stderr or "")
|
||
print(output[-4000:])
|
||
return False
|
||
|
||
for directory in (work_root, spec_root):
|
||
if os.path.exists(directory):
|
||
shutil.rmtree(directory, ignore_errors=True)
|
||
|
||
print(f"✓ 项目已打包到: {os.path.join(build_root, project_name)}")
|
||
return True
|
||
|
||
def _get_panda3d_binary_dir(self):
|
||
try:
|
||
panda3d_spec = importlib.util.find_spec("panda3d.core")
|
||
if panda3d_spec and panda3d_spec.origin:
|
||
panda_package_dir = os.path.dirname(os.path.dirname(os.path.abspath(panda3d_spec.origin)))
|
||
candidate_dirs = [
|
||
os.path.join(panda_package_dir, "bin"),
|
||
os.path.join(os.path.dirname(panda_package_dir), "bin"),
|
||
]
|
||
for candidate_dir in candidate_dirs:
|
||
if os.path.isdir(candidate_dir):
|
||
return candidate_dir
|
||
except Exception as e:
|
||
print(f"⚠️ 获取 Panda3D 二进制目录失败: {e}")
|
||
return ""
|
||
|
||
def _copyScriptsToBuild(self, build_dir, project_path):
|
||
"""将项目脚本编译后复制到构建目录,避免直接暴露源码。"""
|
||
try:
|
||
scripts_dest = os.path.join(build_dir, "scripts")
|
||
scripts_src = self.get_project_scripts_dir(project_path)
|
||
if os.path.exists(scripts_dest):
|
||
shutil.rmtree(scripts_dest)
|
||
os.makedirs(scripts_dest, exist_ok=True)
|
||
|
||
if os.path.exists(scripts_src):
|
||
compiled_count = 0
|
||
fallback_count = 0
|
||
for root, _, files in os.walk(scripts_src):
|
||
rel_root = os.path.relpath(root, scripts_src)
|
||
target_root = scripts_dest if rel_root == "." else os.path.join(scripts_dest, rel_root)
|
||
os.makedirs(target_root, exist_ok=True)
|
||
|
||
for file_name in files:
|
||
if file_name.startswith("__"):
|
||
continue
|
||
|
||
source_file = os.path.join(root, file_name)
|
||
if file_name.endswith(".py"):
|
||
compiled_file = os.path.join(
|
||
target_root,
|
||
f"{os.path.splitext(file_name)[0]}.pyc",
|
||
)
|
||
try:
|
||
py_compile.compile(source_file, cfile=compiled_file, doraise=True)
|
||
compiled_count += 1
|
||
except py_compile.PyCompileError as e:
|
||
fallback_file = os.path.join(target_root, file_name)
|
||
shutil.copy2(source_file, fallback_file)
|
||
fallback_count += 1
|
||
print(f"⚠️ 编译脚本失败,已回退复制源码: {source_file} -> {fallback_file} ({e})")
|
||
elif not file_name.endswith((".pyc", ".pyo", ".log")):
|
||
shutil.copy2(source_file, os.path.join(target_root, file_name))
|
||
|
||
print(f"✓ Scripts已导出到 build/scripts,编译 {compiled_count} 个脚本")
|
||
if fallback_count:
|
||
print(f"⚠️ {fallback_count} 个脚本因编译失败保留为 .py 源码")
|
||
else:
|
||
print("⚠️ 项目中没有 Assets/Scripts 目录")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ 复制脚本文件时出错: {str(e)}")
|
||
|
||
def _copyRenderPipelineRuntime(self, build_dir):
|
||
"""复制运行时需要的 RenderPipeline 资源,并将源码编译为 pyc。"""
|
||
try:
|
||
source_root = os.path.join(self._get_repo_root(), "RenderPipelineFile")
|
||
target_root = build_dir
|
||
|
||
if os.path.exists(target_root):
|
||
shutil.rmtree(target_root)
|
||
|
||
if not os.path.exists(source_root):
|
||
print("⚠️ RenderPipelineFile 文件夹未找到")
|
||
return False
|
||
|
||
blocked_dirs = {"__pycache__", ".git", ".vscode", "samples", "toolkit"}
|
||
blocked_root_files = {"setup.py", "start_daytime_editor.py", "start_plugin_configurator.py"}
|
||
blocked_relative_dirs = {
|
||
os.path.normpath(os.path.join("rplibs", "yaml", "yaml_py2")),
|
||
}
|
||
|
||
for root, dirs, files in os.walk(source_root):
|
||
relative_root = os.path.relpath(root, source_root)
|
||
normalized_relative_root = os.path.normpath(relative_root)
|
||
dirs[:] = [
|
||
name for name in dirs
|
||
if name not in blocked_dirs
|
||
and os.path.normpath(os.path.join(normalized_relative_root, name)) not in blocked_relative_dirs
|
||
]
|
||
target_dir = target_root if relative_root == "." else os.path.join(target_root, relative_root)
|
||
os.makedirs(target_dir, exist_ok=True)
|
||
|
||
for file_name in files:
|
||
if file_name.endswith((".pyc", ".pyo", ".log")):
|
||
continue
|
||
if relative_root == "." and file_name in blocked_root_files:
|
||
continue
|
||
|
||
source_file = os.path.join(root, file_name)
|
||
if file_name.endswith(".py"):
|
||
compiled_file = os.path.join(
|
||
target_dir,
|
||
f"{os.path.splitext(file_name)[0]}.pyc",
|
||
)
|
||
try:
|
||
py_compile.compile(source_file, cfile=compiled_file, doraise=True)
|
||
except py_compile.PyCompileError as e:
|
||
print(f"⚠️ RenderPipeline 脚本编译失败,保留源码: {source_file} ({e})")
|
||
shutil.copy2(source_file, os.path.join(target_dir, file_name))
|
||
else:
|
||
shutil.copy2(source_file, os.path.join(target_dir, file_name))
|
||
|
||
print("✓ RenderPipeline 运行时资源已导出")
|
||
return True
|
||
except Exception as e:
|
||
print(f"⚠️ 导出 RenderPipeline 运行时资源失败: {e}")
|
||
return False
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
def _clearCurrentScene(self):
|
||
"""清空当前场景"""
|
||
try:
|
||
selection = getattr(self.world, "selection", None)
|
||
if selection and hasattr(selection, "clearSelection"):
|
||
try:
|
||
selection.clearSelection()
|
||
except Exception:
|
||
pass
|
||
|
||
script_manager = getattr(self.world, "script_manager", None)
|
||
if script_manager and hasattr(script_manager, "reset_scene_state"):
|
||
try:
|
||
script_manager.reset_scene_state()
|
||
except Exception:
|
||
pass
|
||
|
||
ssbo_editor = getattr(self.world, "ssbo_editor", None)
|
||
if ssbo_editor and hasattr(ssbo_editor, "reset_scene_state"):
|
||
try:
|
||
ssbo_editor.reset_scene_state()
|
||
except Exception:
|
||
pass
|
||
|
||
scene_manager = getattr(self.world, "scene_manager", None)
|
||
if scene_manager:
|
||
tree_widget = scene_manager._get_tree_widget() if hasattr(scene_manager, "_get_tree_widget") else None
|
||
|
||
for model in list(getattr(scene_manager, "models", []) or []):
|
||
try:
|
||
if tree_widget:
|
||
try:
|
||
tree_widget.delete_item(model)
|
||
except Exception:
|
||
pass
|
||
if model and not model.isEmpty():
|
||
model.removeNode()
|
||
except Exception:
|
||
pass
|
||
scene_manager.models = []
|
||
|
||
for collection_name in ("Spotlight", "Pointlight"):
|
||
collection = list(getattr(scene_manager, collection_name, []) or [])
|
||
for light_node in collection:
|
||
try:
|
||
if tree_widget:
|
||
try:
|
||
tree_widget.delete_item(light_node)
|
||
except Exception:
|
||
pass
|
||
if light_node and not light_node.isEmpty():
|
||
light_node.removeNode()
|
||
except Exception:
|
||
pass
|
||
setattr(scene_manager, collection_name, [])
|
||
|
||
terrain_manager = getattr(self.world, "terrain_manager", None)
|
||
terrains = list(getattr(terrain_manager, "terrains", []) or []) if terrain_manager else []
|
||
for terrain in terrains:
|
||
try:
|
||
if tree_widget:
|
||
try:
|
||
tree_widget.delete_item(terrain)
|
||
except Exception:
|
||
pass
|
||
if terrain and not terrain.isEmpty():
|
||
terrain.removeNode()
|
||
except Exception:
|
||
pass
|
||
|
||
cleanup_aux_fn = getattr(scene_manager, "_cleanupAuxiliaryNodes", None)
|
||
if callable(cleanup_aux_fn):
|
||
try:
|
||
cleanup_aux_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
cleanup_render_fn = getattr(scene_manager, "_cleanup_untracked_render_children", None)
|
||
if callable(cleanup_render_fn):
|
||
try:
|
||
cleanup_render_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
update_tree_fn = getattr(scene_manager, "updateSceneTree", None)
|
||
if callable(update_tree_fn):
|
||
try:
|
||
update_tree_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
lui_manager = getattr(self.world, "lui_manager", None)
|
||
if lui_manager:
|
||
clear_lui_fn = getattr(lui_manager, "_clear_lui_structure_runtime", None)
|
||
if callable(clear_lui_fn):
|
||
try:
|
||
clear_lui_fn()
|
||
except Exception:
|
||
pass
|
||
|
||
print("✓ 当前场景已清空")
|
||
except Exception as e:
|
||
print(f"清空场景失败: {str(e)}")
|
||
|
||
def updateWindowTitle(self, parent_window, project_name):
|
||
"""更新窗口标题(保留方法以兼容旧代码)"""
|
||
# 这个方法现在不需要做任何事情,因为我们不再处理UI
|
||
pass
|