diff --git a/project/webgl_packager.py b/project/webgl_packager.py index 437a33f8..32ac74c0 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -50,6 +50,10 @@ class WebGLPackager: 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] = { @@ -123,6 +127,7 @@ class WebGLPackager: file_mapping = { "index.html": "index.html", + "frontend_demo.html": "frontend_demo.html", "style.css": "style.css", "viewer.js": os.path.join("js", "viewer.js"), } @@ -374,6 +379,16 @@ class WebGLPackager: 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]] = [] @@ -400,8 +415,10 @@ class WebGLPackager: manifest = { "meta": { "format_version": "1.0", + "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", @@ -1022,6 +1039,180 @@ class WebGLPackager: 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: @@ -1079,7 +1270,7 @@ class WebGLPackager: entry["texture_overrides"] = textures if subnode_overrides: entry["subnode_overrides"] = subnode_overrides - return entry + return self._attach_lookup_metadata(node, entry) def _extract_animation_settings(self, node, model_source: str) -> Dict[str, Any]: has_animation_hint = False @@ -1893,7 +2084,7 @@ class WebGLPackager: intensity = max(0.0, energy * self.ENERGY_TO_INTENSITY_SCALE) - return { + entry = { "id": node_id, "name": node_name, "kind": kind, @@ -1907,6 +2098,7 @@ class WebGLPackager: "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)) @@ -1942,7 +2134,7 @@ class WebGLPackager: except Exception: pass - return { + entry = { "id": node_id, "name": node_name, "kind": "ground", @@ -1959,6 +2151,7 @@ class WebGLPackager: "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"] @@ -2382,14 +2575,40 @@ class WebGLPackager: 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\npython3 -m http.server 8000\n" - bat_content = "@echo off\npython -m http.server 8000\n" + 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) diff --git a/templates/webgl/frontend_demo.html b/templates/webgl/frontend_demo.html new file mode 100644 index 00000000..78091244 --- /dev/null +++ b/templates/webgl/frontend_demo.html @@ -0,0 +1,579 @@ + + +
+ + +./index.html
+ Selectors: prefer publicId, then id, then exact name. Duplicate names return a structured error.
Coordinates: API defaults to { coordSystem: "panda", space: "local" }. Use coordSystem: "three" to work in Three.js axes. Local means transform relative to parent.
// Same-page JS API
+const viewer = window.EGWebGLViewer;
+await viewer.ready;
+
+const nodes = viewer.nodes.list();
+const hero = nodes.find((node) => node.kind === "model");
+
+viewer.animation.play({ publicId: hero.publicId }, {
+ clip: "Walk",
+ loop: true,
+ restart: true,
+});
+
+viewer.nodes.translate({ publicId: hero.publicId }, { x: 1, y: 0, z: 0 });
+viewer.nodes.setTransform(
+ { publicId: hero.publicId },
+ { rotation: { h: 45, p: 0, r: 0 } },
+ { coordSystem: "panda", space: "local" },
+);
+
+viewer.events.on("animationFinished", (payload) => {
+ console.log("animation finished", payload);
+});
+ // iframe + postMessage
+const iframe = document.querySelector("iframe");
+
+iframe.contentWindow.postMessage({
+ source: "eg-frontend",
+ type: "handshake",
+ id: "hello-1",
+}, iframe.src ? new URL(iframe.src).origin : "*");
+
+window.addEventListener("message", (event) => {
+ if (!event.data || event.data.source !== "eg-webgl") return;
+ console.log("viewer message", event.data);
+});
+
+iframe.contentWindow.postMessage({
+ source: "eg-frontend",
+ type: "command",
+ id: "cmd-1",
+ command: "animation.play",
+ payload: {
+ selector: { publicId: "model:asset-guid:root" },
+ options: { clip: "Idle", loop: true },
+ },
+}, iframe.src ? new URL(iframe.src).origin : "*");
+ Message protocol: commands use { source: "eg-frontend", id, type: "command", command, payload }. Responses/events use { source: "eg-webgl", type, replyTo?, event?, ok?, result?, error? }.
Events: ready, error, animationFinished, stateChanged.
Demo page: after export, open frontend_demo.html to see a working iframe host example.