修复加载项目

This commit is contained in:
Rowland 2026-03-06 09:52:57 +08:00
parent d2cfc77fc6
commit 4a25031cac
15 changed files with 7007 additions and 28 deletions

View File

@ -79,17 +79,17 @@ Size=93,65
Collapsed=0
[Window][新建项目]
Pos=760,354
Pos=490,225
Size=400,300
Collapsed=0
[Window][选择路径]
Pos=660,254
Pos=390,125
Size=600,500
Collapsed=0
[Window][打开项目]
Pos=710,304
Pos=440,175
Size=500,400
Collapsed=0

View File

@ -4,6 +4,7 @@ import json
import datetime
import subprocess
import shutil
from project.webgl_packager import WebGLPackager
class ProjectManager:
@ -11,6 +12,7 @@ class ProjectManager:
self.world = world
self.current_project_path = None
self.project_config = None
self.last_webgl_export_report = None
print("✓ 项目管理系统初始化完成")
@ -300,6 +302,52 @@ class ProjectManager:
return False
# ==================== 项目打包功能 ====================
def buildWebGLPackage(self, output_dir):
"""将当前项目打包为 WebGL 静态站点目录。
Args:
output_dir (str): 用户选择的输出根目录
Returns:
bool: success/partial 返回 Truefailed 返回 False
"""
try:
if not self.current_project_path:
print("错误: 请先创建或打开一个项目!")
return False
if not output_dir:
print("错误: 请指定 WebGL 打包输出目录!")
return False
project_path = os.path.normpath(self.current_project_path)
output_dir = os.path.normpath(output_dir)
packager = WebGLPackager(self.world)
report = packager.package(project_path, output_dir)
self.last_webgl_export_report = report
status = report.get("status", "failed")
report_path = os.path.join(
report.get("output_dir", ""),
"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打包失败")
print(f"报告路径: {report_path}")
return False
except Exception as e:
print(f"WebGL打包过程出错: {str(e)}")
return False
def buildPackage(self, build_dir):
"""打包项目为可执行文件 - 按照Panda3D官方标准方法
@ -839,4 +887,4 @@ setup(
except Exception as e:
print(f"执行打包命令失败: {str(e)}")
return False
return False

995
project/webgl_packager.py Normal file
View File

@ -0,0 +1,995 @@
"""WebGL project packager for EG editor (Three.js static scene export)."""
from __future__ import annotations
import datetime
import json
import os
import re
import shutil
import stat
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
class WebGLPackager:
"""Export current EG scene into a static WebGL package directory."""
BASIS_MATRIX_ROW_MAJOR = [
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
-1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]
ENERGY_TO_INTENSITY_SCALE = 0.001
def __init__(self, world):
self.world = world
self.scene_manager = getattr(world, "scene_manager", None)
self._project_path = ""
self._output_root = ""
self._assets_model_dir = ""
self._assets_texture_dir = ""
self._copied_source_to_uri: Dict[str, str] = {}
self._name_counter: Dict[str, int] = {}
self._node_id_by_pointer: Dict[int, str] = {}
self.report: Dict[str, Any] = {
"status": "failed",
"warnings": [],
"missing_assets": [],
"unsupported_assets": [],
"converted_assets": [],
"copied_assets": [],
"output_dir": "",
}
def package(self, project_path: str, output_dir: str) -> Dict[str, Any]:
"""Export project as WebGL static site and return export report."""
self._project_path = os.path.normpath(project_path)
project_name = os.path.basename(self._project_path.rstrip(os.sep)) or "project"
self._output_root = os.path.normpath(os.path.join(output_dir, f"{project_name}_webgl"))
self._assets_model_dir = os.path.join(self._output_root, "assets", "models")
self._assets_texture_dir = os.path.join(self._output_root, "assets", "textures")
self.report["output_dir"] = self._output_root
try:
if not os.path.isdir(self._project_path):
self._fail(f"项目路径不存在: {self._project_path}")
return self.report
self._prepare_output_dir()
self._copy_templates()
scene_manifest = self._build_scene_manifest(project_name)
self._write_json(
os.path.join(self._output_root, "scene", "scene_webgl.json"),
scene_manifest,
)
self._write_preview_scripts()
status = "success"
if self.report["missing_assets"] or self.report["unsupported_assets"]:
status = "partial"
self.report["status"] = status
except Exception as exc:
self._fail(f"WebGL打包失败: {exc}")
finally:
self._write_json(
os.path.join(self._output_root, "reports", "export_report.json"),
self.report,
)
return self.report
def _prepare_output_dir(self) -> None:
if os.path.isdir(self._output_root):
shutil.rmtree(self._output_root)
os.makedirs(os.path.join(self._output_root, "js"), exist_ok=True)
os.makedirs(os.path.join(self._output_root, "vendor"), exist_ok=True)
os.makedirs(self._assets_model_dir, exist_ok=True)
os.makedirs(self._assets_texture_dir, exist_ok=True)
os.makedirs(os.path.join(self._output_root, "scene"), exist_ok=True)
os.makedirs(os.path.join(self._output_root, "reports"), exist_ok=True)
def _copy_templates(self) -> None:
repo_root = Path(__file__).resolve().parent.parent
template_root = repo_root / "templates" / "webgl"
if not template_root.exists():
raise FileNotFoundError(f"模板目录不存在: {template_root}")
file_mapping = {
"index.html": "index.html",
"style.css": "style.css",
"viewer.js": os.path.join("js", "viewer.js"),
}
for src_name, dst_rel in file_mapping.items():
src = template_root / src_name
dst = Path(self._output_root) / dst_rel
if not src.exists():
raise FileNotFoundError(f"模板文件不存在: {src}")
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(src), str(dst))
vendor_src = template_root / "vendor"
vendor_dst = Path(self._output_root) / "vendor"
if vendor_src.exists():
for entry in vendor_src.iterdir():
if entry.is_file():
shutil.copy2(str(entry), str(vendor_dst / entry.name))
self._try_resolve_vendor_files(vendor_dst)
# Placeholder marker warning
placeholder_file = vendor_dst / "three.module.min.js"
if placeholder_file.exists():
content = placeholder_file.read_text(encoding="utf-8", errors="ignore")
if "EG_VENDOR_PLACEHOLDER" in content:
self.report["warnings"].append(
"当前 vendor 为占位文件,请替换为官方 three.module.min.js / OrbitControls.js / GLTFLoader.js 后再预览。"
)
def _try_resolve_vendor_files(self, vendor_dst: Path) -> None:
"""Try to replace template placeholders with system-installed Three.js modules."""
lookup_roots = [
Path("/usr/share/javascript/three"),
Path("/usr/share/nodejs/three"),
Path("/usr/lib/node_modules/three"),
Path.home() / ".local/lib/node_modules/three",
]
targets = {
"three.module.min.js": [
"build/three.module.min.js",
"build/three.module.js",
],
"OrbitControls.js": [
"examples/jsm/controls/OrbitControls.js",
],
"GLTFLoader.js": [
"examples/jsm/loaders/GLTFLoader.js",
],
}
for dst_name, rel_candidates in targets.items():
dst_path = vendor_dst / dst_name
if not dst_path.exists():
continue
try:
content = dst_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
content = ""
# Only replace placeholders.
if "EG_VENDOR_PLACEHOLDER" not in content:
continue
found_source = None
for root in lookup_roots:
if not root.exists():
continue
for rel in rel_candidates:
candidate = root / rel
if candidate.exists() and candidate.is_file():
found_source = candidate
break
if found_source:
break
if found_source:
shutil.copy2(str(found_source), str(dst_path))
def _build_scene_manifest(self, project_name: str) -> Dict[str, Any]:
render = getattr(self.world, "render", None)
if not render:
raise RuntimeError("world.render 不可用")
export_nodes: List[Any] = []
model_nodes = self._collect_model_nodes()
spot_nodes = self._collect_valid_nodes(getattr(self.scene_manager, "Spotlight", []))
point_nodes = self._collect_valid_nodes(getattr(self.scene_manager, "Pointlight", []))
ground_node = self._get_default_ground_node()
export_nodes.extend(model_nodes)
export_nodes.extend(spot_nodes)
export_nodes.extend(point_nodes)
if ground_node is not None:
export_nodes.append(ground_node)
# Unique by pointer
uniq: Dict[int, Any] = {}
for node in export_nodes:
uniq[id(node)] = node
export_nodes = list(uniq.values())
for index, node in enumerate(export_nodes, start=1):
self._node_id_by_pointer[id(node)] = f"node_{index:04d}"
nodes_json: List[Dict[str, Any]] = []
for node in model_nodes:
entry = self._build_model_node_entry(node)
if entry:
nodes_json.append(entry)
for node in point_nodes:
entry = self._build_light_node_entry(node, kind="point_light")
if entry:
nodes_json.append(entry)
for node in spot_nodes:
entry = self._build_light_node_entry(node, kind="spot_light")
if entry:
nodes_json.append(entry)
if ground_node is not None:
entry = self._build_ground_node_entry(ground_node)
if entry:
nodes_json.append(entry)
manifest = {
"meta": {
"format_version": "1.0",
"project_name": project_name,
"exported_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
"coordinate": {
"source": "panda3d_zup",
"target": "threejs_yup",
"matrix_convention": "panda_row_vector_row_major",
"basis_matrix": self.BASIS_MATRIX_ROW_MAJOR,
},
"camera": self._build_camera_entry(),
"environment": self._build_environment_entry(),
"nodes": nodes_json,
}
return manifest
def _collect_model_nodes(self) -> List[Any]:
if not self.scene_manager:
return []
all_models = self._collect_valid_nodes(getattr(self.scene_manager, "models", []))
light_ptrs = {id(n) for n in self._collect_valid_nodes(getattr(self.scene_manager, "Spotlight", []))}
light_ptrs |= {id(n) for n in self._collect_valid_nodes(getattr(self.scene_manager, "Pointlight", []))}
models: List[Any] = []
for node in all_models:
if id(node) in light_ptrs:
continue
if node.hasTag("light_type"):
continue
if node.getName() in {"render", "camera", "cam"}:
continue
models.append(node)
return models
@staticmethod
def _collect_valid_nodes(nodes: List[Any]) -> List[Any]:
valid = []
for node in nodes or []:
if not node:
continue
try:
if node.isEmpty():
continue
except Exception:
continue
valid.append(node)
return valid
def _get_default_ground_node(self):
ground = getattr(self.world, "ground", None)
if not ground:
return None
try:
if ground.isEmpty():
return None
return ground
except Exception:
return None
def _build_camera_entry(self) -> Dict[str, Any]:
render = getattr(self.world, "render", None)
cam = getattr(self.world, "cam", None) or getattr(self.world, "camera", None)
default = {
"matrix_local_row_major": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
-50.0,
20.0,
1.0,
],
"fov_deg": 80.0,
"near": 0.1,
"far": 10000.0,
}
if not cam or not render:
return default
try:
cam_mat = cam.getMat(render)
fov = 80.0
near = 0.1
far = 10000.0
lens = cam.node().getLens() if cam.node() else None
if lens:
try:
fov = float(lens.getFov()[0])
except Exception:
pass
try:
near = float(lens.getNear())
except Exception:
pass
try:
far = float(lens.getFar())
except Exception:
pass
return {
"matrix_local_row_major": self._mat4_to_row_major_list(cam_mat),
"fov_deg": fov,
"near": near,
"far": far,
}
except Exception:
return default
def _build_environment_entry(self) -> Dict[str, Any]:
ambient = getattr(self.world, "ambient_light", None)
directional = getattr(self.world, "directional_light", None)
ambient_color = [0.2, 0.2, 0.2]
directional_color = [0.8, 0.8, 0.8]
directional_dir = [0.0, 0.0, -1.0]
if ambient and not ambient.isEmpty():
try:
c = ambient.node().getColor()
ambient_color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
if directional and not directional.isEmpty():
try:
c = directional.node().getColor()
directional_color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
try:
q = directional.getQuat(getattr(self.world, "render", directional.getParent()))
fwd = q.getForward()
directional_dir = [float(fwd[0]), float(fwd[1]), float(fwd[2])]
except Exception:
pass
return {
"include_default_ground": True,
"ambient_light": {
"color": ambient_color,
"intensity": 1.0,
},
"directional_light": {
"color": directional_color,
"intensity": 1.0,
"direction": directional_dir,
},
}
def _build_model_node_entry(self, node) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or node_id
model_source, source_tag = self._resolve_model_source(node)
if not model_source:
self.report["missing_assets"].append(
{
"node": node_name,
"reason": "model_path_not_found",
"tags_checked": ["model_path", "saved_model_path", "original_path", "file"],
}
)
return None
model_uri = self._prepare_model_asset(model_source, node_name)
if not model_uri:
self.report["unsupported_assets"].append(
{
"node": node_name,
"source": model_source,
"reason": "model_conversion_failed",
}
)
return None
material_override = self._extract_material_override(node)
textures = self._collect_and_copy_texture_overrides(node, model_source)
entry: Dict[str, Any] = {
"id": node_id,
"name": node_name,
"kind": "model",
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"model": {"uri": model_uri},
"material_override": material_override,
"source_model_tag": source_tag,
}
if textures:
entry["texture_overrides"] = textures
return entry
def _build_light_node_entry(self, node, kind: str) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or node_id
light_obj = node.getPythonTag("rp_light_object") if node.hasPythonTag("rp_light_object") else None
color = [1.0, 1.0, 1.0]
energy = self._safe_float(node.getTag("light_energy"), 5000.0) if node.hasTag("light_energy") else 5000.0
radius = self._safe_float(node.getTag("light_radius"), 30.0) if node.hasTag("light_radius") else 30.0
spot_fov = self._safe_float(node.getTag("light_fov"), 45.0) if node.hasTag("light_fov") else 45.0
inner_ratio = 0.4
if light_obj is not None:
try:
c = getattr(light_obj, "color", None)
if c is not None:
color = [float(c[0]), float(c[1]), float(c[2])]
except Exception:
pass
try:
energy = float(getattr(light_obj, "energy", energy))
except Exception:
pass
try:
radius = float(getattr(light_obj, "radius", radius))
except Exception:
pass
try:
if hasattr(light_obj, "fov"):
spot_fov = float(getattr(light_obj, "fov", spot_fov))
except Exception:
pass
try:
if hasattr(light_obj, "inner_radius"):
inner_ratio = float(getattr(light_obj, "inner_radius", inner_ratio))
except Exception:
pass
intensity = max(0.0, energy * self.ENERGY_TO_INTENSITY_SCALE)
return {
"id": node_id,
"name": node_name,
"kind": kind,
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"light": {
"color": color,
"intensity": intensity,
"range": max(0.0, radius),
"spot_angle_deg": max(1.0, spot_fov),
"inner_cone_ratio": max(0.0, min(1.0, inner_ratio)),
},
}
def _build_ground_node_entry(self, node) -> Optional[Dict[str, Any]]:
node_id = self._node_id_by_pointer.get(id(node))
if not node_id:
return None
parent_id, mat = self._get_parent_and_matrix(node)
node_name = node.getName() or "ground"
color = [0.8, 0.8, 0.8, 1.0]
roughness = 1.0
metallic = 0.0
material = None
try:
material = node.getMaterial()
except Exception:
material = None
if material:
try:
if material.hasBaseColor():
c = material.getBaseColor()
color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
try:
roughness = float(material.getRoughness())
except Exception:
pass
try:
metallic = float(material.getMetallic())
except Exception:
pass
return {
"id": node_id,
"name": node_name,
"kind": "ground",
"parent_id": parent_id,
"matrix_local_row_major": self._mat4_to_row_major_list(mat),
"ground": {
"width": 100.0,
"height": 100.0,
},
"material_override": {
"base_color": [color[0], color[1], color[2], 1.0],
"roughness": roughness,
"metallic": metallic,
"opacity": 1.0,
},
}
def _resolve_model_source(self, node) -> Tuple[Optional[str], str]:
tags = ["model_path", "saved_model_path", "original_path", "file"]
for tag in tags:
if not node.hasTag(tag):
continue
value = (node.getTag(tag) or "").strip()
if not value:
continue
resolved = self._resolve_asset_path(value)
if resolved:
return resolved, tag
return None, ""
def _resolve_asset_path(self, candidate: str) -> Optional[str]:
candidate = (candidate or "").strip()
if not candidate:
return None
if candidate.startswith(("http://", "https://")):
return None
# Absolute path: use directly.
if os.path.isabs(candidate):
abs_candidate = os.path.normpath(candidate)
if os.path.exists(abs_candidate):
return abs_candidate
return None
# Relative path resolution policy.
search_roots = [
self._project_path,
os.path.join(self._project_path, "models"),
os.path.join(self._project_path, "Resources"),
os.path.join(self._project_path, "scenes", "resources"),
os.getcwd(),
]
for root in search_roots:
full = os.path.normpath(os.path.join(root, candidate))
if os.path.exists(full):
return full
return None
def _prepare_model_asset(self, source_path: str, node_name: str) -> Optional[str]:
source_path = os.path.normpath(source_path)
ext = os.path.splitext(source_path)[1].lower()
if ext in {".gltf", ".glb"}:
return self._copy_asset_to_models(source_path)
with tempfile.TemporaryDirectory(prefix="eg_webgl_conv_") as temp_dir:
conversion_source = source_path
if ext == ".bam":
temp_egg = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".egg")
if not self._run_tool_command(["bam2egg", source_path, temp_egg], timeout=120):
return None
temp_obj = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".obj")
if not self._run_tool_command(["egg2obj", temp_egg, temp_obj], timeout=120):
return None
conversion_source = temp_obj
elif ext == ".egg":
temp_obj = os.path.join(temp_dir, self._sanitize_filename(Path(source_path).stem) + ".obj")
if not self._run_tool_command(["egg2obj", source_path, temp_obj], timeout=120):
return None
conversion_source = temp_obj
target_filename = self._unique_filename(node_name or Path(source_path).stem, ".glb")
target_abs = os.path.join(self._assets_model_dir, target_filename)
converter = self._convert_to_glb(conversion_source, target_abs)
if not converter:
return None
self.report["converted_assets"].append(
{
"source": source_path,
"converted_from": conversion_source,
"target": os.path.relpath(target_abs, self._output_root).replace("\\", "/"),
"converter": converter,
}
)
return os.path.relpath(target_abs, self._output_root).replace("\\", "/")
def _convert_to_glb(self, source_path: str, target_path: str) -> str:
scene_manager = self.scene_manager
if scene_manager is None:
return ""
conversion_order = [
"_convertWithBlender",
"_convertWithFBX2glTF",
"_convertWithAssimp",
]
for method_name in conversion_order:
method = getattr(scene_manager, method_name, None)
if not callable(method):
continue
try:
ok = method(source_path, target_path, None)
except TypeError:
ok = method(source_path, target_path)
except Exception:
ok = False
if ok and os.path.exists(target_path):
return method_name
return ""
def _run_tool_command(self, args: List[str], timeout: int) -> bool:
executable = shutil.which(args[0])
if not executable:
self.report["warnings"].append(f"缺少转换工具: {args[0]}")
return False
try:
result = subprocess.run(
args,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except Exception as exc:
self.report["warnings"].append(f"执行命令失败: {' '.join(args)} ({exc})")
return False
if result.returncode != 0:
stderr = (result.stderr or "").strip()
stdout = (result.stdout or "").strip()
detail = stderr or stdout or f"exit={result.returncode}"
self.report["warnings"].append(f"命令失败: {' '.join(args)} -> {detail}")
return False
return True
def _copy_asset_to_models(self, source_path: str) -> str:
source_path = os.path.normpath(source_path)
if source_path in self._copied_source_to_uri:
return self._copied_source_to_uri[source_path]
ext = os.path.splitext(source_path)[1].lower() or ".bin"
safe_name = self._unique_filename(Path(source_path).stem, ext)
target_abs = os.path.join(self._assets_model_dir, safe_name)
shutil.copy2(source_path, target_abs)
rel_uri = os.path.relpath(target_abs, self._output_root).replace("\\", "/")
self._copied_source_to_uri[source_path] = rel_uri
self.report["copied_assets"].append(
{
"source": source_path,
"target": rel_uri,
"type": "model",
}
)
return rel_uri
def _collect_and_copy_texture_overrides(self, node, model_source: str) -> List[Dict[str, Any]]:
textures: List[Dict[str, Any]] = []
texture_pairs = self._extract_texture_stage_and_paths(node)
if not texture_pairs:
return textures
model_dir = os.path.dirname(model_source)
for stage_name, tex_path in texture_pairs:
abs_path = self._resolve_texture_path(tex_path, model_dir)
if not abs_path:
continue
rel_uri = self._copy_asset_to_textures(abs_path)
if rel_uri:
textures.append({"stage": stage_name, "uri": rel_uri})
return textures
def _extract_texture_stage_and_paths(self, node) -> List[Tuple[str, str]]:
pairs: List[Tuple[str, str]] = []
seen: set = set()
nodes_to_scan = [node]
try:
geom_paths = node.findAllMatches("**/+GeomNode")
for i in range(geom_paths.getNumPaths()):
nodes_to_scan.append(geom_paths.getPath(i))
except Exception:
pass
for np in nodes_to_scan:
try:
stages = np.findAllTextureStages()
except Exception:
continue
try:
stage_count = stages.getNumTextureStages()
except Exception:
stage_count = 0
for idx in range(stage_count):
try:
stage = stages.getTextureStage(idx)
texture = np.getTexture(stage)
except Exception:
continue
if not texture:
continue
tex_path = ""
try:
if texture.hasFullpath():
tex_path = texture.getFullpath().toOsSpecific()
except Exception:
tex_path = ""
if not tex_path:
continue
stage_name = stage.getName() if stage else f"stage_{idx}"
key = (stage_name, tex_path)
if key in seen:
continue
seen.add(key)
pairs.append(key)
return pairs
def _resolve_texture_path(self, path_hint: str, model_dir: str) -> Optional[str]:
path_hint = (path_hint or "").strip()
if not path_hint:
return None
if os.path.isabs(path_hint) and os.path.exists(path_hint):
return os.path.normpath(path_hint)
search_roots = [
model_dir,
self._project_path,
os.path.join(self._project_path, "scenes", "resources"),
os.getcwd(),
]
for root in search_roots:
full = os.path.normpath(os.path.join(root, path_hint))
if os.path.exists(full):
return full
return None
def _copy_asset_to_textures(self, source_path: str) -> Optional[str]:
source_path = os.path.normpath(source_path)
cache_key = f"texture::{source_path}"
if cache_key in self._copied_source_to_uri:
return self._copied_source_to_uri[cache_key]
ext = os.path.splitext(source_path)[1].lower() or ".png"
safe_name = self._unique_filename(Path(source_path).stem, ext)
target_abs = os.path.join(self._assets_texture_dir, safe_name)
try:
shutil.copy2(source_path, target_abs)
except Exception:
return None
rel_uri = os.path.relpath(target_abs, self._output_root).replace("\\", "/")
self._copied_source_to_uri[cache_key] = rel_uri
self.report["copied_assets"].append(
{
"source": source_path,
"target": rel_uri,
"type": "texture",
}
)
return rel_uri
def _extract_material_override(self, node) -> Dict[str, Any]:
base_color = [1.0, 1.0, 1.0, 1.0]
roughness = 0.5
metallic = 0.0
material = None
try:
material = node.getMaterial()
except Exception:
material = None
if material:
try:
if material.hasBaseColor():
c = material.getBaseColor()
base_color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
try:
roughness = float(material.getRoughness())
except Exception:
pass
try:
metallic = float(material.getMetallic())
except Exception:
pass
else:
try:
c = node.getColor()
base_color = [float(c[0]), float(c[1]), float(c[2]), float(c[3])]
except Exception:
pass
opacity = max(0.0, min(1.0, float(base_color[3])))
if opacity >= 0.999:
opacity = 1.0
elif opacity <= 0.001:
opacity = 0.0
return {
"base_color": base_color,
"roughness": roughness,
"metallic": metallic,
"opacity": opacity,
}
def _get_parent_and_matrix(self, node) -> Tuple[Optional[str], Any]:
render = getattr(self.world, "render", None)
parent_id = None
try:
parent = node.getParent()
except Exception:
parent = None
if parent and not parent.isEmpty() and id(parent) in self._node_id_by_pointer:
parent_id = self._node_id_by_pointer[id(parent)]
try:
return parent_id, node.getMat(parent)
except Exception:
pass
if render:
try:
return None, node.getMat(render)
except Exception:
pass
try:
return None, node.getMat()
except Exception:
return None, node.getTransform().getMat()
@staticmethod
def _mat4_to_row_major_list(mat4_obj) -> List[float]:
try:
values = []
for r in range(4):
for c in range(4):
values.append(float(mat4_obj.getCell(r, c)))
return values
except Exception:
return [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]
@staticmethod
def _safe_float(value: Any, default: float) -> float:
try:
return float(value)
except Exception:
return float(default)
def _unique_filename(self, stem: str, suffix: str) -> str:
safe_stem = self._sanitize_filename(stem) or "asset"
key = f"{safe_stem}{suffix.lower()}"
index = self._name_counter.get(key, 0)
self._name_counter[key] = index + 1
if index == 0:
return f"{safe_stem}{suffix}"
return f"{safe_stem}_{index:03d}{suffix}"
@staticmethod
def _sanitize_filename(name: str) -> str:
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name or "")).strip("._")
return normalized or "asset"
def _write_preview_scripts(self) -> None:
sh_path = os.path.join(self._output_root, "run_preview.sh")
bat_path = os.path.join(self._output_root, "run_preview.bat")
sh_content = "#!/usr/bin/env bash\npython3 -m http.server 8000\n"
bat_content = "@echo off\npython -m http.server 8000\n"
with open(sh_path, "w", encoding="utf-8") as f:
f.write(sh_content)
with open(bat_path, "w", encoding="utf-8") as f:
f.write(bat_content)
current_mode = os.stat(sh_path).st_mode
os.chmod(sh_path, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@staticmethod
def _write_json(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def _fail(self, message: str) -> None:
self.report["status"] = "failed"
self.report["warnings"].append(message)

View File

@ -175,6 +175,175 @@ class SceneManagerIOMixin:
traceback.print_exc()
return None
def _parse_vec3_tag(self, vec_str, default=None):
"""安全解析保存到Tag里的Vec3字符串。"""
try:
if default is None:
default = Vec3(0, 0, 0)
text = str(vec_str).strip()
for prefix in ("LVecBase3f", "LPoint3f", "LVector3f"):
text = text.replace(prefix, "")
text = text.strip().strip("()")
parts = [p.strip() for p in text.split(",")]
if len(parts) != 3:
return Vec3(default)
x, y, z = map(float, parts)
return Vec3(x, y, z)
except Exception:
return Vec3(default if default is not None else Vec3(0, 0, 0))
def _resolve_saved_model_source_path(self, model_root_node, scene_filename):
"""根据已保存标签尽力恢复模型源文件路径。"""
try:
if not model_root_node or model_root_node.isEmpty():
return None
raw_candidates = []
for tag_name in ("saved_model_path", "model_path", "original_path", "file"):
if model_root_node.hasTag(tag_name):
value = model_root_node.getTag(tag_name).strip()
if value:
raw_candidates.append(value)
if not raw_candidates:
return None
scene_abs = os.path.abspath(scene_filename)
scene_dir = os.path.dirname(scene_abs)
project_dir = os.path.dirname(scene_dir)
search_dirs = [
scene_dir,
project_dir,
os.path.join(project_dir, "resources"),
os.path.join(project_dir, "Resources"),
os.path.join(project_dir, "resources", "models"),
os.path.join(project_dir, "Resources", "models"),
os.path.join(project_dir, "models"),
]
seen = set()
for raw in raw_candidates:
variants = [raw, os.path.normpath(raw)]
if os.path.isabs(raw):
variants.append(os.path.abspath(raw))
else:
for base_dir in search_dirs:
variants.append(os.path.normpath(os.path.join(base_dir, raw)))
basename = os.path.basename(raw)
if basename:
for base_dir in search_dirs:
variants.append(os.path.normpath(os.path.join(base_dir, basename)))
for candidate in variants:
if not candidate:
continue
candidate_abs = os.path.abspath(candidate)
if candidate_abs in seen:
continue
seen.add(candidate_abs)
if candidate_abs == scene_abs:
continue
if os.path.exists(candidate_abs):
return candidate_abs
return None
except Exception:
return None
def _reimport_saved_models_for_ssbo(self, scene, scene_filename, loaded_nodes):
"""在SSBO模式下按保存的源模型路径重建模型根节点避免加载烘焙后结构。"""
replaced_model_names = set()
try:
runtime_import = getattr(self.world, "_import_model_for_runtime", None)
if not callable(runtime_import):
return replaced_model_names
model_roots = []
for node in scene.findAllMatches("**"):
try:
if node and (not node.isEmpty()) and node.hasTag("is_model_root"):
model_roots.append(node)
except Exception:
continue
if not model_roots:
return replaced_model_names
print(f"[SSBO] 开始按源路径重建模型: {len(model_roots)}")
for saved_node in model_roots:
node_name = saved_node.getName()
source_path = self._resolve_saved_model_source_path(saved_node, scene_filename)
if not source_path:
print(f"[SSBO] 跳过模型 {node_name}: 无法解析源文件路径")
continue
try:
imported_node = runtime_import(source_path)
except Exception as import_e:
print(f"[SSBO] 重建模型失败 {node_name}: {import_e}")
continue
if not imported_node or imported_node.isEmpty():
print(f"[SSBO] 重建模型失败 {node_name}: 运行时导入未返回有效节点")
continue
# 复制保存标签,确保后续恢复逻辑可复用。
try:
for tag_key in saved_node.getTagKeys():
imported_node.setTag(tag_key, saved_node.getTag(tag_key))
except Exception:
pass
if node_name:
imported_node.setName(node_name)
imported_node.setTag("is_model_root", "1")
imported_node.setTag("is_scene_element", "1")
imported_node.setTag("model_path", source_path)
imported_node.setTag("saved_model_path", source_path)
imported_node.setTag("original_path", source_path)
# 恢复保存的变换优先Tag其次旧节点当前值
if saved_node.hasTag("transform_pos"):
imported_node.setPos(self._parse_vec3_tag(saved_node.getTag("transform_pos"), saved_node.getPos()))
else:
imported_node.setPos(saved_node.getPos())
if saved_node.hasTag("transform_hpr"):
imported_node.setHpr(self._parse_vec3_tag(saved_node.getTag("transform_hpr"), saved_node.getHpr()))
else:
imported_node.setHpr(saved_node.getHpr())
if saved_node.hasTag("transform_scale"):
imported_node.setScale(self._parse_vec3_tag(saved_node.getTag("transform_scale"), saved_node.getScale()))
else:
imported_node.setScale(saved_node.getScale())
# 恢复可见性。
user_visible = True
if saved_node.hasTag("user_visible"):
user_visible = saved_node.getTag("user_visible").lower() == "true"
imported_node.setPythonTag("user_visible", user_visible)
if user_visible:
imported_node.show()
else:
imported_node.hide()
# 保留父子关系标签,后续统一重建。
if saved_node.hasTag("parent_name"):
imported_node.setTag("parent_name", saved_node.getTag("parent_name"))
loaded_nodes[node_name] = imported_node
replaced_model_names.add(node_name)
if imported_node not in self.models:
self.models.append(imported_node)
print(f"[SSBO] 源路径重建完成: 成功 {len(replaced_model_names)}")
except Exception as e:
print(f"[SSBO] 按源路径重建模型时出错: {e}")
return replaced_model_names
def saveScene(self, filename,project_path):
"""保存场景到BAM文件 - 完整版支持GUI元素,地形"""
try:
@ -639,12 +808,20 @@ class SceneManagerIOMixin:
#存储所有加载的节点,用于后续处理父子关系
loaded_nodes = {} #name->nodePath映射
normalized_scene_path = filename.replace("\\", "/").lower()
is_project_scene_bam = (
normalized_scene_path.endswith("/scenes/scene.bam")
or (normalized_scene_path.endswith(".bam") and "/scenes/" in normalized_scene_path)
)
use_ssbo_scene_import = bool(
getattr(self.world, "use_ssbo_mouse_picking", False) and
getattr(self.world, "use_ssbo_scene_import", False) and
getattr(self.world, "ssbo_editor", None) and
callable(getattr(self.world, "_import_model_for_runtime", None))
callable(getattr(self.world, "_import_model_for_runtime", None)) and
(not is_project_scene_bam)
)
if is_project_scene_bam and getattr(self.world, "use_ssbo_scene_import", False):
print("[SSBO] 检测到项目场景BAM跳过统一导入链路避免重复加载导致崩溃")
ssbo_scene_model = None
if use_ssbo_scene_import:
try:
@ -660,6 +837,14 @@ class SceneManagerIOMixin:
print(f"[SSBO] 统一导入失败,回退旧流程: {e}")
use_ssbo_scene_import = False
ssbo_reimported_model_roots = set()
if (
is_project_scene_bam
and getattr(self.world, "use_ssbo_mouse_picking", False)
and callable(getattr(self.world, "_import_model_for_runtime", None))
):
ssbo_reimported_model_roots = self._reimport_saved_models_for_ssbo(scene, filename, loaded_nodes)
# 遍历场景中的所有节点
def processNode(nodePath, depth=0):
indent = " " * depth
@ -670,8 +855,14 @@ class SceneManagerIOMixin:
# print(f"{indent}[DEBUG] 节点为空,跳过处理")
return
#存储节点以便后续处理父子关系
loaded_nodes[nodePath.getName()] = nodePath
node_name = nodePath.getName()
is_reimported_ssbo_root = (
node_name in ssbo_reimported_model_roots and nodePath.hasTag("is_model_root")
)
# 存储节点以便后续处理父子关系避免覆盖已重建的SSBO模型根节点映射
if not is_reimported_ssbo_root:
loaded_nodes[node_name] = nodePath
if nodePath.getName().startswith('ground'):
print(f"{indent}跳过ground节点: {nodePath.getName()}")
@ -743,29 +934,18 @@ class SceneManagerIOMixin:
# 保留原始材质/颜色状态,避免在场景恢复阶段造成黑模。
# 恢复变换信息
def parseVec3(vec_str):
"""解析向量字符串为Vec3"""
try:
vec_str = vec_str.replace('LVecBase3f', '').replace('LPoint3f', '').strip('()')
x, y, z = map(float, vec_str.split(','))
return Vec3(x, y, z)
except Exception as e:
print(f"解析向量失败: {vec_str}, 错误: {e}")
return Vec3(0, 0, 0)
if nodePath.hasTag("transform_pos"):
pos = parseVec3(nodePath.getTag("transform_pos"))
pos = self._parse_vec3_tag(nodePath.getTag("transform_pos"), nodePath.getPos())
nodePath.setPos(pos)
print(f"{indent}恢复位置: {pos}")
if nodePath.hasTag("transform_hpr"):
hpr = parseVec3(nodePath.getTag("transform_hpr"))
hpr = self._parse_vec3_tag(nodePath.getTag("transform_hpr"), nodePath.getHpr())
nodePath.setHpr(hpr)
print(f"{indent}恢复旋转: {hpr}")
if nodePath.hasTag("transform_scale"):
scale = parseVec3(nodePath.getTag("transform_scale"))
scale = self._parse_vec3_tag(nodePath.getTag("transform_scale"), nodePath.getScale())
nodePath.setScale(scale)
print(f"{indent}恢复缩放: {scale}")
@ -791,6 +971,7 @@ class SceneManagerIOMixin:
nodePath.hasTag("has_scripts")
and nodePath.getTag("has_scripts") == "true"
and not (use_ssbo_scene_import and is_model_root)
and not is_reimported_ssbo_root
):
if hasattr(self.world,'script_manager') and self.world.script_manager:
try:
@ -916,7 +1097,10 @@ class SceneManagerIOMixin:
# 这里我们先保持原挂载关系
pass
else:
if use_ssbo_scene_import and is_model_root:
if is_reimported_ssbo_root and is_model_root:
# 该模型根节点已按源路径重建旧BAM节点不参与挂载恢复。
pass
elif use_ssbo_scene_import and is_model_root:
# SSBO 模式下模型节点由新导入链路接管,这里不直接挂载。
pass
# 其他节点确保挂载到render下
@ -928,7 +1112,9 @@ class SceneManagerIOMixin:
# 为模型节点设置碰撞检测
if is_model_root:
print(f"J{indent}处理模型节点{nodePath.getName()}")
if use_ssbo_scene_import:
if is_reimported_ssbo_root:
print(f"{indent}[SSBO] 已按源路径重建跳过旧BAM模型恢复: {nodePath.getName()}")
elif use_ssbo_scene_import:
# SSBO 模式下整个 scene.bam 已通过统一导入链路载入,
# 这里跳过逐模型旧导入逻辑,避免与菜单导入路径不一致。
pass
@ -956,7 +1142,8 @@ class SceneManagerIOMixin:
# else:
# print(f"{indent}为模型{nodePath.getName()}设置碰撞检测")
# self.setupCollision(nodePath)
self.models.append(nodePath)
if nodePath not in self.models:
self.models.append(nodePath)
# 递归处理子节点
for child in nodePath.getChildren():

View File

@ -975,6 +975,17 @@ class SceneManagerModelMixin:
if not has_animations:
model_path = model_node.getTag("model_path") if model_node.hasTag("model_path") else ""
if model_path:
normalized_model_path = model_path.replace("\\", "/").lower()
is_project_scene_bam = (
normalized_model_path.endswith(".bam")
and "/scenes/" in normalized_model_path
)
# Project scene BAM is an aggregated scene root, not a single actor asset.
# Probing it via Actor(...) is unstable and may crash native Panda3D loader.
if is_project_scene_bam:
model_node.setTag("has_animations", "false")
model_node.setTag("has_animations_checked", "true")
return False
try:
from direct.actor.Actor import Actor
from panda3d.core import Filename

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EG WebGL Scene</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="app">
<canvas id="scene-canvas"></canvas>
<div id="status" class="status">Loading scene...</div>
</div>
<script type="module" src="./js/viewer.js"></script>
</body>
</html>

61
templates/webgl/style.css Normal file
View File

@ -0,0 +1,61 @@
:root {
--bg: #0f1115;
--panel: rgba(16, 20, 28, 0.88);
--text: #d6dde9;
--ok: #77d0b9;
--warn: #e2b272;
--err: #f27878;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: radial-gradient(circle at 20% 20%, #1b2230 0%, var(--bg) 50%, #090b10 100%);
color: var(--text);
font-family: "Segoe UI", "SF Pro Text", "PingFang SC", sans-serif;
}
#scene-canvas {
width: 100%;
height: 100%;
display: block;
}
.status {
position: fixed;
left: 16px;
bottom: 16px;
max-width: min(640px, calc(100vw - 32px));
padding: 10px 12px;
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
backdrop-filter: blur(4px);
line-height: 1.45;
white-space: pre-wrap;
font-size: 13px;
}
.status.ok {
border-color: rgba(119, 208, 185, 0.5);
color: var(--ok);
}
.status.warn {
border-color: rgba(226, 178, 114, 0.55);
color: var(--warn);
}
.status.error {
border-color: rgba(242, 120, 120, 0.65);
color: var(--err);
}

3923
templates/webgl/vendor/GLTFLoader.js vendored Normal file

File diff suppressed because it is too large Load Diff

1229
templates/webgl/vendor/OrbitControls.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

386
templates/webgl/viewer.js Normal file
View File

@ -0,0 +1,386 @@
const statusEl = document.getElementById("status");
const canvas = document.getElementById("scene-canvas");
function setStatus(message, level = "warn") {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.className = `status ${level}`;
}
function rowMajorToMatrix4(THREE, m) {
const mat = new THREE.Matrix4();
mat.set(
m[0], m[1], m[2], m[3],
m[4], m[5], m[6], m[7],
m[8], m[9], m[10], m[11],
m[12], m[13], m[14], m[15],
);
return mat;
}
function pandaRowMajorToMatrix4(THREE, m) {
const mat = new THREE.Matrix4();
// Panda's matrix data uses row-vector convention (translation on last row).
// Three.js expects column-vector convention (translation on last column).
mat.set(
m[0], m[4], m[8], m[12],
m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15],
);
return mat;
}
function convertNodeMatrix(THREE, sourceMatRowMajor, basis, basisInv, matrixConvention = "panda_row_vector_row_major") {
const src = matrixConvention === "panda_row_vector_row_major"
? pandaRowMajorToMatrix4(THREE, sourceMatRowMajor)
: rowMajorToMatrix4(THREE, sourceMatRowMajor);
return basis.clone().multiply(src).multiply(basisInv);
}
function toColorArray(color, fallback = [1, 1, 1]) {
if (!Array.isArray(color) || color.length < 3) return fallback;
return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
}
function applyMaterialOverride(THREE, root, override) {
if (!override) return;
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of list) {
if (mat.color && Array.isArray(override.base_color)) {
const [r, g, b] = override.base_color;
mat.color.setRGB(r ?? 1, g ?? 1, b ?? 1);
}
if (Object.prototype.hasOwnProperty.call(override, "roughness") && "roughness" in mat) {
mat.roughness = Number(override.roughness);
}
if (Object.prototype.hasOwnProperty.call(override, "metallic") && "metalness" in mat) {
mat.metalness = Number(override.metallic);
}
if (Object.prototype.hasOwnProperty.call(override, "opacity")) {
const opacity = THREE.MathUtils.clamp(Number(override.opacity), 0, 1);
const isTransparent = opacity < 0.999;
mat.opacity = opacity;
mat.transparent = isTransparent;
// Prevent "see-through solid mesh" when source GLTF had transparent pipeline state.
mat.depthWrite = !isTransparent;
mat.depthTest = true;
mat.blending = isTransparent ? THREE.NormalBlending : THREE.NoBlending;
if (!isTransparent && "alphaTest" in mat) {
mat.alphaTest = 0;
}
if (!isTransparent && "transmission" in mat) {
mat.transmission = 0;
}
}
mat.needsUpdate = true;
}
});
}
function textureSlotByStage(stageName) {
const key = String(stageName || "").toLowerCase();
if (key.includes("normal")) return "normalMap";
if (key.includes("rough")) return "roughnessMap";
if (key.includes("metal")) return "metalnessMap";
if (key.includes("emission") || key.includes("emissive")) return "emissiveMap";
if (key.includes("ao")) return "aoMap";
if (key.includes("alpha") || key.includes("opacity")) return "alphaMap";
return "map";
}
function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) {
if (!Array.isArray(textureOverrides) || textureOverrides.length === 0) return;
const texBySlot = new Map();
for (const item of textureOverrides) {
if (!item || !item.uri) continue;
const slot = textureSlotByStage(item.stage);
if (texBySlot.has(slot)) continue;
try {
const tex = textureLoader.load(item.uri);
tex.flipY = false;
texBySlot.set(slot, tex);
} catch (err) {
console.warn("Texture load failed:", item.uri, err);
}
}
if (texBySlot.size === 0) return;
root.traverse((obj) => {
if (!obj.isMesh || !obj.material) return;
const list = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of list) {
for (const [slot, tex] of texBySlot.entries()) {
if (slot in mat) {
mat[slot] = tex;
}
}
mat.needsUpdate = true;
}
});
}
function directionToThree(THREE, direction, basis) {
const d = new THREE.Vector3(
Number(direction?.[0] ?? 0),
Number(direction?.[1] ?? 0),
Number(direction?.[2] ?? -1),
);
d.applyMatrix4(basis);
if (d.lengthSq() < 1e-6) d.set(0, 0, -1);
return d.normalize();
}
async function bootstrap() {
setStatus("Loading WebGL dependencies...");
let THREE;
let OrbitControls;
let GLTFLoader;
try {
THREE = await import("../vendor/three.module.min.js");
({ OrbitControls } = await import("../vendor/OrbitControls.js"));
({ GLTFLoader } = await import("../vendor/GLTFLoader.js"));
} catch (err) {
setStatus(
[
"Failed to load local Three.js vendor files.",
"Please replace vendor placeholders with official files:",
"- vendor/three.module.min.js",
"- vendor/OrbitControls.js",
"- vendor/GLTFLoader.js",
"",
String(err),
].join("\n"),
"error",
);
throw err;
}
setStatus("Loading scene manifest...");
const response = await fetch("../scene/scene_webgl.json", { cache: "no-cache" });
if (!response.ok) {
throw new Error(`Failed to load scene manifest: HTTP ${response.status}`);
}
const data = await response.json();
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
});
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x11151c);
const cameraData = data.camera || {};
const camera = new THREE.PerspectiveCamera(
Number(cameraData.fov_deg ?? 80),
window.innerWidth / Math.max(1, window.innerHeight),
Number(cameraData.near ?? 0.1),
Number(cameraData.far ?? 10000),
);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0, 0);
const basis = rowMajorToMatrix4(
THREE,
Array.isArray(data.coordinate?.basis_matrix)
? data.coordinate.basis_matrix
: [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1],
);
const basisInv = basis.clone().invert();
const matrixConvention = String(data.coordinate?.matrix_convention || "panda_row_vector_row_major");
if (Array.isArray(cameraData.matrix_local_row_major) && cameraData.matrix_local_row_major.length === 16) {
const camMat = convertNodeMatrix(
THREE,
cameraData.matrix_local_row_major,
basis,
basisInv,
matrixConvention,
);
camera.matrix.copy(camMat);
camera.matrix.decompose(camera.position, camera.quaternion, camera.scale);
camera.matrixAutoUpdate = true;
camera.updateMatrix();
} else {
camera.position.set(0, -50, 20);
camera.lookAt(0, 0, 0);
}
const env = data.environment || {};
if (env.ambient_light) {
const c = toColorArray(env.ambient_light.color, [0.2, 0.2, 0.2]);
const amb = new THREE.AmbientLight(new THREE.Color(c[0], c[1], c[2]), Number(env.ambient_light.intensity ?? 1));
scene.add(amb);
}
if (env.directional_light) {
const c = toColorArray(env.directional_light.color, [0.8, 0.8, 0.8]);
const dirLight = new THREE.DirectionalLight(
new THREE.Color(c[0], c[1], c[2]),
Number(env.directional_light.intensity ?? 1),
);
const dir = directionToThree(THREE, env.directional_light.direction, basis);
dirLight.position.copy(dir.clone().multiplyScalar(-40));
dirLight.target.position.set(0, 0, 0);
scene.add(dirLight);
scene.add(dirLight.target);
}
const nodeMap = new Map();
const pendingModelLoads = [];
const gltfLoader = new GLTFLoader();
const textureLoader = new THREE.TextureLoader();
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
let obj;
if (node.kind === "point_light") {
const light = node.light || {};
const c = toColorArray(light.color, [1, 1, 1]);
obj = new THREE.PointLight(
new THREE.Color(c[0], c[1], c[2]),
Number(light.intensity ?? 1),
Number(light.range ?? 0),
);
} else if (node.kind === "spot_light") {
const light = node.light || {};
const c = toColorArray(light.color, [1, 1, 1]);
const angle = THREE.MathUtils.degToRad(Number(light.spot_angle_deg ?? 45));
const spot = new THREE.SpotLight(
new THREE.Color(c[0], c[1], c[2]),
Number(light.intensity ?? 1),
Number(light.range ?? 0),
angle,
1 - Number(light.inner_cone_ratio ?? 0.4),
);
spot.target.position.set(0, 0, -1);
obj = spot;
} else if (node.kind === "ground") {
const g = node.ground || {};
const width = Number(g.width ?? 100);
const height = Number(g.height ?? 100);
const m = node.material_override || {};
const bc = Array.isArray(m.base_color) ? m.base_color : [0.8, 0.8, 0.8, 1];
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(Number(bc[0] ?? 0.8), Number(bc[1] ?? 0.8), Number(bc[2] ?? 0.8)),
roughness: Number(m.roughness ?? 1),
metalness: Number(m.metallic ?? 0),
transparent: Number(m.opacity ?? 1) < 1,
opacity: Number(m.opacity ?? 1),
side: THREE.DoubleSide,
});
obj = new THREE.Mesh(new THREE.PlaneGeometry(width, height), mat);
obj.receiveShadow = true;
} else {
obj = new THREE.Group();
const modelUri = node.model?.uri;
if (modelUri) {
const p = new Promise((resolve) => {
gltfLoader.load(
modelUri,
(gltf) => {
const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null);
if (root) {
applyMaterialOverride(THREE, root, node.material_override || null);
applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader);
obj.add(root);
}
resolve();
},
undefined,
(err) => {
console.warn(`Failed to load model ${modelUri}:`, err);
resolve();
},
);
});
pendingModelLoads.push(p);
}
}
obj.name = node.name || node.id || "node";
if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) {
const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention);
obj.matrixAutoUpdate = false;
obj.matrix.copy(converted);
obj.matrix.decompose(obj.position, obj.quaternion, obj.scale);
}
nodeMap.set(node.id, obj);
}
for (const node of Array.isArray(data.nodes) ? data.nodes : []) {
const obj = nodeMap.get(node.id);
if (!obj) continue;
const parent = node.parent_id ? nodeMap.get(node.parent_id) : null;
if (parent) {
parent.add(obj);
} else {
scene.add(obj);
}
if (obj.isSpotLight && obj.target) {
obj.add(obj.target);
}
}
await Promise.all(pendingModelLoads);
const resize = () => {
const w = window.innerWidth;
const h = Math.max(1, window.innerHeight);
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener("resize", resize);
resize();
setStatus(
`Scene ready. Nodes: ${(data.nodes || []).length}.\nUse mouse to orbit, wheel to zoom.`,
"ok",
);
const clock = new THREE.Clock();
const tick = () => {
requestAnimationFrame(tick);
const dt = clock.getDelta();
if (dt >= 0) {
controls.update();
renderer.render(scene, camera);
}
};
tick();
}
bootstrap().catch((err) => {
console.error(err);
setStatus(`Viewer bootstrap failed:\n${String(err)}`, "error");
});

View File

@ -256,12 +256,105 @@ class AppActions:
self.add_info_message("另存为项目(功能待实现)")
# TODO: 实现另存为对话框
# self.show_save_as_dialog = True
def _on_build_webgl_package(self):
"""处理“打包为 WebGL”菜单项。"""
if not hasattr(self, "project_manager") or not self.project_manager:
self.add_error_message("项目管理器未初始化")
return
if not self.project_manager.current_project_path:
self.add_warning_message("请先创建或打开项目再进行WebGL打包")
return
# 导出前先保存当前项目
if not self._save_project_impl():
self.add_error_message("打包前自动保存失败已取消WebGL打包")
return
current_project_path = self.project_manager.current_project_path
initial_dir = os.path.dirname(current_project_path) if current_project_path else os.getcwd()
selected_dir = self._select_directory_system_dialog("选择 WebGL 打包输出目录", initial_dir)
if not selected_dir:
dialog_error = getattr(self, "_last_directory_dialog_error", "")
if dialog_error:
# Fallback: when system dialog is unavailable (e.g. missing tkinter),
# reuse the built-in path browser so user can still choose directory.
self.add_warning_message(f"系统目录选择器不可用: {dialog_error}")
self.path_browser_mode = "webgl_build"
self.path_browser_current_path = initial_dir if os.path.isdir(initial_dir) else os.getcwd()
self.path_browser_selected_path = self.path_browser_current_path
self.show_path_browser = True
self._pending_webgl_package = True
self._refresh_path_browser()
self.add_info_message("已切换到内置路径浏览器,请选择输出目录并点击确定")
else:
self.add_info_message("已取消WebGL打包")
return
self._execute_webgl_package(selected_dir)
def _execute_webgl_package(self, selected_dir):
"""执行WebGL打包并输出消息。"""
ok = self.project_manager.buildWebGLPackage(selected_dir)
report = getattr(self.project_manager, "last_webgl_export_report", None) or {}
status = report.get("status", "failed")
out_dir = report.get("output_dir", "")
report_path = os.path.join(out_dir, "reports", "export_report.json") if out_dir else ""
if ok:
missing_count = len(report.get("missing_assets", []))
unsupported_count = len(report.get("unsupported_assets", []))
if status == "partial":
self.add_warning_message(
f"WebGL打包部分成功: 缺失资源 {missing_count},不支持资源 {unsupported_count}"
)
else:
self.add_success_message("WebGL打包成功")
if out_dir:
self.add_info_message(f"输出目录: {out_dir}")
if report_path:
self.add_info_message(f"报告: {report_path}")
else:
self.add_error_message("WebGL打包失败")
if report_path:
self.add_warning_message(f"请检查报告: {report_path}")
def _on_exit(self):
"""处理退出菜单项"""
self.add_info_message("退出应用程序")
self.userExit()
def _select_directory_system_dialog(self, title, initial_dir=""):
"""打开系统目录选择器并返回目录路径。"""
self._last_directory_dialog_error = ""
try:
import tkinter as tk
from tkinter import filedialog
if not initial_dir or (not os.path.isdir(initial_dir)):
initial_dir = os.getcwd()
root = tk.Tk()
root.withdraw()
try:
root.attributes("-topmost", True)
except Exception:
pass
selected = filedialog.askdirectory(
title=title or "选择目录",
initialdir=initial_dir,
mustexist=True,
)
root.destroy()
return os.path.normpath(selected) if selected else ""
except Exception as e:
print(f"目录选择器打开失败: {e}")
self._last_directory_dialog_error = str(e)
return ""
# ==================== 键盘事件处理函数 ====================
@ -1055,6 +1148,11 @@ class AppActions:
self.ssbo_editor.load_model(file_path)
model_np = getattr(self.ssbo_editor, 'model', None)
normalized_import_path = str(file_path).replace("\\", "/").lower()
is_project_scene_bam = (
normalized_import_path.endswith(".bam")
and "/scenes/" in normalized_import_path
)
# Keep legacy ray-pick fallback usable by adding a collision body.
if model_np:
try:
@ -1073,8 +1171,12 @@ class AppActions:
if hasattr(self, 'scene_manager') and self.scene_manager:
try:
self.scene_manager.setupCollision(model_np)
self.scene_manager._processModelAnimations(model_np)
if not is_project_scene_bam:
self.scene_manager.setupCollision(model_np)
self.scene_manager._processModelAnimations(model_np)
else:
model_np.setTag("has_animations", "false")
model_np.setTag("has_animations_checked", "true")
if hasattr(self.scene_manager, "models") and model_np not in self.scene_manager.models:
self.scene_manager.models.append(model_np)
except Exception as e:

View File

@ -402,6 +402,13 @@ class DialogPanels:
# 导入模型模式:使用选择的文件路径
self.import_file_path = self.path_browser_selected_path
self.add_info_message(f"已选择文件: {self.import_file_path}")
elif self.path_browser_mode == "webgl_build":
output_dir = self.path_browser_current_path
self.add_info_message(f"已选择WebGL输出目录: {output_dir}")
if getattr(self, "_pending_webgl_package", False):
self._pending_webgl_package = False
if hasattr(self, "_execute_webgl_package"):
self._execute_webgl_package(output_dir)
except Exception as e:
self.add_error_message(f"应用路径失败: {e}")

View File

@ -171,6 +171,8 @@ class EditorPanels:
self.app._on_save_project()
if imgui.menu_item("另存为", "", False, True)[1]:
self.app._on_save_as_project()
if imgui.menu_item("打包为 WebGL", "", False, True)[1]:
self.app._on_build_webgl_package()
imgui.separator()
if imgui.menu_item("退出", "Alt+F4", False, True)[1]:
self.app._on_exit()
@ -2460,4 +2462,3 @@ class EditorPanels:
except Exception as e:
print(f"绘制着色模型面板失败: {e}")

View File

@ -352,6 +352,12 @@ class PanelDelegates:
def _on_save_as_project(self, *args, **kwargs):
return self.app_actions._on_save_as_project(*args, **kwargs)
def _on_build_webgl_package(self, *args, **kwargs):
return self.app_actions._on_build_webgl_package(*args, **kwargs)
def _execute_webgl_package(self, *args, **kwargs):
return self.app_actions._execute_webgl_package(*args, **kwargs)
def _on_exit(self, *args, **kwargs):
return self.app_actions._on_exit(*args, **kwargs)
@ -745,4 +751,3 @@ class PanelDelegates:
def loadScript(self, script_path):
return self.runtime_actions.loadScript(script_path)