"""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._public_id_by_pointer: Dict[int, str] = {} self._node_path_by_pointer: Dict[int, str] = {} self._lookup_by_pointer: Dict[int, Dict[str, Any]] = {} self._export_kind_hint_by_pointer: Dict[int, str] = {} self._baseline_subnode_cache: Dict[str, Dict[str, Any]] = {} 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", "frontend_demo.html": "frontend_demo.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) self._copy_optional_postprocess_vendor_files(vendor_dst) self._rewrite_vendor_module_imports(vendor_dst) self._strip_vendor_source_mapping_urls(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 _copy_optional_postprocess_vendor_files(self, vendor_dst: Path) -> None: """Try to copy optional postprocessing modules used by viewer approximation.""" 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", ] optional_targets = { "EffectComposer.js": [ "examples/jsm/postprocessing/EffectComposer.js", ], "RenderPass.js": [ "examples/jsm/postprocessing/RenderPass.js", ], "UnrealBloomPass.js": [ "examples/jsm/postprocessing/UnrealBloomPass.js", ], "ShaderPass.js": [ "examples/jsm/postprocessing/ShaderPass.js", ], "OutputPass.js": [ "examples/jsm/postprocessing/OutputPass.js", ], "CopyShader.js": [ "examples/jsm/shaders/CopyShader.js", ], "LuminosityHighPassShader.js": [ "examples/jsm/shaders/LuminosityHighPassShader.js", ], } for dst_name, rel_candidates in optional_targets.items(): dst_path = vendor_dst / dst_name if dst_path.exists(): 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: try: shutil.copy2(str(found_source), str(dst_path)) except Exception: continue def _rewrite_vendor_module_imports(self, vendor_dst: Path) -> None: """Rewrite bare/relative Three.js addon imports to local vendor paths.""" js_files = [p for p in vendor_dst.glob("*.js") if p.is_file()] if not js_files: return available_names = {p.name for p in js_files} import_re = re.compile( r"""(?P\bfrom\s+)(?P['"])(?P[^'"]+)(?P=quote)""" ) def rewrite_spec(spec: str) -> str: raw = str(spec or "").strip() if not raw: return raw if raw in {"three", "three.module.js", "three.module.min.js"}: return "./three.module.min.js" if raw.startswith("three/addons/") or raw.startswith("three/examples/jsm/"): return "./" + raw.split("/")[-1] if raw.endswith(".js"): basename = os.path.basename(raw) if basename in available_names: return "./" + basename return raw for js_file in js_files: if js_file.name == "three.module.min.js": continue try: content = js_file.read_text(encoding="utf-8", errors="ignore") except Exception: continue replaced = False def _replace(match): nonlocal replaced old_spec = match.group("spec") new_spec = rewrite_spec(old_spec) if new_spec == old_spec: return match.group(0) replaced = True return f"{match.group('prefix')}{match.group('quote')}{new_spec}{match.group('quote')}" updated = import_re.sub(_replace, content) if not replaced: continue try: js_file.write_text(updated, encoding="utf-8") except Exception: continue def _strip_vendor_source_mapping_urls(self, vendor_dst: Path) -> None: """Remove sourceMappingURL hints to avoid noisy 404s in offline preview.""" line_patterns = ( re.compile(r"^\s*//[#@]\s*sourceMappingURL=.*$", re.IGNORECASE), re.compile(r"^\s*/\*[#@]\s*sourceMappingURL=.*\*/\s*$", re.IGNORECASE), ) for js_file in vendor_dst.glob("*.js"): try: content = js_file.read_text(encoding="utf-8", errors="ignore") except Exception: continue lines = content.splitlines() filtered = [ line for line in lines if not any(pattern.match(line) for pattern in line_patterns) ] if len(filtered) == len(lines): continue text = "\n".join(filtered) if content.endswith("\n"): text += "\n" try: js_file.write_text(text, encoding="utf-8") self.report["warnings"].append(f"已移除 sourceMappingURL: vendor/{js_file.name}") except Exception: continue 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}" for node in model_nodes: self._export_kind_hint_by_pointer[id(node)] = "model" for node in point_nodes: self._export_kind_hint_by_pointer[id(node)] = "point_light" for node in spot_nodes: self._export_kind_hint_by_pointer[id(node)] = "spot_light" if ground_node is not None: self._export_kind_hint_by_pointer[id(ground_node)] = "ground" self._assign_export_lookup_metadata(export_nodes) 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) lui_manifest = self._build_lui_manifest() manifest = { "meta": { "format_version": "1.1", "api_version": 1, "project_name": project_name, "exported_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "transports": ["js_api", "postMessage"], }, "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, } if lui_manifest: manifest["lui"] = lui_manifest return manifest def _build_lui_manifest(self) -> Dict[str, Any]: """Serialize scene-space LUI canvases for the WebGL viewer.""" lui_manager = getattr(self.world, "lui_manager", None) if not lui_manager: return {} canvases = list(getattr(lui_manager, "canvases", []) or []) components = list(getattr(lui_manager, "components", []) or []) if not canvases: return {} canvas_entries: List[Dict[str, Any]] = [] for canvas_index, canvas in enumerate(canvases): if not isinstance(canvas, dict): continue render_mode = str(canvas.get("render_mode") or "screen").lower().strip() if render_mode != "world": continue entry = self._build_lui_canvas_entry(canvas_index, canvas, components) if entry: canvas_entries.append(entry) if not canvas_entries: return {} return { "version": 1, "source": "lui_manager", "canvases": canvas_entries, } def _build_lui_canvas_entry( self, canvas_index: int, canvas: Dict[str, Any], components: List[Dict[str, Any]], ) -> Dict[str, Any]: mat = self._lui_canvas_matrix(canvas) width, height = self._lui_canvas_size(canvas) component_entries = [] for comp_index, comp in enumerate(components): if not isinstance(comp, dict): continue try: if int(comp.get("canvas_index", -1)) != canvas_index: continue except Exception: continue entry = self._build_lui_component_entry(comp_index, comp, components) if entry: component_entries.append(entry) component_entries.sort( key=lambda item: ( self._safe_float(item.get("sort"), 1.0) + self._safe_float(item.get("z_offset"), 0.0), int(item.get("index", 0)), ) ) return { "id": f"lui_canvas_{canvas_index}", "name": str(canvas.get("name") or f"Canvas {canvas_index + 1}"), "render_mode": "world", "visible": bool(canvas.get("visible", True)), "matrix_local_row_major": self._mat4_to_row_major_list(mat) if mat is not None else self._identity_mat4_row_major(), "world_position": self._lui_triplet(canvas.get("world_position"), (0.0, 8.0, 2.0)), "world_hpr": self._lui_triplet(canvas.get("world_hpr"), (0.0, 0.0, 0.0)), "world_scale": self._lui_triplet(canvas.get("world_scale"), (1.0, 1.0, 1.0)), "width": width, "height": height, "world_pixels_per_unit": max(1.0, self._safe_float(canvas.get("world_pixels_per_unit"), 280.0)), "world_depth_test": bool(canvas.get("world_depth_test", True)), "world_interactive": bool(canvas.get("world_interactive", True)), "world_two_sided": bool(canvas.get("world_two_sided", True)), "background_color": self._lui_canvas_background_color(canvas), "title_text": self._lui_text_value(canvas.get("title_text") or self._lui_object_text(canvas.get("title_label"))), "title_visible": bool(canvas.get("title_visible", False)), "components": component_entries, } def _build_lui_component_entry( self, comp_index: int, comp: Dict[str, Any], components: List[Dict[str, Any]], ) -> Dict[str, Any]: left, top = self._lui_component_abs_pos(comp_index, components) width = max(1.0, self._safe_float(comp.get("width"), 100.0)) height = max(1.0, self._safe_float(comp.get("height"), 30.0)) comp_type = str(comp.get("type") or "Component") entry: Dict[str, Any] = { "index": comp_index, "type": comp_type, "name": str(comp.get("name") or f"{comp_type}_{comp_index}"), "left": left, "top": top, "width": width, "height": height, "sort": self._safe_float(comp.get("sort"), 1.0), "z_offset": self._safe_float(comp.get("z_offset"), 0.0), "text": self._lui_text_value(comp.get("text") or self._lui_object_text(comp.get("object"))), "value": self._lui_json_scalar(comp.get("value", "")), "placeholder": str(comp.get("placeholder") or ""), "checked": bool(comp.get("checked", False)), "color": self._normalize_lui_color(comp.get("color"), (1.0, 1.0, 1.0, 1.0)), "text_color": self._normalize_lui_color(comp.get("text_color"), (1.0, 1.0, 1.0, 1.0)), "font_size": max(8.0, self._safe_float(comp.get("font_size"), 14.0)), "min_value": self._safe_float(comp.get("min_value", comp.get("min", 0.0)), 0.0), "max_value": self._safe_float(comp.get("max_value", comp.get("max", 100.0)), 100.0), "selected_option_id": self._lui_json_scalar(comp.get("selected_option_id")), "options": self._serialize_lui_options(comp.get("options")), } texture_path = str(comp.get("texture_path") or comp.get("image_path") or "").strip() texture_abs = self._resolve_lui_asset_path(texture_path) if texture_abs: texture_uri = self._copy_asset_to_textures(texture_abs) if texture_uri: entry["texture_uri"] = texture_uri return entry def _lui_canvas_matrix(self, canvas: Dict[str, Any]) -> Any: node = canvas.get("node") render = getattr(self.world, "render", None) if node is not None: try: if not hasattr(node, "isEmpty") or not node.isEmpty(): if render is not None: return node.getMat(render) return node.getMat() except Exception: pass try: from panda3d.core import NodePath temp = NodePath("lui_web_export_canvas_transform") temp.setPos(*self._lui_triplet(canvas.get("world_position"), (0.0, 8.0, 2.0))) temp.setHpr(*self._lui_triplet(canvas.get("world_hpr"), (0.0, 0.0, 0.0))) temp.setScale(*self._lui_triplet(canvas.get("world_scale"), (1.0, 1.0, 1.0))) return temp.getMat() except Exception: return None def _lui_canvas_size(self, canvas: Dict[str, Any]) -> Tuple[float, float]: panel = canvas.get("panel") width = self._safe_float(canvas.get("width"), 0.0) height = self._safe_float(canvas.get("height"), 0.0) if panel is not None: try: width = self._safe_float(getattr(panel, "width", width), width) height = self._safe_float(getattr(panel, "height", height), height) except Exception: pass try: if width <= 0 and hasattr(panel, "get_width"): width = self._safe_float(panel.get_width(), width) if height <= 0 and hasattr(panel, "get_height"): height = self._safe_float(panel.get_height(), height) except Exception: pass return max(1.0, width or 800.0), max(1.0, height or 600.0) def _lui_canvas_background_color(self, canvas: Dict[str, Any]) -> List[float]: background = canvas.get("background") try: if background is not None and hasattr(background, "color"): return self._normalize_lui_color(background.color, (0.1, 0.1, 0.1, 0.65)) except Exception: pass return self._normalize_lui_color(canvas.get("background_color"), (0.1, 0.1, 0.1, 0.65)) def _lui_component_abs_pos( self, comp_index: int, components: List[Dict[str, Any]], visited: Optional[set] = None, ) -> Tuple[float, float]: if comp_index < 0 or comp_index >= len(components): return 0.0, 0.0 if visited is None: visited = set() if comp_index in visited: return 0.0, 0.0 visited.add(comp_index) comp = components[comp_index] left = self._safe_float(comp.get("left"), 0.0) top = self._safe_float(comp.get("top"), 0.0) parent_index = comp.get("parent_index") try: parent_index_int = int(parent_index) except Exception: parent_index_int = -1 if 0 <= parent_index_int < len(components): px, py = self._lui_component_abs_pos(parent_index_int, components, visited) return px + left, py + top return left, top def _resolve_lui_asset_path(self, path_hint: str) -> Optional[str]: text = str(path_hint or "").strip() if not text or text.lower() == "blank": return None if text.startswith(("http://", "https://", "data:", "blob:")): return None if os.path.isabs(text) and os.path.exists(text): return os.path.normpath(text) roots = [ self._project_path, os.path.join(self._project_path, "Resources"), os.path.join(self._project_path, "scenes", "resources"), os.getcwd(), ] for root in roots: candidate = os.path.normpath(os.path.join(root, text)) if os.path.exists(candidate): return candidate return None @staticmethod def _normalize_lui_color(value: Any, fallback: Tuple[float, float, float, float]) -> List[float]: if not isinstance(value, (list, tuple)): value = fallback vals = list(value[:4]) while len(vals) < 4: vals.append(1.0) out = [] for idx, fallback_value in enumerate(fallback): try: num = float(vals[idx]) except Exception: num = float(fallback_value) out.append(max(0.0, min(1.0, num))) return out @staticmethod def _lui_triplet(value: Any, fallback: Tuple[float, float, float]) -> List[float]: if not isinstance(value, (list, tuple)) or len(value) < 3: value = fallback out = [] for idx, fallback_value in enumerate(fallback): try: out.append(float(value[idx])) except Exception: out.append(float(fallback_value)) return out @staticmethod def _lui_text_value(value: Any) -> str: return "" if value is None else str(value) @staticmethod def _lui_json_scalar(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): return value return str(value) @staticmethod def _lui_object_text(obj: Any) -> str: if obj is None: return "" for attr in ("text", "value", "label"): try: value = getattr(obj, attr, None) except Exception: value = None if value is not None: return str(value) return "" @staticmethod def _serialize_lui_options(options: Any) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] if not isinstance(options, (list, tuple)): return out for index, item in enumerate(options): if isinstance(item, dict): option_id = item.get("id", item.get("value", index)) label = item.get("label", item.get("text", item.get("value", option_id))) elif isinstance(item, (list, tuple)) and item: option_id = item[0] label = item[1] if len(item) > 1 else item[0] else: option_id = index label = item out.append({"id": WebGLPackager._lui_json_scalar(option_id), "label": "" if label is None else str(label)}) return out def _collect_model_nodes(self) -> List[Any]: if not self.scene_manager: return [] all_models: List[Any] = [] ssbo_editor = getattr(self.world, "ssbo_editor", None) source_model_root = getattr(ssbo_editor, "source_model_root", None) if ssbo_editor else None if source_model_root: try: if not source_model_root.isEmpty(): snapshot_fn = getattr(ssbo_editor, "_snapshot_top_level_transforms_to_source_root", None) snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None) if callable(snapshot_fn): try: snapshot_fn() except Exception: pass if callable(snapshot_material_fn): try: snapshot_material_fn() except Exception: pass all_models = self._collect_valid_nodes(list(source_model_root.getChildren())) except Exception: all_models = [] if not all_models: 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 # Only export true model roots. Scene traversal may put many child nodes # into scene_manager.models, but they should be replayed via subnode overrides. is_model_root = False try: is_model_root = node.hasTag("is_model_root") except Exception: is_model_root = False has_model_path_tag = False for tag_name in ("model_path", "saved_model_path", "original_path", "asset_path", "file"): try: if node.hasTag(tag_name): has_model_path_tag = True break except Exception: continue if not is_model_root and not has_model_path_tag: 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 entry = { "include_default_ground": True, "ambient_light": { "color": ambient_color, "intensity": 1.0, }, "directional_light": { "color": directional_color, "intensity": 1.0, "direction": directional_dir, }, } skybox_entry = self._extract_skybox_entry() if skybox_entry: entry["skybox"] = skybox_entry render_pipeline = self._extract_render_pipeline_settings() if render_pipeline: entry["render_pipeline"] = render_pipeline return entry def _extract_skybox_entry(self) -> Dict[str, Any]: """Export skybox texture config for viewer background.""" skybox_source = self._find_runtime_skybox_texture_source() if not skybox_source: skybox_source = self._find_fallback_skybox_source() if not skybox_source: return {} if not self._is_supported_skybox_image(skybox_source): self.report["warnings"].append(f"天空盒格式暂不支持,已跳过: {skybox_source}") return {} uri = self._copy_asset_to_textures(skybox_source) if not uri: self.report["warnings"].append(f"天空盒资源复制失败: {skybox_source}") return {} projection = self._guess_skybox_projection(skybox_source) lower_color = self._extract_skybox_lower_hemisphere_color(skybox_source) return { "enabled": True, "type": projection, "uri": uri, "apply_environment": projection != "skydome", "clip_lower_hemisphere": projection == "skydome", "lower_hemisphere_color": lower_color, "horizon_blend": 0.06, "horizon_sample_v": 0.01, "lower_tint_strength": 0.65, } def _extract_skybox_lower_hemisphere_color(self, skybox_source: str) -> List[float]: """Pick lower hemisphere fill color, prefer horizon-adjacent sky tone.""" horizon_rgb = self._estimate_skybox_horizon_rgb(skybox_source) if horizon_rgb: return horizon_rgb clear_rgb = self._extract_world_clear_rgb() if clear_rgb and max(clear_rgb) > 0.02: return clear_rgb # Final fallback: dark bluish neutral. return [0.08, 0.10, 0.13] def _extract_world_clear_rgb(self) -> List[float]: """Read active clear/background color from world/showbase if available.""" def _to_rgb(value: Any) -> List[float]: if value is None: return [] try: if hasattr(value, "__len__") and len(value) >= 3: # type: ignore[arg-type] r = float(value[0]) # type: ignore[index] g = float(value[1]) # type: ignore[index] b = float(value[2]) # type: ignore[index] return [max(0.0, min(1.0, r)), max(0.0, min(1.0, g)), max(0.0, min(1.0, b))] except Exception: return [] return [] world_getters = ("getBackgroundColor", "get_background_color") for name in world_getters: getter = getattr(self.world, name, None) if callable(getter): try: rgb = _to_rgb(getter()) except Exception: rgb = [] if rgb: return rgb win = getattr(self.world, "win", None) if win and hasattr(win, "getClearColor"): try: rgb = _to_rgb(win.getClearColor()) except Exception: rgb = [] if rgb: return rgb rp = getattr(self.world, "render_pipeline", None) showbase = getattr(rp, "_showbase", None) if rp else None rp_win = getattr(showbase, "win", None) if showbase else None if rp_win and hasattr(rp_win, "getClearColor"): try: rgb = _to_rgb(rp_win.getClearColor()) except Exception: rgb = [] if rgb: return rgb return [] @staticmethod def _estimate_skybox_horizon_rgb(path: str) -> List[float]: """Estimate horizon color from skydome texture bottom band.""" try: from PIL import Image, ImageStat # type: ignore except Exception: return [] try: with Image.open(path) as im: rgb_img = im.convert("RGB") width, height = rgb_img.size if width <= 0 or height <= 0: return [] band_h = max(1, int(height // 64)) band = rgb_img.crop((0, height - band_h, width, height)) stat = ImageStat.Stat(band) mean = stat.mean[:3] if stat.mean else [0.0, 0.0, 0.0] return [ max(0.0, min(1.0, float(mean[0]) / 255.0)), max(0.0, min(1.0, float(mean[1]) / 255.0)), max(0.0, min(1.0, float(mean[2]) / 255.0)), ] except Exception: return [] def _find_runtime_skybox_texture_source(self) -> str: candidates: List[str] = [] skybox_np = getattr(self.world, "skybox", None) if skybox_np and not self._is_np_empty(skybox_np): candidates.extend(self._extract_texture_sources_from_node(skybox_np)) render = getattr(self.world, "render", None) if render: try: skyboxes = render.findAllMatches("**/skybox*") except Exception: skyboxes = None if skyboxes: try: count = skyboxes.getNumPaths() except Exception: count = 0 for i in range(count): try: np = skyboxes.getPath(i) except Exception: continue if self._is_np_empty(np): continue candidates.extend(self._extract_texture_sources_from_node(np)) for source in candidates: if source and self._is_supported_skybox_image(source): return source return "" def _extract_texture_sources_from_node(self, node) -> List[str]: out: List[str] = [] for _, tex_hint in self._extract_texture_stage_and_paths(node): resolved = self._resolve_skybox_path(tex_hint) if resolved: out.append(resolved) return out def _resolve_skybox_path(self, path_hint: str) -> str: text = str(path_hint or "").strip() if not text: return "" if os.path.isabs(text) and os.path.exists(text): return os.path.normpath(text) repo_root = str(Path(__file__).resolve().parent.parent) roots = [ self._project_path, os.path.join(self._project_path, "Resources"), os.path.join(self._project_path, "scenes", "resources"), repo_root, os.path.join(repo_root, "RenderPipelineFile"), os.path.join(repo_root, "RenderPipelineFile", "data"), os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox"), os.getcwd(), ] for root in roots: if not root: continue full = os.path.normpath(os.path.join(root, text)) if os.path.exists(full): return full return "" def _find_fallback_skybox_source(self) -> str: repo_root = str(Path(__file__).resolve().parent.parent) candidates = [ os.path.join(self._project_path, "Resources", "skybox.jpg"), os.path.join(self._project_path, "Resources", "skybox.png"), os.path.join(self._project_path, "skybox.jpg"), os.path.join(self._project_path, "skybox.png"), os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox.jpg"), os.path.join(repo_root, "RenderPipelineFile", "data", "builtin_models", "skybox", "skybox-2.jpg"), ] for path in candidates: if path and os.path.exists(path) and self._is_supported_skybox_image(path): return os.path.normpath(path) return "" @staticmethod def _is_supported_skybox_image(path: str) -> bool: ext = os.path.splitext(str(path or ""))[1].lower() return ext in {".jpg", ".jpeg", ".png", ".webp", ".avif"} @staticmethod def _guess_skybox_projection(path: str) -> str: text = str(path or "").replace("\\", "/").lower() if "/builtin_models/skybox/skybox" in text: # RenderPipeline default assets are skydome textures. return "skydome" width = 0 height = 0 try: from PIL import Image # type: ignore with Image.open(path) as im: width, height = im.size except Exception: width, height = 0, 0 if width > 0 and height > 0: ratio = float(width) / float(max(1, height)) if 3.2 <= ratio <= 4.8: return "skydome" if 1.8 <= ratio <= 2.2: return "equirectangular" return "equirectangular" def _extract_render_pipeline_settings(self) -> Dict[str, Any]: rp = getattr(self.world, "render_pipeline", None) plugin_mgr = getattr(rp, "plugin_mgr", None) if rp else None if not plugin_mgr: return {} enabled_plugins = set(getattr(plugin_mgr, "enabled_plugins", set()) or set()) tone_operator = str( self._get_rp_plugin_setting(plugin_mgr, "color_correction", "tonemap_operator", "optimized") ).strip().lower() if not tone_operator: tone_operator = "optimized" exposure_scale = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "color_correction", "exposure_scale", 1.0), 1.0, ) min_exposure = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "color_correction", "min_exposure_value", 0.01), 0.01, ) max_exposure = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "color_correction", "max_exposure_value", 1.0), 1.0, ) if max_exposure < min_exposure: min_exposure, max_exposure = max_exposure, min_exposure manual_camera = bool( self._get_rp_plugin_setting(plugin_mgr, "color_correction", "manual_camera_parameters", False) ) web_exposure_boost = 1.0 if tone_operator in {"optimized", "uncharted2"}: web_exposure_boost = 1.6 elif tone_operator == "reinhard": web_exposure_boost = 1.25 elif tone_operator in {"exponential", "exponential2"}: web_exposure_boost = 1.15 bloom_enabled = "bloom" in enabled_plugins bloom_strength = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "bloom", "bloom_strength", 0.0), 0.0, ) bloom_mips = int(self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "bloom", "num_mipmaps", 6), 6.0, )) bloom_lens_dirt = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "bloom", "lens_dirt_factor", 0.0), 0.0, ) pssm_enabled = "pssm" in enabled_plugins shadow_resolution = int(self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "pssm", "resolution", 1024), 1024.0, )) shadow_resolution = max(256, min(4096, shadow_resolution)) shadow_max_distance = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "pssm", "max_distance", 50.0), 50.0, ) shadow_use_pcf = bool( self._get_rp_plugin_setting(plugin_mgr, "pssm", "use_pcf", True) ) ao_enabled = "ao" in enabled_plugins ao_strength = self._safe_float( self._get_rp_plugin_setting(plugin_mgr, "ao", "occlusion_strength", 1.0), 1.0, ) ao_technique = str( self._get_rp_plugin_setting(plugin_mgr, "ao", "technique", "SSAO") ).strip().upper() or "SSAO" daytime_value = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0) fog_enabled = "volumetrics" in enabled_plugins fog_ramp = self._safe_float( self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_ramp_size", 2000.0), 2000.0, ) fog_intensity = self._safe_float( self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_intensity", 0.0), 0.0, ) fog_color_raw = self._get_rp_day_setting(plugin_mgr, rp, "volumetrics", "fog_color", (140.0, 150.0, 165.0)) fog_color = self._normalize_daytime_color(fog_color_raw) # Viewer side optional postprocessing modules. has_bloom_vendor = all( os.path.exists(os.path.join(self._output_root, "vendor", name)) for name in ("EffectComposer.js", "RenderPass.js", "UnrealBloomPass.js") ) if bloom_enabled and not has_bloom_vendor: self.report["warnings"].append( "Bloom 已启用,但未找到 EffectComposer/RenderPass/UnrealBloomPass,本次 Web 导出将退化为无 Bloom。" ) return { "source": "render_pipeline", "daytime": daytime_value, "enabled_plugins": sorted(str(v) for v in enabled_plugins), "tone_mapping": { "operator": tone_operator, "exposure_scale": exposure_scale, "web_exposure_boost": web_exposure_boost, "web_use_tone_mapping": False, "manual_camera_parameters": manual_camera, "min_exposure": min_exposure, "max_exposure": max_exposure, }, "bloom": { "enabled": bloom_enabled and bloom_strength > 1e-5, "strength": max(0.0, bloom_strength), "mipmaps": max(2, bloom_mips), "lens_dirt_factor": max(0.0, min(1.0, bloom_lens_dirt)), "vendor_available": has_bloom_vendor, }, "shadows": { "enabled": pssm_enabled, "resolution": shadow_resolution, "max_distance": max(1.0, shadow_max_distance), "use_pcf": shadow_use_pcf, "web_enable": False, }, "ao": { "enabled": ao_enabled, "technique": ao_technique, "occlusion_strength": max(0.0, ao_strength), }, "fog": { "enabled": fog_enabled and fog_intensity > 1e-6, "color": fog_color, "intensity": max(0.0, fog_intensity), "ramp_size": max(1.0, fog_ramp), }, "aa": { "smaa_enabled": "smaa" in enabled_plugins, "fxaa_enabled": "fxaa" in enabled_plugins, }, } @staticmethod def _get_rp_plugin_setting(plugin_mgr, plugin_id: str, setting_id: str, default: Any) -> Any: try: plugin_settings = getattr(plugin_mgr, "settings", {}).get(plugin_id) if plugin_settings and setting_id in plugin_settings: handle = plugin_settings[setting_id] return getattr(handle, "value", default) except Exception: pass return default def _get_rp_day_setting(self, plugin_mgr, rp, plugin_id: str, setting_id: str, default: Any) -> Any: try: day_settings = getattr(plugin_mgr, "day_settings", {}).get(plugin_id) if not day_settings or setting_id not in day_settings: return default handle = day_settings[setting_id] if not hasattr(handle, "get_scaled_value_at"): return default daytime = self._safe_float(getattr(getattr(rp, "daytime_mgr", None), "time", 12.0), 12.0) return handle.get_scaled_value_at(daytime) except Exception: return default @staticmethod def _normalize_daytime_color(value: Any) -> List[float]: if isinstance(value, (list, tuple)) and len(value) >= 3: out = [ float(value[0]), float(value[1]), float(value[2]), ] else: out = [0.55, 0.6, 0.65] if any(v > 1.0 for v in out): out = [v / 255.0 for v in out] return [ max(0.0, min(1.0, out[0])), max(0.0, min(1.0, out[1])), max(0.0, min(1.0, out[2])), ] def _assign_export_lookup_metadata(self, export_nodes: List[Any]) -> None: self._public_id_by_pointer.clear() self._node_path_by_pointer.clear() self._lookup_by_pointer.clear() export_nodes = self._collect_valid_nodes(export_nodes) if not export_nodes: return parent_by_pointer: Dict[int, Optional[Any]] = {} children_by_parent_pointer: Dict[Optional[int], List[Any]] = {} for node in export_nodes: parent = self._get_export_parent(node) parent_by_pointer[id(node)] = parent parent_ptr = id(parent) if parent else None children_by_parent_pointer.setdefault(parent_ptr, []).append(node) segment_by_pointer: Dict[int, str] = {} for parent_ptr, children in children_by_parent_pointer.items(): del parent_ptr children.sort(key=self._get_export_sibling_sort_key) occurrence_by_name: Dict[str, int] = {} for node in children: segment_base = self._build_public_path_segment(node) occurrence = occurrence_by_name.get(segment_base, 0) occurrence_by_name[segment_base] = occurrence + 1 segment_by_pointer[id(node)] = f"{segment_base}#{occurrence}" path_cache: Dict[int, str] = {} def build_path(node) -> str: node_ptr = id(node) cached = path_cache.get(node_ptr) if cached is not None: return cached parent = parent_by_pointer.get(node_ptr) segment = segment_by_pointer.get(node_ptr, f"{self._build_public_path_segment(node)}#0") if parent and id(parent) in self._node_id_by_pointer: parent_path = build_path(parent) path = f"{parent_path}/{segment}" if parent_path else segment else: path = segment path_cache[node_ptr] = path return path public_id_base_by_pointer: Dict[int, str] = {} duplicate_counts: Dict[str, int] = {} for node in export_nodes: node_ptr = id(node) path = build_path(node) self._node_path_by_pointer[node_ptr] = path public_base = self._build_public_id_base(node, path) duplicate_index = duplicate_counts.get(public_base, 0) duplicate_counts[public_base] = duplicate_index + 1 public_id = public_base if duplicate_index == 0 else f"{public_base}#{duplicate_index}" public_id_base_by_pointer[node_ptr] = public_base self._public_id_by_pointer[node_ptr] = public_id for node in export_nodes: node_ptr = id(node) node_id = self._node_id_by_pointer.get(node_ptr, "") node_name = self._safe_node_name(node) or node_id or "node" kind = self._export_kind_hint_by_pointer.get(node_ptr, "node") parent = parent_by_pointer.get(node_ptr) parent_id = self._node_id_by_pointer.get(id(parent)) if parent else None parent_public_id = self._public_id_by_pointer.get(id(parent)) if parent else None asset_guid = self._safe_get_tag_value(node, "asset_guid") imported_node_key = self._resolve_imported_node_key_for_public_id(node) self._lookup_by_pointer[node_ptr] = { "id": node_id, "public_id": self._public_id_by_pointer.get(node_ptr, ""), "name": node_name, "kind": kind, "path": self._node_path_by_pointer.get(node_ptr, ""), "parent_id": parent_id, "parent_public_id": parent_public_id, "asset_guid": asset_guid or "", "imported_node_key": imported_node_key or "", } def _get_export_parent(self, node): try: parent = node.getParent() except Exception: parent = None if parent and not parent.isEmpty() and id(parent) in self._node_id_by_pointer: return parent return None def _get_export_sibling_sort_key(self, node) -> Tuple[int, str, str]: sibling_index = self._get_node_sibling_index(node) return ( sibling_index, self._safe_node_name(node).lower(), self._node_id_by_pointer.get(id(node), ""), ) @staticmethod def _get_node_sibling_index(node) -> int: try: parent = node.getParent() except Exception: parent = None if not parent or parent.isEmpty(): return 0 try: children = list(parent.getChildren()) except Exception: return 0 target_ptr = id(node) for index, child in enumerate(children): if id(child) == target_ptr: return index return 0 def _build_public_path_segment(self, node) -> str: kind = self._export_kind_hint_by_pointer.get(id(node), "node") base_name = self._safe_node_name(node) if not base_name: base_name = kind sanitized = self._sanitize_public_id_segment(base_name) return sanitized or kind def _build_public_id_base(self, node, path: str) -> str: kind = self._export_kind_hint_by_pointer.get(id(node), "node") if kind == "model": asset_guid = self._safe_get_tag_value(node, "asset_guid") if asset_guid: imported_node_key = self._resolve_imported_node_key_for_public_id(node) or "root" return f"model:{asset_guid}:{imported_node_key}" return f"scene:{kind}:{path}" def _resolve_imported_node_key_for_public_id(self, node) -> str: for tag_name in ("imported_node_key", "source_model_node_key", "ssbo_tree_key", "tree_item_key"): value = self._safe_get_tag_value(node, tag_name) if value: return value return "" @staticmethod def _sanitize_public_id_segment(value: str) -> str: text = str(value or "").strip() if not text: return "" text = text.replace("\\", "/") text = re.sub(r"\s+", "_", text) text = re.sub(r"[/:]+", "_", text) text = re.sub(r"_+", "_", text) return text.strip("._") @staticmethod def _safe_node_name(node) -> str: try: return str(node.getName() or "").strip() except Exception: return "" def _attach_lookup_metadata(self, node, entry: Dict[str, Any]) -> Dict[str, Any]: node_ptr = id(node) lookup = dict(self._lookup_by_pointer.get(node_ptr, {}) or {}) entry["public_id"] = self._public_id_by_pointer.get(node_ptr, "") entry["path"] = self._node_path_by_pointer.get(node_ptr, "") entry["parent_public_id"] = lookup.get("parent_public_id") entry["lookup"] = lookup return entry 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", "asset_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) subnode_overrides = self._collect_ssbo_subnode_transform_overrides(node) if not subnode_overrides: subnode_overrides = self._collect_subnode_transform_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, } animation = self._extract_animation_settings(node, model_source) if animation: entry["animation"] = animation scripts = self._extract_script_settings(node) if scripts: entry["scripts"] = scripts if textures: entry["texture_overrides"] = textures if subnode_overrides: entry["subnode_overrides"] = subnode_overrides return self._attach_lookup_metadata(node, entry) def _extract_animation_settings(self, node, model_source: str) -> Dict[str, Any]: has_animation_hint = False try: if node.hasTag("has_animations"): has_animation_hint = (node.getTag("has_animations").strip().lower() == "true") except Exception: has_animation_hint = False python_animation_flag = self._safe_get_python_tag(node, "animation") if python_animation_flag is True: has_animation_hint = True clip_name = ( self._safe_get_python_tag(node, "selected_animation") or self._safe_get_tag_value(node, "saved_selected_animation") or self._safe_get_tag_value(node, "selected_animation") ) clip_name = str(clip_name).strip() if clip_name is not None else "" speed_value = ( self._safe_get_python_tag(node, "anim_speed") or self._safe_get_tag_value(node, "saved_anim_speed") or self._safe_get_tag_value(node, "anim_speed") ) speed = self._safe_float(speed_value, 1.0) if abs(speed) < 1e-4: speed = 1.0 mode = ( self._safe_get_python_tag(node, "anim_play_mode") or self._safe_get_tag_value(node, "saved_anim_play_mode") or self._safe_get_tag_value(node, "anim_play_mode") or "" ) mode = str(mode).strip().lower() if mode not in {"play", "loop", "pause", "stop"}: mode = "loop" if not (has_animation_hint or clip_name): return {} return { "enabled": True, "clip_name": clip_name, "speed": speed, "mode": mode, "autoplay": mode != "stop", } def _extract_script_settings(self, node) -> List[Dict[str, Any]]: runtime_entries = self._extract_runtime_script_entries(node) tag_entries = self._extract_tag_script_entries(node) if not runtime_entries and not tag_entries: return [] merged_entries: List[Dict[str, Any]] = [] merged_index: Dict[str, int] = {} for entry in runtime_entries: name = str(entry.get("name", "")).strip() key = self._normalize_script_name_key(name) if not key: continue merged_index[key] = len(merged_entries) merged_entries.append(dict(entry)) for entry in tag_entries: name = str(entry.get("name", "")).strip() key = self._normalize_script_name_key(name) if not key: continue if key in merged_index: runtime_entry = merged_entries[merged_index[key]] if entry.get("file") and not runtime_entry.get("file"): runtime_entry["file"] = entry.get("file") continue merged_index[key] = len(merged_entries) merged_entries.append(dict(entry)) return merged_entries def _extract_runtime_script_entries(self, node) -> List[Dict[str, Any]]: script_manager = getattr(self.world, "script_manager", None) if not script_manager: return [] get_scripts = getattr(script_manager, "get_scripts_on_object", None) if not callable(get_scripts): return [] try: script_components = get_scripts(node) or [] except Exception: script_components = [] if not script_components: return [] out: List[Dict[str, Any]] = [] loader = getattr(script_manager, "loader", None) find_script_file = getattr(loader, "find_script_file", None) if loader else None build_script_reference = getattr(script_manager, "build_script_reference", None) for component in script_components: script_instance = getattr(component, "script_instance", None) script_name = str(getattr(component, "script_name", "")).strip() if not script_name and script_instance is not None: script_name = script_instance.__class__.__name__ script_name = str(script_name or "").strip() if not script_name: continue script_file = "" if callable(find_script_file): try: script_file = str(find_script_file(script_name) or "").strip() except Exception: script_file = "" entry: Dict[str, Any] = { "name": script_name, "enabled": bool(getattr(component, "enabled", True)), } if callable(build_script_reference): try: reference = build_script_reference(script_name, script_file) or {} except Exception: reference = {} if isinstance(reference, dict): for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"): value = str(reference.get(key, "") or "").strip() if value: entry[key] = value elif script_file: entry["file"] = script_file params = self._serialize_script_instance_params(script_instance) if params: entry["params"] = params out.append(entry) return out def _extract_tag_script_entries(self, node) -> List[Dict[str, Any]]: has_scripts = self._safe_get_tag_value(node, "has_scripts").lower() == "true" raw = self._safe_get_tag_value(node, "scripts_info") if not has_scripts and not raw: return [] if not raw: return [] try: info_list = json.loads(raw) except Exception: self.report["warnings"].append( f"scripts_info 解析失败,已忽略: {node.getName()}" ) return [] if not isinstance(info_list, list): return [] out: List[Dict[str, Any]] = [] for item in info_list: if not isinstance(item, dict): continue script_name = str(item.get("name", "")).strip() if not script_name: continue entry: Dict[str, Any] = { "name": script_name, "enabled": bool(item.get("enabled", True)), } for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"): value = str(item.get(key, "") or "").strip() if value: entry[key] = value params = item.get("params") if isinstance(params, dict) and params: entry["params"] = params out.append(entry) return out @staticmethod def _normalize_script_name_key(script_name: str) -> str: normalized = str(script_name or "").strip().lower() if normalized.endswith(".py"): normalized = normalized[:-3] normalized = re.sub(r"[^a-z0-9]+", "", normalized) return normalized def _serialize_script_instance_params(self, script_instance) -> Dict[str, Any]: if not script_instance: return {} blocked_keys = { "enabled", "gameObject", "transform", "world", } out: Dict[str, Any] = {} instance_vars = getattr(script_instance, "__dict__", {}) or {} for key, value in instance_vars.items(): key_str = str(key).strip() if not key_str: continue if key_str.startswith("_") or key_str in blocked_keys: continue serialized = self._serialize_script_value(value, depth=0) if serialized is None and value is not None: continue out[key_str] = serialized return out def _serialize_script_value(self, value: Any, depth: int = 0) -> Any: if depth > 4: return None if value is None: return None if isinstance(value, (bool, int, float, str)): return value if self._is_nodepath_like(value): ref = self._serialize_node_reference(value) return {"__node_ref__": ref} if ref else None vec_value = self._serialize_vec_like(value) if vec_value is not None: return vec_value if isinstance(value, (list, tuple)): out = [] for item in value: serialized = self._serialize_script_value(item, depth + 1) if serialized is None and item is not None: continue out.append(serialized) return out if isinstance(value, dict): out = {} for k, v in value.items(): key = str(k) if not key: continue serialized = self._serialize_script_value(v, depth + 1) if serialized is None and v is not None: continue out[key] = serialized return out return None @staticmethod def _is_nodepath_like(value: Any) -> bool: return ( value is not None and hasattr(value, "getName") and hasattr(value, "getParent") and hasattr(value, "isEmpty") ) def _serialize_node_reference(self, node) -> Dict[str, Any]: if self._is_np_empty(node): return {} ref: Dict[str, Any] = {} node_id = self._node_id_by_pointer.get(id(node)) if node_id: ref["node_id"] = node_id try: node_name = str(node.getName() or "").strip() except Exception: node_name = "" if node_name: ref["node_name"] = node_name if node_id: return ref # Fallback: track ancestor exported node + child name chain. chain: List[str] = [] current = node for _ in range(64): if self._is_np_empty(current): break current_id = self._node_id_by_pointer.get(id(current)) if current_id: ref["ancestor_node_id"] = current_id chain.reverse() if chain: ref["child_name_chain"] = chain break try: current_name = str(current.getName() or "").strip() except Exception: current_name = "" if current_name: chain.append(current_name) try: current = current.getParent() except Exception: break return ref @staticmethod def _serialize_vec_like(value: Any) -> Optional[List[float]]: get_components = [] for attr in ("getX", "getY", "getZ", "getW"): getter = getattr(value, attr, None) if callable(getter): get_components.append(getter) if not get_components: return None out: List[float] = [] for getter in get_components: try: out.append(float(getter())) except Exception: return None return out def _collect_ssbo_subnode_transform_overrides(self, model_node) -> Dict[str, Any]: """ Collect changed subnode transforms from SSBO controller runtime state. This path is required because SSBO edits may not exist in model_node's ordinary child hierarchy. """ ssbo_editor = getattr(self.world, "ssbo_editor", None) controller = getattr(ssbo_editor, "controller", None) if ssbo_editor else None controller_model = getattr(controller, "model", None) if controller else None if not controller or self._is_np_empty(controller_model) or self._is_np_empty(model_node): return {} if not self._nodepath_equivalent(controller_model, model_node): model_source_key = self._nodepath_source_key(model_node) controller_source_key = self._nodepath_source_key(controller_model) same_source = bool(model_source_key and controller_source_key and model_source_key == controller_source_key) same_name = False try: same_name = (controller_model.getName() == model_node.getName()) except Exception: same_name = False if not same_source and not same_name: return {} id_to_object_np = getattr(controller, "id_to_object_np", {}) id_to_name = getattr(controller, "id_to_name", {}) global_transforms = getattr(controller, "global_transforms", []) tree_root_key = str(getattr(controller, "tree_root_key", "0") or "0") if not isinstance(id_to_object_np, dict) or not isinstance(id_to_name, dict): return {} by_index_map: Dict[Tuple[int, ...], List[float]] = {} by_index_base_map: Dict[Tuple[int, ...], List[float]] = {} by_name_map: Dict[Tuple[str, ...], List[float]] = {} by_name_base_map: Dict[Tuple[str, ...], List[float]] = {} for gid, obj_np in id_to_object_np.items(): if self._is_np_empty(obj_np): continue key = id_to_name.get(gid) if key is None: continue key = str(key) if not key: continue index_path = self._parse_ssbo_tree_key_to_index_path(key, tree_root_key) if not index_path: continue try: cur_mat = obj_np.getMat(controller_model) except Exception: try: cur_mat = obj_np.get_mat(controller_model) except Exception: continue base_mat = None try: if int(gid) < len(global_transforms): base_mat = global_transforms[int(gid)] except Exception: base_mat = None if base_mat is not None and self._mat4_objects_close(cur_mat, base_mat): continue mat_values = self._mat4_to_row_major_list(cur_mat) if not mat_values or self._is_identity_matrix_list(mat_values): continue base_values = None if base_mat is not None: try: base_values = self._mat4_to_row_major_list(base_mat) except Exception: base_values = None if base_values and len(base_values) != 16: base_values = None existing = by_index_map.get(index_path) if existing and not self._matrix_list_close(existing, mat_values): # Multiple geoms mapped to same tree node with different mats: # keep first one and warn once. self.report["warnings"].append( f"SSBO 子节点矩阵冲突,保留首个值: {model_node.getName()} path={list(index_path)}" ) continue if not existing: by_index_map[index_path] = mat_values if base_values: by_index_base_map[index_path] = base_values name_path = self._ssbo_index_path_to_name_path(controller, index_path, tree_root_key) if name_path: raw_name_path = tuple(str(v).strip() for v in name_path if str(v).strip()) normalized_name_path = self._normalize_name_path(name_path) export_paths = [] if raw_name_path: export_paths.append(raw_name_path) if normalized_name_path and normalized_name_path != raw_name_path: export_paths.append(normalized_name_path) for p in export_paths: by_name_map[p] = mat_values if base_values and (p not in by_name_base_map): by_name_base_map[p] = base_values if not by_index_map: return {} by_index = [] for path, mat in sorted(by_index_map.items(), key=lambda x: (len(x[0]), x[0])): item = { "path": [int(v) for v in path], "matrix_local_row_major": mat, } base_values = by_index_base_map.get(path) if base_values: item["base_matrix_model_root_row_major"] = base_values by_index.append(item) by_name = [] for path, mat in sorted(by_name_map.items(), key=lambda x: (len(x[0]), x[0])): item = { "path": [str(v) for v in path], "matrix_local_row_major": mat, } base_values = by_name_base_map.get(path) if base_values: item["base_matrix_model_root_row_major"] = base_values by_name.append(item) payload = { "source": "ssbo", # SSBO runtime stores matrices in model-root space # (obj_np.getMat(controller.model)). "matrix_space": "model_root", "by_index": by_index, } if by_name: payload["by_name"] = by_name self.report["warnings"].append( f"SSBO子节点变换已导出: {model_node.getName()} ({len(by_index)} items)" ) return payload @staticmethod def _is_np_empty(np) -> bool: if not np: return True try: return np.isEmpty() except Exception: try: return np.is_empty() except Exception: return True @staticmethod def _nodepath_equivalent(a, b) -> bool: try: if a == b: return True except Exception: pass try: return a.node() == b.node() except Exception: return False @staticmethod def _nodepath_source_key(np) -> str: if not np: return "" for tag_name in ("saved_model_path", "model_path", "original_path", "file"): try: if np.hasTag(tag_name): value = (np.getTag(tag_name) or "").strip() if value: return value.replace("\\", "/").lower() except Exception: continue return "" @staticmethod def _parse_ssbo_tree_key_to_index_path(key: str, tree_root_key: str) -> Tuple[int, ...]: text = str(key).strip() if not text: return tuple() parts = [p for p in text.split("/") if p != ""] if not parts: return tuple() # keys are like "0/1/2"; drop tree root segment. if parts and parts[0] == str(tree_root_key): parts = parts[1:] out: List[int] = [] for p in parts: try: out.append(int(p)) except Exception: return tuple() return tuple(out) def _ssbo_index_path_to_name_path(self, controller, index_path: Tuple[int, ...], tree_root_key: str) -> Tuple[str, ...]: tree_nodes = getattr(controller, "tree_nodes", {}) or {} if str(tree_root_key) not in tree_nodes: return tuple() parent_key = str(tree_root_key) out: List[str] = [] for child_slot in index_path: parent = tree_nodes.get(parent_key) if not isinstance(parent, dict): return tuple() children = parent.get("children", []) if not isinstance(children, list): return tuple() if child_slot < 0 or child_slot >= len(children): return tuple() child_key = children[child_slot] child = tree_nodes.get(child_key, {}) child_name = str(child.get("name", "")) # Build occurrence index among same-name siblings. occur = 0 for i in range(child_slot): prev_key = children[i] prev_name = str(tree_nodes.get(prev_key, {}).get("name", "")) if prev_name == child_name: occur += 1 out.append(f"{child_name}#{occur}") parent_key = child_key return tuple(out) @staticmethod def _normalize_name_path(path: Tuple[str, ...]) -> Tuple[str, ...]: """Drop empty segments only; keep duplicate segments to preserve depth.""" normalized: List[str] = [] for seg in path: s = str(seg).strip() if not s: continue normalized.append(s) return tuple(normalized) def _collect_subnode_transform_overrides(self, root_node, model_source: str) -> Dict[str, Any]: """ Collect changed local transforms for descendants under a model root. Only export deltas against a baseline loaded from model_source to avoid replaying all intrinsic node transforms (which can cause double transforms). """ try: if not root_node or root_node.isEmpty(): return {} except Exception: return {} current_snapshot = self._snapshot_subnode_local_matrices(root_node) if not current_snapshot["by_index"]: return {} baseline_snapshot = self._load_baseline_subnode_snapshot(model_source) if not baseline_snapshot["by_index"] and not baseline_snapshot["by_name"]: # No reliable baseline -> skip overrides to avoid corrupt scale/rotation. self.report["warnings"].append( f"跳过子节点变换导出(缺少基线): {root_node.getName() if hasattr(root_node, 'getName') else 'unknown'}" ) return {} changed_by_index: List[Dict[str, Any]] = [] changed_by_name: List[Dict[str, Any]] = [] for index_path, payload in current_snapshot["by_index"].items(): current_mat = payload["matrix_local_row_major"] name_path = payload["name_path"] baseline_payload = baseline_snapshot["by_index"].get(index_path) if baseline_payload is None: baseline_payload = baseline_snapshot["by_name"].get(name_path) baseline_mat = baseline_payload["matrix_local_row_major"] if baseline_payload else None if baseline_mat is not None and self._matrix_list_close(current_mat, baseline_mat): continue # Safety: identity overrides often indicate hierarchy mismatch and can # destroy source glTF built-in transforms when replayed in web runtime. if self._is_identity_matrix_list(current_mat): continue changed_by_index.append( { "path": [int(v) for v in index_path], "matrix_local_row_major": current_mat, } ) changed_by_name.append( { "path": [str(v) for v in name_path], "matrix_local_row_major": current_mat, } ) if not changed_by_index and not changed_by_name: return {} return { "matrix_space": "local_parent", "by_index": changed_by_index, "by_name": changed_by_name, } def _snapshot_subnode_local_matrices(self, root_node) -> Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]]: snapshot: Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]] = { "by_index": {}, "by_name": {}, } try: if not root_node or root_node.isEmpty(): return snapshot except Exception: return snapshot def walk(node, index_path: Tuple[int, ...], name_path: Tuple[str, ...]) -> None: if index_path: parent = None try: parent = node.getParent() except Exception: parent = None if parent and not parent.isEmpty(): local_mat = node.getMat(parent) else: local_mat = node.getMat() matrix_row_major = self._mat4_to_row_major_list(local_mat) payload = { "index_path": index_path, "name_path": name_path, "matrix_local_row_major": matrix_row_major, } snapshot["by_index"][index_path] = payload snapshot["by_name"][name_path] = payload name_count: Dict[str, int] = {} for child_index, child in enumerate(node.getChildren()): child_name = "" try: child_name = child.getName() or "" except Exception: child_name = "" occur = name_count.get(child_name, 0) name_count[child_name] = occur + 1 walk( child, index_path + (child_index,), name_path + (f"{child_name}#{occur}",), ) try: walk(root_node, tuple(), tuple()) except Exception as exc: self.report["warnings"].append( f"采集子节点矩阵失败: {root_node.getName() if hasattr(root_node, 'getName') else 'unknown'} ({exc})" ) return snapshot def _load_baseline_subnode_snapshot(self, model_source: str) -> Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]]: key = os.path.normpath(model_source or "") if not key: return {"by_index": {}, "by_name": {}} if key in self._baseline_subnode_cache: return self._baseline_subnode_cache[key] snapshot: Dict[str, Dict[Tuple[Any, ...], Dict[str, Any]]] = {"by_index": {}, "by_name": {}} loader = getattr(self.world, "loader", None) if loader is None: self._baseline_subnode_cache[key] = snapshot return snapshot baseline_root = None try: baseline_root = loader.loadModel(key) if baseline_root and not baseline_root.isEmpty(): snapshot = self._snapshot_subnode_local_matrices(baseline_root) except Exception as exc: self.report["warnings"].append(f"加载基线模型失败: {key} ({exc})") snapshot = {"by_index": {}, "by_name": {}} finally: try: if baseline_root and not baseline_root.isEmpty(): baseline_root.removeNode() except Exception: pass self._baseline_subnode_cache[key] = snapshot return snapshot @staticmethod def _matrix_list_close(a: List[float], b: List[float], eps: float = 1e-5) -> bool: if (not isinstance(a, list)) or (not isinstance(b, list)) or len(a) != 16 or len(b) != 16: return False for i in range(16): try: if abs(float(a[i]) - float(b[i])) > eps: return False except Exception: return False return True @staticmethod def _mat4_objects_close(a, b, eps: float = 1e-5) -> bool: if a is None or b is None: return False try: for r in range(4): for c in range(4): if abs(float(a.getCell(r, c)) - float(b.getCell(r, c))) > eps: return False return True except Exception: return False @staticmethod def _is_identity_matrix_list(values: List[float], eps: float = 1e-6) -> bool: if not isinstance(values, list) or len(values) != 16: return False identity = [ 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, ] for i in range(16): try: if abs(float(values[i]) - identity[i]) > eps: return False except Exception: return False return True 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) entry = { "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)), }, } return self._attach_lookup_metadata(node, entry) 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 entry = { "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, }, } return self._attach_lookup_metadata(node, entry) def _resolve_model_source(self, node) -> Tuple[Optional[str], str]: tags = ["model_path", "saved_model_path", "original_path", "asset_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 _identity_mat4_row_major() -> List[float]: 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) @staticmethod def _safe_get_python_tag(node, tag_name: str) -> Any: try: if hasattr(node, "hasPythonTag") and node.hasPythonTag(tag_name): return node.getPythonTag(tag_name) except Exception: pass try: value = node.getPythonTag(tag_name) return value except Exception: return None @staticmethod def _safe_get_tag_value(node, tag_name: str) -> str: try: if node.hasTag(tag_name): return (node.getTag(tag_name) or "").strip() except Exception: return "" return "" 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") readme_path = os.path.join(self._output_root, "PREVIEW.txt") sh_content = ( "#!/usr/bin/env bash\n" "set -e\n" "cd \"$(dirname \"$0\")\"\n" "echo \"Serving WebGL preview at http://127.0.0.1:8000\"\n" "python3 -m http.server 8000\n" ) bat_content = ( "@echo off\n" "cd /d \"%~dp0\"\n" "echo Serving WebGL preview at http://127.0.0.1:8000\n" "python -m http.server 8000\n" ) readme_content = ( "EG WebGL Preview\n" "================\n\n" "Do not open index.html directly with file:// . Modern browsers will block ES modules and fetch requests.\n\n" "Use one of these methods instead:\n" "1. Run run_preview.sh (Linux/macOS) or run_preview.bat (Windows)\n" "2. Or open a terminal in this folder and run:\n" " python3 -m http.server 8000\n\n" "Then open:\n" "http://127.0.0.1:8000/index.html\n" "http://127.0.0.1:8000/frontend_demo.html\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) with open(readme_path, "w", encoding="utf-8") as f: f.write(readme_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)