996 lines
33 KiB
Python
996 lines
33 KiB
Python
"""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)
|