前端交互
This commit is contained in:
parent
3168996afb
commit
22ccfcd55d
@ -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)
|
||||
|
||||
579
templates/webgl/frontend_demo.html
Normal file
579
templates/webgl/frontend_demo.html
Normal 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>
|
||||
@ -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>
|
||||
<script type="module" src="./js/viewer.js"></script>
|
||||
</details>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user