前端交互

This commit is contained in:
Rowland 2026-04-17 14:37:45 +08:00
parent 3168996afb
commit 22ccfcd55d
5 changed files with 2230 additions and 117 deletions

View File

@ -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)

View File

@ -0,0 +1,579 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EG WebGL Frontend Demo</title>
<style>
:root {
color-scheme: light;
--bg: #f5efe4;
--panel: rgba(255, 252, 246, 0.95);
--panel-strong: #fffaf0;
--border: rgba(67, 51, 31, 0.18);
--text: #2d241a;
--muted: #72614c;
--accent: #2f7f6d;
--accent-strong: #1e5f52;
--danger: #9a3d2e;
--shadow: 0 20px 50px rgba(63, 41, 19, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(47, 127, 109, 0.12), transparent 32%),
radial-gradient(circle at right center, rgba(223, 138, 87, 0.14), transparent 28%),
linear-gradient(180deg, #f9f4ea 0%, var(--bg) 100%);
}
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 20px;
min-height: 100vh;
padding: 20px;
}
.panel,
.viewer-shell {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.panel {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.row.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}
input,
select,
button,
textarea {
width: 100%;
border-radius: 12px;
border: 1px solid var(--border);
padding: 11px 12px;
font: inherit;
color: var(--text);
background: var(--panel-strong);
}
button {
cursor: pointer;
background: var(--accent);
border-color: transparent;
color: #fff;
font-weight: 600;
}
button.secondary {
background: #efe4cf;
color: var(--text);
border-color: var(--border);
}
button.danger {
background: var(--danger);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.status {
padding: 12px 14px;
border-radius: 14px;
background: rgba(47, 127, 109, 0.08);
border: 1px solid rgba(47, 127, 109, 0.15);
color: var(--accent-strong);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
.viewer-shell {
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.viewer-head strong {
font-size: 16px;
}
.viewer-shell iframe {
flex: 1;
width: 100%;
min-height: 70vh;
border: 0;
background: #111;
}
textarea {
min-height: 220px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
background: rgba(47, 127, 109, 0.08);
border-radius: 8px;
padding: 2px 6px;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.viewer-shell iframe {
min-height: 58vh;
}
}
</style>
</head>
<body>
<div class="layout">
<aside class="panel">
<div class="section">
<h1>Frontend Interaction Demo</h1>
<p>这个页面模拟你的前端宿主页,通过 <code>iframe + postMessage</code> 控制导出的 WebGL 页面。</p>
</div>
<div class="section">
<div class="row two">
<button id="handshake-btn">1. Handshake</button>
<button id="refresh-btn" class="secondary">2. Load Nodes</button>
</div>
<div class="status" id="status-box">Waiting for handshake...</div>
</div>
<div class="section">
<div>
<label for="node-select">Controlled Node</label>
<select id="node-select">
<option value="">请先 handshake 再加载节点</option>
</select>
</div>
<div class="hint">选择器优先使用 <code>publicId</code>,避免同名节点冲突。</div>
</div>
<div class="section">
<div class="row two">
<button id="toggle-visible-btn" class="secondary">Toggle Visible</button>
<button id="get-state-btn" class="secondary">Get State</button>
</div>
<div class="row">
<button id="move-left-btn" class="secondary">Move -X</button>
<button id="move-forward-btn" class="secondary">Move +Y</button>
<button id="move-right-btn" class="secondary">Move +X</button>
</div>
<div class="row two">
<button id="rotate-btn" class="secondary">Rotate H +45</button>
<button id="reset-transform-btn" class="secondary">Reset Local</button>
</div>
<div class="hint">这里默认用 Panda 坐标系,本地空间:<code>{ coordSystem: "panda", space: "local" }</code></div>
</div>
<div class="section">
<div>
<label for="clip-input">Animation Clip</label>
<input id="clip-input" value="Idle" placeholder="例如 Idle / Walk / Run" />
</div>
<div class="row">
<button id="play-btn">Play</button>
<button id="pause-btn" class="secondary">Pause</button>
<button id="stop-btn" class="danger">Stop</button>
</div>
<div class="row two">
<button id="speed-up-btn" class="secondary">Speed x1.5</button>
<button id="seek-btn" class="secondary">Seek 0.5s</button>
</div>
</div>
<div class="section">
<label for="log-box">Message Log</label>
<textarea id="log-box" readonly></textarea>
</div>
</aside>
<section class="viewer-shell">
<div class="viewer-head">
<strong>Embedded Viewer</strong>
<span class="hint">iframe src: <code>./index.html</code></span>
</div>
<iframe id="viewer-frame" src="./index.html" title="EG WebGL Viewer"></iframe>
</section>
</div>
<script>
const viewerFrame = document.getElementById("viewer-frame");
const statusBox = document.getElementById("status-box");
const logBox = document.getElementById("log-box");
const nodeSelect = document.getElementById("node-select");
const clipInput = document.getElementById("clip-input");
const pendingReplies = new Map();
let sequence = 0;
let handshakeDone = false;
let readyReceived = false;
let knownNodes = [];
function getTargetOrigin() {
try {
const target = new URL(viewerFrame.getAttribute("src") || "./index.html", window.location.href);
return target.origin && target.origin !== "null" ? target.origin : "*";
} catch (err) {
return "*";
}
}
function nextId(prefix) {
sequence += 1;
return prefix + "-" + String(sequence);
}
function setStatus(text) {
statusBox.textContent = text;
}
function logLine(label, payload) {
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
const stamp = new Date().toLocaleTimeString();
logBox.value = "[" + stamp + "] " + label + "\n" + text + "\n\n" + logBox.value;
}
function postEnvelope(envelope) {
const targetWindow = viewerFrame.contentWindow;
if (!targetWindow) {
throw new Error("Viewer iframe not ready");
}
targetWindow.postMessage(envelope, getTargetOrigin());
}
function sendHandshake() {
const id = nextId("handshake");
return new Promise((resolve, reject) => {
pendingReplies.set(id, { resolve, reject });
postEnvelope({
source: "eg-frontend",
type: "handshake",
id,
});
}).then((result) => {
handshakeDone = true;
setStatus("Handshake success. ready=" + String(!!result.ready) + ", apiVersion=" + String(result.apiVersion));
logLine("handshake ok", result);
return result;
});
}
function sendCommand(command, payload) {
if (!handshakeDone) {
return Promise.reject(new Error("Please handshake first"));
}
const id = nextId("cmd");
return new Promise((resolve, reject) => {
pendingReplies.set(id, { resolve, reject });
postEnvelope({
source: "eg-frontend",
type: "command",
id,
command,
payload,
});
});
}
function getSelectedSelector() {
const publicId = nodeSelect.value;
if (!publicId) {
throw new Error("Please choose a node first");
}
return { publicId };
}
function fillNodeSelect(nodes) {
knownNodes = Array.isArray(nodes) ? nodes.slice() : [];
nodeSelect.innerHTML = "";
if (knownNodes.length === 0) {
const option = document.createElement("option");
option.value = "";
option.textContent = "没有可控制节点";
nodeSelect.appendChild(option);
return;
}
for (const node of knownNodes) {
const option = document.createElement("option");
option.value = node.publicId || node.id || "";
option.textContent = (node.name || "(unnamed)") + " [" + (node.kind || "node") + "]";
nodeSelect.appendChild(option);
}
}
async function refreshNodes() {
const nodes = await sendCommand("nodes.list");
fillNodeSelect(nodes);
setStatus("Loaded " + String(nodes.length) + " nodes");
logLine("nodes.list", nodes);
return nodes;
}
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.source !== "eg-webgl") {
return;
}
if (data.type === "response") {
const pending = pendingReplies.get(data.replyTo);
if (!pending) {
return;
}
pendingReplies.delete(data.replyTo);
if (data.ok) {
pending.resolve(data.result);
} else {
pending.reject(data.error || { code: "unknown_error", message: "Unknown error" });
}
return;
}
if (data.type === "event") {
logLine("event:" + data.event, data.payload);
if (data.event === "ready") {
readyReceived = true;
setStatus("Viewer ready. nodeCount=" + String(data.payload?.nodeCount || 0));
fillNodeSelect(data.payload?.nodes || []);
}
}
});
async function runAction(label, action) {
try {
const result = await action();
if (result !== undefined) {
logLine(label, result);
}
return result;
} catch (error) {
const payload = error && typeof error === "object" ? error : { message: String(error) };
logLine(label + " error", payload);
setStatus("Last error: " + String(payload.message || payload.code || error));
throw error;
}
}
document.getElementById("handshake-btn").addEventListener("click", () => {
runAction("handshake", async () => {
const result = await sendHandshake();
if (result.ready && !readyReceived) {
await refreshNodes();
}
return result;
});
});
document.getElementById("refresh-btn").addEventListener("click", () => {
runAction("nodes.list", refreshNodes);
});
document.getElementById("get-state-btn").addEventListener("click", () => {
runAction("nodes.getState", async () => {
return sendCommand("nodes.getState", {
selector: getSelectedSelector(),
});
});
});
document.getElementById("toggle-visible-btn").addEventListener("click", () => {
runAction("nodes.setVisible", async () => {
const selector = getSelectedSelector();
const state = await sendCommand("nodes.getState", { selector });
return sendCommand("nodes.setVisible", {
selector,
visible: !state.visible,
});
});
});
document.getElementById("move-left-btn").addEventListener("click", () => {
runAction("nodes.translate(-x)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: -1, y: 0, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("move-forward-btn").addEventListener("click", () => {
runAction("nodes.translate(+y)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: 0, y: 1, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("move-right-btn").addEventListener("click", () => {
runAction("nodes.translate(+x)", async () => {
return sendCommand("nodes.translate", {
selector: getSelectedSelector(),
delta: { x: 1, y: 0, z: 0 },
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("rotate-btn").addEventListener("click", () => {
runAction("nodes.setTransform(rotation)", async () => {
return sendCommand("nodes.setTransform", {
selector: getSelectedSelector(),
patch: {
rotation: { h: 45, p: 0, r: 0 },
},
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("reset-transform-btn").addEventListener("click", () => {
runAction("nodes.setTransform(reset)", async () => {
return sendCommand("nodes.setTransform", {
selector: getSelectedSelector(),
patch: {
position: { x: 0, y: 0, z: 0 },
rotation: { h: 0, p: 0, r: 0 },
scale: { x: 1, y: 1, z: 1 },
},
options: { coordSystem: "panda", space: "local" },
});
});
});
document.getElementById("play-btn").addEventListener("click", () => {
runAction("animation.play", async () => {
return sendCommand("animation.play", {
selector: getSelectedSelector(),
options: {
clip: clipInput.value.trim(),
loop: true,
restart: true,
},
});
});
});
document.getElementById("pause-btn").addEventListener("click", () => {
runAction("animation.pause", async () => {
return sendCommand("animation.pause", {
selector: getSelectedSelector(),
});
});
});
document.getElementById("stop-btn").addEventListener("click", () => {
runAction("animation.stop", async () => {
return sendCommand("animation.stop", {
selector: getSelectedSelector(),
options: { reset: true },
});
});
});
document.getElementById("speed-up-btn").addEventListener("click", () => {
runAction("animation.setSpeed", async () => {
return sendCommand("animation.setSpeed", {
selector: getSelectedSelector(),
speed: 1.5,
});
});
});
document.getElementById("seek-btn").addEventListener("click", () => {
runAction("animation.seek", async () => {
return sendCommand("animation.seek", {
selector: getSelectedSelector(),
time: 0.5,
});
});
});
viewerFrame.addEventListener("load", () => {
setStatus("Viewer iframe loaded. Click Handshake to start.");
});
</script>
</body>
</html>

View File

@ -10,7 +10,104 @@
<div id="app">
<canvas id="scene-canvas"></canvas>
<div id="status" class="status">Loading scene...</div>
<details class="docs-panel">
<summary>Frontend Integration</summary>
<div class="docs-content">
<p><strong>Selectors:</strong> prefer <code>publicId</code>, then <code>id</code>, then exact <code>name</code>. Duplicate names return a structured error.</p>
<p><strong>Coordinates:</strong> API defaults to <code>{ coordSystem: "panda", space: "local" }</code>. Use <code>coordSystem: "three"</code> to work in Three.js axes. Local means transform relative to parent.</p>
<pre><code>// 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);
});</code></pre>
<pre><code>// 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 : "*");</code></pre>
<p><strong>Message protocol:</strong> commands use <code>{ source: "eg-frontend", id, type: "command", command, payload }</code>. Responses/events use <code>{ source: "eg-webgl", type, replyTo?, event?, ok?, result?, error? }</code>.</p>
<p><strong>Events:</strong> <code>ready</code>, <code>error</code>, <code>animationFinished</code>, <code>stateChanged</code>.</p>
<p><strong>Demo page:</strong> after export, open <code>frontend_demo.html</code> to see a working iframe host example.</p>
</div>
</details>
</div>
<script type="module" src="./js/viewer.js"></script>
<script type="module">
const candidates = ["./js/viewer.js", "./viewer.js"];
const statusEl = document.getElementById("status");
if (window.location.protocol === "file:") {
if (statusEl) {
statusEl.textContent = [
"This page cannot run from file://",
"",
"Please start a local HTTP server in the exported WebGL folder, for example:",
"python3 -m http.server 8000",
"",
"Then open:",
"http://127.0.0.1:8000/index.html",
"http://127.0.0.1:8000/frontend_demo.html",
].join("\n");
statusEl.className = "status error";
}
throw new Error("file_protocol_not_supported");
}
let loaded = false;
let lastError = null;
for (const specifier of candidates) {
try {
await import(specifier);
loaded = true;
break;
} catch (error) {
lastError = error;
}
}
if (!loaded) {
console.error(lastError);
if (statusEl) {
statusEl.textContent = `Failed to load viewer bootstrap:\n${String(lastError?.message || lastError)}`;
statusEl.className = "status error";
}
}
</script>
</body>
</html>

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff