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 @@ + + + + + + EG WebGL Frontend Demo + + + +
+ + +
+
+ Embedded Viewer + iframe src: ./index.html +
+ +
+
+ + + + diff --git a/templates/webgl/index.html b/templates/webgl/index.html index de20292c..361525c9 100644 --- a/templates/webgl/index.html +++ b/templates/webgl/index.html @@ -10,7 +10,104 @@
Loading scene...
+
+ Frontend Integration +
+

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.

+
+
- + diff --git a/templates/webgl/style.css b/templates/webgl/style.css index 85e3d98a..c241d42f 100644 --- a/templates/webgl/style.css +++ b/templates/webgl/style.css @@ -59,3 +59,62 @@ body, border-color: rgba(242, 120, 120, 0.65); color: var(--err); } + +.docs-panel { + position: fixed; + top: 16px; + right: 16px; + width: min(540px, calc(100vw - 32px)); + max-height: min(78vh, 860px); + overflow: auto; + background: rgba(11, 15, 24, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + backdrop-filter: blur(10px); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); +} + +.docs-panel summary { + cursor: pointer; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + user-select: none; +} + +.docs-content { + padding: 0 14px 14px; + font-size: 12px; + line-height: 1.55; + color: rgba(214, 221, 233, 0.92); +} + +.docs-content p { + margin: 0 0 10px; +} + +.docs-content pre { + margin: 0 0 12px; + padding: 12px; + overflow: auto; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.docs-content code { + font-family: "SFMono-Regular", "Consolas", "Liberation Mono", monospace; + font-size: 12px; +} + +@media (max-width: 720px) { + .docs-panel { + top: auto; + right: 12px; + bottom: 72px; + left: 12px; + width: auto; + max-height: 48vh; + } +} diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index 333e0be3..df568e0c 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -1,5 +1,9 @@ const statusEl = document.getElementById("status"); const canvas = document.getElementById("scene-canvas"); +const VIEWER_MODULE_URL = new URL(import.meta.url); +const PACK_BASE_URL = /\/js\/viewer\.js$/i.test(VIEWER_MODULE_URL.pathname) + ? new URL("../", VIEWER_MODULE_URL) + : new URL("./", VIEWER_MODULE_URL); function setStatus(message, level = "warn") { if (!statusEl) return; @@ -7,6 +11,19 @@ function setStatus(message, level = "warn") { statusEl.className = `status ${level}`; } +function resolvePackUrl(relativePath) { + return new URL(relativePath, PACK_BASE_URL).href; +} + +function resolveAssetUrl(rawUrl) { + const text = String(rawUrl || "").trim(); + if (!text) return text; + if (/^(?:[a-z]+:)?\/\//i.test(text) || /^(?:data|blob|file):/i.test(text) || text.startsWith("/")) { + return text; + } + return resolvePackUrl(text.replace(/^\.\//, "")); +} + function rowMajorToMatrix4(THREE, m) { const mat = new THREE.Matrix4(); mat.set( @@ -103,7 +120,7 @@ function applyTextureOverrides(THREE, root, textureOverrides, textureLoader) { if (texBySlot.has(slot)) continue; try { - const tex = textureLoader.load(item.uri); + const tex = textureLoader.load(resolveAssetUrl(item.uri)); tex.flipY = false; texBySlot.set(slot, tex); } catch (err) { @@ -1066,9 +1083,9 @@ async function tryCreateBloomComposer(THREE, renderer, scene, camera, bloomConfi try { const [{ EffectComposer }, { RenderPass }, { UnrealBloomPass }] = await Promise.all([ - import("../vendor/EffectComposer.js"), - import("../vendor/RenderPass.js"), - import("../vendor/UnrealBloomPass.js"), + import(resolvePackUrl("vendor/EffectComposer.js")), + import(resolvePackUrl("vendor/RenderPass.js")), + import(resolvePackUrl("vendor/UnrealBloomPass.js")), ]); const composer = new EffectComposer(renderer); @@ -1102,7 +1119,7 @@ function applySkyboxConfig(THREE, scene, env, textureLoader) { return new Promise((resolve) => { textureLoader.load( - sky.uri, + resolveAssetUrl(sky.uri), (tex) => { if ("colorSpace" in tex) { tex.colorSpace = THREE.SRGBColorSpace; @@ -1241,57 +1258,302 @@ function pickAnimationClip(clips, requestedName) { return clips[0]; } -function setupModelAnimation(THREE, root, gltf, animationConfig, nodeName) { +function createViewerError(code, message, details = null) { + const error = new Error(String(message || code || "Viewer error")); + error.name = "EGWebGLViewerError"; + error.code = String(code || "viewer_error"); + if (details !== null && details !== undefined) { + error.details = details; + } + return error; +} + +function serializeViewerError(error, fallbackCode = "viewer_error") { + if (!error) { + return { code: fallbackCode, message: "Unknown error" }; + } + + return { + code: String(error.code || fallbackCode), + message: String(error.message || "Unknown error"), + details: error.details ?? null, + }; +} + +function cloneJsonSafe(value, fallback = null) { + if (value === undefined) return fallback; + try { + return JSON.parse(JSON.stringify(value)); + } catch (err) { + return fallback; + } +} + +function makeDeferred() { + const deferred = { + promise: null, + resolve: null, + reject: null, + }; + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + return deferred; +} + +function createEventHub() { + const listeners = new Map(); + + return { + on(eventName, handler) { + const key = String(eventName || "").trim(); + if (!key || typeof handler !== "function") { + return () => {}; + } + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key).add(handler); + return () => this.off(key, handler); + }, + off(eventName, handler) { + const key = String(eventName || "").trim(); + const set = listeners.get(key); + if (!set) return false; + const removed = set.delete(handler); + if (set.size === 0) { + listeners.delete(key); + } + return removed; + }, + emit(eventName, payload) { + const key = String(eventName || "").trim(); + if (!key) return; + const set = listeners.get(key); + if (!set || set.size === 0) return; + for (const handler of Array.from(set)) { + try { + handler(payload); + } catch (err) { + console.warn("[EGWebGLViewer] Event handler failed:", key, err); + } + } + }, + }; +} + +function createAnimationController(THREE, root, gltf, animationConfig, nodeInfo, emitEvent) { const config = animationConfig && typeof animationConfig === "object" ? animationConfig : {}; if (config.enabled === false) { - return { mixer: null, clipName: "", mode: "stop", started: false }; + return null; } const clips = Array.isArray(gltf?.animations) ? gltf.animations : []; if (clips.length === 0) { if (config.clip_name || config.mode) { - console.warn("[WebGLPack] Animation requested but no clips found:", nodeName || "(unnamed)"); + console.warn("[WebGLPack] Animation requested but no clips found:", nodeInfo?.name || "(unnamed)"); } - return { mixer: null, clipName: "", mode: "stop", started: false }; - } - - const mode = String(config.mode || "loop").toLowerCase(); - const clip = pickAnimationClip(clips, config.clip_name); - if (!clip) { - return { mixer: null, clipName: "", mode, started: false }; + return null; } const mixer = new THREE.AnimationMixer(root); - const action = mixer.clipAction(clip); - const speedRaw = Number(config.speed ?? 1.0); - const speed = Number.isFinite(speedRaw) && Math.abs(speedRaw) > 1e-4 ? speedRaw : 1.0; + const clipNames = clips.map((clip) => String(clip?.name || "")).filter((name) => !!name); + const initialMode = String(config.mode || "loop").toLowerCase(); + const initialSpeedRaw = Number(config.speed ?? 1.0); + const initialSpeed = Number.isFinite(initialSpeedRaw) && Math.abs(initialSpeedRaw) > 1e-4 + ? initialSpeedRaw + : 1.0; - action.enabled = true; - action.clampWhenFinished = (mode === "play"); - if (mode === "play") { - action.setLoop(THREE.LoopOnce, 1); - } else { - action.setLoop(THREE.LoopRepeat, Infinity); - } - action.setEffectiveTimeScale(speed); - - const shouldPlay = Boolean(config.autoplay ?? (mode !== "stop")); - const startTimeRaw = Number(config.start_time ?? 0); - const duration = Math.max(0.0001, Number(clip.duration || 0.0001)); - const startTime = Number.isFinite(startTimeRaw) ? (startTimeRaw % duration) : 0; - - action.play(); - action.time = startTime; - if (!shouldPlay || mode === "stop" || mode === "pause") { - action.paused = true; - } - - return { + const state = { mixer, - clipName: String(clip.name || ""), - mode, - started: shouldPlay && mode !== "stop" && mode !== "pause", + clips, + clipNames, + currentClip: null, + currentAction: null, + speed: initialSpeed, + paused: false, + playing: false, + loop: true, + lastMode: initialMode, }; + + const normalizeTimeForClip = (clip, timeValue) => { + const duration = Math.max(0.0001, Number(clip?.duration || 0.0001)); + const time = Number(timeValue ?? 0); + if (!Number.isFinite(time)) return 0; + if (time < 0) return 0; + return duration > 0 ? (time % duration) : 0; + }; + + const selectClip = (requestedName) => { + const clip = pickAnimationClip(clips, requestedName || state.currentClip?.name || config.clip_name); + if (!clip) { + throw createViewerError("animation_clip_not_found", "Animation clip not found", { + requested: requestedName || config.clip_name || "", + availableClips: clipNames, + node: nodeInfo?.publicId || nodeInfo?.id || nodeInfo?.name || "", + }); + } + return clip; + }; + + const applyAction = (requestedName, options = {}) => { + const clip = selectClip(requestedName); + const requestedLoop = Object.prototype.hasOwnProperty.call(options, "loop") + ? !!options.loop + : !["play", "stop", "pause"].includes(String(options.mode || state.lastMode || initialMode).toLowerCase()); + const restart = Object.prototype.hasOwnProperty.call(options, "restart") ? !!options.restart : true; + const pauseAfterSetup = !!options.pause; + const startTime = normalizeTimeForClip(clip, options.startTime); + + mixer.stopAllAction(); + const action = mixer.clipAction(clip); + action.enabled = true; + action.clampWhenFinished = !requestedLoop; + action.setLoop(requestedLoop ? THREE.LoopRepeat : THREE.LoopOnce, requestedLoop ? Infinity : 1); + action.setEffectiveTimeScale(state.speed); + if (restart) { + action.reset(); + } + action.play(); + action.time = startTime; + action.paused = pauseAfterSetup; + + state.currentClip = clip; + state.currentAction = action; + state.paused = pauseAfterSetup; + state.playing = !pauseAfterSetup; + state.loop = requestedLoop; + state.lastMode = requestedLoop ? "loop" : "play"; + + return action; + }; + + const ensureCurrentAction = (requestedName = "") => { + if (state.currentAction && state.currentClip) { + if (!requestedName || String(state.currentClip.name || "") === String(requestedName)) { + return state.currentAction; + } + } + return applyAction(requestedName, { + restart: true, + pause: true, + loop: state.loop, + mode: state.lastMode, + startTime: 0, + }); + }; + + const getSnapshot = () => { + const clip = state.currentClip; + const action = state.currentAction; + return { + available: true, + clip: String(clip?.name || ""), + clipNames: clipNames.slice(), + duration: Math.max(0, Number(clip?.duration || 0)), + time: action ? Math.max(0, Number(action.time || 0)) : 0, + paused: !!state.paused, + playing: !!state.playing, + speed: Number(state.speed || 1), + loop: !!state.loop, + }; + }; + + mixer.addEventListener("finished", () => { + state.playing = false; + state.paused = false; + if (typeof emitEvent === "function") { + emitEvent("animationFinished", { + node: { + id: nodeInfo?.id || "", + publicId: nodeInfo?.publicId || "", + name: nodeInfo?.name || "", + kind: nodeInfo?.kind || "", + }, + animation: getSnapshot(), + }); + } + }); + + const controller = { + mixer, + clips, + getSnapshot, + play(options = {}) { + const opts = (options && typeof options === "object") ? options : {}; + if (Object.prototype.hasOwnProperty.call(opts, "speed")) { + this.setSpeed(opts.speed); + } + const action = applyAction(opts.clip, { + loop: Object.prototype.hasOwnProperty.call(opts, "loop") ? !!opts.loop : state.loop, + restart: Object.prototype.hasOwnProperty.call(opts, "restart") ? !!opts.restart : true, + pause: false, + startTime: opts.startTime, + mode: Object.prototype.hasOwnProperty.call(opts, "loop") && !opts.loop ? "play" : state.lastMode, + }); + action.paused = false; + state.paused = false; + state.playing = true; + return getSnapshot(); + }, + pause() { + const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || ""); + action.paused = true; + state.paused = true; + state.playing = false; + return getSnapshot(); + }, + stop(options = {}) { + const opts = (options && typeof options === "object") ? options : {}; + const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || ""); + action.stop(); + if (opts.reset !== false) { + action.reset(); + action.time = 0; + } + action.paused = true; + state.paused = false; + state.playing = false; + return getSnapshot(); + }, + seek(timeValue) { + const action = ensureCurrentAction(state.currentClip?.name || config.clip_name || ""); + action.time = normalizeTimeForClip(state.currentClip, timeValue); + return getSnapshot(); + }, + setSpeed(speedValue) { + const numericSpeed = Number(speedValue); + if (!Number.isFinite(numericSpeed) || Math.abs(numericSpeed) < 1e-4) { + throw createViewerError("invalid_animation_speed", "Animation speed must be a finite non-zero number", { + speed: speedValue, + }); + } + state.speed = numericSpeed; + if (state.currentAction) { + state.currentAction.setEffectiveTimeScale(state.speed); + } + return getSnapshot(); + }, + }; + + const shouldAutoplay = Boolean(config.autoplay ?? !["stop", "pause"].includes(initialMode)); + const initialClip = pickAnimationClip(clips, config.clip_name); + if (initialClip && (shouldAutoplay || initialMode === "pause" || initialMode === "stop")) { + applyAction(initialClip.name || "", { + loop: initialMode === "loop", + restart: true, + pause: !shouldAutoplay || initialMode === "pause" || initialMode === "stop", + startTime: config.start_time, + mode: initialMode, + }); + if (initialMode === "stop") { + controller.stop({ reset: true }); + } + } + + return controller; } function toFiniteNumber(value, fallback) { @@ -1491,15 +1753,264 @@ function resolveScriptNodeRef(refValue, nodeMap, nodeNameLookup) { return null; } -function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, ownerName) { +function vec3ToPlain(vec) { + return [ + toFiniteNumber(vec?.x, 0), + toFiniteNumber(vec?.y, 0), + toFiniteNumber(vec?.z, 0), + ]; +} + +function normalizeTripletInput(input, fallback, keyAliases = ["x", "y", "z"]) { + const out = Array.isArray(fallback) ? fallback.slice(0, 3) : [0, 0, 0]; + while (out.length < 3) out.push(0); + + if (Array.isArray(input)) { + for (let i = 0; i < 3; i += 1) { + if (input[i] !== undefined) { + out[i] = toFiniteNumber(input[i], out[i]); + } + } + return out; + } + + if (input && typeof input === "object") { + for (let i = 0; i < 3; i += 1) { + const aliases = Array.isArray(keyAliases?.[i]) ? keyAliases[i] : [keyAliases[i]]; + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(input, alias)) { + out[i] = toFiniteNumber(input[alias], out[i]); + break; + } + } + } + } + + return out; +} + +function pandaPositionToThreeArray(position) { + const value = normalizeTripletInput(position, [0, 0, 0]); + return [value[0], value[2], -value[1]]; +} + +function threePositionToPandaArray(position) { + const value = normalizeTripletInput(position, [0, 0, 0]); + return [value[0], -value[2], value[1]]; +} + +function pandaScaleToThreeArray(scale) { + const value = normalizeTripletInput(scale, [1, 1, 1]); + return [value[0], value[2], value[1]]; +} + +function threeScaleToPandaArray(scale) { + const value = normalizeTripletInput(scale, [1, 1, 1]); + return [value[0], value[2], value[1]]; +} + +function pandaHprToThreeQuaternion(THREE, hpr) { + const [h, p, r] = normalizeTripletInput(hpr, [0, 0, 0], [["h", "x"], ["p", "y"], ["r", "z"]]); + return new THREE.Quaternion().setFromEuler(new THREE.Euler( + THREE.MathUtils.degToRad(p), + THREE.MathUtils.degToRad(h), + THREE.MathUtils.degToRad(-r), + "YXZ", + )); +} + +function threeQuaternionToPandaHpr(THREE, quaternion) { + const euler = new THREE.Euler().setFromQuaternion(quaternion, "YXZ"); + return [ + THREE.MathUtils.radToDeg(euler.y), + THREE.MathUtils.radToDeg(euler.x), + -THREE.MathUtils.radToDeg(euler.z), + ]; +} + +function threeQuaternionToEulerDegrees(THREE, quaternion, order = "XYZ") { + const euler = new THREE.Euler().setFromQuaternion(quaternion, order); + return [ + THREE.MathUtils.radToDeg(euler.x), + THREE.MathUtils.radToDeg(euler.y), + THREE.MathUtils.radToDeg(euler.z), + ]; +} + +function threeEulerDegreesToQuaternion(THREE, rotation, order = "XYZ") { + const [x, y, z] = normalizeTripletInput(rotation, [0, 0, 0]); + return new THREE.Quaternion().setFromEuler(new THREE.Euler( + THREE.MathUtils.degToRad(x), + THREE.MathUtils.degToRad(y), + THREE.MathUtils.degToRad(z), + order, + )); +} + +function readObjectTransformState(THREE, object3d, coordSystem = "panda", space = "local") { + const coord = String(coordSystem || "panda").toLowerCase(); + const refSpace = String(space || "local").toLowerCase(); + const position = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + const scale = new THREE.Vector3(); + + if (refSpace === "world") { + object3d.getWorldPosition(position); + object3d.getWorldQuaternion(quaternion); + object3d.getWorldScale(scale); + } else { + position.copy(object3d.position); + quaternion.copy(object3d.quaternion); + scale.copy(object3d.scale); + } + + if (coord === "three") { + return { + position: vec3ToPlain(position), + rotation: threeQuaternionToEulerDegrees(THREE, quaternion, "XYZ"), + scale: vec3ToPlain(scale), + }; + } + + return { + position: threePositionToPandaArray(vec3ToPlain(position)), + rotation: threeQuaternionToPandaHpr(THREE, quaternion), + scale: threeScaleToPandaArray(vec3ToPlain(scale)), + }; +} + +function setObjectWorldTransform(THREE, object3d, worldPosition, worldQuaternion, worldScale) { + const parent = object3d.parent; + if (!parent) { + object3d.position.copy(worldPosition); + object3d.quaternion.copy(worldQuaternion); + object3d.scale.copy(worldScale); + markObjectTransformDirty(object3d); + return; + } + + parent.updateMatrixWorld(true); + + const parentPosition = new THREE.Vector3(); + const parentQuaternion = new THREE.Quaternion(); + const parentScale = new THREE.Vector3(); + parent.matrixWorld.decompose(parentPosition, parentQuaternion, parentScale); + + const localPosition = worldPosition.clone(); + parent.worldToLocal(localPosition); + + const parentQuaternionInverse = parentQuaternion.clone().invert(); + const localQuaternion = parentQuaternionInverse.multiply(worldQuaternion.clone()); + + const localScale = new THREE.Vector3( + parentScale.x !== 0 ? worldScale.x / parentScale.x : worldScale.x, + parentScale.y !== 0 ? worldScale.y / parentScale.y : worldScale.y, + parentScale.z !== 0 ? worldScale.z / parentScale.z : worldScale.z, + ); + + object3d.position.copy(localPosition); + object3d.quaternion.copy(localQuaternion); + object3d.scale.copy(localScale); + markObjectTransformDirty(object3d); +} + +function applyTransformPatchToEntry(THREE, entry, patch, options = {}) { + const object3d = entry?.obj; + if (!object3d || !object3d.isObject3D) { + throw createViewerError("node_not_ready", "Target node is not ready for transform updates", { + node: entry?.publicId || entry?.id || "", + }); + } + + const coordSystem = String(options.coordSystem || "panda").toLowerCase(); + const space = String(options.space || "local").toLowerCase(); + if (!["panda", "three"].includes(coordSystem)) { + throw createViewerError("invalid_coord_system", "coordSystem must be 'panda' or 'three'", { + coordSystem, + }); + } + if (!["local", "world"].includes(space)) { + throw createViewerError("invalid_space", "space must be 'local' or 'world'", { + space, + }); + } + + const current = readObjectTransformState(THREE, object3d, coordSystem, space); + const nextPosition = Object.prototype.hasOwnProperty.call(patch || {}, "position") + ? normalizeTripletInput(patch.position, current.position) + : current.position.slice(); + const rotationAliases = coordSystem === "panda" + ? [["h", "x"], ["p", "y"], ["r", "z"]] + : [["x"], ["y"], ["z"]]; + const nextRotation = Object.prototype.hasOwnProperty.call(patch || {}, "rotation") + ? normalizeTripletInput(patch.rotation, current.rotation, rotationAliases) + : current.rotation.slice(); + const nextScale = Object.prototype.hasOwnProperty.call(patch || {}, "scale") + ? normalizeTripletInput(patch.scale, current.scale) + : current.scale.slice(); + + const targetPositionArray = coordSystem === "three" + ? nextPosition + : pandaPositionToThreeArray(nextPosition); + const targetScaleArray = coordSystem === "three" + ? nextScale + : pandaScaleToThreeArray(nextScale); + const targetQuaternion = coordSystem === "three" + ? threeEulerDegreesToQuaternion(THREE, nextRotation, "XYZ") + : pandaHprToThreeQuaternion(THREE, nextRotation); + + const targetPosition = new THREE.Vector3(...targetPositionArray); + const targetScale = new THREE.Vector3(...targetScaleArray); + + if (space === "world") { + setObjectWorldTransform(THREE, object3d, targetPosition, targetQuaternion, targetScale); + } else { + object3d.position.copy(targetPosition); + object3d.quaternion.copy(targetQuaternion); + object3d.scale.copy(targetScale); + markObjectTransformDirty(object3d); + } + + object3d.updateMatrixWorld(true); + return readObjectTransformState(THREE, object3d, coordSystem, space); +} + +function translateEntry(THREE, entry, delta, options = {}) { + const object3d = entry?.obj; + if (!object3d || !object3d.isObject3D) { + throw createViewerError("node_not_ready", "Target node is not ready for movement", { + node: entry?.publicId || entry?.id || "", + }); + } + + const coordSystem = String(options.coordSystem || "panda").toLowerCase(); + const space = String(options.space || "local").toLowerCase(); + const current = readObjectTransformState(THREE, object3d, coordSystem, space); + const numericDelta = normalizeTripletInput(delta, [0, 0, 0]); + const nextPosition = [ + current.position[0] + numericDelta[0], + current.position[1] + numericDelta[1], + current.position[2] + numericDelta[2], + ]; + return applyTransformPatchToEntry(THREE, entry, { position: nextPosition }, { coordSystem, space }); +} + +function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, ownerName) { + const object3d = entry?.obj; + if (!object3d) return null; const scriptName = String(scriptCfg?.name ?? "").trim(); if (!scriptName) return null; const key = normalizeScriptName(scriptName); const params = (scriptCfg?.params && typeof scriptCfg.params === "object") ? scriptCfg.params : {}; const enabled = toBoolean(scriptCfg?.enabled, true); + const withOwnerState = (state) => ({ + ownerId: entry?.id || "", + ownerPublicId: entry?.publicId || "", + ...state, + }); if (key === "moverscript" || key === "mover") { - const state = { + const state = withOwnerState({ name: scriptName, key, enabled, @@ -1520,12 +2031,12 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, applyPandaAxisDeltaToThreeLocal(object3d, this.axis, delta); markObjectTransformDirty(object3d); }, - }; + }); return state; } if (key === "rotatorscript" || key === "rotator") { - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1536,11 +2047,11 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, object3d.rotation.y += THREE.MathUtils.degToRad(this.speedDeg * dt); markObjectTransformDirty(object3d); }, - }; + }); } if (key === "scalerscript" || key === "scaler") { - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1562,12 +2073,12 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, } markObjectTransformDirty(object3d); }, - }; + }); } if (key === "colorchangerscript" || key === "colorchanger") { ensureUniqueMaterialsForObject(object3d); - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1617,11 +2128,11 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, } applyColorToObject(object3d, rgba); }, - }; + }); } if (key === "bouncerscript" || key === "bouncer") { - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1647,14 +2158,14 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, object3d.position.y = this.originalHeight + offset; markObjectTransformDirty(object3d); }, - }; + }); } if (key === "followerscript" || key === "follower") { const targetHint = Object.prototype.hasOwnProperty.call(params, "target") ? params.target : (Object.prototype.hasOwnProperty.call(params, "target_ref") ? params.target_ref : null); - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1696,11 +2207,11 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, object3d.lookAt(this.tempTargetPos); markObjectTransformDirty(object3d); }, - }; + }); } if (key === "comboanimatorscript" || key === "comboanimator") { - return { + return withOwnerState({ name: scriptName, key, enabled, @@ -1714,7 +2225,7 @@ function createScriptState(THREE, scriptCfg, object3d, nodeMap, nodeNameLookup, object3d.position.y = this.originalHeight + Math.abs(Math.sin(this.time * 3.0)); markObjectTransformDirty(object3d); }, - }; + }); } console.warn("[WebGLPack] Unsupported script in Web viewer:", { @@ -1730,22 +2241,28 @@ function buildScriptRuntimeStates(THREE, nodes, nodeMap) { const nodeNameLookup = buildNodeNameLookup(nodeMap); for (const node of nodes) { - const obj = nodeMap.get(node?.id); - if (!obj) continue; - const scripts = Array.isArray(node?.scripts) ? node.scripts : []; + const entry = node; + const obj = entry?.obj || nodeMap.get(entry?.id); + if (!obj || !entry) continue; + entry.scriptStates = []; + entry.unsupportedScripts = []; + + const scripts = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : []; for (const scriptCfg of scripts) { const state = createScriptState( THREE, scriptCfg, - obj, + entry, nodeMap, nodeNameLookup, - node?.name || node?.id || "", + entry?.name || entry?.id || "", ); if (state) { + entry.scriptStates.push(state); runtimes.push(state); } else { unsupportedCount += 1; + entry.unsupportedScripts.push(String(scriptCfg?.name || "")); } } } @@ -1753,6 +2270,599 @@ function buildScriptRuntimeStates(THREE, nodes, nodeMap) { return { runtimes, unsupportedCount }; } +function makeRuntimeEntry(node, obj) { + return { + id: String(node?.id || ""), + publicId: String(node?.public_id || node?.lookup?.public_id || ""), + name: String(node?.name || node?.id || "node"), + kind: String(node?.kind || "node"), + path: String(node?.path || node?.lookup?.path || ""), + parentId: node?.parent_id ? String(node.parent_id) : "", + parentPublicId: node?.parent_public_id ? String(node.parent_public_id) : "", + nodeData: node, + obj, + modelRoot: node?.kind === "model" ? null : obj, + modelLoaded: node?.kind !== "model", + modelError: "", + animationController: null, + scriptStates: [], + unsupportedScripts: [], + }; +} + +function registerRuntimeEntry(runtime, entry) { + runtime.nodeOrder.push(entry); + runtime.nodesById.set(entry.id, entry); + if (entry.publicId) { + runtime.nodesByPublicId.set(entry.publicId, entry); + } + if (entry.name) { + if (!runtime.nodesByName.has(entry.name)) { + runtime.nodesByName.set(entry.name, []); + } + runtime.nodesByName.get(entry.name).push(entry); + } +} + +function buildNodeSelectorPayload(entry) { + return { + id: entry?.id || "", + publicId: entry?.publicId || "", + name: entry?.name || "", + kind: entry?.kind || "", + path: entry?.path || "", + }; +} + +function buildAnimationState(entry) { + if (entry?.animationController) { + return entry.animationController.getSnapshot(); + } + const cfg = (entry?.nodeData?.animation && typeof entry.nodeData.animation === "object") ? entry.nodeData.animation : {}; + return { + available: false, + clip: String(cfg.clip_name || ""), + clipNames: [], + duration: 0, + time: 0, + paused: String(cfg.mode || "").toLowerCase() === "pause", + playing: false, + speed: toFiniteNumber(cfg.speed, 1), + loop: String(cfg.mode || "loop").toLowerCase() === "loop", + }; +} + +function buildScriptStates(entry) { + const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : []; + const runtimeByKey = new Map(); + for (const state of entry?.scriptStates || []) { + runtimeByKey.set(normalizeScriptName(state?.name || state?.key || ""), state); + } + + const out = []; + for (const scriptCfg of declared) { + const name = String(scriptCfg?.name || ""); + const key = normalizeScriptName(name); + const runtimeState = runtimeByKey.get(key); + out.push({ + name, + key, + enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true), + supported: !!runtimeState, + }); + if (runtimeState) { + runtimeByKey.delete(key); + } + } + + for (const runtimeState of runtimeByKey.values()) { + out.push({ + name: String(runtimeState?.name || ""), + key: normalizeScriptName(runtimeState?.name || runtimeState?.key || ""), + enabled: !!runtimeState.enabled, + supported: true, + }); + } + + return out; +} + +function buildNodeSummary(entry) { + return { + id: entry?.id || "", + publicId: entry?.publicId || "", + name: entry?.name || "", + kind: entry?.kind || "", + path: entry?.path || "", + parentId: entry?.parentId || "", + parentPublicId: entry?.parentPublicId || "", + visible: !!entry?.obj?.visible, + modelLoaded: !!entry?.modelLoaded, + hasAnimation: !!entry?.animationController || !!entry?.nodeData?.animation, + scriptCount: buildScriptStates(entry).length, + }; +} + +function buildNodeState(THREE, entry) { + return { + ...buildNodeSummary(entry), + transform: { + local: { + panda: readObjectTransformState(THREE, entry.obj, "panda", "local"), + three: readObjectTransformState(THREE, entry.obj, "three", "local"), + }, + world: { + panda: readObjectTransformState(THREE, entry.obj, "panda", "world"), + three: readObjectTransformState(THREE, entry.obj, "three", "world"), + }, + }, + animation: buildAnimationState(entry), + scripts: buildScriptStates(entry), + modelError: entry?.modelError || "", + }; +} + +function buildSceneInfo(runtime) { + return { + ready: !!runtime?.isReady, + meta: cloneJsonSafe(runtime?.manifest?.meta, {}), + coordinate: cloneJsonSafe(runtime?.manifest?.coordinate, {}), + environment: cloneJsonSafe(runtime?.manifest?.environment, {}), + nodeCount: runtime?.nodeOrder?.length || 0, + unsupportedScriptCount: runtime?.unsupportedScriptCount || 0, + nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)), + }; +} + +function assertRuntimeReady(runtime) { + if (!runtime?.isReady) { + throw createViewerError("viewer_not_ready", "Viewer is not ready yet. Await window.EGWebGLViewer.ready first."); + } +} + +function resolveRuntimeEntry(runtime, selector) { + assertRuntimeReady(runtime); + + let publicId = ""; + let id = ""; + let name = ""; + + if (typeof selector === "string") { + const text = String(selector).trim(); + if (!text) { + throw createViewerError("invalid_selector", "Selector must not be empty"); + } + publicId = text; + id = text; + name = text; + } else if (selector && typeof selector === "object") { + publicId = String(selector.publicId || selector.public_id || "").trim(); + id = String(selector.id || "").trim(); + name = String(selector.name || "").trim(); + } else { + throw createViewerError("invalid_selector", "Selector must be a string or object"); + } + + if (publicId && runtime.nodesByPublicId.has(publicId)) { + return runtime.nodesByPublicId.get(publicId); + } + if (id && runtime.nodesById.has(id)) { + return runtime.nodesById.get(id); + } + if (name) { + const matches = runtime.nodesByName.get(name) || []; + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw createViewerError("ambiguous_node_name", `Multiple nodes matched name '${name}'`, { + name, + matches: matches.map((entry) => buildNodeSelectorPayload(entry)), + }); + } + } + + throw createViewerError("node_not_found", "Node not found", { + selector: cloneJsonSafe(selector, selector), + }); +} + +function emitRuntimeEvent(runtime, eventName, payload) { + const eventPayload = { + timestamp: Date.now(), + ...cloneJsonSafe(payload, {}), + }; + runtime.eventHub.emit(eventName, eventPayload); + if (runtime.bridge && typeof runtime.bridge.emitRemoteEvent === "function") { + runtime.bridge.emitRemoteEvent(eventName, eventPayload); + } + return eventPayload; +} + +function reportRuntimeError(runtime, error, context = {}) { + const serialized = serializeViewerError(error); + console.warn("[EGWebGLViewer]", serialized.code, serialized.message, serialized.details || ""); + emitRuntimeEvent(runtime, "error", { + error: serialized, + context: cloneJsonSafe(context, {}), + }); + return serialized; +} + +function runApiAction(runtime, actionName, fn) { + try { + return fn(); + } catch (error) { + reportRuntimeError(runtime, error, { + source: "js_api", + action: actionName, + }); + throw error; + } +} + +function createViewerApi(runtime, THREE) { + const api = { + ready: runtime.readyDeferred.promise, + scene: { + getInfo() { + return runApiAction(runtime, "scene.getInfo", () => { + assertRuntimeReady(runtime); + return buildSceneInfo(runtime); + }); + }, + }, + nodes: { + list() { + return runApiAction(runtime, "nodes.list", () => { + assertRuntimeReady(runtime); + return runtime.nodeOrder.map((entry) => buildNodeSummary(entry)); + }); + }, + getState(selector) { + return runApiAction(runtime, "nodes.getState", () => { + const entry = resolveRuntimeEntry(runtime, selector); + return buildNodeState(THREE, entry); + }); + }, + setVisible(selector, visible) { + return runApiAction(runtime, "nodes.setVisible", () => { + const entry = resolveRuntimeEntry(runtime, selector); + entry.obj.visible = !!visible; + entry.obj.updateMatrixWorld(true); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "visibility", + state: result, + }); + return result; + }); + }, + setTransform(selector, patch, options = {}) { + return runApiAction(runtime, "nodes.setTransform", () => { + const entry = resolveRuntimeEntry(runtime, selector); + applyTransformPatchToEntry(THREE, entry, patch || {}, options || {}); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "transform", + state: result, + }); + return result; + }); + }, + translate(selector, delta, options = {}) { + return runApiAction(runtime, "nodes.translate", () => { + const entry = resolveRuntimeEntry(runtime, selector); + translateEntry(THREE, entry, delta || [0, 0, 0], options || {}); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "transform", + state: result, + }); + return result; + }); + }, + setScriptEnabled(selector, enabled, options = {}) { + return runApiAction(runtime, "nodes.setScriptEnabled", () => { + const entry = resolveRuntimeEntry(runtime, selector); + const wantedKey = String(options?.name || "").trim() + ? normalizeScriptName(options.name) + : ""; + let changed = 0; + for (const state of entry.scriptStates || []) { + if (wantedKey && normalizeScriptName(state?.name || state?.key || "") !== wantedKey) { + continue; + } + state.enabled = !!enabled; + changed += 1; + } + if (wantedKey && changed === 0) { + throw createViewerError("script_not_found", "Requested script was not found on the node", { + node: buildNodeSelectorPayload(entry), + script: options?.name || "", + }); + } + if (!wantedKey && changed === 0) { + throw createViewerError("script_not_supported", "Node has no Web-controllable scripts to toggle", { + node: buildNodeSelectorPayload(entry), + }); + } + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "script", + state: result, + }); + return result; + }); + }, + }, + animation: { + play(selector, options = {}) { + return runApiAction(runtime, "animation.play", () => { + const entry = resolveRuntimeEntry(runtime, selector); + if (!entry.animationController) { + throw createViewerError("animation_not_available", "Node has no controllable animation", { + node: buildNodeSelectorPayload(entry), + }); + } + entry.animationController.play(options || {}); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "animation", + state: result, + }); + return result; + }); + }, + pause(selector) { + return runApiAction(runtime, "animation.pause", () => { + const entry = resolveRuntimeEntry(runtime, selector); + if (!entry.animationController) { + throw createViewerError("animation_not_available", "Node has no controllable animation", { + node: buildNodeSelectorPayload(entry), + }); + } + entry.animationController.pause(); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "animation", + state: result, + }); + return result; + }); + }, + stop(selector, options = {}) { + return runApiAction(runtime, "animation.stop", () => { + const entry = resolveRuntimeEntry(runtime, selector); + if (!entry.animationController) { + throw createViewerError("animation_not_available", "Node has no controllable animation", { + node: buildNodeSelectorPayload(entry), + }); + } + entry.animationController.stop(options || {}); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "animation", + state: result, + }); + return result; + }); + }, + seek(selector, time) { + return runApiAction(runtime, "animation.seek", () => { + const entry = resolveRuntimeEntry(runtime, selector); + if (!entry.animationController) { + throw createViewerError("animation_not_available", "Node has no controllable animation", { + node: buildNodeSelectorPayload(entry), + }); + } + entry.animationController.seek(time); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "animation", + state: result, + }); + return result; + }); + }, + setSpeed(selector, speed) { + return runApiAction(runtime, "animation.setSpeed", () => { + const entry = resolveRuntimeEntry(runtime, selector); + if (!entry.animationController) { + throw createViewerError("animation_not_available", "Node has no controllable animation", { + node: buildNodeSelectorPayload(entry), + }); + } + entry.animationController.setSpeed(speed); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "animation", + state: result, + }); + return result; + }); + }, + }, + events: { + on(eventName, handler) { + return runtime.eventHub.on(eventName, handler); + }, + off(eventName, handler) { + return runtime.eventHub.off(eventName, handler); + }, + }, + }; + + window.EGWebGLViewer = api; + return api; +} + +function createMessageBridge(runtime, api) { + const commandHandlers = new Map([ + ["scene.getInfo", () => api.scene.getInfo()], + ["nodes.list", () => api.nodes.list()], + ["nodes.getState", (payload) => api.nodes.getState(payload?.selector ?? payload)], + ["nodes.setVisible", (payload) => api.nodes.setVisible(payload?.selector, payload?.visible)], + ["nodes.setTransform", (payload) => api.nodes.setTransform(payload?.selector, payload?.patch ?? payload?.transform ?? {}, payload?.options ?? {})], + ["nodes.translate", (payload) => api.nodes.translate(payload?.selector, payload?.delta ?? payload?.translation ?? [0, 0, 0], payload?.options ?? {})], + ["nodes.setScriptEnabled", (payload) => api.nodes.setScriptEnabled(payload?.selector, payload?.enabled, payload?.options ?? {})], + ["animation.play", (payload) => api.animation.play(payload?.selector, payload?.options ?? {})], + ["animation.pause", (payload) => api.animation.pause(payload?.selector)], + ["animation.stop", (payload) => api.animation.stop(payload?.selector, payload?.options ?? {})], + ["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)], + ["animation.setSpeed", (payload) => api.animation.setSpeed(payload?.selector, payload?.speed)], + ]); + + let trustedOrigin = ""; + + const sendEnvelope = (targetWindow, targetOrigin, envelope) => { + if (!targetWindow || typeof targetWindow.postMessage !== "function") return false; + try { + targetWindow.postMessage(envelope, targetOrigin || "*"); + return true; + } catch (err) { + console.warn("[EGWebGLViewer] postMessage failed:", err); + return false; + } + }; + + const sendResponse = (targetWindow, targetOrigin, replyTo, ok, result, error) => { + const envelope = { + source: "eg-webgl", + type: "response", + replyTo: replyTo ?? null, + ok: !!ok, + }; + if (ok) { + envelope.result = cloneJsonSafe(result, null); + } else { + envelope.error = serializeViewerError(error); + } + sendEnvelope(targetWindow, targetOrigin, envelope); + }; + + const emitRemoteEvent = (eventName, payload) => { + if (!trustedOrigin || window.parent === window) return false; + return sendEnvelope(window.parent, trustedOrigin, { + source: "eg-webgl", + type: "event", + event: String(eventName || ""), + payload: cloneJsonSafe(payload, {}), + }); + }; + + const onMessage = async (event) => { + const data = event?.data; + if (!data || typeof data !== "object") return; + if (String(data.source || "") !== "eg-frontend") return; + + if (String(data.type || "") === "handshake") { + if (!trustedOrigin) { + trustedOrigin = String(event.origin || ""); + } + + if (String(event.origin || "") !== trustedOrigin) { + sendResponse( + event.source, + event.origin, + data.id, + false, + null, + createViewerError("untrusted_origin", "Only the first handshake origin is allowed", { + trustedOrigin, + receivedOrigin: event.origin, + }), + ); + return; + } + + sendResponse(event.source, event.origin, data.id, true, { + accepted: true, + apiVersion: runtime?.manifest?.meta?.api_version ?? 1, + ready: !!runtime?.isReady, + transports: cloneJsonSafe(runtime?.manifest?.meta?.transports, ["js_api", "postMessage"]), + }); + + if (runtime?.isReady) { + emitRemoteEvent("ready", buildSceneInfo(runtime)); + } + return; + } + + if (String(data.type || "") !== "command") return; + + if (!trustedOrigin) { + sendResponse( + event.source, + event.origin, + data.id, + false, + null, + createViewerError("handshake_required", "Send a handshake message before issuing commands"), + ); + return; + } + + if (String(event.origin || "") !== trustedOrigin) { + sendResponse( + event.source, + event.origin, + data.id, + false, + null, + createViewerError("untrusted_origin", "Command origin does not match the trusted handshake origin", { + trustedOrigin, + receivedOrigin: event.origin, + }), + ); + return; + } + + const command = String(data.command || "").trim(); + const handler = commandHandlers.get(command); + if (!handler) { + sendResponse( + event.source, + event.origin, + data.id, + false, + null, + createViewerError("unknown_command", `Unknown command '${command}'`, { + command, + }), + ); + return; + } + + try { + const result = await handler(data.payload || {}); + sendResponse(event.source, event.origin, data.id, true, result, null); + } catch (error) { + reportRuntimeError(runtime, error, { command }); + sendResponse(event.source, event.origin, data.id, false, null, error); + } + }; + + window.addEventListener("message", onMessage); + return { + emitRemoteEvent, + dispose() { + window.removeEventListener("message", onMessage); + }, + }; +} + +let activeViewerRuntime = null; + async function bootstrap() { setStatus("Loading WebGL dependencies..."); @@ -1761,9 +2871,9 @@ async function bootstrap() { let GLTFLoader; try { - THREE = await import("../vendor/three.module.min.js"); - ({ OrbitControls } = await import("../vendor/OrbitControls.js")); - ({ GLTFLoader } = await import("../vendor/GLTFLoader.js")); + THREE = await import(resolvePackUrl("vendor/three.module.min.js")); + ({ OrbitControls } = await import(resolvePackUrl("vendor/OrbitControls.js"))); + ({ GLTFLoader } = await import(resolvePackUrl("vendor/GLTFLoader.js"))); } catch (err) { setStatus( [ @@ -1782,9 +2892,20 @@ async function bootstrap() { setStatus("Loading scene manifest..."); - const response = await fetch("../scene/scene_webgl.json", { cache: "no-cache" }); + let response; + try { + response = await fetch(resolvePackUrl("scene/scene_webgl.json"), { cache: "no-cache" }); + } catch (err) { + const message = window.location.protocol === "file:" + ? "Failed to load scene manifest. If you opened the page directly from the filesystem, please preview it through a local HTTP server or open a packaged export directory." + : "Failed to load scene manifest."; + throw createViewerError("manifest_fetch_failed", message, { + cause: String(err?.message || err), + url: resolvePackUrl("scene/scene_webgl.json"), + }); + } if (!response.ok) { - throw new Error(`Failed to load scene manifest: HTTP ${response.status}`); + throw createViewerError("manifest_load_failed", `Failed to load scene manifest: HTTP ${response.status}`); } const data = await response.json(); @@ -1840,6 +2961,31 @@ async function bootstrap() { camera.lookAt(0, 0, 0); } + const runtime = { + manifest: data, + scene, + camera, + controls, + renderer, + basis, + basisInv, + matrixConvention, + nodeOrder: [], + nodesById: new Map(), + nodesByPublicId: new Map(), + nodesByName: new Map(), + eventHub: createEventHub(), + readyDeferred: makeDeferred(), + animationControllers: [], + unsupportedScriptCount: 0, + isReady: false, + bridge: null, + }; + activeViewerRuntime = runtime; + + const api = createViewerApi(runtime, THREE); + runtime.bridge = createMessageBridge(runtime, api); + const env = data.environment || {}; const rpProfile = (env.render_pipeline && typeof env.render_pipeline === "object") ? env.render_pipeline @@ -1875,9 +3021,6 @@ async function bootstrap() { const nodeMap = new Map(); const pendingModelLoads = []; - const animationMixers = []; - const animationStates = []; - const gltfLoader = new GLTFLoader(); for (const node of sceneNodes) { @@ -1931,11 +3074,27 @@ async function bootstrap() { obj.castShadow = false; } else { obj = new THREE.Group(); + } + + obj.name = node.name || node.id || "node"; + + if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) { + const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention); + obj.matrixAutoUpdate = false; + obj.matrix.copy(converted); + obj.matrix.decompose(obj.position, obj.quaternion, obj.scale); + } + + nodeMap.set(String(node.id || ""), obj); + const entry = makeRuntimeEntry(node, obj); + registerRuntimeEntry(runtime, entry); + + if (node.kind === "model") { const modelUri = node.model?.uri; if (modelUri) { - const p = new Promise((resolve) => { + const pendingLoad = new Promise((resolve) => { gltfLoader.load( - modelUri, + resolveAssetUrl(modelUri), (gltf) => { const root = gltf.scene || (Array.isArray(gltf.scenes) ? gltf.scenes[0] : null); if (root) { @@ -1950,23 +3109,19 @@ async function bootstrap() { applyMaterialOverride(THREE, root, node.material_override || null); applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader); applyShadowFlags(root, renderProfile.shadowEnabled); - const anim = setupModelAnimation( + entry.modelRoot = root; + entry.modelLoaded = true; + const controller = createAnimationController( THREE, root, gltf, node.animation || null, - node.name || node.id || "", + buildNodeSelectorPayload(entry), + (eventName, payload) => emitRuntimeEvent(runtime, eventName, payload), ); - if (anim.mixer) { - animationMixers.push(anim.mixer); - } - if (anim.clipName) { - animationStates.push({ - node: node.name || node.id || "node", - clip: anim.clipName, - mode: anim.mode, - started: anim.started, - }); + if (controller) { + entry.animationController = controller; + runtime.animationControllers.push(controller); } obj.add(root); } @@ -1974,40 +3129,28 @@ async function bootstrap() { }, undefined, (err) => { + entry.modelLoaded = false; + entry.modelError = String(err || "model_load_failed"); console.warn(`Failed to load model ${modelUri}:`, err); resolve(); }, ); }); - pendingModelLoads.push(p); + pendingModelLoads.push(pendingLoad); } } - - obj.name = node.name || node.id || "node"; - - if (Array.isArray(node.matrix_local_row_major) && node.matrix_local_row_major.length === 16) { - const converted = convertNodeMatrix(THREE, node.matrix_local_row_major, basis, basisInv, matrixConvention); - obj.matrixAutoUpdate = false; - obj.matrix.copy(converted); - obj.matrix.decompose(obj.position, obj.quaternion, obj.scale); - } - - nodeMap.set(node.id, obj); } - for (const node of sceneNodes) { - const obj = nodeMap.get(node.id); - if (!obj) continue; - - const parent = node.parent_id ? nodeMap.get(node.parent_id) : null; + for (const entry of runtime.nodeOrder) { + const parent = entry.parentId ? nodeMap.get(entry.parentId) : null; if (parent) { - parent.add(obj); + parent.add(entry.obj); } else { - scene.add(obj); + scene.add(entry.obj); } - if (obj.isSpotLight && obj.target) { - obj.add(obj.target); + if (entry.obj.isSpotLight && entry.obj.target) { + entry.obj.add(entry.obj.target); } } @@ -2021,9 +3164,11 @@ async function bootstrap() { renderProfile.bloom, ); const composer = bloomSetup.composer; - const scriptRuntime = buildScriptRuntimeStates(THREE, sceneNodes, nodeMap); + runtime.composer = composer; + + const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, nodeMap); const scriptStates = scriptRuntime.runtimes; - const unsupportedScriptCount = scriptRuntime.unsupportedCount; + runtime.unsupportedScriptCount = scriptRuntime.unsupportedCount; const resize = () => { const w = window.innerWidth; @@ -2039,8 +3184,13 @@ async function bootstrap() { window.addEventListener("resize", resize); resize(); + runtime.isReady = true; + const readyInfo = buildSceneInfo(runtime); + runtime.readyDeferred.resolve(readyInfo); + emitRuntimeEvent(runtime, "ready", readyInfo); + setStatus( - `Scene ready. Nodes: ${sceneNodes.length}, Animations: ${animationStates.length}, Scripts: ${scriptStates.length}${unsupportedScriptCount > 0 ? ` (unsupported: ${unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`, + `Scene ready. Nodes: ${sceneNodes.length}, Animations: ${runtime.animationControllers.length}, Scripts: ${scriptStates.length}${runtime.unsupportedScriptCount > 0 ? ` (unsupported: ${runtime.unsupportedScriptCount})` : ""}, Skybox: ${skyboxState.enabled ? "on" : "off"}, ToneMap: ${renderProfile.toneMappingEnabled ? "on" : "off"}, Shadows: ${renderProfile.shadowEnabled ? "on" : "off"}, Fog: ${renderProfile.fogApplied ? "on" : "off"}, Bloom: ${bloomSetup.enabled ? "on" : "off"}.\nUse mouse to orbit, wheel to zoom.`, "ok", ); @@ -2049,9 +3199,9 @@ async function bootstrap() { requestAnimationFrame(tick); const dt = clock.getDelta(); if (dt >= 0) { - for (const mixer of animationMixers) { + for (const controller of runtime.animationControllers) { try { - mixer.update(dt); + controller.mixer.update(dt); } catch (err) { // Keep render loop alive even if one mixer fails. } @@ -2081,5 +3231,14 @@ async function bootstrap() { bootstrap().catch((err) => { console.error(err); - setStatus(`Viewer bootstrap failed:\n${String(err)}`, "error"); + if (activeViewerRuntime?.readyDeferred?.reject) { + activeViewerRuntime.readyDeferred.reject(err); + } + if (activeViewerRuntime) { + reportRuntimeError(activeViewerRuntime, err, { source: "bootstrap" }); + } + setStatus(`Viewer bootstrap failed:\n${String(err?.message || err)}`, "error"); + if (window.EGWebGLViewer && window.EGWebGLViewer.events && typeof window.EGWebGLViewer.events.on === "function") { + // no-op: keep API surface intact when bootstrap fails later + } });