新增 WMS 搬运规则与任务管理

支持搬运规则维护、候选预览、任务生成、预占、下发、取消和完成,并完善仓储管理前端交互。
This commit is contained in:
ArtoriasWu
2026-06-25 11:02:22 +08:00
parent 1cb8091bbe
commit 15405ba114
101 changed files with 4167704 additions and 152 deletions
@@ -0,0 +1,11 @@
{
"models": [
{
"id": "英招",
"name": "英招",
"file": "英招.glb",
"root": "lux_root",
"config": "英招.json"
}
]
}
Binary file not shown.
+290
View File
@@ -0,0 +1,290 @@
{
"version": 2,
"description": "analyze 仅生成组件骨架;类型与动作请在查看器按组件配置后导出。",
"model": {
"id": "英招",
"file": "英招.glb",
"root": "lux_root"
},
"baseline": {
"组件1": {
"glbNode": "主体",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件2": {
"glbNode": "主体.001",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件3": {
"glbNode": "light_1",
"type": "light",
"motion": {
"role": "light",
"kind": "emissive"
},
"hasMesh": true,
"light": {
"color": "#00ff55",
"effect": "solid",
"intensity": 100
}
},
"组件4": {
"glbNode": "light_2",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件5": {
"glbNode": "light_3",
"type": "light",
"motion": {
"role": "light",
"kind": "emissive"
},
"hasMesh": true,
"light": {
"color": "#00ff55",
"effect": "solid",
"intensity": 100
}
},
"组件6": {
"glbNode": "light_4",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件7": {
"glbNode": "light_5",
"type": "light",
"motion": {
"role": "light",
"kind": "emissive"
},
"hasMesh": true,
"light": {
"color": "#00ff55",
"effect": "solid",
"intensity": 100
}
},
"组件8": {
"glbNode": "underrideTugger_differWheel",
"type": "wheel",
"motion": {
"role": "wheel",
"kind": "rotate",
"axis": "y"
},
"hasMesh": true,
"action": {
"wheelSpeedFactor": 1,
"wheelSteerAngleDeg": 0
}
},
"组件9": {
"glbNode": "underrideTugger_lifter",
"type": "actuator",
"motion": {
"role": "lift",
"kind": "translate",
"axis": "y"
},
"hasMesh": true,
"action": {
"liftStart": -0.05,
"liftEnd": -0.05
}
},
"组件10": {
"glbNode": "前座",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件11": {
"glbNode": "前座2",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件12": {
"glbNode": "前座3",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件13": {
"glbNode": "前座3.001",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件14": {
"glbNode": "启动页2",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件15": {
"glbNode": "平面",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件16": {
"glbNode": "平面.001",
"type": "static",
"motion": null,
"hasMesh": true
},
"组件17": {
"glbNode": "柱体.001",
"type": "wheel",
"motion": {
"role": "wheel",
"kind": "rotate",
"axis": "z"
},
"hasMesh": true,
"action": {
"wheelSpeedFactor": 1,
"wheelSteerAngleDeg": 0
}
},
"组件18": {
"glbNode": "柱体.003",
"type": "wheel",
"motion": {
"role": "wheel",
"kind": "rotate",
"axis": "z"
},
"hasMesh": true,
"action": {
"wheelSpeedFactor": 1,
"wheelSteerAngleDeg": 0
}
},
"组件19": {
"glbNode": "雷达",
"type": "static",
"motion": null,
"hasMesh": true
}
},
"groups": {},
"actions": {
"zuozhuan": {
"label": "左转",
"uiClass": "mode-running",
"parameters": {
"defaultWheelSpeed": 0.6,
"wheelSpinMultiplier": 3,
"liftOffsets": [
0,
0.05,
0.1,
0.15
],
"cameraScanAmplitudeDeg": 15,
"cameraScanHz": 0.8,
"liftTweenDuration": 1.2,
"wheelSteerTweenDuration": 0.8,
"liftMotionFactorFull": 1000,
"liftPreviewHz": 0.35,
"motionPreviewHz": 0.4,
"motionPreviewAmplitudeDeg": 18,
"actionDraftPreviewSec": 0
},
"components": {
"组件3": {
"type": "light",
"motion": {
"role": "light",
"kind": "emissive"
},
"light": {
"color": "#00ff55",
"effect": "blink",
"intensity": 100,
"hz": 4,
"intensityOn": 100,
"intensityOff": 0
}
},
"组件18": {
"type": "wheel",
"action": {
"wheelSpeedFactor": 1,
"wheelSteerAngleDeg": 30
}
},
"组件17": {
"type": "wheel",
"action": {
"wheelSpeedFactor": 1,
"wheelSteerAngleDeg": 30
}
},
"组件5": {
"type": "light",
"motion": {
"role": "light",
"kind": "emissive"
},
"light": {
"color": "#00ff55",
"effect": "blink",
"intensity": 100,
"hz": 4,
"intensityOn": 100,
"intensityOff": 0
}
}
}
},
"jusheng": {
"label": "举升",
"uiClass": "mode-lift",
"parameters": {
"defaultWheelSpeed": 0.6,
"wheelSpinMultiplier": 3,
"liftOffsets": [
0,
0.05,
0.1,
0.15
],
"cameraScanAmplitudeDeg": 15,
"cameraScanHz": 0.8,
"liftTweenDuration": 1.2,
"wheelSteerTweenDuration": 0.8,
"liftMotionFactorFull": 1000,
"liftPreviewHz": 0.35,
"motionPreviewHz": 0.4,
"motionPreviewAmplitudeDeg": 18,
"actionDraftPreviewSec": 0
},
"components": {
"组件9": {
"type": "actuator",
"motion": {
"role": "lift",
"kind": "translate",
"axis": "y"
},
"action": {
"liftStart": -0.05,
"liftEnd": 0.05
}
}
}
}
}
}
+139
View File
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""
扫描 agv/*.glb 中的网格节点,生成最小化 JSON 配置骨架。
- 组件显示名为:组件1、组件2、组件3 …(不沿用 Blender 原始节点名)
- 每个组件仅生成默认骨架(static / 无 motion);不识别灯光、轮子等类型
- 灯光/轮子等专用字段请在查看器中改类型后保存,或由你手写 JSON
- 不生成 actions;基准在 baseline,动作在查看器按组件配置
用法:
python analyze_agv.py
python analyze_agv.py 英招
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from pygltflib import GLTF2
ROOT = Path(__file__).resolve().parent
AGV_DIR = ROOT / "agv"
def component_label(index_1based: int) -> str:
return f"组件{index_1based}"
def _detect_glb_root(glb_path: Path) -> str:
try:
g = GLTF2().load(str(glb_path))
if g.scenes and g.scenes[0].nodes:
idx = g.scenes[0].nodes[0]
if 0 <= idx < len(g.nodes) and g.nodes[idx].name:
return g.nodes[idx].name
except Exception:
pass
return "SceneRoot"
def update_manifest() -> Path:
AGV_DIR.mkdir(parents=True, exist_ok=True)
models = []
for path in sorted(AGV_DIR.glob("*.glb")):
cfg = path.with_suffix(".json")
models.append(
{
"id": path.stem,
"name": path.stem.replace("_", " "),
"file": path.name,
"root": _detect_glb_root(path),
"config": cfg.name if cfg.exists() else None,
}
)
manifest = AGV_DIR / "manifest.json"
manifest.write_text(json.dumps({"models": models}, ensure_ascii=False, indent=2), encoding="utf-8")
return manifest
def list_mesh_nodes(glb_path: Path, root_name: str) -> list[dict[str, Any]]:
"""列出除根节点外的所有命名节点(含无 mesh 的空节点,便于完整对应)。"""
g = GLTF2().load(str(glb_path))
out = []
for node in g.nodes:
if not node.name or node.name == root_name:
continue
out.append({"name": node.name, "hasMesh": node.mesh is not None})
return out
def analyze_glb(glb_path: Path, model_id: str | None = None) -> dict[str, Any]:
model_id = model_id or glb_path.stem
root = _detect_glb_root(glb_path)
# 仅含实际网格的节点,避免把空集合/根分组也算成组件
nodes = [n for n in list_mesh_nodes(glb_path, root) if n["hasMesh"]]
components: dict[str, Any] = {}
for i, n in enumerate(nodes, start=1):
label = component_label(i)
components[label] = {
"glbNode": (n["name"] or "").replace("\u00a0", " ").strip(),
"type": "static",
"motion": None,
"hasMesh": n["hasMesh"],
}
return {
"version": 2,
"description": "analyze 仅生成组件骨架;类型与动作请在查看器按组件配置后导出。",
"model": {"id": model_id, "file": glb_path.name, "root": root},
"baseline": components,
"groups": {},
"actions": {},
}
def write_config(glb_path: Path, model_id: str | None = None) -> Path:
cfg = analyze_glb(glb_path, model_id)
out = Path(AGV_DIR) / f"{cfg['model']['id']}.json"
out.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
return out
def analyze_all() -> list[Path]:
written = []
for glb in sorted(Path(AGV_DIR).glob("*.glb")):
written.append(write_config(glb))
update_manifest()
return written
def main(argv: list[str]) -> None:
if len(argv) > 1:
arg = argv[1]
path = Path(arg)
if not path.is_absolute():
path = Path(AGV_DIR) / (arg if arg.endswith(".glb") else f"{arg}.glb")
if not path.exists():
print(f"文件不存在: {path}")
sys.exit(1)
out = write_config(path)
update_manifest()
print(f"已生成: {out}")
data = json.loads(out.read_text(encoding="utf-8"))
print(f"{len(data.get('baseline', data.get('components', {})))} 个基准组件(组件1 …)")
print(" actions: 空(动作在查看器按组件配置)")
return
paths = analyze_all()
print(f"已分析 {len(paths)} 个模型:")
for p in paths:
data = json.loads(p.read_text(encoding="utf-8"))
bl = data.get("baseline", data.get("components", {}))
print(f" {p.name}: {len(bl)} 个基准组件, actions={len(data.get('actions', data.get('states', {})))}")
if __name__ == "__main__":
main(sys.argv)
@@ -0,0 +1,283 @@
# AGV 数字孪生 GLB 导出规范
> **文档版本**1.1
> **适用对象**:负责 AGV 三维建模与 GLB 导出的同事
> **对接系统**Web 查看器(Three.js)、PanguCanvas 桌面播放器
> **交付范围**:仅需交付 GLB 模型文件;JSON 配置由程序同事在收到 GLB 后生成与维护
---
## 1. 背景与目标
我们使用 **GLB 模型** 驱动 AGV 数字孪生(轮子旋转/转向、举升、灯光、动作切换等);配套的 JSON 配置由程序侧根据 GLB 自动生成,**无需建模同事参与**。
当前部分模型在导出时未统一层级与坐标,导致程序在运行时不得不:
- 猜测轮子旋转轴与轴心位置
- 动态插入临时空节点
- 做复杂的世界坐标 ↔ 本地坐标换算
- 兼容混乱的节点命名(中文、`.001` 后缀、空格等)
**本规范的目标**:把几何与运动语义在 Blender 导出阶段写进 GLB,程序侧只做「查节点 + 改 transform / 材质」,降低联调成本、减少 bug。
---
## 2. 交付物清单
每款 AGV **只需交付一个文件**
| 文件 | 说明 |
|------|------|
| `{车型名}.glb` | 按本规范导出的模型 |
可选(联调时有助于排查问题):Blender 源文件(`.blend`)、各可动件行程与轴方向的简要说明。
导出前请自检(见第 10 节)。收到 GLB 后,程序同事会负责生成配置、绑定动作并完成验证。
---
## 3. 场景层级(必须遵守)
请在 Blender 中按以下结构组织,**导出前建好空物体(Empty)层级**
```
agv_root ← 唯一根节点(程序侧 root 固定为 "agv_root"
├── body/ ← 静态车体
│ └── chassis
├── wheels/
│ ├── wheel_fl/ ← 左前舵轮(命名按实际车型调整)
│ │ ├── steer ← 转向节点:绕本地 Y 轴旋转
│ │ │ └── spin ← 滚转节点:绕本地 X 轴旋转
│ │ │ └── mesh ← 轮子网格
│ ├── wheel_fr/
│ └── ...
├── actuators/
│ └── lift_fork/ ← 举升机构:沿本地 Y 轴平移
│ └── mesh
├── lights/ ← 可选分组,非必须
│ ├── light_status_l/
│ └── light_status_r/
└── sensors/
└── lidar_top/
```
### 3.1 根节点
- **名称固定为 `agv_root`**(不要用 `lux_root``Scene``StealthAGV` 等)
- 原点建议:车辆地面接触面几何中心,或后轴中心;**Y = 0 贴地**
- 车头朝向建议统一为 **+Z**(见第 4 节)
### 3.2 轮子层级(最重要)
每个可动轮子必须包含三层空物体:
| 节点 | 作用 | 程序操作 |
|------|------|----------|
| `wheel_xxx` | 轮子总成,固定在父级 | 程序按节点名绑定 |
| `steer` | 转向 | `rotation.y`(或约定的转向轴) |
| `spin` | 滚转 | `rotation.x`(所有轮子统一) |
| `mesh` | 视觉网格 | 不直接改 transform |
**所有轮子的滚转轴统一为 spin 节点的本地 X 轴,转向轴统一为 steer 节点的本地 Y 轴。**
麦轮、舵轮等特殊情况也用独立命名的 wheel 节点表达,不要在不同轮子上使用不同旋转轴约定。
### 3.3 举升 / 执行机构
- 举升件放在 `actuators/`
- **收回位置 = 本地 Y = 0**(相对 actuators 父节点或 lift 节点自身约定)
- **完全伸出位置 = 本地 Y = 行程米数**(如 0.15 m = 15 cm
- 行程以 **米** 为单位,与 Blender 中 1 单位 = 1 米 保持一致(程序侧会据此配置)
### 3.4 灯光
- 每个需独立控制的指示灯单独 **命名节点**(如 `light_status_l`),程序按节点名绑定
- 节点下可有 mesh,材质 **可与其他部件共用**;程序运行时会 clone,无需刻意拆材质
- 材质使用 PBR,支持 **Emission** 即可(亮灭由程序改 emissive 控制)
- 不必强制放在 `lights/` 分组下,但名称建议带 `light_` 前缀便于识别
### 3.5 静态件
- 不参与动作的零件合并进 `body/chassis`,减少零散节点
- 仅保留需要单独隐藏、透明或换色的零件为独立节点
---
## 4. 坐标系与单位
| 项目 | 要求 |
|------|------|
| 坐标系 | **Y 轴向上**glTF 标准),**右手系** |
| 单位 | **1 Blender 单位 = 1 米** |
| 朝向 | 车头 **+Z**,左右 **±X**,上下 **Y**(全项目统一) |
| Transform | 导出前对每个可动节点 **Apply All Transforms**Ctrl+A),scale 应为 `(1, 1, 1)` |
| 全局旋转 | **不要在模型根节点上留 ±90° 的「纠正旋转」**;朝向在 Blender 里摆好再导出 |
> 说明:若导出时未 Apply Transform 或 scale ≠ 1,程序侧举升、轮子会出现位移异常或「部件飞出视野」。
---
## 5. 命名规范
### 5.1 必须
- 仅使用 **ASCII 英文字母、数字、下划线**
- 使用 **snake_case**`wheel_fl``lift_fork``light_status_l`
- 按角色加前缀:
| 前缀 | 用途 | 示例 |
|------|------|------|
| `body_` / 放在 body/ 下 | 静态车体 | `body/chassis` |
| `wheel_` | 轮子 | `wheel_fl`, `wheel_rl` |
| `lift_` / 放在 actuators/ 下 | 举升 | `lift_fork` |
| `light_` | 指示灯 | `light_status_l` |
| `sensor_` | 传感器 | `sensor_lidar_top` |
### 5.2 禁止
- 中文节点名(如 `前座``雷达``柱体`
- Blender 自动后缀(如 `主体.001``平面.003`
- 空格、全角字符、不可见字符(含 NBSP)
- 无语义的编号(如仅有 `light_1``light_2` 而不说明用途)
- 多个不相关含义的节点重名
### 5.3 推荐命名示例(舵轮 AGV)
```
agv_root
body/chassis
wheels/wheel_fl/steer/spin/mesh
wheels/wheel_fr/steer/spin/mesh
wheels/wheel_rl/spin/mesh ← 从动轮可无 steer 层,spin 下挂 mesh
actuators/lift_fork/mesh
lights/light_status_l
lights/light_status_r
sensors/lidar_top
```
---
## 6. 材质要求
| 项目 | 要求 |
|------|------|
| 材质类型 | **PBR Principled BSDF** → 导出为 glTF Standard Material |
| 车体 | 正常 albedo / roughness / metalness,贴图嵌入 GLB |
| 指示灯 | PBR 材质,支持 Emission;可与车体共用材质 |
| 材质实例 | 无特殊要求;程序侧会对需动画的部件 clone 材质 |
| 法线 | 导出时包含 Normals;有贴图时包含 Tangents |
---
## 7. Blender 导出设置
**File → Export → glTF 2.0 (.glb/.gltf)**
| 选项 | 设置 |
|------|------|
| Format | **glTF Binary (.glb)** |
| Include | Selected Objects **关闭**(导出整场景) |
| Transform | **+Y Up** |
| Geometry | 勾选 Apply Modifiers、UVs、Normals;有 normal map 时勾选 Tangents |
| Material | Export 开启 |
| Compression | 开发联调阶段 **关闭 Draco**(上线可按体积需求再开) |
| 层级 | **保持层级**,不要勾选 Flatten / 合并为单 mesh |
导出后请确认文件大小合理,并用 glTF 查看器预览,确认模型显示正常。
---
## 8. 节点命名与程序侧映射(供参考,无需建模同事配置)
程序会根据 GLB 节点名自动绑定逻辑组件。**请按第 5 节规范命名**,便于程序识别;以下类型说明仅供理解命名意图,JSON 配置由程序同事完成。
### 8.1 组件类型(程序侧配置)
| type | 用途 | motion.role |
|------|------|-------------|
| `static` / `body` | 静态件 | 无 |
| `wheel` | 轮子 | `wheel` |
| `actuator` | 举升等 | `lift` |
| `light` | 指示灯 | `light` |
| `sensor` | 传感器(可选动画) | `lidar` / `camera` 等 |
### 8.2 命名与用途对照
| 节点示例 | 程序侧用途 | 建模侧要求 |
|----------|------------|------------|
| `wheel_fl` | 轮子旋转/转向 | 含 `steer``spin``mesh` 层级 |
| `lift_fork` | 举升平移 | 收回 Y=0,伸出 Y=行程(米) |
| `light_status_l` | 指示灯 emissive | 单独命名节点,材质支持 Emission |
| `body/chassis` | 静态车体 | 不参与 transform 动画 |
---
## 9. 常见问题对照(旧模型 vs 规范)
以现有「英招」模型为例,以下做法请避免:
| 现状问题 | 规范做法 |
|----------|----------|
| 根节点名 `lux_root` | 改为 `agv_root` |
| 节点 `柱体.003``主体.001` | 语义命名如 `wheel_rl` |
| 同车型轮子 axis 有的 y、有的 z | 统一预置 spin/steeraxis 统一 x/y |
| 6 个 light 节点对应 3 盏灯 | 合并为 3 个 `light_*` 节点 |
| 19 个零散 static 组件 | 合并 body,仅保留可动件独立节点 |
| 举升行程不清晰或为负值 | 在 Blender 摆好 0 ~ 行程(米),并附简要说明 |
---
## 10. 导出前自检清单
请在交付前逐项确认:
- [ ] 根节点名为 `agv_root`Y=0 贴地,车头朝 +Z
- [ ] 所有可动节点已 **Apply All Transforms**scale = (1,1,1)
- [ ] 每个舵轮含 `steer``spin``mesh` 层级
- [ ] 所有轮子滚转轴为 spin 的 **本地 X**,转向轴为 steer 的 **本地 Y**
- [ ] 举升行程在 Blender 中以米制摆好(如收回 0 m、伸出 0.15 m)
- [ ] 节点名全部为 ASCII snake_case,无中文、无 `.001` 后缀
- [ ] 需独立控制的指示灯有单独命名节点(`light_*`
- [ ] 静态件已合并到 body,无多余零散 mesh
- [ ] glTF 导出:+Y Up、保留层级、Apply Modifiers
- [ ] 用任意 glTF 查看器预览 GLB,确认无错位、无缩放异常
---
## 11. 联调流程
**建模同事:**
1. 按本规范完成 Blender 场景
2. 导出 `{车型名}.glb` 并交付给程序同事
3. (建议)一并提供 `.blend` 源文件及可动件行程说明
**程序同事(建模同事无需参与):**
1. 将 GLB 放入项目 `agv/` 目录
2. 生成 JSON 配置、绑定动作并在查看器 / PanguCanvas 中验证
如有疑问,请提供:**Blender 源文件 + GLB + 各可动件行程与轴方向说明**,便于对照排查。
---
## 12. 附录:层级示意图
```
agv_root (原点贴地, 车头 +Z)
┌─────────────────┼─────────────────┐
│ │ │
body/ wheels/ actuators/
│ │ │
chassis wheel_fl lift_fork
│ │
steer ── spin mesh
│ │
spin mesh
mesh
```
---
*文档维护:数字孪生开发组 · 如有规范变更请同步更新本文档版本号*
@@ -0,0 +1,151 @@
# AGV 播放 API
> 版本 1 · `http://127.0.0.1:8765`(先 `python serve.py`,并保持 `play.html` 打开)
> 集成步骤与代码示例见 [接入教程.md](./接入教程.md)。
对外仅 **2 个 HTTP 接口**、**3 个动作**。
---
## 接口
| 方法 | 路径 | 用途 |
|------|------|------|
| `GET` | `/api/catalog` | 查有哪些车、每辆车有哪些动作 |
| `POST` | `/api/agv` | 控制播放(`playAction` / `stopAction` / `clearActions` |
---
## 协议
**POST 请求体:**
```json
{
"version": 1,
"method": "playAction",
"params": {
"modelId": "英招",
"actionId": "dongzuo1"
}
}
```
| 字段 | 说明 |
|------|------|
| `version` | 固定 `1` |
| `method` | `playAction` · `stopAction` · `clearActions` |
| `params.modelId` | **必填**,车辆 ID |
| `params.actionId` | `playAction` / `stopAction` 必填 |
**响应:**
```json
{
"version": 1,
"ok": true,
"data": {
"dispatched": true,
"subscribers": 1,
"modelId": "英招",
"activeActions": ["dongzuo1"]
},
"error": null
}
```
`subscribers: 0` 表示播放页未打开,命令已缓存,打开 `play.html` 后自动执行。
---
## 1. GET /api/catalog — 查目录
```
GET http://127.0.0.1:8765/api/catalog
```
响应 `data.vehicles` 示例:
```json
[
{
"modelId": "英招",
"name": "英招",
"actions": [
{ "id": "dongzuo1", "label": "动作1" }
]
}
]
```
---
## 2. POST /api/agv — 控制播放
须先打开:`http://127.0.0.1:8765/viewer/play.html`
### playAction — 开启动作(自动切车)
```json
{
"version": 1,
"method": "playAction",
"params": {
"modelId": "英招",
"actionId": "dongzuo1"
}
}
```
### stopAction — 关闭指定动作
```json
{
"version": 1,
"method": "stopAction",
"params": {
"modelId": "英招",
"actionId": "dongzuo1"
}
}
```
### clearActions — 关闭该车全部动作
```json
{
"version": 1,
"method": "clearActions",
"params": {
"modelId": "英招"
}
}
```
---
## 联调流程
```
GET /api/catalog → 取 modelId、actionId
POST playAction → 播放页执行
POST stopAction / clearActions → 停止
```
---
## 常见问题
| 现象 | 处理 |
|------|------|
| 页面打不开 | 只保留一个 `python serve.py` 进程 |
| Postman 卡住 | 重启 `serve.py` |
| `subscribers: 0` | 先打开 `play.html` |
| `params.modelId 必填` | 三个动作都要带 `modelId` |
| `UNKNOWN_METHOD` | POST 只能用上述 3 个 method;查目录用 GET |
---
*数字孪生开发组*
@@ -0,0 +1,368 @@
# AGV GLB 接入教程
> 面向 MES、数字孪生大屏、调度平台等外部系统的集成指南。
> API 协议细节见 [AGV播放API.md](./AGV播放API.md)。
---
## 架构
```
┌─────────────────┐ HTTP POST/GET ┌──────────────┐ SSE 推送 ┌─────────────────┐
│ 你的业务系统 │ ──────────────────────▶│ serve.py │ ───────────────▶│ play.html │
│ (任意语言) │ /api/catalog │ :8765 │ /api/agv/events│ (3D 播放页) │
│ │ /api/agv │ │ │ │
└─────────────────┘ └──────────────┘ └─────────────────┘
```
| 组件 | 职责 |
|------|------|
| **业务系统** | 调用 HTTP 接口,无需了解 Three.js |
| **serve.py** | 协议校验、命令缓存与转发 |
| **play.html** | 3D 渲染;须保持打开才会执行动作 |
播放页未打开时,命令会**缓存**;打开 `play.html` 后自动同步执行。
---
## 快速开始
### 1. 启动服务
**Windows(推荐):** 双击项目根目录 `start.bat`
```powershell
# 或命令行
.\start.ps1
```
**手动启动:**
```bash
python serve.py
# 默认端口 8765;可通过环境变量 PORT 修改
```
首次部署且需使用 `analyze_agv.py` 扫描 GLB 时:
```powershell
.\start.ps1 -Setup
```
### 2. 打开播放页
服务启动后,在浏览器中保持以下页面打开:
```
http://127.0.0.1:8765/viewer/play.html
```
配置页(编辑动作,非播放必需):
```
http://127.0.0.1:8765/viewer/index.html
```
### 3. 验证连通
```bash
curl http://127.0.0.1:8765/api/catalog
```
返回 `ok: true``data.vehicles` 非空即表示服务正常。
### 4. 发送第一条播放命令
```bash
curl -X POST http://127.0.0.1:8765/api/agv \
-H "Content-Type: application/json" \
-d "{\"version\":1,\"method\":\"playAction\",\"params\":{\"modelId\":\"英招\",\"actionId\":\"zuozhuan\"}}"
```
响应中 `data.subscribers >= 1` 表示播放页在线;为 `0` 时命令已缓存,打开播放页后会自动执行。
---
## 依赖说明
| 用途 | 要求 |
|------|------|
| 运行服务 + 播放 | Python 3.10+(标准库即可) |
| 前端 Three.js | CDN 加载,**无需 npm install** |
| GLB 扫描工具 | 可选,运行 `.\start.ps1 -Setup` 安装 |
---
## 接入方式
### 方式 AHTTP API(推荐)
业务系统直接请求 `serve.py` 提供的 REST 接口,适合 Python、Java、C#、Node.js 等任意语言。
### 方式 B:iframe 嵌入(适合前端大屏)
`play.html` 嵌入 iframe,通过 `postMessage``AgvPlaybackApi` 控制,协议字段与 HTTP 一致。
---
## 标准流程
```
① 启动 serve.py
② 打开 play.html(可全屏或 iframe 嵌入大屏)
③ GET /api/catalog → 获取 modelId、actionId
④ POST /api/agv → playAction / stopAction / clearActions
⑤ 检查 data.subscribers >= 1
```
---
## HTTP API
**Base URL** `http://127.0.0.1:8765`
**协议版本:** `version: 1`
**Content-Type** `application/json`
### GET /api/catalog — 查询目录
接入前先调用,获取可用的 `modelId``actionId`
```
GET http://127.0.0.1:8765/api/catalog
```
响应示例:
```json
{
"version": 1,
"ok": true,
"data": {
"vehicles": [
{
"modelId": "英招",
"name": "英招",
"actions": [
{ "id": "zuozhuan", "label": "左转" }
]
}
]
},
"error": null
}
```
### POST /api/agv — 控制播放
| method | 说明 | params |
|--------|------|--------|
| `playAction` | 播放动作,自动切换车辆 | `modelId` + `actionId` 必填 |
| `stopAction` | 停止指定动作 | `modelId` + `actionId` 必填 |
| `clearActions` | 停止该车全部动作 | 仅 `modelId` 必填 |
**playAction 示例:**
```json
{
"version": 1,
"method": "playAction",
"params": {
"modelId": "英招",
"actionId": "zuozhuan"
}
}
```
**成功响应:**
```json
{
"version": 1,
"ok": true,
"data": {
"dispatched": true,
"subscribers": 1,
"modelId": "英招",
"activeActions": ["zuozhuan"]
},
"error": null
}
```
**失败响应:**
```json
{
"version": 1,
"ok": false,
"data": null,
"error": {
"code": "INVALID_REQUEST",
"message": "params.modelId 必填"
}
}
```
| 响应字段 | 含义 |
|----------|------|
| `subscribers` | 当前连接的播放页数量;`0` = 无播放页,命令已缓存 |
| `activeActions` | 当前正在播放的动作 ID 列表 |
| `dispatched` | 命令是否已成功写入总线 |
更多请求/响应示例见 [AGV播放API.md](./AGV播放API.md)。
---
## 代码示例
### Python
```python
import requests
BASE = "http://127.0.0.1:8765"
def get_catalog():
r = requests.get(f"{BASE}/api/catalog", timeout=5)
r.raise_for_status()
return r.json()["data"]["vehicles"]
def play_action(model_id: str, action_id: str):
payload = {
"version": 1,
"method": "playAction",
"params": {"modelId": model_id, "actionId": action_id},
}
r = requests.post(f"{BASE}/api/agv", json=payload, timeout=5)
r.raise_for_status()
data = r.json()
if not data["ok"]:
raise RuntimeError(data["error"]["message"])
if data["data"]["subscribers"] == 0:
print("警告: 播放页未打开,命令已缓存")
return data
vehicles = get_catalog()
vehicle = vehicles[0]
action_id = vehicle["actions"][0]["id"]
play_action(vehicle["modelId"], action_id)
```
### JavaScript
```javascript
const BASE = "http://127.0.0.1:8765";
async function getCatalog() {
const res = await fetch(`${BASE}/api/catalog`);
const json = await res.json();
return json.data.vehicles;
}
async function playAction(modelId, actionId) {
const res = await fetch(`${BASE}/api/agv`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version: 1,
method: "playAction",
params: { modelId, actionId },
}),
});
const json = await res.json();
if (!json.ok) throw new Error(json.error.message);
return json;
}
```
### iframe 嵌入
```html
<iframe
id="agv-player"
src="http://127.0.0.1:8765/viewer/play.html"
style="width:100%;height:100%;border:none"
></iframe>
<script>
const iframe = document.getElementById("agv-player");
// postMessage(协议与 HTTP 一致)
iframe.contentWindow.postMessage(
{
version: 1,
id: "req-1",
method: "playAction",
params: { modelId: "英招", actionId: "zuozhuan" },
},
"*"
);
window.addEventListener("message", (e) => {
if (e.data?.id === "req-1") console.log("播放结果:", e.data);
});
// 同源时可直接调用 AgvPlaybackApi
iframe.onload = async () => {
const api = iframe.contentWindow.AgvPlaybackApi;
await api.request({
version: 1,
method: "playAction",
params: { modelId: "英招", actionId: "zuozhuan" },
});
};
</script>
```
---
## 业务映射建议
| 业务事件 | API 调用 |
|----------|----------|
| AGV 开始某动作 | `playAction` + 对应 `actionId` |
| AGV 停止当前动作 | `stopAction` + 对应 `actionId` |
| AGV 复位 / 全部停止 | `clearActions` |
| 切换 AGV 型号 | `playAction` 带新的 `modelId`(自动切车) |
建议启动时拉一次 catalog,将业务编码(如 `"TURN_LEFT"`)映射到 `actionId`,避免硬编码。
---
## 生产部署
1. **播放页常开**:大屏或工控机全屏打开 `play.html`,或用 iframe 嵌入业务页面
2. **单实例**:同一端口只运行一个 `serve.py`
3. **自定义端口**`PORT=9000 python serve.py``.\start.ps1 -Port 9000`
4. **健康检查**:定期 `GET /api/catalog` 确认服务存活
5. **新增车辆**:将 GLB + JSON 放入 `agv/`,更新 `agv/manifest.json`,重启服务后 catalog 自动更新
---
## 常见问题
| 现象 | 处理 |
|------|------|
| `subscribers: 0` | 先打开 `play.html` |
| 页面打不开 | 只保留一个 `serve.py` 进程 |
| Postman 卡住 | 重启 `serve.py` |
| `params.modelId 必填` | 三个 method 都要带 `modelId` |
| `UNKNOWN_METHOD` | POST 只能用 3 个 method;查目录用 GET |
| `动作不存在` | 先调 catalog 核对 `actionId` |
---
## 相关文档
| 文档 | 说明 |
|------|------|
| [AGV播放API.md](./AGV播放API.md) | HTTP 协议与请求/响应格式 |
| [AGV_GLB导出规范.md](./AGV_GLB导出规范.md) | GLB 模型导出与配置规范 |
---
*数字孪生开发组*
+12
View File
@@ -0,0 +1,12 @@
{
"name": "agv_glb",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+182
View File
@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
"""播放命令总线:HTTP 写入期望状态,播放页通过 SSE 订阅并执行。"""
from __future__ import annotations
import queue
import threading
import time
from typing import Any
REMOTE_METHODS = frozenset({"playAction", "stopAction", "clearActions"})
def _as_string(v: Any) -> str:
return v.strip() if isinstance(v, str) else ""
def _as_string_list(v: Any) -> list[str]:
if isinstance(v, list):
return [_as_string(x) for x in v if _as_string(x)]
if isinstance(v, str):
return [s for s in v.replace("+", ",").split(",") if s.strip()]
return []
class PlaybackHub:
def __init__(self) -> None:
self._lock = threading.Lock()
self._revision = 0
self._state: dict[str, Any] = {"modelId": None, "activeActions": []}
self._subscribers: list[queue.Queue] = []
def _snapshot_locked(self) -> dict[str, Any]:
return {
"modelId": self._state.get("modelId"),
"activeActions": list(self._state.get("activeActions") or []),
"revision": self._revision,
"updatedAt": self._state.get("updatedAt"),
}
def get_state(self) -> dict[str, Any]:
with self._lock:
return self._snapshot_locked()
def subscriber_count(self) -> int:
with self._lock:
return len(self._subscribers)
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=128)
with self._lock:
self._subscribers.append(q)
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
if q in self._subscribers:
self._subscribers.remove(q)
def _bump_locked(self) -> int:
self._revision += 1
self._state["updatedAt"] = time.time()
return self._revision
def _broadcast_locked(self, message: dict[str, Any]) -> None:
dead: list[queue.Queue] = []
for sub in self._subscribers:
try:
sub.put_nowait(message)
except queue.Full:
dead.append(sub)
for sub in dead:
if sub in self._subscribers:
self._subscribers.remove(sub)
def _set_model(self, model_id: str) -> None:
if self._state.get("modelId") != model_id:
self._state["activeActions"] = []
self._state["modelId"] = model_id
def _require_model(self, params: dict[str, Any]) -> str:
model_id = _as_string(params.get("modelId") or params.get("model"))
if not model_id:
raise ValueError("params.modelId 必填")
self._set_model(model_id)
return model_id
def _apply(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
params = params if isinstance(params, dict) else {}
if method == "playAction":
model_id = self._require_model(params)
active = list(self._state.get("activeActions") or [])
ids = _as_string_list(params.get("actionIds") or params.get("actions"))
if not ids:
one = _as_string(params.get("actionId") or params.get("action"))
if one:
ids = [one]
if not ids:
raise ValueError("params.actionId 必填")
for action_id in ids:
if action_id not in active:
active.append(action_id)
self._state["activeActions"] = active
return {"modelId": model_id, "played": ids, "activeActions": active}
if method == "stopAction":
model_id = self._require_model(params)
active = list(self._state.get("activeActions") or [])
ids = _as_string_list(params.get("actionIds") or params.get("actions"))
if not ids:
one = _as_string(params.get("actionId") or params.get("action"))
if one:
ids = [one]
if not ids:
raise ValueError("params.actionId 必填")
active = [a for a in active if a not in ids]
self._state["activeActions"] = active
return {"modelId": model_id, "stopped": ids, "activeActions": active}
if method == "clearActions":
model_id = self._require_model(params)
self._state["activeActions"] = []
return {"modelId": model_id, "activeActions": []}
raise ValueError(f"不支持的 method: {method}")
def _emit_locked(self, method: str, params: dict[str, Any], req_id: Any, source_client_id: str | None) -> dict[str, Any]:
data = self._apply(method, params)
revision = self._bump_locked()
state = self._snapshot_locked()
self._broadcast_locked(
{
"version": 1,
"id": req_id,
"revision": revision,
"method": method,
"params": params,
"state": state,
"sourceClientId": source_client_id,
}
)
return {
"dispatched": True,
"subscribers": len(self._subscribers),
"revision": revision,
"state": state,
**data,
}
def dispatch(self, method: str, params: dict[str, Any] | None, req_id: Any = None) -> dict[str, Any]:
if method not in REMOTE_METHODS:
raise ValueError(f"method「{method}」不可远程调度")
with self._lock:
return self._emit_locked(method, params or {}, req_id, None)
def report_from_client(
self,
client_id: str,
model_id: str | None,
active_actions: list[str] | None,
) -> dict[str, Any]:
with self._lock:
if model_id:
self._state["modelId"] = model_id
if active_actions is not None:
self._state["activeActions"] = list(active_actions)
revision = self._bump_locked()
state = self._snapshot_locked()
self._broadcast_locked(
{
"version": 1,
"revision": revision,
"method": "syncState",
"params": {},
"state": state,
"sourceClientId": client_id,
}
)
return {"revision": revision, "state": state, "subscribers": len(self._subscribers)}
HUB = PlaybackHub()
@@ -0,0 +1,3 @@
numpy>=1.24.0
trimesh>=4.0.0
pygltflib>=1.16.0
+379
View File
@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""本地预览:UTF-8 静态资源 + 配置落盘 API。用法: python serve.py"""
from __future__ import annotations
import http.server
import json
import os
import queue
import sys
from pathlib import Path
from playback_hub import HUB
ROOT = Path(__file__).resolve().parent
AGV_DIR = (ROOT / "agv").resolve()
PORT = int(os.environ.get("PORT", "8765"))
UTF8_TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml; charset=utf-8",
}
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def end_headers(self):
ext = Path(self.path.split("?", 1)[0]).suffix.lower()
if ext in UTF8_TYPES:
self.send_header("Content-Type", UTF8_TYPES[ext])
if ext in {".html", ".js", ".json", ".css"}:
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def do_OPTIONS(self):
path = self.path.split("?", 1)[0]
if path.startswith("/api/"):
self.send_response(204)
self._cors_headers()
self.end_headers()
return
self.send_error(404, "Not Found")
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/api/catalog":
self._json_response(200, self._build_catalog_response())
return
if path == "/api/agv/state":
state = HUB.get_state()
self._json_response(
200,
{
"version": 1,
"ok": True,
"data": {**state, "subscribers": HUB.subscriber_count()},
"error": None,
},
)
return
if path == "/api/agv/events":
self._agv_events_sse()
return
super().do_GET()
def do_POST(self):
path = self.path.split("?", 1)[0]
if path == "/api/save-config":
self._save_config()
return
if path == "/api/agv":
self._agv_api()
return
if path == "/api/agv/report":
self._agv_report()
return
self.send_error(404, "Not Found")
def _save_config(self):
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
payload = json.loads(raw)
filename = payload["file"]
config = payload["config"]
except (KeyError, json.JSONDecodeError, UnicodeDecodeError) as e:
self._json_response(400, {"ok": False, "error": f"请求体无效: {e}"})
return
if not isinstance(filename, str) or not filename.endswith(".json"):
self._json_response(400, {"ok": False, "error": "仅允许保存 agv/*.json"})
return
if "/" in filename or "\\" in filename or ".." in filename:
self._json_response(400, {"ok": False, "error": "非法文件名"})
return
target = (AGV_DIR / filename).resolve()
if not str(target).startswith(str(AGV_DIR)):
self._json_response(403, {"ok": False, "error": "路径越界"})
return
try:
AGV_DIR.mkdir(parents=True, exist_ok=True)
text = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
target.write_text(text, encoding="utf-8")
except OSError as e:
self._json_response(500, {"ok": False, "error": str(e)})
return
self._json_response(200, {"ok": True, "file": filename, "path": str(target.relative_to(ROOT))})
def _read_manifest_models(self) -> list[dict]:
manifest_path = AGV_DIR / "manifest.json"
if not manifest_path.exists():
return []
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
models = data.get("models")
return models if isinstance(models, list) else []
def _map_actions(self, cfg: dict) -> list[dict]:
actions = cfg.get("actions") or {}
if not isinstance(actions, dict):
return []
out = []
for action_id, act in actions.items():
if not isinstance(act, dict):
continue
out.append(
{
"id": action_id,
"label": act.get("label") or action_id,
"uiClass": act.get("uiClass") or "mode-idle",
}
)
return out
def _build_catalog_response(self) -> dict:
vehicles = []
for m in self._read_manifest_models():
if not isinstance(m, dict):
continue
model_id = m.get("id") or ""
cfg_file = m.get("config") or (f"{model_id}.json" if model_id else "")
item = {
"modelId": model_id,
"name": m.get("name") or model_id,
"file": m.get("file"),
"config": cfg_file,
"root": m.get("root"),
"actions": [],
"configError": None,
}
if not cfg_file:
item["configError"] = "缺少 config 字段"
vehicles.append(item)
continue
cfg_path = (AGV_DIR / cfg_file).resolve()
if not str(cfg_path).startswith(str(AGV_DIR)) or not cfg_path.exists():
item["configError"] = f"缺少配置 {cfg_file}"
vehicles.append(item)
continue
try:
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
item["actions"] = self._map_actions(cfg)
item["root"] = m.get("root") or (cfg.get("model") or {}).get("root") or item["root"]
except (OSError, json.JSONDecodeError) as e:
item["configError"] = str(e)
vehicles.append(item)
return {"version": 1, "ok": True, "data": {"vehicles": vehicles}, "error": None}
def _agv_api(self):
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
payload = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self._json_response(400, {"version": 1, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": str(e)}})
return
req_id = payload.get("id")
version = payload.get("version")
method = (payload.get("method") or "").strip()
if version != 1:
self._json_response(400, {"version": 1, "id": req_id, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": "version 须为 1"}})
return
if not method:
self._json_response(400, {"version": 1, "id": req_id, "ok": False, "data": None, "error": {"code": "INVALID_REQUEST", "message": "缺少 method"}})
return
if method not in {"playAction", "stopAction", "clearActions"}:
self._json_response(
400,
{
"version": 1,
"id": req_id,
"ok": False,
"data": None,
"error": {
"code": "UNKNOWN_METHOD",
"message": "POST /api/agv 仅支持 playAction、stopAction、clearActions;查询请用 GET /api/catalog",
},
},
)
return
try:
params = payload.get("params") or {}
self._validate_remote(method, params)
data = HUB.dispatch(method, params, req_id)
hint = None
if data.get("subscribers", 0) == 0:
hint = "无播放页在线,命令已缓存;打开 play.html 后将自动同步"
self._json_response(
200,
{
"version": 1,
"id": req_id,
"ok": True,
"data": data,
"hint": hint,
"error": None,
},
)
except ValueError as e:
self._json_response(
400,
{
"version": 1,
"id": req_id,
"ok": False,
"data": None,
"error": {"code": "INVALID_REQUEST", "message": str(e)},
},
)
def _find_model(self, model_id: str) -> dict | None:
for m in self._read_manifest_models():
if isinstance(m, dict) and m.get("id") == model_id:
return m
return None
def _load_model_config(self, model_id: str) -> tuple[dict | None, str | None]:
entry = self._find_model(model_id)
if not entry:
return None, f"模型不存在: {model_id}"
cfg_file = entry.get("config") or f"{model_id}.json"
cfg_path = (AGV_DIR / cfg_file).resolve()
if not str(cfg_path).startswith(str(AGV_DIR)) or not cfg_path.exists():
return None, f"缺少配置 {cfg_file}"
try:
return json.loads(cfg_path.read_text(encoding="utf-8")), None
except (OSError, json.JSONDecodeError) as e:
return None, str(e)
@staticmethod
def _pick_action_ids(params: dict) -> list[str]:
ids = params.get("actionIds") or params.get("actions")
if isinstance(ids, list):
return [str(x).strip() for x in ids if str(x).strip()]
if isinstance(ids, str):
return [s.strip() for s in ids.replace("+", ",").split(",") if s.strip()]
one = params.get("actionId") or params.get("action")
return [str(one).strip()] if one else []
def _validate_remote(self, method: str, params: dict) -> None:
model_id = (params.get("modelId") or params.get("model") or "").strip()
if not model_id:
raise ValueError("params.modelId 必填")
if not self._find_model(model_id):
raise ValueError(f"模型不存在: {model_id}")
if method == "clearActions":
return
cfg, err = self._load_model_config(model_id)
if err:
raise ValueError(err)
if method in {"playAction", "stopAction"}:
action_ids = self._pick_action_ids(params)
if not action_ids:
raise ValueError("params.actionId 必填")
known = set((cfg.get("actions") or {}).keys())
for action_id in action_ids:
if action_id not in known:
raise ValueError(f"动作不存在: {action_id}(模型 {model_id}")
def _agv_report(self):
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
payload = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
self._json_response(400, {"ok": False, "error": str(e)})
return
client_id = str(payload.get("clientId") or "").strip()
if not client_id:
self._json_response(400, {"ok": False, "error": "clientId 必填"})
return
model_id = payload.get("modelId")
if isinstance(model_id, str):
model_id = model_id.strip() or None
active = payload.get("activeActions")
if active is not None and not isinstance(active, list):
self._json_response(400, {"ok": False, "error": "activeActions 须为数组"})
return
data = HUB.report_from_client(client_id, model_id, active)
self._json_response(200, {"ok": True, "data": data})
def _cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _sse_write(self, payload: dict):
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
self.wfile.write(line.encode("utf-8"))
self.wfile.flush()
def _agv_events_sse(self):
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self._cors_headers()
self.end_headers()
sub = HUB.subscribe()
try:
state = HUB.get_state()
self._sse_write({"type": "connected", "revision": state.get("revision", 0), "state": state})
while True:
try:
msg = sub.get(timeout=15)
self._sse_write(msg)
except queue.Empty:
self._sse_write({"type": "ping"})
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
HUB.unsubscribe(sub)
def _json_response(self, code: int, data: dict):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self._cors_headers()
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def main():
os.chdir(ROOT)
with http.server.ThreadingHTTPServer(("", PORT), Handler) as httpd:
print(f"Serving {ROOT}")
print(f"配置页: http://127.0.0.1:{PORT}/viewer/index.html")
print(f"播放页: http://127.0.0.1:{PORT}/viewer/play.html")
print(f"远程API: GET /api/catalog | POST /api/agv (playAction / stopAction / clearActions)")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n已停止服务")
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
@echo off
chcp 65001 >nul 2>&1
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start.ps1" %*
if errorlevel 1 (
echo.
pause
)
+143
View File
@@ -0,0 +1,143 @@
# AGV GLB 一键启动
# 用法:
# .\start.ps1 启动服务并打开播放页
# .\start.ps1 -Setup 首次部署:创建 .venv 并安装 requirements.txt
# .\start.ps1 -NoBrowser 仅启动服务,不打开浏览器
param(
[switch]$Setup,
[switch]$NoBrowser,
[int]$Port = 8765
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
function Write-Step([string]$Msg) {
Write-Host ('>> ' + $Msg) -ForegroundColor Cyan
}
function Find-Python {
foreach ($cmd in @('py -3', 'python3', 'python')) {
try {
$ver = Invoke-Expression ($cmd + ' --version 2>&1') | Out-String
if ($ver -match 'Python (\d+)\.(\d+)') {
$major = [int]$Matches[1]
$minor = [int]$Matches[2]
if ($major -gt 3 -or ($major -eq 3 -and $minor -ge 10)) {
return @{ Cmd = $cmd; Version = ($major.ToString() + '.' + $minor.ToString()) }
}
}
} catch { }
}
return $null
}
function Get-VenvPython {
$winPy = Join-Path $Root '.venv\Scripts\python.exe'
if (Test-Path $winPy) { return $winPy }
$unixPy = Join-Path $Root '.venv/bin/python'
if (Test-Path $unixPy) { return $unixPy }
return $null
}
function Test-PortInUse([int]$P) {
try {
$conn = Get-NetTCPConnection -LocalPort $P -State Listen -ErrorAction SilentlyContinue
return [bool]$conn
} catch {
$out = netstat -ano | Select-String (':' + $P + '\s+.*LISTENING')
return [bool]$out
}
}
function Wait-ServerReady([string]$BaseUrl, [int]$TimeoutSec = 30) {
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -Uri ($BaseUrl + '/api/catalog') -UseBasicParsing -TimeoutSec 2
if ($resp.StatusCode -eq 200) { return $true }
} catch {
Start-Sleep -Milliseconds 400
}
}
return $false
}
Write-Host ''
Write-Host ' AGV GLB 本地服务' -ForegroundColor Green
Write-Host '==================' -ForegroundColor Green
Write-Host ''
$pyInfo = Find-Python
if (-not $pyInfo) {
Write-Host '错误: 未找到 Python 3.10+,请从 https://www.python.org/downloads/ 安装' -ForegroundColor Red
exit 1
}
Write-Step ('Python ' + $pyInfo.Version + ' (' + $pyInfo.Cmd + ')')
$venvPy = Get-VenvPython
if ($Setup) {
Write-Step '创建虚拟环境 .venv ...'
if (-not $venvPy) {
Invoke-Expression ($pyInfo.Cmd + ' -m venv .venv')
$venvPy = Get-VenvPython
if (-not $venvPy) {
Write-Host '错误: 虚拟环境创建失败' -ForegroundColor Red
exit 1
}
}
Write-Step '安装 Python 依赖 (analyze_agv.py 等工具需要) ...'
& $venvPy -m pip install --upgrade pip
& $venvPy -m pip install -r requirements.txt
Write-Host '依赖安装完成。' -ForegroundColor Green
}
if ($venvPy) {
Write-Step '使用虚拟环境: .venv'
} else {
Write-Step '使用系统 Pythonserve.py 无需额外依赖)'
Write-Host '提示: 运行 .\start.ps1 -Setup 可安装 analyze_agv.py 所需依赖' -ForegroundColor DarkYellow
}
$baseUrl = 'http://127.0.0.1:' + $Port
if (Test-PortInUse $Port) {
Write-Host ('警告: 端口 ' + $Port + ' 已被占用。若 serve.py 已在运行,可直接访问:') -ForegroundColor Yellow
Write-Host (' 播放页: ' + $baseUrl + '/viewer/play.html') -ForegroundColor White
Write-Host (' 配置页: ' + $baseUrl + '/viewer/index.html') -ForegroundColor White
if (-not $NoBrowser) {
Start-Process ($baseUrl + '/viewer/play.html')
}
exit 0
}
$env:PORT = $Port.ToString()
Write-Step ('启动 serve.py,端口 ' + $Port + ' ...')
if ($venvPy) {
$proc = Start-Process -FilePath $venvPy -ArgumentList 'serve.py' -WorkingDirectory $Root -PassThru -WindowStyle Normal
} else {
$arg = '/k ' + $pyInfo.Cmd + ' serve.py'
$proc = Start-Process -FilePath 'cmd.exe' -ArgumentList $arg -WorkingDirectory $Root -PassThru -WindowStyle Normal
}
Write-Step '等待服务就绪 ...'
if (-not (Wait-ServerReady $baseUrl)) {
Write-Host '错误: 服务 30 秒内未响应,请查看 serve.py 窗口中的报错。' -ForegroundColor Red
exit 1
}
Write-Host ''
Write-Host ('服务已启动 (PID ' + $proc.Id + ')') -ForegroundColor Green
Write-Host (' 播放页: ' + $baseUrl + '/viewer/play.html') -ForegroundColor White
Write-Host (' 配置页: ' + $baseUrl + '/viewer/index.html') -ForegroundColor White
Write-Host (' API: GET ' + $baseUrl + '/api/catalog | POST ' + $baseUrl + '/api/agv') -ForegroundColor White
Write-Host ''
Write-Host ('关闭服务: 在 serve.py 窗口按 Ctrl+C,或结束 PID ' + $proc.Id) -ForegroundColor DarkGray
Write-Host ''
if (-not $NoBrowser) {
Start-Process ($baseUrl + '/viewer/play.html')
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# AGV GLB 一键启动(Linux / macOS
# 用法:
# ./start.sh 启动服务并打开播放页
# ./start.sh --setup 首次部署:创建 .venv 并安装 requirements.txt
# ./start.sh --no-browser 仅启动服务
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
SETUP=0
NO_BROWSER=0
PORT="${PORT:-8765}"
for arg in "$@"; do
case "$arg" in
--setup) SETUP=1 ;;
--no-browser) NO_BROWSER=1 ;;
-h|--help)
echo "用法: ./start.sh [--setup] [--no-browser]"
exit 0
;;
esac
done
step() { printf '>> %s\n' "$1"; }
find_python() {
for cmd in python3 python py; do
if command -v "$cmd" >/dev/null 2>&1; then
ver=$("$cmd" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)
major=${ver%%.*}
minor=${ver#*.}
if [ "$major" -gt 3 ] 2>/dev/null || { [ "$major" -eq 3 ] && [ "$minor" -ge 10 ]; }; then
echo "$cmd"
return 0
fi
fi
done
return 1
}
venv_python() {
if [ -x "$ROOT/.venv/bin/python" ]; then
echo "$ROOT/.venv/bin/python"
fi
}
port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | grep -q ":${PORT} "
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1
else
return 1
fi
}
wait_ready() {
local url="http://127.0.0.1:${PORT}/api/catalog"
for _ in $(seq 1 75); do
if curl -sf "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.4
done
return 1
}
open_browser() {
local url="http://127.0.0.1:${PORT}/viewer/play.html"
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
fi
}
echo ""
echo " AGV GLB 本地服务"
echo "=================="
echo ""
PY=$(find_python) || { echo "错误: 未找到 Python 3.10+"; exit 1; }
step "Python $($PY --version 2>&1 | awk '{print $2}') ($PY)"
VENV_PY=$(venv_python || true)
if [ "$SETUP" -eq 1 ]; then
step "创建虚拟环境 .venv ..."
if [ ! -x "$ROOT/.venv/bin/python" ]; then
"$PY" -m venv .venv
fi
VENV_PY="$ROOT/.venv/bin/python"
step "安装 Python 依赖 ..."
"$VENV_PY" -m pip install --upgrade pip
"$VENV_PY" -m pip install -r requirements.txt
echo "依赖安装完成。"
fi
RUN_PY="${VENV_PY:-$PY}"
if [ -n "$VENV_PY" ]; then
step "使用虚拟环境: .venv"
else
step "使用系统 Pythonserve.py 无需额外依赖)"
echo "提示: 运行 ./start.sh --setup 可安装 analyze_agv.py 所需依赖"
fi
BASE="http://127.0.0.1:${PORT}"
if port_in_use; then
echo "警告: 端口 ${PORT} 已被占用。"
echo " 播放页: ${BASE}/viewer/play.html"
echo " 配置页: ${BASE}/viewer/index.html"
[ "$NO_BROWSER" -eq 0 ] && open_browser
exit 0
fi
step "启动 serve.py (端口 ${PORT}) ..."
export PORT
"$RUN_PY" serve.py &
SRV_PID=$!
step "等待服务就绪 ..."
if ! wait_ready; then
echo "错误: 服务在 30 秒内未响应。"
kill "$SRV_PID" 2>/dev/null || true
exit 1
fi
echo ""
echo "服务已启动 (PID ${SRV_PID})"
echo " 播放页: ${BASE}/viewer/play.html"
echo " 配置页: ${BASE}/viewer/index.html"
echo " API: GET ${BASE}/api/catalog | POST ${BASE}/api/agv"
echo ""
echo "关闭服务: kill ${SRV_PID} 或 Ctrl+C"
echo ""
[ "$NO_BROWSER" -eq 0 ] && open_browser
wait "$SRV_PID"
+227
View File
@@ -0,0 +1,227 @@
/**
* AGV 播放页外部 API:标准化请求/响应格式。
*
* 嵌入 iframe 时父页面:
* await iframe.contentWindow.AgvPlaybackApi.getCatalog()
* await iframe.contentWindow.AgvPlaybackApi.request({ version: 1, method: "playAction", params: { modelId: "英招", actionId: "dongzuo1" } })
*
* 跨窗口 postMessage(父 → 播放页):
* iframe.contentWindow.postMessage({ version: 1, method: "playAction", params: { modelId: "英招", actionId: "dongzuo1" }, id: "req-1" }, "*")
* 播放页回复:{ version: 1, id: "req-1", ok: true, data: { ... } }
*/
export const API_VERSION = 1;
export const METHODS = {
PLAY_ACTION: "playAction",
STOP_ACTION: "stopAction",
CLEAR_ACTIONS: "clearActions",
};
const ALLOWED_METHODS = new Set(Object.values(METHODS));
const ERROR = {
INVALID_REQUEST: "INVALID_REQUEST",
UNKNOWN_METHOD: "UNKNOWN_METHOD",
INTERNAL_ERROR: "INTERNAL_ERROR",
};
function isPlainObject(v) {
return v != null && typeof v === "object" && !Array.isArray(v);
}
function asString(v) {
return typeof v === "string" ? v.trim() : "";
}
function asStringList(v) {
if (Array.isArray(v)) return v.map(asString).filter(Boolean);
if (typeof v === "string") return v.split(/[,+\s]+/).map((s) => s.trim()).filter(Boolean);
return [];
}
async function ensureModel(controller, params) {
const modelId = asString(params.modelId ?? params.model);
if (!modelId) return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "params.modelId 必填" } };
const cur = controller.getState();
if (cur.modelId === modelId && cur.ready) return { ok: true };
await controller.loadModel(modelId);
return { ok: true };
}
/** @typedef {{ version: number, id?: string, method: string, params?: object }} AgvApiRequest */
/** @typedef {{ version: number, id?: string, ok: boolean, data?: object|null, error?: { code: string, message: string }|null }} AgvApiResponse */
/**
* 校验并规范化入站请求。
* @param {unknown} raw
* @returns {{ ok: true, request: AgvApiRequest } | { ok: false, error: { code: string, message: string } } }
*/
export function parseRequest(raw) {
if (!isPlainObject(raw)) {
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "请求体须为 JSON 对象" } };
}
const version = Number(raw.version);
if (version !== API_VERSION) {
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: `不支持的 version,当前为 ${API_VERSION}` } };
}
const method = asString(raw.method);
if (!method) {
return { ok: false, error: { code: ERROR.INVALID_REQUEST, message: "缺少 method" } };
}
if (!ALLOWED_METHODS.has(method)) {
return { ok: false, error: { code: ERROR.UNKNOWN_METHOD, message: "仅支持 playAction、stopAction、clearActions" } };
}
const params = isPlainObject(raw.params) ? raw.params : {};
const id = raw.id != null ? String(raw.id) : undefined;
return { ok: true, request: { version, method, params, id } };
}
/**
* @param {string|undefined} id
* @param {boolean} ok
* @param {object|null} [data]
* @param {{ code: string, message: string }|null} [error]
* @returns {AgvApiResponse}
*/
export function formatResponse(id, ok, data = null, error = null) {
return { version: API_VERSION, id, ok, data, error };
}
/**
* @param {object} controller
* @param {{ onStateChange?: (state: object) => void, allowedOrigins?: string[]|null }} [options]
*/
export function createPlaybackApi(controller, options = {}) {
const allowedOrigins = options.allowedOrigins ?? null;
let messageBound = false;
function emitState() {
try {
const state = controller.getState();
options.onStateChange?.(state);
dispatchEvent(new CustomEvent("agv-playback", { detail: { type: "state", state } }));
} catch (_) { /* ignore */ }
}
async function dispatch(req) {
const { method, params = {} } = req;
try {
switch (method) {
case METHODS.PLAY_ACTION: {
const ensured = await ensureModel(controller, params);
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
const ids = asStringList(params.actionIds ?? params.actions).length
? asStringList(params.actionIds ?? params.actions)
: [asString(params.actionId ?? params.action)];
const played = [];
for (const id of ids) {
if (!id) continue;
const r = controller.playAction(id);
if (!r.ok) return formatResponse(req.id, false, null, r.error);
played.push(id);
}
if (!played.length) {
return formatResponse(req.id, false, null, { code: ERROR.INVALID_REQUEST, message: "params.actionId 必填" });
}
emitState();
return formatResponse(req.id, true, {
modelId: controller.getState().modelId,
activeActions: controller.getState().activeActions,
played,
});
}
case METHODS.STOP_ACTION: {
const ensured = await ensureModel(controller, params);
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
const ids = asStringList(params.actionIds ?? params.actions).length
? asStringList(params.actionIds ?? params.actions)
: [asString(params.actionId ?? params.action)];
const stopped = [];
for (const id of ids) {
if (!id) continue;
const r = controller.stopAction(id);
if (!r.ok) return formatResponse(req.id, false, null, r.error);
stopped.push(id);
}
if (!stopped.length) {
return formatResponse(req.id, false, null, { code: ERROR.INVALID_REQUEST, message: "params.actionId 必填" });
}
emitState();
return formatResponse(req.id, true, {
modelId: controller.getState().modelId,
activeActions: controller.getState().activeActions,
stopped,
});
}
case METHODS.CLEAR_ACTIONS: {
const ensured = await ensureModel(controller, params);
if (!ensured.ok) return formatResponse(req.id, false, null, ensured.error);
controller.clearActions();
emitState();
return formatResponse(req.id, true, { modelId: controller.getState().modelId, activeActions: [] });
}
default:
return formatResponse(req.id, false, null, { code: ERROR.UNKNOWN_METHOD, message: "仅支持 playAction、stopAction、clearActions" });
}
} catch (e) {
return formatResponse(req.id, false, null, {
code: ERROR.INTERNAL_ERROR,
message: e?.message || String(e),
});
}
}
function isOriginAllowed(origin) {
if (!allowedOrigins || allowedOrigins.includes("*")) return true;
return allowedOrigins.includes(origin);
}
function onMessage(event) {
if (!isOriginAllowed(event.origin)) return;
const parsed = parseRequest(event.data);
if (!parsed.ok) {
const res = formatResponse(undefined, false, null, parsed.error);
event.source?.postMessage?.(res, event.origin);
return;
}
if (!parsed.request.method) return;
Promise.resolve(dispatch(parsed.request)).then((res) => {
if (event.source && typeof event.source.postMessage === "function") {
event.source.postMessage(res, event.origin);
}
});
}
function attach(target = window) {
const api = {
version: API_VERSION,
METHODS,
request: (raw) => {
const parsed = parseRequest({ version: API_VERSION, ...raw });
if (!parsed.ok) return Promise.resolve(formatResponse(raw?.id, false, null, parsed.error));
return Promise.resolve(dispatch(parsed.request));
},
getCatalog: () => fetch("/api/catalog").then((r) => r.json()),
getState: () => controller.getState(),
parseRequest,
formatResponse,
};
target.AgvPlaybackApi = api;
if (!messageBound) {
addEventListener("message", onMessage);
messageBound = true;
}
return api;
}
function detach() {
if (messageBound) {
removeEventListener("message", onMessage);
messageBound = false;
}
delete window.AgvPlaybackApi;
}
return { attach, detach, dispatch, parseRequest, formatResponse };
}
File diff suppressed because it is too large Load Diff
+932
View File
@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AGV 配置</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel2: #1f2633;
--card: #1b2230;
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--text-dim: #6e7681;
--inspector-w: 360px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
#app {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
min-height: 0;
}
/* ── Top bar ── */
#topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.agv-info {
flex: 1;
min-width: 0;
}
#agvInfoName {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agv-stats {
display: flex;
gap: 12px;
margin-top: 2px;
font-size: 11px;
color: var(--text-muted);
}
.agv-stats span::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
margin-right: 5px;
vertical-align: middle;
}
.agv-stats span:nth-child(2)::before {
background: var(--success);
}
.agv-stats span:nth-child(3)::before {
background: var(--warning);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#agvModel {
background: var(--panel2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
min-width: 120px;
}
.topbar-link {
font-size: 11px;
color: var(--primary);
text-decoration: none;
white-space: nowrap;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 6px;
}
.topbar-link:hover {
background: var(--panel2);
}
#syncBaseline {
padding: 5px 14px;
border: none;
border-radius: 6px;
background: var(--primary);
color: #fff;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
#syncBaseline:hover {
background: var(--primary-hover);
}
/* ── Main layout ── */
#layout {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--inspector-w);
min-height: 0;
overflow: hidden;
}
#canvas-wrap {
position: relative;
min-width: 0;
min-height: 0;
background: #080b10;
border-right: 1px solid var(--border);
}
#canvas-wrap::after {
content: "点击选中 · 红X 绿Y 蓝Z";
position: absolute;
bottom: 8px;
left: 10px;
z-index: 2;
font-size: 10px;
color: var(--text-dim);
pointer-events: none;
}
#canvas-wrap canvas {
display: block;
width: 100%;
height: 100%;
}
/* ── Inspector ── */
#inspector {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
overflow: hidden;
}
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.tab {
flex: 1;
padding: 9px 0;
border: none;
background: transparent;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.tab:hover {
color: var(--text);
background: var(--panel2);
}
.tab.active {
color: var(--text);
font-weight: 600;
border-bottom-color: var(--primary);
}
.tab-panel {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 10px;
scrollbar-gutter: stable;
}
/* ── Form elements ── */
label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
margin-bottom: 5px;
gap: 8px;
color: var(--text-muted);
}
label span {
flex-shrink: 0;
}
select,
input[type="text"],
input[type="number"],
input[type="search"] {
flex: 1;
max-width: 62%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 5px;
padding: 4px 7px;
font-size: 12px;
}
input[type="color"] {
flex: none;
width: 38px;
height: 24px;
max-width: 38px;
padding: 1px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
}
input[type="checkbox"] {
accent-color: var(--primary);
}
button {
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: var(--primary);
color: #fff;
cursor: pointer;
font-size: 12px;
margin-top: 6px;
}
button:hover {
background: var(--primary-hover);
}
button.secondary {
background: var(--panel2);
border: 1px solid var(--border);
color: var(--text);
}
button.secondary:hover {
background: var(--card);
}
button.primary-green {
background: #166534;
}
button.primary-green:hover {
background: #15803d;
}
.hidden {
display: none !important;
}
.hint {
color: var(--text-dim);
font-size: 10px;
line-height: 1.4;
margin: 2px 0 6px;
}
.ok {
color: var(--success);
}
.err {
color: var(--danger);
}
/* ── Setting groups ── */
.setting-group {
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
}
.setting-group-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
/* ── Component cards ── */
.component-scroll {
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.component-card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 7px;
background: var(--card);
cursor: pointer;
border: 1px solid var(--border);
transition: border-color 0.12s, box-shadow 0.12s;
}
.component-card:hover {
border-color: #484f58;
background: #222b3a;
}
.component-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
background: #1a2744;
}
.component-card.in-draft {
border-color: #1d4ed8;
opacity: 0.85;
}
.component-card.compact {
padding: 6px 8px;
}
.component-card-icon {
font-size: 14px;
width: 22px;
text-align: center;
flex-shrink: 0;
color: var(--text-muted);
}
.component-card-body {
min-width: 0;
flex: 1;
}
.component-card-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.component-card-type {
font-size: 10px;
color: var(--text-dim);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comp-meta {
font-size: 10px;
color: var(--text-muted);
margin-bottom: 6px;
word-break: break-all;
line-height: 1.45;
}
.comp-meta strong {
color: var(--text);
}
.comp-meta code {
font-size: 10px;
color: #79c0ff;
}
/* ── Workflow steps ── */
.workflow-step {
margin-bottom: 10px;
}
.workflow-step-title {
font-size: 10px;
font-weight: 600;
color: var(--primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.workflow-step-title::before {
content: attr(data-step);
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 9px;
flex-shrink: 0;
}
.row-btns {
display: flex;
gap: 6px;
margin-top: 4px;
}
.row-btns button {
flex: 1;
margin-top: 0;
}
/* ── Action cards ── */
#actionList {
display: flex;
flex-direction: column;
gap: 5px;
}
.action-card {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 7px;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.12s;
}
.action-card:hover {
border-color: #484f58;
}
.action-card.active {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success);
}
.action-card-body {
flex: 1;
min-width: 0;
}
.action-card-title {
font-size: 12px;
font-weight: 500;
}
.action-card-meta {
font-size: 10px;
color: var(--text-dim);
margin-top: 1px;
}
.action-card-del {
width: 24px;
height: 24px;
flex-shrink: 0;
margin: 0;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--danger);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.action-card-del:hover {
background: #3d1515;
border-color: var(--danger);
}
.action-comp-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
background: var(--card);
border: 1px solid var(--border);
cursor: pointer;
}
.action-comp-chip.active {
border-color: var(--success);
box-shadow: 0 0 0 1px var(--success);
}
.action-comp-chip .chip-del {
margin-left: auto;
border: none;
background: transparent;
color: var(--danger);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
}
/* ── Status bar ── */
#statusbar {
padding: 4px 12px;
font-size: 10px;
color: var(--text-dim);
background: var(--panel);
border-top: 1px solid var(--border);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Toast ── */
#toast-container {
position: fixed;
bottom: 28px;
left: 12px;
z-index: 9999;
display: flex;
flex-direction: column-reverse;
align-items: flex-start;
gap: 8px;
pointer-events: none;
max-width: min(360px, calc(100vw - var(--inspector-w) - 24px));
}
.toast {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: var(--panel2);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
font-size: 12px;
max-width: 320px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-ok {
border-left: 3px solid var(--success);
}
.toast-warn {
border-left: 3px solid var(--warning);
}
.toast-err {
border-left: 3px solid var(--danger);
}
.toast-icon {
font-weight: 700;
flex-shrink: 0;
}
.toast-ok .toast-icon {
color: var(--success);
}
.toast-warn .toast-icon {
color: var(--warning);
}
@media (max-width: 900px) {
#layout {
grid-template-columns: 1fr;
grid-template-rows: 1fr var(--inspector-w);
}
#canvas-wrap {
border-right: none;
border-bottom: 1px solid var(--border);
}
#toast-container {
max-width: calc(100vw - 24px);
}
}
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<div class="agv-info">
<div id="agvInfoName"></div>
<div class="agv-stats">
<span id="statComponents">组件 0</span>
<span id="statActions">动作 0</span>
<span id="statLights">灯光 0</span>
</div>
</div>
<div class="topbar-actions">
<select id="agvModel">
<option value="">加载列表...</option>
</select>
<a class="topbar-link" href="./play.html">播放</a>
<button type="button" id="syncBaseline">保存配置</button>
</div>
</header>
<div id="layout">
<div id="canvas-wrap"></div>
<aside id="inspector">
<div class="tabs">
<button type="button" id="tabBaseline" class="tab active">基础配置</button>
<button type="button" id="tabActions" class="tab">动作配置</button>
</div>
<div id="tabPanelBaseline" class="tab-panel">
<input type="search" id="componentFilter" placeholder="筛选组件 / GLB 节点 / 车壳"
style="width:100%;max-width:100%;margin-bottom:8px;" />
<div id="componentList" class="component-scroll"></div>
<p id="compMeta" class="comp-meta"></p>
<div class="setting-group">
<div class="setting-group-title">组件类型</div>
<label><span>类型</span>
<select id="componentType">
<option value="static">静态件</option>
<option value="body">车体结构</option>
<option value="light">灯光</option>
<option value="wheel">轮子</option>
<option value="actuator">可动机构</option>
<option value="sensor">传感器</option>
<option value="safety">安全件</option>
</select>
</label>
</div>
<div class="setting-group" id="actionPanel">
<div class="setting-group-title">参数配置</div>
<div id="panelStatic" class="hint">静态件无动作参数,仅参与装配</div>
<div id="panelLight" class="hidden">
<label><span>颜色</span> <input id="compLightColor" type="color" value="#00ff55" /></label>
<label><span>光效</span>
<select id="compLightEffect">
<option value="solid">常亮</option>
<option value="breath">呼吸</option>
<option value="blink">闪烁</option>
<option value="strobe">爆闪</option>
<option value="flow_forward">前向流光</option>
<option value="flow_backward">反向流光</option>
<option value="blink_segment">分段闪</option>
</select>
</label>
<label><span>亮度</span> <input id="compLightIntensity" type="number" step="any" value="100" /></label>
<label id="compLightHzRow" class="hidden"><span>频率 Hz</span> <input id="compLightHz" type="number"
step="any" value="4" /></label>
<label id="compLightPeriodRow" class="hidden"><span>周期 s</span> <input id="compLightPeriod" type="number"
step="any" value="2" /></label>
</div>
<div id="panelWheel" class="hidden">
<label><span>旋转轴</span>
<select id="wheelAxis">
<option value="x">X</option>
<option value="y">Y</option>
<option value="z">Z</option>
</select>
</label>
<label><span>转速倍率</span> <input id="wheelSpeedFactor" type="number" step="any" value="1" /></label>
<label><span>转向角 (°)</span> <input id="wheelSteerAngleDeg" type="number" step="any" value="0" /></label>
</div>
<div id="panelMotion" class="hidden">
<label><span>机构角色</span>
<select id="componentMotionRole">
<option value="lift">顶升</option>
<option value="lidar">雷达</option>
<option value="camera">相机</option>
</select>
</label>
<label><span>运动方式</span>
<select id="componentMotionKind">
<option value="translate">平移</option>
<option value="rotate">旋转</option>
</select>
</label>
<label><span>轴向</span>
<select id="componentMotionAxis">
<option value="y">Y</option>
<option value="x">X</option>
<option value="z">Z</option>
</select>
</label>
<label id="liftStartRow" class="hidden"><span>起始 (m)</span> <input id="liftStart" type="number" step="any"
value="0" /></label>
<label id="liftEndRow" class="hidden"><span>终止 (m)</span> <input id="liftEnd" type="number" step="any"
value="0.15" /></label>
</div>
<div id="panelCamera" class="hidden">
<label><span>扫描摆动</span> <input type="checkbox" id="actionCamScan" checked /></label>
<label><span>LED 呼吸</span> <input type="checkbox" id="actionCamLedBreath" checked /></label>
<label><span>LED 颜色</span> <input id="actionCamLedColor" type="color" value="#00afff" /></label>
</div>
<div id="panelEmergency" class="hidden">
<label><span>颜色</span> <input id="actionEmerColor" type="color" value="#ff0000" /></label>
<label><span>效果</span>
<select id="actionEmerEffect">
<option value="blink">闪烁</option>
<option value="strobe">爆闪</option>
<option value="solid">常亮</option>
<option value="off">熄灭</option>
</select>
</label>
<label><span>频率 Hz</span> <input id="actionEmerHz" type="number" step="any" value="4" /></label>
</div>
</div>
<button type="button" id="applyComponent" class="primary-green">保存到基准</button>
</div>
<div id="tabPanelActions" class="tab-panel hidden">
<div class="workflow-step">
<div class="workflow-step-title" data-step="1">选择组件</div>
<div id="actionCompPickList" class="component-scroll" style="max-height:120px;"></div>
<div class="row-btns">
<button type="button" id="addAllActionComp" class="secondary">全部加入</button>
</div>
<p class="hint">点击组件加入动作 · 已选 <span id="actionSelectedCount">0</span></p>
<div id="actionSelectedList" class="component-scroll" style="max-height:80px;min-height:32px;"></div>
</div>
<div class="workflow-step">
<div class="workflow-step-title" data-step="2">配置参数</div>
<div id="actionCfgBox" class="setting-group hidden">
<div class="setting-group-title" id="actionCfgTitle"></div>
<label><span>类型</span>
<select id="actComponentType">
<option value="static">静态件</option>
<option value="body">车体结构</option>
<option value="light">灯光</option>
<option value="wheel">轮子</option>
<option value="actuator">可动机构</option>
<option value="sensor">传感器</option>
<option value="safety">安全件</option>
</select>
</label>
<div id="actPanelStatic" class="hidden">
<label><span>透明度</span> <input id="actOpacity" type="number" min="0" max="1" step="0.05"
value="0.25" /></label>
<button type="button" id="applyOpacityToAll" class="secondary">应用到全部静态件</button>
</div>
<div id="actPanelLight" class="hidden">
<label><span>颜色</span> <input id="actLightColor" type="color" value="#00ff55" /></label>
<label><span>光效</span>
<select id="actLightEffect">
<option value="solid">常亮</option>
<option value="breath">呼吸</option>
<option value="blink">闪烁</option>
<option value="strobe">爆闪</option>
<option value="flow_forward">前向流光</option>
<option value="flow_backward">反向流光</option>
<option value="blink_segment">分段闪</option>
</select>
</label>
<label><span>亮度</span> <input id="actLightIntensity" type="number" step="any" value="100" /></label>
<label id="actLightHzRow" class="hidden"><span>频率 Hz</span> <input id="actLightHz" type="number"
step="any" value="4" /></label>
<label id="actLightPeriodRow" class="hidden"><span>周期 s</span> <input id="actLightPeriod" type="number"
step="any" value="2" /></label>
</div>
<div id="actPanelWheel" class="hidden">
<p class="hint">自转轴:<span id="actWheelAxisHint"></span> · 红X 绿Y 蓝Z</p>
<label><span>转速倍率</span> <input id="actWheelSpeedFactor" type="number" step="any" value="1" /></label>
<label><span>转向角 (°)</span> <input id="actWheelSteerAngleDeg" type="number" step="any"
value="0" /></label>
</div>
<div id="actPanelMotion" class="hidden">
<label><span>机构角色</span>
<select id="actMotionRole">
<option value="lift">顶升</option>
<option value="lidar">雷达</option>
<option value="camera">相机</option>
</select>
</label>
<label><span>运动方式</span>
<select id="actMotionKind">
<option value="translate">平移</option>
<option value="rotate">旋转</option>
</select>
</label>
<label><span>轴向</span>
<select id="actMotionAxis">
<option value="y">Y</option>
<option value="x">X</option>
<option value="z">Z</option>
</select>
</label>
<label id="actLiftStartRow" class="hidden"><span>起始 (m)</span> <input id="actLiftStart" type="number"
step="any" value="0" /></label>
<label id="actLiftEndRow" class="hidden"><span>终止 (m)</span> <input id="actLiftEnd" type="number"
step="any" value="0.15" /></label>
</div>
<div id="actPanelCamera" class="hidden">
<label><span>扫描摆动</span> <input type="checkbox" id="actCamScan" checked /></label>
<label><span>LED 呼吸</span> <input type="checkbox" id="actCamLedBreath" checked /></label>
<label><span>LED 颜色</span> <input id="actCamLedColor" type="color" value="#00afff" /></label>
</div>
<div id="actPanelEmergency" class="hidden">
<label><span>颜色</span> <input id="actEmerColor" type="color" value="#ff0000" /></label>
<label><span>效果</span>
<select id="actEmerEffect">
<option value="blink">闪烁</option>
<option value="strobe">爆闪</option>
<option value="solid">常亮</option>
<option value="off">熄灭</option>
</select>
</label>
<label><span>频率 Hz</span> <input id="actEmerHz" type="number" step="any" value="4" /></label>
</div>
<button type="button" id="saveActionDraftComp" class="primary-green">保存该组件</button>
</div>
<p id="actionCfgPlaceholder" class="hint">请先在步骤 1 选择组件</p>
</div>
<div class="workflow-step">
<div class="workflow-step-title" data-step="3">保存动作</div>
<label><span>显示名称</span>
<input type="text" id="actionLabel" lang="zh-CN" autocomplete="off" spellcheck="false"
placeholder="左转、举升" />
</label>
<div class="row-btns">
<button type="button" id="saveAsAction">保存动作</button>
<button type="button" id="clearActionDraft" class="secondary">清空</button>
</div>
</div>
<div class="setting-group" style="margin-top:4px;">
<div class="setting-group-title">已定义动作</div>
<div id="actionList"></div>
</div>
</div>
</aside>
</div>
<footer id="statusbar">
<div id="status">就绪</div>
</footer>
</div>
<div id="toast-container"></div>
<!-- hidden compat elements -->
<select id="actionCompPick" class="hidden" aria-hidden="true">
<option value=""></option>
</select>
<button type="button" id="addActionComp" class="hidden" aria-hidden="true"></button>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/",
"pinyin-pro": "https://cdn.jsdelivr.net/npm/pinyin-pro@3.26.0/dist/index.mjs"
}}
</script>
<script type="module" src="./app.js"></script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AGV 动作播放</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
background: #0f1117;
color: #e8eaed;
overflow: hidden;
}
#canvas-wrap {
width: 100vw;
height: 100vh;
}
#panel {
position: fixed;
top: 16px;
left: 16px;
width: 280px;
max-height: calc(100vh - 32px);
overflow-y: auto;
background: rgba(22, 26, 36, 0.94);
border: 1px solid #2a3142;
border-radius: 12px;
padding: 14px;
backdrop-filter: blur(8px);
z-index: 10;
}
h1 {
font-size: 15px;
margin-bottom: 4px;
}
.sub {
font-size: 11px;
color: #8b95a8;
margin-bottom: 10px;
line-height: 1.4;
}
.sub a {
color: #8eb4ff;
}
label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
margin-bottom: 8px;
gap: 8px;
}
select {
flex: 1;
background: #1a1f2b;
color: #e8eaed;
border: 1px solid #2a3142;
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
}
button {
width: 100%;
padding: 8px;
border: none;
border-radius: 8px;
background: #2a3142;
color: #fff;
cursor: pointer;
font-size: 12px;
margin-top: 4px;
}
button:hover {
background: #3a4558;
}
button.active {
box-shadow: 0 0 0 1px #5dd39e;
}
.mode-running {
background: #1a3d28;
}
.mode-lift {
background: #3d3520;
}
.mode-fault {
background: #3d1a1a;
}
.mode-idle {
background: #1a3348;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tags button {
width: auto;
flex: 1 1 calc(50% - 6px);
min-width: 72px;
margin-top: 0;
}
#activeTag {
color: #c5d0e0;
font-size: 12px;
margin: 8px 0 4px;
min-height: 18px;
}
#remoteStatus {
font-size: 11px;
margin-top: 8px;
line-height: 1.4;
color: #8b95a8;
}
#remoteStatus.ok {
color: #5dd39e;
}
#remoteStatus.warn {
color: #e8c468;
}
#remoteStatus.err {
color: #f07178;
}
#status {
font-size: 11px;
color: #7d899e;
margin-top: 10px;
line-height: 1.5;
}
.ok {
color: #5dd39e;
}
.err {
color: #f07178;
}
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<div id="panel">
<h1>AGV 动作播放</h1>
<p class="sub">外部通过 <code>GET /api/catalog</code><code>POST /api/agv</code> 控制。<a href="./index.html">去配置页</a>
</p>
<label><span>模型</span><select id="modelSelect">
<option>加载中...</option>
</select></label>
<button type="button" id="clearAll">全部关闭</button>
<p id="activeTag">已开启:无</p>
<div id="actionTags" class="tags"></div>
<div id="remoteStatus">远程:连接中…</div>
<div id="status">加载中...</div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
<script type="module" src="./player.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
/**
* 远程播放桥接:SSE 订阅 + 命令队列 + 本地状态回传。
*/
import { API_VERSION } from "./api.js";
const API_ROOT = "/api/agv";
const REMOTE_METHODS = new Set(["playAction", "stopAction", "clearActions"]);
const CLIENT_ID = globalThis.crypto?.randomUUID?.() || `client-${Date.now()}`;
export function connectRemotePlayback(playbackApi, { onStatus } = {}) {
let lastRevision = 0;
let connected = false;
let es = null;
const queue = [];
let draining = false;
let applyingRemote = 0;
function setStatus(text, kind = "idle") {
onStatus?.({ text, kind, connected, revision: lastRevision });
}
function shouldSkipEcho(msg) {
return msg?.sourceClientId && msg.sourceClientId === CLIENT_ID;
}
async function waitUntilReady(maxMs = 20000) {
const start = Date.now();
while (Date.now() - start < maxMs) {
if (playbackApi.getState?.().ready) return true;
await new Promise((r) => setTimeout(r, 150));
}
return playbackApi.getState?.().ready;
}
async function withRemoteApply(fn) {
applyingRemote += 1;
try {
return await fn();
} finally {
applyingRemote -= 1;
}
}
async function ensureModel(modelId) {
if (!modelId) return true;
const cur = playbackApi.getState?.() || {};
if (cur.modelId === modelId && cur.ready) return true;
const res = await playbackApi.request({
version: API_VERSION,
method: "clearActions",
params: { modelId },
});
if (!res.ok) {
setStatus(`远程加载失败: ${res.error?.message || "unknown"}`, "error");
return false;
}
return waitUntilReady();
}
async function applySnapshot(state) {
if (!state) return false;
const revision = Number(state.revision) || 0;
if (revision <= lastRevision) return true;
return withRemoteApply(async () => {
const modelId = state.modelId;
const desired = state.activeActions || [];
if (!(await ensureModel(modelId))) return false;
const cur = playbackApi.getState?.() || {};
const current = new Set(cur.activeActions || []);
const want = new Set(desired);
for (const id of current) {
if (!want.has(id)) {
const res = await playbackApi.request({
version: API_VERSION,
method: "stopAction",
params: { modelId: cur.modelId || modelId, actionId: id },
});
if (!res.ok) {
setStatus(`远程动作同步失败: ${res.error?.message || "unknown"}`, "error");
return false;
}
}
}
for (const id of want) {
if (!current.has(id)) {
const res = await playbackApi.request({
version: API_VERSION,
method: "playAction",
params: { modelId: cur.modelId || modelId, actionId: id },
});
if (!res.ok) {
setStatus(`远程动作同步失败: ${res.error?.message || "unknown"}`, "error");
return false;
}
}
}
lastRevision = revision;
return true;
});
}
async function runCommand(msg) {
if (msg.type === "connected") {
connected = true;
setStatus("远程已连接", "ok");
if (msg.state) await applySnapshot(msg.state);
return;
}
if (msg.type === "ping") return;
if (!msg.method) return;
const revision = Number(msg.revision) || 0;
if (revision && revision <= lastRevision) return;
if (shouldSkipEcho(msg)) {
if (revision) lastRevision = Math.max(lastRevision, revision);
return;
}
if (msg.method === "syncState") {
await applySnapshot(msg.state);
return;
}
if (!REMOTE_METHODS.has(msg.method)) return;
await withRemoteApply(async () => {
const p = msg.params || {};
if (!(await ensureModel(p.modelId || p.model))) return;
await waitUntilReady();
const res = await playbackApi.request({
version: API_VERSION,
id: msg.id,
method: msg.method,
params: p,
});
if (!res.ok) {
setStatus(`远程命令失败: ${res.error?.message || "unknown"}`, "error");
return;
}
if (revision) lastRevision = Math.max(lastRevision, revision);
});
}
function enqueue(msg) {
queue.push(msg);
drain();
}
async function drain() {
if (draining) return;
draining = true;
while (queue.length) {
const msg = queue.shift();
try {
await runCommand(msg);
} catch (e) {
console.warn("[remote-bridge]", e);
setStatus(`远程执行异常: ${e.message}`, "error");
}
}
draining = false;
}
function connectSse() {
if (es) es.close();
es = new EventSource(`${API_ROOT}/events`);
es.onmessage = (ev) => {
try {
enqueue(JSON.parse(ev.data));
} catch (e) {
console.warn("[remote-bridge] bad event", e);
}
};
es.onerror = () => {
connected = false;
setStatus("远程连接断开,自动重连…", "warn");
};
}
async function fetchServerState() {
const res = await fetch(`${API_ROOT}/state?t=${Date.now()}`);
const body = await res.json();
return body.ok ? body.data : null;
}
async function syncFromServer() {
try {
const state = await fetchServerState();
if (!state || (state.revision || 0) <= 0) return state;
await applySnapshot(state);
return state;
} catch (e) {
setStatus("远程状态同步失败", "warn");
return null;
}
}
let reportTimer = null;
function reportLocalState() {
if (applyingRemote > 0) return;
clearTimeout(reportTimer);
reportTimer = setTimeout(async () => {
const s = playbackApi.getState?.();
if (!s?.ready || applyingRemote > 0) return;
try {
await fetch(`${API_ROOT}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: CLIENT_ID,
modelId: s.modelId,
activeActions: s.activeActions,
}),
});
} catch (_) { /* ignore */ }
}, 250);
}
connectSse();
setStatus("等待远程连接…", "idle");
return {
clientId: CLIENT_ID,
fetchServerState,
syncFromServer,
reportLocalState,
close: () => {
clearTimeout(reportTimer);
es?.close();
},
};
}