修复加载项目
This commit is contained in:
parent
d2cfc77fc6
commit
4a25031cac
@ -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
|
||||
|
||||
|
||||
@ -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 返回 True,failed 返回 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
995
project/webgl_packager.py
Normal 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)
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
16
templates/webgl/index.html
Normal file
16
templates/webgl/index.html
Normal 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
61
templates/webgl/style.css
Normal 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
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
1229
templates/webgl/vendor/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
templates/webgl/vendor/three.module.min.js
vendored
Normal file
8
templates/webgl/vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
386
templates/webgl/viewer.js
Normal file
386
templates/webgl/viewer.js
Normal 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");
|
||||
});
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user