diff --git a/.gitignore b/.gitignore
index ef1d986..8517934 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
.idea
.vscode
output*
-demo_*
.DS_Store
__pycache__/
*.py[cod]
diff --git a/backend/main.py b/backend/main.py
index 06e0d76..2af11d0 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -20,7 +20,7 @@
from backend import api
from backend.api import router as api_router
from backend.config import config
-from backend.sio_handlers import SimNamespace, RealNamespace, MujocoNamespace, start_game_loop, set_act_runtime as set_sio_act_runtime, start_mujoco_game_loop
+from backend.sio_handlers import SimNamespace, RealNamespace, start_game_loop, set_act_runtime as set_sio_act_runtime
from backend.utils import set_broadcast_sio, setup_socket_logging
# 配置日志 - 生产环境只记录关键事件
@@ -54,7 +54,6 @@ async def lifespan(app: FastAPI):
# 启动两个命名空间的状态广播
start_game_loop(sio, namespace="/sim")
start_game_loop(sio, namespace="/real")
- start_mujoco_game_loop(sio, namespace="/mujoco")
yield
@@ -87,7 +86,6 @@ async def lifespan(app: FastAPI):
# 注册三个独立的命名空间
sio.register_namespace(SimNamespace("/sim"))
sio.register_namespace(RealNamespace("/real"))
-sio.register_namespace(MujocoNamespace("/mujoco"))
# 设置sio_server到api模块
api.set_sio_server(sio)
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 59dfc43..e7b6214 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -23,5 +23,3 @@ pytest>=8.0.0
python-multipart
einops
redis>=5.0.0
-
-mujoco
diff --git a/backend/services/mujoco_renderer/__init__.py b/backend/services/mujoco_renderer/__init__.py
deleted file mode 100644
index 2929fc5..0000000
--- a/backend/services/mujoco_renderer/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from backend.services.mujoco_renderer.renderer import MujocoRenderer
-from backend.services.mujoco_renderer.state import MujocoState
-
-__all__ = ["MujocoRenderer", "MujocoState"]
\ No newline at end of file
diff --git a/backend/services/mujoco_renderer/renderer.py b/backend/services/mujoco_renderer/renderer.py
deleted file mode 100644
index 3922d5c..0000000
--- a/backend/services/mujoco_renderer/renderer.py
+++ /dev/null
@@ -1,131 +0,0 @@
-from __future__ import annotations
-
-import logging
-import os
-from typing import Tuple
-
-import mujoco
-import numpy as np
-
-logger = logging.getLogger(__name__)
-
-
-class MujocoRenderer:
- """Dual-view MuJoCo renderer for top-down and first-person views."""
-
- def __init__(self, xml_path: str | None = None):
- if xml_path is None:
- xml_path = os.path.join(
- os.path.dirname(__file__), "..", "..", "..", "mujoco", "car_arm.xml"
- )
- self._xml_path = os.path.abspath(xml_path)
-
- self._model = mujoco.MjModel.from_xml_path(self._xml_path)
- self._data = mujoco.MjData(self._model)
-
- self._renderer_topdown = mujoco.Renderer(self._model, width=640, height=480)
- self._renderer_firstperson = mujoco.Renderer(self._model, width=640, height=480)
-
- self._cam_topdown = mujoco.MjvCamera()
- self._cam_firstperson = mujoco.MjvCamera()
- mujoco.mjv_defaultCamera(self._cam_topdown)
- mujoco.mjv_defaultCamera(self._cam_firstperson)
-
- self._cam_state = {"azimuth": 0.0, "elevation": -70.0, "distance": 6.0}
- self._setup_cameras()
- logger.info(f"MujocoRenderer initialized with {self._xml_path}")
- def _camera_name_to_id(self, name: str) -> int:
- """Convert camera name to ID using mujoco.mj_name2id."""
- return mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_CAMERA, name)
-
- def _actuator_name_to_id(self, name: str) -> int:
- """Convert actuator name to ID using mujoco.mj_name2id."""
- return mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, name)
-
- def _setup_cameras(self) -> None:
- """Configure top-down (free orbit) and first-person (fixed) cameras."""
- self._cam_topdown.type = mujoco.mjtCamera.mjCAMERA_FREE
- self._cam_topdown.lookat = np.array([0, 0, 0.3])
- self._cam_topdown.distance = self._cam_state["distance"]
- self._cam_topdown.elevation = self._cam_state["elevation"]
- self._cam_topdown.azimuth = self._cam_state["azimuth"]
-
- self._cam_firstperson.type = mujoco.mjtCamera.mjCAMERA_FIXED
- self._cam_firstperson.fixedcamid = self._camera_name_to_id("firstperson")
-
- def update_topdown_camera(self, delta_azimuth: float, delta_elevation: float) -> None:
- """Rotate the top-down camera by mouse drag deltas."""
- sensitivity = 0.3
- self._cam_state["azimuth"] += delta_azimuth * sensitivity
- self._cam_state["elevation"] += delta_elevation * sensitivity
- self._cam_state["elevation"] = max(-89.0, min(89.0, self._cam_state["elevation"]))
-
- self._cam_topdown.azimuth = self._cam_state["azimuth"]
- self._cam_topdown.elevation = self._cam_state["elevation"]
-
- def update_topdown_distance(self, delta: float) -> None:
- """Zoom the top-down camera by scroll delta."""
- self._cam_state["distance"] += delta * 0.5
- self._cam_state["distance"] = max(0.5, min(50.0, self._cam_state["distance"]))
- self._cam_topdown.distance = self._cam_state["distance"]
-
- def get_topdown_image(self) -> np.ndarray:
- """Render top-down view."""
- self._renderer_topdown.update_scene(self._data, self._cam_topdown)
- return self._renderer_topdown.render()
-
- def get_firstperson_image(self) -> np.ndarray:
- """Render first-person view from camera on car."""
- self._renderer_firstperson.update_scene(self._data, self._cam_firstperson)
- return self._renderer_firstperson.render()
-
- def step(self, arm_action: dict | None = None) -> None:
- """Step the simulation."""
- if arm_action is not None:
- for actuator_name, torque in arm_action.items():
- try:
- actuator_id = self._actuator_name_to_id(actuator_name)
- if actuator_id >= 0:
- self._data.actuator_force[actuator_id] = torque
- except Exception:
- pass
-
- mujoco.mj_step(self._model, self._data)
-
- def set_wheel_torques(self, left_torque: float, right_torque: float) -> None:
- """Apply torques to wheels for differential drive.
-
- Args:
- left_torque: torque for left wheels (fl, rl)
- right_torque: torque for right wheels (fr, rr)
- """
- wheel_torques = {
- "motor_wheel_fl": left_torque,
- "motor_wheel_rl": left_torque,
- "motor_wheel_fr": right_torque,
- "motor_wheel_rr": right_torque,
- }
- for actuator_name, torque in wheel_torques.items():
- try:
- actuator_id = self._actuator_name_to_id(actuator_name)
- if actuator_id >= 0:
- self._data.actuator_force[actuator_id] = torque
- except Exception:
- pass
-
- def get_state(self) -> dict:
- """Get current state."""
- car_body_id = mujoco.mj_name2id(
- self._model, mujoco.mjtObj.mjOBJ_BODY, "car"
- )
- return {
- "car_pos": self._data.xpos[car_body_id].copy(),
- "car_quat": self._data.xquat[car_body_id].copy(),
- "arm_qpos": self._data.qpos[11:].copy(),
- "arm_qvel": self._data.qvel[10:].copy(),
- }
-
- def close(self) -> None:
- """Clean up renderers."""
- self._renderer_topdown.close()
- self._renderer_firstperson.close()
\ No newline at end of file
diff --git a/backend/services/mujoco_renderer/service.py b/backend/services/mujoco_renderer/service.py
deleted file mode 100644
index 25d5bd7..0000000
--- a/backend/services/mujoco_renderer/service.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""
-MuJoCo 渲染服务 - 提供双视角渲染能力
-"""
-from __future__ import annotations
-
-import base64
-import logging
-from io import BytesIO
-from typing import Optional
-
-import mujoco
-import numpy as np
-from PIL import Image
-
-logger = logging.getLogger(__name__)
-
-_renderer: Optional["MujocoRenderer"] = None
-_mujoco_service: Optional["MujocoService"] = None
-
-
-def get_renderer() -> "MujocoRenderer":
- """获取全局 MuJoCo 渲染器单例"""
- global _renderer
- if _renderer is None:
- from backend.services.mujoco_renderer.renderer import MujocoRenderer
- _renderer = MujocoRenderer()
- return _renderer
-
-
-def get_mujoco_service() -> "MujocoService":
- """获取全局 MuJoCo 服务单例"""
- global _mujoco_service
- if _mujoco_service is None:
- _mujoco_service = MujocoService()
- return _mujoco_service
-
-
-def close_mujoco_service() -> None:
- """关闭 MuJoCo 服务"""
- global _mujoco_service
- if _mujoco_service:
- _mujoco_service.close()
- _mujoco_service = None
-
-
-class MujocoService:
- """MuJoCo 服务 - 管理渲染器和状态"""
-
- def __init__(self):
- self._renderer = get_renderer()
- self._interval_ms = 50
-
- @property
- def interval_ms(self) -> int:
- return self._interval_ms
-
- def set_arm_action(self, yaw: float, pitch: float, roll: float) -> None:
- """设置机械臂动作(仅设定力矩,由 game loop 统一步进)"""
- action = {
- "motor_yaw": yaw * 50,
- "motor_pitch": pitch * 50,
- "motor_roll": roll * 30,
- }
- for actuator_name, torque in action.items():
- try:
- actuator_id = mujoco.mj_name2id(
- self._renderer._model, mujoco.mjtObj.mjOBJ_ACTUATOR, actuator_name
- )
- if actuator_id >= 0:
- self._renderer._data.actuator_force[actuator_id] = torque
- except Exception:
- pass
-
- def set_car_action(self, vel_left: float, vel_right: float) -> None:
- """设置小车差速动作"""
- self._renderer.set_wheel_torques(vel_left, vel_right)
-
- def step(self) -> None:
- """单步模拟"""
- self._renderer.step()
-
- def update_topdown_camera(self, delta_azimuth: float, delta_elevation: float) -> None:
- """旋转俯视相机"""
- self._renderer.update_topdown_camera(delta_azimuth, delta_elevation)
-
- def update_topdown_distance(self, delta: float) -> None:
- """缩放俯视相机"""
- self._renderer.update_topdown_distance(delta)
-
- def render(self) -> tuple[str, str, dict]:
- """
- 渲染双视角图像
- Returns: (topdown_b64, firstperson_b64, state)
- """
- topdown_img = self._renderer.get_topdown_image()
- firstperson_img = self._renderer.get_firstperson_image()
-
- topdown_b64 = self._image_to_b64(topdown_img)
- firstperson_b64 = self._image_to_b64(firstperson_img)
- state = self._renderer.get_state()
-
- return topdown_b64, firstperson_b64, state
-
- def _image_to_b64(self, img: np.ndarray) -> str:
- """numpy 图像转 base64"""
- if img.shape[2] == 3:
- pil_img = Image.fromarray(img)
- else:
- pil_img = Image.fromarray(img[:, :, :3])
- buffer = BytesIO()
- pil_img.save(buffer, format="JPEG", quality=85)
- return base64.b64encode(buffer.getvalue()).decode("utf-8")
-
- def close(self) -> None:
- """关闭渲染器"""
- global _renderer
- if _renderer:
- _renderer.close()
- _renderer = None
\ No newline at end of file
diff --git a/backend/services/mujoco_renderer/state.py b/backend/services/mujoco_renderer/state.py
deleted file mode 100644
index e19709d..0000000
--- a/backend/services/mujoco_renderer/state.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
-import mujoco
-
-if TYPE_CHECKING:
- from backend.services.mujoco_renderer.renderer import MujocoRenderer
-
-
-class MujocoState:
- """Manages MuJoCo scene state and car+arm control."""
-
- def __init__(self, renderer: MujocoRenderer):
- self._renderer = renderer
-
- def set_arm_position(self, qpos: list[float]) -> None:
- """Set arm joint positions [yaw, pitch, roll, wrist...]."""
- if len(qpos) >= 1:
- self._renderer._data.qpos[11] = qpos[0]
- if len(qpos) >= 2:
- self._renderer._data.qpos[12] = qpos[1]
- if len(qpos) >= 3:
- self._renderer._data.qpos[13] = qpos[2]
-
- def get_state(self) -> dict:
- """Get current state dict."""
- return self._renderer.get_state()
-
- def reset(self) -> None:
- """Reset to initial state."""
- mujoco.mj_resetData(self._renderer._model, self._renderer._data)
\ No newline at end of file
diff --git a/backend/sio_handlers/__init__.py b/backend/sio_handlers/__init__.py
index a271f8b..d514674 100644
--- a/backend/sio_handlers/__init__.py
+++ b/backend/sio_handlers/__init__.py
@@ -11,8 +11,6 @@
from backend.sio_handlers.core.namespace import SimNamespace as _SimNamespace
from backend.sio_handlers.core.runtime import SioRuntimeState
from backend.sio_handlers.core.tasks import game_loop_task
-from backend.sio_handlers.domains.mujoco import MujocoEventsMixin
-from backend.sio_handlers.core.base import BaseSimNamespace
logger = logging.getLogger(__name__)
@@ -77,27 +75,6 @@ def set_act_runtime(runtime):
_real_runtime_state.set_act_runtime(runtime)
-class MujocoNamespace(
- MujocoEventsMixin,
- BaseSimNamespace,
-):
- """MuJoCo 仿真页面专用命名空间 - /mujoco"""
-
- def __init__(
- self,
- namespace: str | None = "/mujoco",
- runtime: SioRuntimeState | None = None,
- sim_controller: SimController | None = None,
- episode_service: EpisodeService | None = None,
- ):
- super().__init__(
- namespace=namespace,
- runtime=runtime or _sim_runtime_state,
- sim_controller=sim_controller or _get_sim_controller(),
- episode_service=episode_service or _get_sim_episode_service(),
- )
-
-
class SimNamespace(_SimNamespace):
"""Sim 页面专用命名空间 - /sim"""
def __init__(
@@ -150,48 +127,3 @@ def start_game_loop(
controller = sim_controller or _get_sim_controller()
asyncio.create_task(game_loop_task(sio_server, runtime_state, controller, namespace=namespace))
-
-
-async def mujoco_game_loop_task(sio_server, runtime: SioRuntimeState, namespace: str = "/mujoco"):
- """MuJoCo 渲染循环 - 持续渲染并推送状态给连接的客户端"""
- from backend.services.mujoco_renderer.service import get_mujoco_service
-
- logger.info(f"[mujoco_game_loop] 任务已启动, namespace={namespace}")
- service = get_mujoco_service()
- frame_count = 0
-
- while True:
- try:
- frame_count += 1
- if frame_count % 100 == 0:
- logger.info(f"[mujoco_game_loop] frame={frame_count}, clients={len(runtime.connected_clients)}")
-
- if runtime.connected_clients:
- # 每帧都渲染并推送(~50ms 间隔)
- topdown, firstperson, state = service.render()
- # Convert ndarray values in state to lists
- serializable_state = {
- k: v.tolist() if hasattr(v, "tolist") else v
- for k, v in state.items()
- }
- payload = {
- "topdown": topdown,
- "firstperson": firstperson,
- "state": serializable_state,
- }
- # 广播给所有连接的客户端
- await sio_server.emit("mujoco_state_update", payload, namespace=namespace)
-
- # 物理步进
- service.step()
-
- except Exception as exc:
- logger.error(f"[mujoco_game_loop] 错误: {exc}")
-
- await asyncio.sleep(0.05)
-
-
-def start_mujoco_game_loop(sio_server, namespace: str = "/mujoco"):
- """启动 MuJoCo 游戏循环"""
- # 使用 _sim_runtime_state 作为 runtime(MujocoNamespace 也用它)
- asyncio.create_task(mujoco_game_loop_task(sio_server, _sim_runtime_state, namespace=namespace))
diff --git a/backend/sio_handlers/domains/mujoco/__init__.py b/backend/sio_handlers/domains/mujoco/__init__.py
deleted file mode 100644
index 5b6b422..0000000
--- a/backend/sio_handlers/domains/mujoco/__init__.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-from backend.services.mujoco_renderer.service import get_mujoco_service
-
-logger = logging.getLogger(__name__)
-
-
-class MujocoEventsMixin:
- """MuJoCo 相关 Socket.IO 事件处理"""
-
- async def on_connect(self, sid: str, environ: dict, auth: dict | None = None):
- """客户端连接"""
- self.runtime.connected_clients.add(sid)
- logger.info(f"[mujoco] 客户端连接: {sid}")
- await self.emit("connected", {"sid": sid})
-
- async def on_disconnect(self, sid: str):
- """客户端断开"""
- self.runtime.connected_clients.discard(sid)
- logger.info(f"[mujoco] 客户端断开: {sid}")
-
- async def on_mujoco_action(self, sid: str, data: dict):
- """
- 处理 MuJoCo 机械臂控制动作
- data: { yaw: float, pitch: float, roll: float }
- """
- logger.info(f"[mujoco_action] sid={sid}, data={data}")
- service = get_mujoco_service()
- yaw = data.get("yaw", 0)
- pitch = data.get("pitch", 0)
- roll = data.get("roll", 0)
- service.set_arm_action(yaw, pitch, roll)
-
- async def on_mujoco_car_action(self, sid: str, data: dict):
- """
- 处理 MuJoCo 小车差速控制动作
- data: { vel_left: float, vel_right: float }
- """
- logger.info(f"[mujoco_car_action] sid={sid}, data={data}")
- service = get_mujoco_service()
- vel_left = data.get("vel_left", 0)
- vel_right = data.get("vel_right", 0)
- service.set_car_action(vel_left, vel_right)
-
- async def on_mujoco_camera_move(self, sid: str, data: dict):
- """
- 处理鼠标拖拽旋转俯视相机
- data: { delta_azimuth: float, delta_elevation: float }
- """
- service = get_mujoco_service()
- delta_azimuth = data.get("delta_azimuth", 0)
- delta_elevation = data.get("delta_elevation", 0)
- service.update_topdown_camera(delta_azimuth, delta_elevation)
-
- async def on_mujoco_camera_zoom(self, sid: str, data: dict):
- """
- 处理鼠标滚轮缩放
- data: { delta: float }
- """
- service = get_mujoco_service()
- service.update_topdown_distance(data.get("delta", 0))
-
- async def on_get_mujoco_state(self, sid: str):
- """请求当前 MuJoCo 状态和图像"""
- service = get_mujoco_service()
- topdown, firstperson, state = service.render()
- serializable_state = {
- k: v.tolist() if hasattr(v, "tolist") else v
- for k, v in state.items()
- }
- await self.emit("mujoco_state_update", {
- "topdown": topdown,
- "firstperson": firstperson,
- "state": serializable_state,
- })
\ No newline at end of file
diff --git a/docs/src/MuJoCo/No_11.md b/docs/src/MuJoCo/No_11.md
new file mode 100644
index 0000000..92d8402
--- /dev/null
+++ b/docs/src/MuJoCo/No_11.md
@@ -0,0 +1,596 @@
+# No.11 抛射体轨迹优化(NLopt 非线性规划)
+
+本节介绍一个**完全不同的控制范式** —— **离线轨迹优化**(trajectory optimization)。前 9 节都是「**在线**」控制器:每步读状态、算控制量。No.11 是「**离线**」求解:先用 NLopt 找最优初始速度(v, θ),然后**开环播放**让小球飞向目标。
+
+> **核心思想**:把仿真器当作「约束求值器」,扔给优化器,**让算法自己找参数**。
+
+---
+
+## 文件说明
+
+```
+mujoco/No_11/
+├── ball.xml # MuJoCo XML 模型文件(地面 + 圆柱 + 球 + 目标盒)
+└── projectile_opt.py # 完整脚本:含 NLopt 优化、simulator、开环播放
+```
+
+> No.11 **没有**最小脚本(`no_11.py`)。要看效果必须跑 `projectile_opt.py`。
+
+---
+
+## 一、ball.xml 详解
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 场景布局
+
+```
+ ┌──┐ ← 目标盒 (5, 0, 2.1)
+ │ │
+ │ │
+ ┌──────────┴──┴──────────┐
+ │ cylinder (5, 0, 1) │ ← 视觉参考柱
+ │ size="0.2 1" │
+ └──────────┬──────────────┘
+ │
+══════════════════╪═══════════════ ← 地面
+ ● ← 抛射体 (0, 0, 0.1)
+ ball (mass=1)
+```
+
+| 物体 | 位置 | 作用 |
+|------|------|------|
+| 地面 | z=0 | 球碰到就停 |
+| 圆柱 | (5, 0, 1),半径 0.2,高 2 | **视觉**参考柱(无质量)|
+| **球** | (0, 0, 0.1),mass=1 | **抛射体**,free 关节 |
+| **盒** | (5, 0, 2.1),mass=0.1 | **目标**,需要被击中 |
+
+> **关键设计**:圆柱**只是视觉**(无质量),目标**是盒**(不是柱顶)。这个反直觉设计让优化目标**精确**(不用算柱顶坐标)。
+
+---
+
+## 二、核心:弹道优化(NLopt)
+
+### 2.1 优化问题定义
+
+```
+ min_X 0
+ subject to 0.1 ≤ v ≤ 10000
+ 0.1 ≤ θ ≤ π/2 - 0.1
+ 0.1 ≤ T ≤ 10000
+ x(T) = 5.0 ← 落点 x
+ z(T) = 2.1 ← 落点 z
+```
+
+**3 个决策变量**:`X = [v, θ, T]`(初速度大小、发射角、飞行时间)
+
+**目标函数**:恒为 0(**可行性问题**,找满足约束的参数即可)
+
+**2 个等式约束**:飞行 T 秒后,位置 (x, z) 必须等于目标 (5.0, 2.1)
+
+### 2.2 为什么需要优化?
+
+经典抛物线公式的**解析解**存在(`v = sqrt(g·R² / (2·cos²θ·(tanθ - H/R))`),但:
+- 仿真器**有空气阻力、接触、积分误差**等复杂因素
+- 想在「**真实仿真**」里命中目标,解析解**不准确**
+- 用**仿真器本身**作为约束求值器,可以自动处理这些细节
+
+### 2.3 优化算法:COBYLA
+
+```python
+opt = nlopt.opt(nlopt.LN_COBYLA, 3)
+```
+
+| 属性 | 值 | 含义 |
+|------|----|------|
+| 算法 | `LN_COBYLA` | **无导数**、**约束**优化算法 |
+| 维度 | 3 | 决策变量数 = 3 |
+
+**为什么 COBYLA**:
+- **不需要梯度**(仿真器的约束没法解析求导)
+- **支持等式/不等式约束**
+- 对**小规模、低维**问题效率够用
+
+### 2.4 完整代码
+
+```python
+def optimize_ic(x):
+ opt = nlopt.opt(nlopt.LN_COBYLA, 3)
+ opt.set_lower_bounds([0.1, 0.1, 0.1])
+ opt.set_upper_bounds([10000.0, np.pi/2 - 0.1, 10000.0])
+ opt.set_min_objective(cost_func) # cost = 0
+ opt.add_equality_mconstraint(equality_constraints, tol=[1e-4, 1e-4])
+ opt.set_xtol_rel(1e-4)
+ sol = opt.optimize(x)
+ return sol
+
+def cost_func(x, grad):
+ return 0.0 # 可行性问题,cost 恒为 0
+
+def equality_constraints(result, x, grad):
+ pos = simulator(x) # 仿真 → 终态位置
+ result[0] = pos[0] - 5.0 # x 误差
+ result[1] = pos[1] - 2.1 # z 误差
+```
+
+> **`simulator(x)` 是核心**:把决策变量 `(v, θ, T)` 转成「**仿真 T 秒后**」的位置。**这个函数是优化器的「黑箱」**。
+
+---
+
+## 三、simulator 函数详解:仿真器当作约束求值器
+
+```python
+def simulator(x):
+ v, theta, time_of_flight = x[0], x[1], x[2]
+
+ # 1. 设置初速度
+ data.qvel[0] = v * np.cos(theta) # x 方向
+ data.qvel[2] = v * np.sin(theta) # z 方向
+
+ # 2. 仿真 T 秒
+ while data.time < time_of_flight:
+ mj.mj_step(model, data)
+
+ # 3. 读终态位置
+ pos = np.array([data.qpos[0], data.qpos[2]])
+
+ # 4. 重置 data(为下一次调用准备)
+ mj.mj_resetData(model, data)
+
+ return pos
+```
+
+### 3.1 输入 → 输出
+
+| 输入 | 含义 |
+|------|------|
+| `x[0]` = v | 速度大小 |
+| `x[1]` = θ | 发射角(与水平面夹角)|
+| `x[2]` = T | 飞行时间 |
+
+| 输出 | 含义 |
+|------|------|
+| `pos[0]` | T 秒后的 x 坐标 |
+| `pos[1]` | T 秒后的 z 坐标 |
+
+### 3.2 为什么用 `data.time` 作为循环条件?
+
+```python
+while data.time < time_of_flight:
+ mj.mj_step(model, data)
+```
+
+`data.time` 由 MuJoCo 内部维护,**每一步 mj_step 增加 `model.opt.timestep`**。当 `data.time` 达到 `time_of_flight` 时停止。
+
+### 3.3 为什么 `mj_resetData` 放在最后?
+
+每次 `simulator` 调用都会**修改**全局 `data`(设初速度、跑仿真)。如果不重置,下一次调用会从「**上一次终态**」开始 → 结果完全错。
+
+`mj_resetData` 把 `data.qpos` 和 `data.qvel` 复位到 XML 里的初始值。
+
+> **潜在 bug**:`simulator` 假设初始 `qpos` 和 `qvel` 就是 XML 默认值。如果你在调用 `simulator` 之前**先**动了 `data`,状态会污染。
+
+### 3.4 `qvel[0]`、`qvel[2]` 的索引
+
+`` 给 body **6 个 DOF**(3 平移 + 3 旋转):
+
+| `qvel` 索引 | 含义 | No.11 是否用到 |
+|------------|------|---------------|
+| 0 | x 方向线速度 | ✅ |
+| 1 | y 方向线速度 | ❌ |
+| 2 | z 方向线速度 | ✅ |
+| 3 | 绕 x 角速度 | ❌ |
+| 4 | 绕 y 角速度 | ❌ |
+| 5 | 绕 z 角速度 | ❌ |
+
+所以 `qvel[0] = v·cos(θ)`、`qvel[2] = v·sin(θ)`。
+
+---
+
+## 四、init_controller:找最优 v, θ, T
+
+```python
+def init_controller(model, data):
+ # 初始猜测
+ v = 10.0
+ theta = np.pi / 4
+ time_of_flight = 2.0
+
+ if NLOPT_IMPORTED:
+ sol = optimize_ic(np.array([v, theta, time_of_flight]))
+ else:
+ sol = np.array([9.398687489285555, 1.2184054599970882, 1.5654456340479144])
+
+ v_sol, theta_sol = sol[0], sol[1]
+ simend = sol[2] + 2 # 仿真时间稍长于飞行时间
+
+ data.qvel[0] = v_sol * np.cos(theta_sol)
+ data.qvel[2] = v_sol * np.sin(theta_sol)
+```
+
+### 4.1 Fallback 机制
+
+```python
+try:
+ import nlopt
+except ImportError:
+ print("nlopt not imported, switching to pre-computed solution")
+ NLOPT_IMPORTED = False
+```
+
+**鲁棒性设计**:如果没装 nlopt,**用预计算解** `sol = [9.40, 1.22, 1.57]`。
+
+预计算解 = 优化器对初始猜测 `[10, π/4, 2]` 跑出来的解。
+
+### 4.2 初始猜测的物理意义
+
+| 变量 | 初始值 | 物理含义 |
+|------|--------|---------|
+| v | 10.0 m/s | 较快速度(够得着 5m 远) |
+| θ | π/4 ≈ 45° | 经典最优抛射角(无空气阻力时)|
+| T | 2.0 s | 估计飞行时间 |
+
+### 4.3 simend 的设计
+
+```python
+simend = sol[2] + 2 # 仿真时间比飞行时间长 2 秒
+```
+
+飞行 T 秒后球**已经落地**。多给 2 秒让球**在地面滚/停**,方便看效果。
+
+---
+
+## 五、controller 详解:空函数
+
+```python
+def controller(model, data):
+ pass
+```
+
+**这是 No.11 最大的特点**:
+
+| 之前 No.4-9 | No.11 |
+|-------------|-------|
+| controller 每步算 `u` | controller **什么都不做** |
+
+**为什么**?因为:
+- 优化器**已经算好**了 v 和 θ
+- `init_controller` 已经把 `qvel[0]` 和 `qvel[2]` 设成最优值
+- 之后**开环播放**,让物理引擎自己把球送过去
+- **不需要任何反馈**
+
+> **这就是「开环控制」的极致形式** —— 控制器**不存在**。
+
+---
+
+## 六、主循环:开环播放
+
+```python
+init_controller(model, data) # 离线找最优 v, θ
+mj.set_mjcb_control(controller) # 注册空 controller
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data) # 物理步进,**没有控制输入**
+
+ if (data.time >= simend):
+ break
+
+ # 相机跟随球
+ cam.lookat[0] = data.qpos[0]
+ mj.mjv_updateScene(...)
+ mj.mjr_render(...)
+ glfw.swap_buffers(window)
+ glfw.poll_events()
+```
+
+整个主循环**没有**读状态、算控制量、写 actuator —— **纯粹的物理仿真 + 渲染**。
+
+---
+
+## 七、跟 No.4-9 的本质区别
+
+| 维度 | No.4-9 在线控制 | **No.11 离线优化** |
+|------|-----------------|-------------------|
+| **决策时机** | 每步 | **一次性**(init_controller) |
+| **反馈** | 必须有 | **不需要**(开环)|
+| **控制律** | `u = K(x)`, `Δq = J⁻¹·Δx` 等 | **没有控制律** |
+| **目标** | 跟踪/镇定/轨迹 | **一次性命中目标** |
+| **方法** | PD、IK、LQR、FSM | **非线性规划(NLopt)** |
+| **仿真器角色** | 物理引擎 | **约束求值器** |
+| **失败恢复** | 可以反馈纠错 | **不行**(开环)|
+| **计算成本** | 每步 O(n²) ~ O(n³) | 离线 O(N) 次仿真 |
+| **鲁棒性** | 中(取决于控制器)| 差(参数不准就 miss) |
+
+### 控制思想对比
+
+```
+No.4-9: 「**在线**」
+ 每步: 读 x → 算 u → 写 actuator
+ 优点: 鲁棒于扰动和参数误差
+ 缺点: 需要设计控制器、调参
+
+No.11: 「**离线**」
+ 一次性: 找参数 (v, θ, T) → 设初速度 → 开环播放
+ 优点: 不需要控制器,理论最优
+ 缺点: 完全开环,扰动即失败
+```
+
+**No.11 是「**模型预测 + 开环执行**」的最简形式**。
+
+---
+
+## 八、整体流程图
+
+```
+启动 ───────────────────────────────────────────
+ │
+ ├─ 加载 ball.xml
+ ├─ 创建 model, data
+ │
+ ├─ init_controller:
+ │ ├─ 设初始猜测 (v=10, θ=π/4, T=2)
+ │ │
+ │ └─ 调 optimize_ic:
+ │ │
+ │ └─ 循环(COBYLA 内部):
+ │ │
+ │ ├─ 提议新参数 (v', θ', T')
+ │ │
+ │ ├─ 调 simulator(v', θ', T'):
+ │ │ ├─ 设 qvel
+ │ │ ├─ while data.time < T': mj_step
+ │ │ ├─ 读 (qpos[0], qpos[2])
+ │ │ └─ mj_resetData
+ │ │
+ │ └─ 算约束违反: (pos - target)
+ │
+ ├─ 解: v_sol, θ_sol, T_sol
+ ├─ data.qvel[0] = v_sol * cos(θ_sol)
+ ├─ data.qvel[2] = v_sol * sin(θ_sol)
+ └─ simend = T_sol + 2
+
+主循环 ───────────────────────────────────────────
+ 每帧 (60Hz):
+ 内层 1000Hz: mj_step(**无控制**)
+ 外层: 渲染 + 相机跟随
+
+ 最终: 球**精准**落在目标盒 (5, 0, 2.1) 附近
+```
+
+---
+
+## 九、运行方法
+
+```bash
+# 1. 安装依赖
+pip install nlopt numpy
+
+# 2. 运行
+cd mujoco/No_11/
+mjpython projectile_opt.py
+```
+
+预期效果:
+- 球从 (0, 0, 0.1) 出发
+- 按优化器算的 (v, θ) 抛射
+- **精准**落在 (5, 0, 2.1) 的目标盒上
+- 相机自动跟随
+
+> ⚠️ **第一次启动会卡 1-3 秒** —— NLopt 在跑优化(大约 50-200 次 `simulator` 调用)。这是**离线代价**。
+
+---
+
+## 十、调参 / 玩转
+
+### 1. 改目标位置
+
+```python
+# 改 XML
+
→
+
+# 改约束
+result[0] = pos[0] - 5.0 → result[0] = pos[0] - 7.0
+result[1] = pos[1] - 2.1 → result[1] = pos[1] - 3.0
+```
+
+### 2. 加快优化速度
+
+```python
+opt.set_xtol_rel(1e-4) → opt.set_xtol_rel(1e-2) # 粗糙一点
+tol = [1e-4, 1e-4] → tol = [1e-2, 1e-2] # 约束放宽
+```
+
+### 3. 改用更快的算法
+
+```python
+opt = nlopt.opt(nlopt.LN_COBYLA, 3) # 慢但稳
+opt = nlopt.opt(nlopt.LN_NELDERMEAD, 3) # 单纯形,无约束
+opt = nlopt.opt(nlopt.GN_AGS, 3) # 全局优化(慢但能找到全局最优)
+```
+
+### 4. 加成本函数
+
+```python
+def cost_func(x, grad):
+ # 最小化发射能量
+ return 0.5 * x[0]**2
+
+# 注意:这样 min 不再是 0,是 0.5·v²
+# 优化器会找「**最省力**」的命中方式
+```
+
+---
+
+## 十一、常见问题
+
+### 1. `ImportError: No module named nlopt`
+
+**解决**:
+```bash
+# macOS
+brew install nlopt
+pip install nlopt
+
+# 或 conda
+conda install -c conda-forge nlopt
+```
+
+装不上就**用预计算解**(代码里已经 fallback)。
+
+### 2. 球飞出去没命中目标
+
+**可能原因**:
+- 数值精度不够(`tol=[1e-4, 1e-4]` 太松)
+- 初始猜测离真实解太远,COBYLA 陷入局部
+- 仿真器本身有 bug(mj_resetData 顺序错)
+
+**调试**:
+```python
+def simulator(x):
+ print(f" try v={x[0]:.2f} θ={x[1]:.2f} T={x[2]:.2f}")
+ v, theta, time_of_flight = x[0], x[1], x[2]
+ data.qvel[0] = v * np.cos(theta)
+ data.qvel[2] = v * np.sin(theta)
+ while data.time < time_of_flight:
+ mj.mj_step(model, data)
+ pos = np.array([data.qpos[0], data.qpos[2]])
+ mj.mj_resetData(model, data)
+ print(f" → pos=({pos[0]:.2f}, {pos[1]:.2f})")
+ return pos
+```
+
+### 3. 优化要好几秒
+
+**原因**:COBYLA 是**无梯度**算法,需要**很多次** `simulator` 调用(每次 ~1000 步仿真)。
+
+**加速**:
+- 用有限差分给 `grad` 参数填值,配合 `LD_MMA`(需要梯度)
+- 用更小的 `tol`
+- 减少决策变量数
+
+### 4. 球飞完砸穿了地面
+
+**原因**:积分器在大 v 下不稳定。
+
+**解决**:
+- 减小 `timestep`(XML 里的)
+- 增大阻尼(无中生有……这个例子没有)
+
+### 5. `simulator` 重置 data 之后,初始条件变了?
+
+**原因**:`mj_resetData` 把 qpos 恢复到 XML 里的值。如果**之后**再调 simulator,初始 v 又是基于这个新 qpos。
+
+**验证**:
+```python
+print(data.qpos) # 调用 simulator 后
+# 应该是 [0, 0, 0.1, 1, 0, 0, 0] (位置+四元数)
+```
+
+### 6. 为什么目标盒是 free 关节?
+
+因为**目标盒也要被重力影响**(mass=0.1)。如果用固定关节,撞上去会**刚性弹开**。free 关节让它**能跟着被撞飞**,物理上更真实。
+
+### 7. 跟 MPC 什么关系?
+
+| | No.11 离线优化 | MPC(在线) |
+|---|---|---|
+| 求解时机 | 一次性 | **每个控制步** |
+| 计算预算 | 多 | 少 |
+| 反应扰动 | ❌ | ✅ |
+| 实现复杂度 | 简单 | 复杂 |
+
+**No.11 是 MPC 的「最简离线版」**。
+
+### 8. `data.qvel[0]` 是线速度还是广义速度?
+
+对 free joint,**是线速度**(单位 m/s)。对 hinge joint,是**角速度**(rad/s)。这是**约定**。
+
+### 9. 怎么改成「**加空气阻力**」?
+
+XML 里加:
+```xml
+
+```
+
+MuJoCo 会自动算空气阻力。**约束函数会相应改变**(解析公式不准确了,必须靠优化器)。
+
+### 10. 优化器解不稳定(每次结果不一样)
+
+**原因**:COBYLA 是**确定性的**,但浮点累积误差可能让结果差 1e-3。
+
+**解决**:
+- `np.random.seed(0)` (如果有随机性)
+- 用 `set_xtol_rel(1e-6)` 更紧的容差
+- 多目标:用**全局**算法(`GN_AGS`)
+
+---
+
+## 十二、整体公式对应
+
+```
+──────── 优化问题(数学)───────
+min_{v, θ, T} 0
+subject to:
+ 0.1 ≤ v ≤ 10000
+ 0.1 ≤ θ ≤ π/2 - 0.1
+ 0.1 ≤ T ≤ 10000
+ x_simulator(v, θ, T) = 5.0
+ z_simulator(v, θ, T) = 2.1
+
+──────── 仿真器(约束求值)───────
+simulator(v, θ, T):
+ qvel[0] = v·cos(θ)
+ qvel[2] = v·sin(θ)
+ while time < T: mj_step
+ return (qpos[0], qpos[2])
+
+──────── 优化器(COBYLA)───────
+sol = nlopt.LN_COBYLA.optimize([v, θ, T])
+~ 50-200 次 simulator 调用
+
+──────── 开环播放(主循环)───────
+mj_step(model, data) # 无控制输入
+球从初速度自然飞到目标
+```
+
+---
+
+## 十三、一句话总结
+
+> **No.11 = 「离线轨迹优化 + 开环播放」**。把仿真器当作**约束求值器**扔给 NLopt,让 COBYLA 算法**自动找**最优 (v, θ, T) 让球命中目标。**没有控制律、没有反馈、controller 是空函数** —— 这是「**模型预测 + 开环执行**」的最简形式,也是 No.4-9 在线控制范式的对照面。
diff --git a/docs/src/MuJoCo/No_12.md b/docs/src/MuJoCo/No_12.md
new file mode 100644
index 0000000..7575b24
--- /dev/null
+++ b/docs/src/MuJoCo/No_12.md
@@ -0,0 +1,582 @@
+# No.12 双摆 Lemniscate 数值逆运动学
+
+本节把 No.6 的解析 Jacobian IK **升级**为 **NLopt 数值 IK**,并把目标轨迹从**圆**换成 **Lemniscate(∞ 字形曲线)**。核心思想是:把仿真器当作「**黑箱 FK**」,扔给 COBYLA 优化器,**让算法自己解 IK**,不用手算 Jacobian。
+
+> **核心对比**:No.6 = 解析 IK(手算 J⁻¹);No.12 = 数值 IK(NLopt 自动解)。各有取舍。
+
+---
+
+## 文件说明
+
+```
+mujoco/No_12/
+├── manipulator.xml # MuJoCo XML 模型文件
+└── manipulator_ik.py # 完整脚本:含 NLopt IK、Lemniscate 轨迹、matplotlib 可视化
+```
+
+> No.12 **没有**最小脚本(`no_12.py`)。要看效果必须跑 `manipulator_ik.py`。
+>
+> ⚠️ 需要额外依赖:`pip install nlopt matplotlib`
+
+---
+
+## 一、manipulator.xml 详解(对比 No.6)
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### XML 跟 No.6 几乎一样
+
+| 配置项 | No.6 | No.12 |
+|--------|------|-------|
+| 关节 | `pin`, `pin2` | 同 |
+| `euler` | `0 90 0` | 同 |
+| 末端 site | `` | ``(**改名**)|
+| 重力 | `gravity="0 0 0"` | 同 |
+| actuator | 2 position + 2 velocity servo | 同 |
+| sensor | framepos + framelinvel | 同 |
+| timestep | 0.0001 | **0.001**(粗 10 倍)|
+
+> 唯一明显差异:**site 名字从 `endeff` 改成 `tip`**,**timestep 粗 10 倍**。其他完全一致。
+
+---
+
+## 二、Lemniscate(∞ 字形)轨迹
+
+### 2.1 参数方程
+
+```python
+def get_lemniscate_ref(t):
+ wt = omega * t
+ denominator = 1 + np.sin(wt)**2
+
+ x = center_x + (a * np.cos(wt)) / denominator
+ z = center_z + (a * np.sin(wt) * np.cos(wt)) / denominator
+ return np.array([x, z])
+```
+
+**Lemniscate of Bernoulli** 的标准参数化(极坐标版转笛卡尔):
+
+```
+x(t) = a·cos(ωt) / (1 + sin²(ωt))
+z(t) = a·sin(ωt)·cos(ωt) / (1 + sin²(ωt))
+```
+
+### 2.2 形状示意
+
+```
+ z
+ ↑
+ ╭───╮ ╭───╮
+ │ │ │ │ ← ∞ 字
+ ╰───┼─┼───╯
+ └─┘───→ x
+ ╭───╮ ╭───╮
+ │ │ │ │
+ ╰───╯ ╰───╯
+```
+
+### 2.3 参数含义
+
+| 参数 | 值 | 含义 |
+|------|-----|------|
+| `a` | 0.25 | **半轴长**(控制 ∞ 字大小)|
+| `omega` | 0.4 | **角频率**(rad/s) |
+| 周期 | `2π/ω ≈ 15.7s` | 画完一个 ∞ 需 15.7 秒 |
+
+### 2.4 simend 设计
+
+```python
+simend = 0.25 + 2 * np.pi / omega # ≈ 15.95 秒
+```
+
+**0.25 秒** 启动 + **一个完整周期**(2π/ω)。
+
+### 2.5 圆心锚定
+
+```python
+def init_controller(model, data):
+ end_eff_pos = forward_kinematics([-0.5, 1.0])
+ center_x = end_eff_pos[0] - 0.25 # 圆心 x = 当前末端 x - a
+ center_z = end_eff_pos[1] # 圆心 z = 当前末端 z
+```
+
+**为什么要锚定**?让 Lemniscate **从末端当前位置附近开始**,自然过渡,而不是凭空出现。
+
+---
+
+## 三、核心:数值 IK(NLopt)
+
+### 3.1 优化问题
+
+```
+ min_{q1, q2} 0
+ subject to -π ≤ q1 ≤ π
+ -π ≤ q2 ≤ π
+ FK(q) = X_target
+```
+
+| 维度 | No.6 解析 IK | **No.12 数值 IK** |
+|------|--------------|------------------|
+| 决策变量 | Δq(关节修正)| q1, q2(**绝对**关节角)|
+| 求解方法 | `J⁻¹ · Δx` | **NLopt COBYLA** |
+| 目标 | 误差最小 | **可行性**(cost=0)|
+| 约束 | 无 | **2 个等式约束**(末端 = 目标)|
+| 求解时间 | O(1) 微秒级 | O(N) ms 级(N=迭代次数)|
+
+### 3.2 完整 IK 函数
+
+```python
+def inverse_kinematics(x):
+ opt = nlopt.opt(nlopt.LN_COBYLA, 2)
+ opt.set_lower_bounds([-np.pi, -np.pi])
+ opt.set_upper_bounds([np.pi, np.pi])
+ opt.set_min_objective(cost_func)
+ tol = [1e-4, 1e-4]
+ opt.add_equality_mconstraint(equality_constraints, tol)
+ opt.set_xtol_rel(1e-4)
+ sol = opt.optimize(x)
+ return sol
+```
+
+**完全没用到任何关于这个机械臂的 Jacobian 或 DH 参数** —— 纯靠**仿真器的 FK** 当黑箱。
+
+### 3.3 约束函数 = 仿真器当 FK
+
+```python
+def equality_constraints(result, x, grad):
+ end_eff_pos = forward_kinematics(x) # 调仿真器算 FK
+ result[0] = end_eff_pos[0] - X_target[0] # x 误差
+ result[1] = end_eff_pos[1] - X_target[1] # z 误差
+```
+
+跟 No.11 的 `simulator` **思路一致**:仿真器 = 约束求值器。
+
+---
+
+## 四、forward_kinematics:单独 `data_sim` 的妙用
+
+### 4.1 关键设计
+
+```python
+# 主代码里(第 259 行)
+data_sim = mj.MjData(model) # 独立的 FK 仿真数据
+```
+
+**为什么需要独立的 `data_sim`?**
+
+| 用途 | 用什么 data |
+|------|------------|
+| **主仿真**(动画显示)| `data` |
+| **IK 计算**(每步调一次)| `data_sim` |
+
+**好处**:
+- IK 计算**不污染**主仿真的状态
+- IK 调 `mj_forward` 不会**反过来**影响主仿真
+- 主仿真跑 1000 步,IK 跑 N 次(不同 q 试探),**互不干扰**
+
+### 4.2 实现
+
+```python
+def forward_kinematics(q):
+ data_sim.qpos[0] = q[0]
+ data_sim.qpos[1] = q[1]
+ data_sim.ctrl[0] = data_sim.qpos[0] # ← 多余,见 FAQ
+ data_sim.ctrl[2] = data_sim.qpos[1]
+
+ mj.mj_forward(model, data_sim)
+
+ end_eff_pos = np.array([
+ data_sim.sensordata[0], # tip x
+ data_sim.sensordata[2] # tip z
+ ])
+ return end_eff_pos
+```
+
+**关键**:`mj_forward` 不消耗时间步,**只算一次**前向运动学。
+
+---
+
+## 五、controller 详解
+
+```python
+def controller(model, data):
+ global X_target
+
+ # 1. 算当前时刻的 Lemniscate 目标
+ X_target = get_lemniscate_ref(data.time)
+
+ # 2. 当前关节角作为初始猜测
+ qpos = np.array([data.qpos[0], data.qpos[1]])
+
+ # 3. 调 NLopt 解 IK
+ sol = inverse_kinematics(qpos)
+
+ # 4. 写 position servo 目标
+ data.ctrl[0] = sol[0]
+ data.ctrl[2] = sol[1]
+```
+
+### 5.1 每步都重新解 IK
+
+**是的,每步都调 NLopt**。这是 No.12 的**最大开销**。
+
+### 5.2 `X_target` 全局变量
+
+`X_target` 在 `controller` 里被赋值,但**在 `equality_constraints` 里被读**。这俩函数**不直接传参**,靠 `global` 共享。
+
+```python
+def equality_constraints(result, x, grad):
+ global X_target # 读全局
+ ...
+```
+
+> **设计选择**:用全局变量避免在 `opt.add_equality_mconstraint` 的 callback 里传参。**能用但丑陋**。
+
+### 5.3 `set_mjcb_control` 没注册
+
+```python
+# mj.set_mjcb_control(controller) ← 注释掉
+```
+
+**为什么注释掉**?因为主循环里**手动**调 controller:
+
+```python
+while (data.time - simstart < 0.1):
+ controller(model, data) # 手动调
+ mj.mj_step(model, data)
+```
+
+**好处**:
+- **每 0.1 秒**才调一次 controller(**不是每 mj_step**)
+- 避免每步 1000Hz 跑 NLopt(太慢)
+
+**坏处**:
+- 不是标准用法
+- 跟 `mj_step` 解耦,**可能漏调**(如果 `data.time - simstart >= 0.1` 的判断出问题)
+
+---
+
+## 六、init_controller 详解
+
+```python
+def init_controller(model, data):
+ global center_x, center_z, X_target
+
+ # 1. 用某个 q 算末端位置,确定 Lemniscate 中心
+ end_eff_pos = forward_kinematics([-0.5, 1.0])
+ center_x = end_eff_pos[0] - 0.25
+ center_z = end_eff_pos[1]
+
+ # 2. 解 t=0 时的 IK,得到初始关节角
+ q_guess = np.array([-0.5, 1.0])
+ X_target = get_lemniscate_ref(0.0)
+ q_pos = inverse_kinematics(q_guess)
+
+ # 3. 写初始关节角
+ data.qpos[0] = q_pos[0]
+ data.qpos[1] = q_pos[1]
+```
+
+**为什么这里也解一次 IK**?因为 `get_lemniscate_ref(0.0)` 给的初始目标位置**可能**不是当前 FK 位置,**先解一次**让仿真正确起步。
+
+---
+
+## 七、graph() 可视化(运行结束后)
+
+```python
+def graph():
+ # 收集的 end_eff_pos
+ end_eff_pos_arr = np.concatenate(end_eff_pos, axis=1)
+
+ # 参考 Lemniscate
+ wt = omega * np.linspace(0.0, simend, 500)
+ denominator = 1 + np.sin(wt)**2
+ leminiscate_x = center_x + (a * np.cos(wt)) / denominator
+ leminiscate_z = center_z + (a * np.sin(wt) * np.cos(wt)) / denominator
+
+ # 画图
+ fig, ax = plt.subplots(1, 1, figsize=(8, 5))
+ ax.plot(end_eff_pos_arr[0, :], end_eff_pos_arr[1, :], color="cornflowerblue", ...)
+ ax.plot(leminiscate_x, leminiscate_z, color="darkorange", ...)
+ ax.set_aspect("equal")
+ plt.show(block=False)
+ plt.pause(5)
+ plt.close()
+```
+
+**在主循环结束后**画 matplotlib 对比图:实测末端轨迹(蓝)vs 参考 Lemniscate(橙)。
+
+> **依赖**:需要 `matplotlib` + LaTeX(`mpl.rcParams['text.usetex'] = True`)。**没装 LaTeX 会报错**。
+
+---
+
+## 八、跟 No.6 解析 IK 的对比
+
+| 维度 | No.6 解析 IK | **No.12 数值 IK** |
+|------|--------------|------------------|
+| **核心公式** | `Δq = J⁻¹ · Δx` | **NLopt COBYLA** |
+| **需要 Jacobian 吗** | ✅ 必须手算 | ❌ 完全不用 |
+| **需要 FK 闭式解吗** | ❌ | ❌(用**仿真器**当 FK)|
+| **需要系统参数吗** | ✅(杆长 L1, L2)| ❌ |
+| **每步求解时间** | O(1) ≈ 10 μs | O(N) ≈ 1-10 ms |
+| **精度** | 精确(一阶近似)| 近似(靠 tol 控制)|
+| **奇异点** | 需手动处理 | 自动避开(靠约束)|
+| **适用性** | 仅简单机械臂 | **任何系统**(黑箱)|
+| **可扩展到 N 关节吗** | 难(手写 J)| ✅ 直接通用 |
+
+### 数值 IK 的核心优势
+
+**你完全不需要知道系统的 Jacobian、DH 参数、连杆长度**。只要有仿真器,**任何机器人**都能用这套方法。
+
+这就是 **model-free IK** 的吸引力 —— **系统变了就换仿真器,算法代码完全不用改**。
+
+### 数值 IK 的核心劣势
+
+- **慢**:每步要解 50-200 次 NLopt
+- **不稳定**:可能卡在**局部最优**(COBYLA 是局部算法)
+- **依赖初值**:初始猜测 `x0` 选得不好可能不收敛
+
+---
+
+## 九、整体控制流程图
+
+```
+启动 ───────────────────────────────────────────
+ │
+ ├─ 创建 data (主仿真) + data_sim (FK 用)
+ ├─ init_controller:
+ │ ├─ 锚定 Lemniscate 中心
+ │ ├─ 解 t=0 的 IK
+ │ └─ 设 data.qpos 为解出来的关节角
+ └─ 注册 controller(实际**手动**调)
+
+主循环 (60Hz) ────────────────────────────────────
+ 内层 (0.1s 跑 100 步):
+ │
+ ├─ 手动调 controller:
+ │ ├─ 读 data.time
+ │ ├─ X_target = get_lemniscate_ref(t) ← 算参考目标
+ │ ├─ 用当前 qpos 当初始猜测
+ │ ├─ inverse_kinematics(qpos):
+ │ │ └─ COBYLA 内部:
+ │ │ └─ equality_constraints:
+ │ │ └─ forward_kinematics(q'):
+ │ │ ├─ data_sim.qpos = q'
+ │ │ ├─ mj_forward
+ │ │ └─ return (data_sim.sensordata[0], [2])
+ │ └─ data.ctrl[0] = sol[0] (pin)
+ │ └─ data.ctrl[2] = sol[1] (pin2)
+ │
+ └─ mj_step(用 ctrl 推进物理)
+
+ 外层: 渲染 + 收集 end_eff_pos
+ 结束: graph() 画 matplotlib 对比图
+```
+
+---
+
+## 十、运行方法
+
+```bash
+pip install nlopt matplotlib
+cd mujoco/No_12/
+mjpython manipulator_ik.py
+```
+
+预期效果:
+- 双摆末端在 (x, z) 平面**画 ∞ 字**
+- 持续约 15.7 秒(一个完整周期)
+- 结束后弹 matplotlib 窗口,**对比实测 vs 参考轨迹**
+- 两条曲线**应该重合**(数值 IK 精度足够)
+
+> ⚠️ **没装 LaTeX 会报错**(`text.usetex = True` 那行)。可以注释掉 `mpl.rcParams['text.usetex'] = True`。
+
+---
+
+## 十一、常见问题 / Bugs
+
+### 1. ⚠️ 重复的 `forward_kinematics` 函数(dead code)
+
+```python
+# 第 45-58 行:错误的版本
+def forward_kinematics(self, q): # ← 多了个 self
+ ...
+
+# 第 78-91 行:正确的版本
+def forward_kinematics(q):
+ ...
+```
+
+**第一个永远不会被调用**(Python 用的是第二个)。可以删掉。
+
+### 2. ⚠️ `forward_kinematics` 里的 `ctrl` 设置是死代码
+
+```python
+data_sim.ctrl[0] = data_sim.qpos[0]
+data_sim.ctrl[2] = data_sim.qpos[1]
+```
+
+`mj_forward` **不会**应用 actuator dynamics。这两行**对 FK 结果没影响**。可以删。
+
+### 3. ⚠️ `set_mjcb_control` 注释掉
+
+```python
+# mj.set_mjcb_control(controller)
+```
+
+靠**手动**调 controller:
+```python
+while (data.time - simstart < 0.1):
+ controller(model, data)
+ mj.mj_step(model, data)
+```
+
+**能用但不规范**。如果想标准用法,恢复 `set_mjcb_control` 并删除主循环里的手动调用。
+
+### 4. matplotlib LaTeX 报错
+
+```
+! LaTeX Error: File `amsmath.sty' not found.
+```
+
+**解决**:
+```python
+# 注释掉
+# mpl.rcParams['text.usetex'] = True
+# mpl.rcParams['text.latex.preamble'] = r'\usepackage{amsmath}'
+```
+
+或装 MacTeX(macOS)/ texlive-full(Linux)。
+
+### 5. NLopt 每步都跑,太慢
+
+**观察**:每个 0.1s 的控制步要解 1 次 NLopt ≈ 1-10ms。加上 mj_step 100 步 ≈ 0.1s,**单帧总耗时 0.1-0.2s** → 实际跑 5-10x 实时速度。
+
+**加速**:
+- 降低 NLopt 容差(`tol=[1e-3, 1e-3]`)
+- 减少迭代次数(`xtol_rel=1e-3`)
+- 用**解析 IK**(No.6)代替
+
+### 6. 末端轨迹**不**完全画 ∞ 字
+
+**可能原因**:
+- NLopt 容差太大
+- 奇异点附近 IK 解不稳
+- 仿真器自身数值误差
+
+**调试**:在 `controller` 里加:
+```python
+print(f"X_target={X_target}, sol={sol}, FK(sol)={forward_kinematics(sol)}")
+```
+
+### 7. `X_target` 全局变量的隐患
+
+```python
+def controller(model, data):
+ global X_target
+ X_target = get_lemniscate_ref(data.time) # 写
+
+def equality_constraints(result, x, grad):
+ global X_target # 读
+ ...
+```
+
+**隐患**:如果 `equality_constraints` 在 `controller` **之前**被调(理论上不会),会读到上一步的 `X_target`。
+
+**更好的做法**:`inverse_kinematics(x_target)` 显式传参,不用全局变量。
+
+### 8. 跟 No.6 比速度差异
+
+| 任务 | No.6 解析 | No.12 数值 |
+|------|-----------|------------|
+| 单步 IK | ≈ 10 μs | ≈ 1-10 ms |
+| 1000 步仿真 | ≈ 10 ms | ≈ 1-10 s |
+| 实时性 | 1000+ Hz | 1-10 Hz |
+
+> **No.12 不能用于实时高频控制**。**适合离线规划 + 重放**(MPC 风格)。
+
+### 9. 跟 No.11 抛射体优化的区别
+
+| | No.11 抛射体 | No.12 Lemniscate IK |
+|---|---|---|
+| 优化变量 | v, θ, T(3 个)| q1, q2(2 个)|
+| 目标 | 命中目标点 | 跟踪连续轨迹 |
+| 调用频率 | 1 次(启动时)| **每 0.1s** |
+| 控制器 | 空 | 写 `data.ctrl` |
+
+**No.12 是 No.11 的「**在线版**」** —— 每步都做一次小优化。
+
+### 10. 用更高效的优化器?
+
+| 算法 | 速度 | 适用 |
+|------|------|------|
+| `LN_COBYLA` | 慢 | 当前(无梯度、鲁棒)|
+| `LD_MMA` | 快 | 需要梯度(手动有限差分)|
+| `GN_AGS` | 很慢 | 全局最优 |
+
+---
+
+## 十二、整体公式对应
+
+```
+─────── 参考轨迹(任务空间)──────
+X*(t) = (x_lemniscate(t), z_lemniscate(t)) ← Lemniscate 参数化
+
+─────── IK 优化(每 0.1s 一次)──────
+min_{q1, q2} 0
+s.t. FK(q) = X*(t)
+ -π ≤ q ≤ π
+
+─────── 仿真器作 FK(黑箱)──────
+data_sim.qpos = q
+mj_forward
+return (data_sim.sensordata[0], data_sim.sensordata[2])
+
+─────── 实时控制(每 0.1s 一次)──────
+data.ctrl[0] = sol[0]
+data.ctrl[2] = sol[1]
+position servo (kp=100) 内部做 PD 到位
+
+─────── 物理仿真(每 0.0001s 一次)──────
+mj_step 用 ctrl 推进
+末端到达 sol 对应的位置
+```
+
+---
+
+## 十三、一句话总结
+
+> **No.12 = 「Lemniscate 目标 + NLopt 数值 IK + 独立 data_sim FK + matplotlib 对比可视化」**。跟 No.6 的解析 IK 相比,**牺牲了速度换来了通用性** —— 不需要推导 Jacobian 也能让任意系统跟踪任意轨迹。**核心代价**是每步 1-10ms 的 NLopt 求解开销,**所以只能 10Hz 控制频率**。
diff --git a/docs/src/MuJoCo/No_13.md b/docs/src/MuJoCo/No_13.md
new file mode 100644
index 0000000..4e299d1
--- /dev/null
+++ b/docs/src/MuJoCo/No_13.md
@@ -0,0 +1,610 @@
+# No.13 双足步行机器人(Biped)—— 3 状态机并行控制
+
+本节把 No.9 的**单腿 hopper** 扩展为**双足步行机器人**。核心升级:
+1. **3 个并行 FSM**(髋 + 膝1 + 膝2),每个负责自己的状态切换
+2. **四元数状态估计** —— 用 `quat2euler` 算腿的倾斜角
+3. **斜坡重力** —— 通过旋转重力模拟斜面行走
+4. **腿角色互换** —— leg1/leg2 在 STANCE/SWING 间切换
+
+---
+
+## 文件说明
+
+```
+mujoco/No_13/
+├── biped.xml # MuJoCo XML 模型文件
+└── biped.py # 完整脚本:3 FSM + 状态估计 + 斜坡重力
+```
+
+> No.13 **没有**最小脚本(`no_13.py`)。要看效果必须跑 `biped.py`。
+>
+> ⚠️ 需要 `scipy`:`pip install scipy`
+
+---
+
+## 一、biped.xml 详解
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### body 拓扑与索引
+
+```
+worldbody (0)
+└── leg1 (1) ← "主" body,3 个关节 (x, z, pin)
+ ├── foot1 (2) ← 1 个关节 (knee1)
+ └── leg2 (3) ← "副" body,1 个关节 (hip)
+ └── foot2 (4) ← 1 个关节 (knee2)
+```
+
+> **注意**:leg1 是个**奇怪的"复合体"** —— 它**同时**是身体、第一条腿、还**承载着**第二条腿(leg2 通过 hip 关节接在 leg1 上)。这是个简化的「**双足一体的躯干**」模型。
+
+### 关节总览
+
+| 关节 | 所属 body | 类型 | 物理含义 |
+|------|----------|------|---------|
+| `x` | leg1 | slide | 整体水平平移 |
+| `z` | leg1 | slide | 整体垂直平移 |
+| `pin` | leg1 | hinge | (**未使用** —— 见 FAQ) |
+| `knee1` | foot1 | slide | 腿 1 伸缩(脚上下)|
+| `hip` | leg2 | hinge | 腿 2 摆动(绕 y 轴)|
+| `knee2` | foot2 | slide | 腿 2 伸缩 |
+
+> ⚠️ `pin` 关节在 XML 里**定义但未使用**(代码里没设 `data.ctrl[X]` 给它),是个**遗留设计**。
+
+### 跟 No.9 hopper 的对比
+
+| 维度 | No.9 Hopper | No.13 Biped |
+|------|-------------|-------------|
+| 腿数 | 1 | 2 |
+| 关节数 | 4 | 5(knee1, knee2, hip, x, z)|
+| 躯干 | 单 sphere | leg1(cylinder + 嵌套 leg2)|
+| FSM 数 | 1 | **3 并行**(hip + knee1 + knee2)|
+| 状态估计 | 直接读 qvel | **四元数 → Euler** |
+| 斜坡支持 | ❌ | ✅ 旋转重力 |
+| 关节可视化 | ❌ | ✅ `mjVIS_JOINT` |
+| leg1 的 `pin` 关节 | 有且用 | **有但不用**(遗留)|
+
+---
+
+## 二、核心:3 个并行 FSM
+
+### 2.1 FSM 拓扑
+
+```python
+fsm_hip = FSM_LEG2_SWING # 髋:哪条腿是摆动腿
+fsm_knee1 = FSM_KNEE1_STANCE # 膝1 状态
+fsm_knee2 = FSM_KNEE2_STANCE # 膝2 状态
+```
+
+**3 个 FSM 独立运行**,**不**互相嵌套:
+
+```
+状态空间 = fsm_hip × fsm_knee1 × fsm_knee2 = 2 × 2 × 2 = 8 种组合
+```
+
+### 2.2 状态定义
+
+```python
+# 髋 FSM
+FSM_LEG1_SWING = 0 # leg1 正在摆动
+FSM_LEG2_SWING = 1 # leg2 正在摆动
+
+# 膝1 FSM
+FSM_KNEE1_STANCE = 0 # 脚1 触地
+FSM_KNEE1_RETRACT = 1 # 脚1 抬起
+
+# 膝2 FSM
+FSM_KNEE2_STANCE = 0 # 脚2 触地
+FSM_KNEE2_RETRACT = 1 # 脚2 抬起
+```
+
+### 2.3 状态转移图
+
+#### 髋 FSM(决定哪条腿摆动)
+
+```
+ foot2 着地 + leg1 越过竖直
+ ┌──────────────────────────────────────┐
+ │ ▼
+┌─────────┐ ┌─────────┐
+│ LEG1 │ │ LEG2 │
+│ _SWING │ ◀─────────────────────── │ _SWING │
+│ (leg1 摆)│ foot1 着地 + leg2 越过竖直 │ (leg2 摆)│
+└─────────┘ └─────────┘
+```
+
+**关键转移条件**:
+- `pos_foot2[2] < 0.05`(脚 2 触地)
+- `abs_leg1 < 0.0`(leg1 倾斜过竖直线)
+
+**物理含义**:脚 2 落地的瞬间,**判断身体有没有倾斜到对侧**(`abs_leg1 < 0`)—— 是的话切到 leg1 摆动。
+
+#### 膝 1 FSM
+
+```
+ leg1 越过竖直 (abs_leg1 > 0.1)
+ ┌──────────────────────────────────────┐
+ │ ▼
+┌──────────────┐ ┌──────────────┐
+│ KNEE1 │ ◀────────────────────│ KNEE1 │
+│ _STANCE │ │ _RETRACT │
+│ (脚1 触地) │ │ (脚1 抬起) │
+└──────────────┘ └──────────────┘
+ ▲ │
+ │ foot2 着地 + leg1 越过竖直 │
+ └──────────────────────────────────────┘
+```
+
+#### 膝 2 FSM
+
+**对称于膝 1**,把上面的 leg1 替换为 leg2,foot1 替换为 foot2。
+
+### 2.4 状态转移代码
+
+```python
+# 髋转移
+if fsm_hip == FSM_LEG2_SWING and pos_foot2[2] < 0.05 and abs_leg1 < 0.0:
+ fsm_hip = FSM_LEG1_SWING
+if fsm_hip == FSM_LEG1_SWING and pos_foot1[2] < 0.05 and abs_leg2 < 0.0:
+ fsm_hip = FSM_LEG2_SWING
+
+# 膝 1 转移
+if fsm_knee1 == FSM_KNEE1_STANCE and pos_foot2[2] < 0.05 and abs_leg1 < 0.0:
+ fsm_knee1 = FSM_KNEE1_RETRACT
+if fsm_knee1 == FSM_KNEE1_RETRACT and abs_leg1 > 0.1:
+ fsm_knee1 = FSM_KNEE1_STANCE
+
+# 膝 2 转移(对称)
+if fsm_knee2 == FSM_KNEE2_STANCE and pos_foot1[2] < 0.05 and abs_leg2 < 0.0:
+ fsm_knee2 = FSM_KNEE2_RETRACT
+if fsm_knee2 == FSM_KNEE2_RETRACT and abs_leg2 > 0.1:
+ fsm_knee2 = FSM_KNEE2_STANCE
+```
+
+### 2.5 状态-控制映射
+
+```python
+# 髋控制
+if fsm_hip == FSM_LEG1_SWING: data.ctrl[0] = -0.5 # 髋目标 -0.5 rad
+if fsm_hip == FSM_LEG2_SWING: data.ctrl[0] = +0.5 # 髋目标 +0.5 rad
+
+# 膝 1 控制
+if fsm_knee1 == FSM_KNEE1_STANCE: data.ctrl[2] = 0.0
+if fsm_knee1 == FSM_KNEE1_RETRACT: data.ctrl[2] = -0.25
+
+# 膝 2 控制
+if fsm_knee2 == FSM_KNEE2_STANCE: data.ctrl[4] = 0.0
+if fsm_knee2 == FSM_KNEE2_RETRACT: data.ctrl[4] = -0.25
+```
+
+| 状态 | 关节目标 | 物理含义 |
+|------|---------|---------|
+| `LEG1_SWING` | hip = -0.5 | 髋**前**摆 |
+| `LEG2_SWING` | hip = +0.5 | 髋**后**摆 |
+| `KNEE_STANCE` | knee = 0.0 | 脚**伸**出(不缩回)|
+| `KNEE_RETRACT` | knee = -0.25 | 脚**缩**回(抬起)|
+
+---
+
+## 三、状态估计:四元数 → 欧拉角
+
+### 3.1 为什么需要状态估计?
+
+代码里**没有** `data.qpos[hip]` 这种直接的关节角可用 —— 因为 `hip` 关节是 leg2 的,**但 leg1 的「倾斜角」**需要从 `xquat[1]`(leg1 的四元数)**推算**。
+
+### 3.2 关键代码
+
+```python
+quat_leg1 = data.xquat[1, :] # leg1 的世界四元数 (w, x, y, z)
+euler_leg1 = quat2euler(quat_leg1) # 转成欧拉角 (roll, pitch, yaw)
+abs_leg1 = -euler_leg1[1] # 取 -pitch 作为「腿倾角」
+
+pos_foot1 = data.xpos[2, :] # 脚1 世界坐标
+```
+
+### 3.3 quat2euler 详解
+
+```python
+def quat2euler(quat):
+ # SciPy 用 [x, y, z, w],MuJoCo 用 [w, x, y, z]
+ _quat = np.concatenate([quat[1:], quat[:1]]) # 转换顺序
+ r = R.from_quat(_quat)
+ euler = r.as_euler('xyz', degrees=False) # roll, pitch, yaw
+ return euler
+```
+
+**两个坑**:
+
+| 来源 | 四元数顺序 | 例子 |
+|------|-----------|------|
+| **MuJoCo** | `[w, x, y, z]`(w 在前)| `data.xquat` |
+| **SciPy** | `[x, y, z, w]`(w 在后)| `R.from_quat()` |
+
+代码用 `np.concatenate([quat[1:], quat[:1]])` 转换顺序。
+
+### 3.4 为什么 `abs_leg1 = -euler_leg1[1]`(取负)?
+
+`euler_leg1[1]` 是 **pitch**(绕 y 轴的旋转)。`axis="0 -1 0"` 意味着关节**绕 -y 旋转**,所以四元数给出的 pitch 跟「腿的真实倾斜」**符号相反**。
+
+`abs_leg1 = -pitch` 是个**手调的符号修正**。**没有通用公式** —— 取决于:
+- 旋转轴方向
+- 局部坐标系
+- 欧拉角顺序('xyz' vs 'zyx')
+
+> **替代方案**:用 `mj.rotateQuaternion` 直接算相对角度,避开欧拉角的歧义。
+
+---
+
+## 四、斜坡重力(ramp 模拟)
+
+```python
+# 倾斜重力 0.1 rad ≈ 5.7°
+model.opt.gravity[0] = 9.81 * np.sin(0.1) # x 分量(向右)
+model.opt.gravity[2] = -9.81 * np.cos(0.1) # z 分量(向下)
+```
+
+### 4.1 物理含义
+
+```
+原来: gravity = (0, 0, -9.81) ← 纯向下
+现在: gravity = (0.98, 0, -9.76) ← 略带向右的水平分量
+```
+
+等效于机器人走**5.7° 斜坡**。水平分量 `9.81·sin(0.1) ≈ 0.98` 会让机器人**向右滑**。
+
+### 4.2 为什么这么模拟而不是加斜面几何?
+
+| 方案 | 实现 | 优点 |
+|------|------|------|
+| **改重力**(No.13 用)| `model.opt.gravity = ...` | 一行代码,几何不变 |
+| 改 `` 朝向 | 旋转地面 | 视觉更真实 |
+| 加 `quat` 旋转 | 给 body 旋转 | 可控但复杂 |
+
+No.13 用第一种 —— **最小改动**测试 FSM 能不能应对扰动。
+
+---
+
+## 五、joint frame 可视化
+
+```python
+opt.flags[mj.mjtVisFlag.mjVIS_JOINT] = 1
+```
+
+开启**关节轴可视化**,每个关节会显示**红/绿/蓝**三轴(对应 x/y/z)。**调试用**,不影响物理。
+
+```
+ ┌─ 红轴 (x)
+ │
+ ────┼─── 绿轴 (y) ← 关节
+ │
+ └─ 蓝轴 (z)
+```
+
+---
+
+## 六、init_controller 详解
+
+```python
+def init_controller(model, data):
+ data.qpos[4] = 0.5 # 设置 knee1 初始位置
+ data.ctrl[0] = data.qpos[4] # 髋目标 = 0.5
+```
+
+**只设了 2 个值**:
+- `qpos[4] = 0.5`:knee1 初始位置 0.5 m(让脚 1 初始抬高)
+- `ctrl[0] = qpos[4]`:髋目标也设 0.5(可能是个**遗留错误**,应该是 hip 的目标值而非 knee1 的 qpos)
+
+> ⚠️ **可疑的 init 逻辑**:`data.ctrl[0] = data.qpos[4]` 把 knee1 的位置赋值给 hip 的目标,**不是同一个量**。可能是 bug。
+
+---
+
+## 七、set_mjcb_control 又被注释掉了
+
+```python
+# mj.set_mjcb_control(controller)
+```
+
+跟 No.12 一样,靠**手动**调 controller:
+
+```python
+while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+ controller(model, data) # 手动调
+```
+
+**后果**:每 1/60s 调一次(≈60Hz),每步 0.001s 物理里调 16-17 次 controller。**会重复判断 FSM 转移条件**(但状态没变就不会切换)。
+
+---
+
+## 八、整体控制流程图
+
+```
+启动 ───────────────────────────────────────────
+ │
+ ├─ 创建 model, data
+ ├─ 设斜坡重力: gravity = (0.98, 0, -9.76)
+ ├─ 设初始 qpos[4] = 0.5 (knee1 抬高)
+ └─ init_controller
+
+主循环 (60Hz) ────────────────────────────────────
+ 内层 (~16 次 mj_step / controller):
+ │
+ ├─ mj_step (物理推进)
+ │
+ └─ controller(model, data):
+ │
+ ├─ 状态估计:
+ │ ├─ quat_leg1 = data.xquat[1, :]
+ │ ├─ euler_leg1 = quat2euler(quat_leg1)
+ │ ├─ abs_leg1 = -euler_leg1[1]
+ │ ├─ quat_leg2 = data.xquat[3, :]
+ │ ├─ euler_leg2 = quat2euler(quat_leg2)
+ │ ├─ abs_leg2 = -euler_leg2[1]
+ │ ├─ pos_foot1 = data.xpos[2, :]
+ │ └─ pos_foot2 = data.xpos[4, :]
+ │
+ ├─ FSM 转移:
+ │ ├─ fsm_hip: 哪条腿摆动
+ │ ├─ fsm_knee1: 脚1 状态
+ │ └─ fsm_knee2: 脚2 状态
+ │
+ └─ 控制:
+ ├─ data.ctrl[0] = ±0.5 (hip)
+ ├─ data.ctrl[2] = 0.0 / -0.25 (knee1)
+ └─ data.ctrl[4] = 0.0 / -0.25 (knee2)
+
+ 外层: 渲染 + 相机跟随 + 关节可视化
+```
+
+---
+
+## 九、运行方法
+
+```bash
+pip install scipy
+cd mujoco/No_13/
+mjpython biped.py
+```
+
+预期效果:
+- 机器人**向右走**(受斜坡重力推)
+- 两条腿**交替摆动**
+- 关节上有**红绿蓝三轴**指示
+- 相机**跟随**(`cam.lookat[0] = data.qpos[0]`)
+
+---
+
+## 十、跟 No.9 单腿 hopper 的对比
+
+| 维度 | No.9 Hopper | **No.13 Biped** |
+|------|-------------|-----------------|
+| 腿数 | 1 | 2 |
+| FSM 数 | 1(4 状态)| **3(各 2 状态)** |
+| FSM 关系 | 单 FSM 串行 | **3 FSM 并行** |
+| 状态数 | 4 | 8 = 2³ |
+| 状态估计 | 读 `qvel[1]` | **四元数 → 欧拉** |
+| 斜坡 | ❌ | ✅ 旋转重力 |
+| joint 可视化 | ❌ | ✅ `mjVIS_JOINT` |
+| 复用 XML 关节 | 6 个全用 | **5 个用**(`pin` 遗留)|
+
+### 控制思想对比
+
+```
+No.9: 「单 FSM 串行」
+ FSM_AIR1 → STANCE1 → STANCE2 → AIR2 → AIR1 → ...
+ (时间/事件驱动,单线流程)
+
+No.13: 「3 FSM 并行」
+ 髋 FSM: 决定哪条腿摆
+ 膝1 FSM: 决定脚 1 抬起/放下
+ 膝2 FSM: 决定脚 2 抬起/放下
+ (3 个独立状态机,组合出 8 种可能)
+```
+
+**No.13 更接近真实双足控制** —— 双足行走本质上是**多状态机的协调**。
+
+---
+
+## 十一、调参指南
+
+| 想改 | 改什么 | 效果 |
+|------|--------|------|
+| 走更快 | `FSM_LEG*_SWING` 时 `ctrl[0]` ±0.5 改 ±0.8 | 步幅变大 |
+| 走更稳 | 减小斜坡角度 `0.1 → 0.05` | 扰动变小 |
+| 跳着走 | 让 KNEE_RETRACT 更早触发(改 `abs_leg1 > 0.1` → `> 0.05`)| 抬脚时机更早 |
+| 走更慢 | 把 `knee` 的 kp 减小(1000 → 100)| 腿反应迟钝 |
+| 让 leg1 的 `pin` 用上 | 在 controller 里加 `data.ctrl[X] = ...` | (需要先确定 ctrl 索引)|
+
+---
+
+## 十二、常见问题 / Bugs
+
+### 1. `pin` 关节在 XML 定义了但未使用
+
+**原因**:`pin` 关节的 `ctrl` 通道在 controller 里**从来没被设过**。这是个**遗留设计**。
+
+**解决**:要么删掉 XML 里的 `pin` 关节,要么在 controller 里驱动它。
+
+### 2. `init_controller` 里的可疑赋值
+
+```python
+data.ctrl[0] = data.qpos[4] # ← 髋目标 = 膝1 位置?
+```
+
+`ctrl[0]` 是 hip 位置伺服的目标,赋值 `qpos[4]`(knee1 的位置)**意义不明**。可能是 bug。
+
+**可能正解**:
+```python
+data.ctrl[0] = 0.5 # hip 目标设个固定值
+```
+
+### 3. `set_mjcb_control` 注释掉
+
+跟 No.12 同款问题,靠手动调用。能用但不规范。
+
+### 4. `abs_leg1 = -euler_leg1[1]` 为什么取负?
+
+**手调的符号修正**,跟 `axis="0 -1 0"`(负 y 轴)有关。**没有通用公式**。如果改了轴方向,这个负号也得改。
+
+### 5. 机器人不走路 / 摔倒
+
+**可能原因**:
+- FSM 没切换(状态估计不对)
+- 斜坡角度太大
+- 关节增益太小(`pservo_hip kp=5` 太小!)
+
+**调试**:
+```python
+def controller(model, data):
+ global fsm_hip, fsm_knee1, fsm_knee2, step_no
+ print(f"fsm_hip={fsm_hip}, fsm_knee1={fsm_knee1}, "
+ f"abs_leg1={abs_leg1:.2f}, abs_leg2={abs_leg2:.2f}, "
+ f"pos_foot1={pos_foot1[2]:.2f}, pos_foot2={pos_foot2[2]:.2f}")
+```
+
+### 6. `pservo_hip` 的 kp=5 是不是太小?
+
+**是**。`knee` 是 1000,hip 是 5 —— 差 200 倍。**但这是刻意的**:hip 应该是「**软**」关节,让腿自然摆动;knee 是「**硬**」关节,保证支撑稳定。
+
+如果走路时髋**反应太慢**,可以试 kp=20-50。
+
+### 7. 没装 scipy 报错
+
+```
+ImportError: No module named scipy
+```
+
+**解决**:`pip install scipy`(用于四元数 → 欧拉的转换)。
+
+### 8. `data.xpos[2, :]` 跟 `data.xpos[4, :]` 是什么意思?
+
+MuJoCo 按 XML 声明顺序给 body 编号:
+
+| 索引 | body |
+|------|------|
+| 0 | worldbody |
+| 1 | leg1 |
+| 2 | foot1 |
+| 3 | leg2 |
+| 4 | foot2 |
+
+所以 `[2, :]` 是 foot1,`[4, :]` 是 foot2。**这是个脆弱的硬编码**,XML 一改就错。
+
+**更鲁棒**:
+```python
+foot1_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_BODY, "foot1")
+pos_foot1 = data.xpos[foot1_id, :]
+```
+
+### 9. 怎么从「走路」变成「跑」?
+
+**跑** = 有**飞行相**(两脚同时离地)。No.13 当前控制是**两脚交替**,没飞行相。
+
+**改成跑**:
+- 在 FSM 里加 `FSM_AIR` 状态
+- 当两脚都离地时进入 AIR
+- AIR 阶段不设 `knee` 目标(让脚自然下落)
+
+### 10. 跟 No.7 LQR、No.6 IK 怎么选?
+
+| 任务 | 推荐 |
+|------|------|
+| 单点镇定(双足站直)| No.7 LQR |
+| 末端抓取 | No.6 IK / No.12 数值 IK |
+| 跳跃/跑步 | **No.13 风格多 FSM** |
+| 离线轨迹规划 | No.11 NLopt |
+
+---
+
+## 十三、整体公式对应
+
+```
+────── 机器人形态(XML)──────
+leg1 (3 关节) + leg2 (1 关节) + foot1/foot2 (各 1 关节)
+共 5 个有效关节: x, z, knee1, hip, knee2
+
+────── 状态估计(controller)──────
+quat_leg1 = data.xquat[1, :] # leg1 四元数
+euler_leg1 = quat2euler(quat_leg1) # → 欧拉
+abs_leg1 = -euler_leg1[1] # 取负的 pitch
+
+────── 3 FSM 状态机 ──────
+fsm_hip: LEG1_SWING ↔ LEG2_SWING
+fsm_knee1: STANCE ↔ RETRACT
+fsm_knee2: STANCE ↔ RETRACT
+总组合: 2 × 2 × 2 = 8
+
+────── 状态-控制映射 ──────
+LEG1_SWING → hip = -0.5
+LEG2_SWING → hip = +0.5
+STANCE → knee = 0.0
+RETRACT → knee = -0.25
+
+────── 斜坡重力 ──────
+gravity = (9.81·sin(0.1), 0, -9.81·cos(0.1))
+ ≈ (0.98, 0, -9.76) ← 5.7° 斜面
+```
+
+---
+
+## 十四、一句话总结
+
+> **No.13 = 「双足机器人 + 3 个并行 FSM + 四元数状态估计 + 斜坡重力」**。把 No.9 的单腿 hopper 扩成双足,引入**多 FSM 协调**的范式(比单 FSM 更接近真实机器人控制),用 `quat2euler` 从世界姿态反推腿倾斜角,配合**旋转重力**模拟斜面扰动。**核心复杂度**是 8 种 FSM 组合的协调,**核心新工具**是四元数 → 欧拉的转换。
diff --git a/docs/src/MuJoCo/No_5.md b/docs/src/MuJoCo/No_5.md
new file mode 100644
index 0000000..7d13ea1
--- /dev/null
+++ b/docs/src/MuJoCo/No_5.md
@@ -0,0 +1,458 @@
+# No.5 双摆有限状态机(FSM)轨迹跟踪
+
+本节介绍在 No.4 双摆模型的基础上,引入**有限状态机(Finite State Machine, FSM)** 实现**多段轨迹跟踪**。系统按时间在 `HOLD → SWING1 → SWING2 → STOP` 四个状态间切换,每个阶段跟踪一条三次多项式轨迹。
+
+---
+
+## 文件说明
+
+本节的示例文件位于 `mujoco/No_5/` 目录下:
+
+```
+mujoco/No_5/
+├── no_5.py # 最小主脚本(使用 viewer.launch_passive)
+├── doublependulum_fsm.py # 完整交互脚本(使用 GLFW + FSM 控制器)
+└── doublependulum_fsm.xml # MuJoCo XML 模型文件
+```
+
+---
+
+## 一、doublependulum_fsm.xml 详解(对比 No.4 的 doublependulum.xml)
+
+### No.5 doublependulum_fsm.xml 完整代码
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### XML 配置对比表
+
+| 配置项 | No.4 (doublependulum.xml) | No.5 (doublependulum_fsm.xml) |
+|--------|---------------------------|-------------------------------|
+| 关节 | `hinge` × 2 | `hinge` × 2(相同) |
+| body 层级 | 双层嵌套 | 双层嵌套(相同) |
+| 初始 body 位置 | `(0, 0, 2.5)` | `(0, 0, 1.25)`(更低) |
+| 初始 body 朝向 | `euler="0 0 0"` | `euler="0 180 0"`(**新增**,翻转朝下) |
+| 积分器 | RK4 | RK4(相同) |
+| timestep | 0.0001 | 0.0001(相同) |
+| `` 元素 | 无 | **新增** `energy` / `contact` |
+| actuator | **无** | **新增** 2× motor + 4× servo |
+| 驱动方式 | `qfrc_applied` | `data.ctrl`(通过 actuator) |
+
+### 关键变更说明
+
+#### 1. 高度降低 + 朝向翻转
+No.5 把第一连杆固定点从 z=2.5 降到 z=1.25,并加上 `euler="0 180 0"`,让摆杆**初始朝下悬挂**。这是因为 FSM 任务是要让双摆**向上甩到顶部**(类似杂技「手倒立」),需要低悬挂点 + 朝下初始位形。
+
+#### 2. 引入 actuator
+No.4 通过 `data.qfrc_applied` 直接施加广义力,**不经过 actuator**。No.5 改用 actuator 中的 motor + `data.ctrl` 通道,这是更标准的工业控制接口。
+
+#### 3. actuator 通道映射
+
+| actuator 名 | 关节 | ctrl 索引 | 用途 |
+|------------|------|----------|------|
+| `torque` | pin | `ctrl[0]` | 第一个关节的力矩输入 |
+| `torque2` | pin2 | `ctrl[3]` | 第二个关节的力矩输入 |
+| `pservo1` / `vservo1` | pin | ctrl[1] / ctrl[2] | 预留(kp=0, kv=0,未启用) |
+| `pservo2` / `vservo2` | pin2 | ctrl[4] / ctrl[5] | 预留(kp=0, kv=0,未启用) |
+
+> 注意:motor 和 servo 共享同一关节的 ctrl 索引空间,所以 `pin` 的 motor 占 `ctrl[0]`,servo 占 `ctrl[1]/[2]`;`pin2` 的 motor 占 `ctrl[3]`,servo 占 `ctrl[4]/[5]`。控制器中需要先清空所有 6 个 ctrl 通道再赋值。
+
+---
+
+## 二、no_5.py 详解(最小脚本)
+
+### 完整代码
+
+```python
+import time
+
+import mujoco
+import mujoco.viewer
+
+model = mujoco.MjModel.from_xml_path('doublependulum_fsm.xml')
+data = mujoco.MjData(model)
+
+with mujoco.viewer.launch_passive(model, data) as viewer:
+ while viewer.is_running():
+ mujoco.mj_step(model, data)
+ viewer.sync()
+ time.sleep(1e-3)
+```
+
+### 与 no_4.py 的核心差异
+
+| 项 | no_4.py | no_5.py |
+|----|---------|---------|
+| `import time` 位置 | 放在 `import mujoco` 之后 | 放在**最顶部**(PEP 8 标准) |
+| 加载的 XML | `ball.xml`(**bug**,本意是双摆) | `doublependulum_fsm.xml`(**修正**) |
+| 帧率控制 | `time.sleep(1/500)` ≈ 2ms | `time.sleep(1e-3)` = 1ms |
+| controller | ❌ | ❌(仅观察模型,无控制) |
+| 初始条件 | 未设置 | 未设置(XML 默认下垂) |
+
+> 关键改进:`no_4.py` 加载的是 `ball.xml`(一个**误用**),而 `no_5.py` 正确加载了双摆模型。最小脚本只验证「模型能加载、能跑」,**不展示 FSM 控制效果**——要看 FSM 必须运行 `doublependulum_fsm.py`。
+
+---
+
+## 三、doublependulum_fsm.py 详解(完整脚本)
+
+### 完整代码
+
+```python
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+import os
+
+xml_path = 'doublependulum_fsm.xml'
+simend = 5
+
+# 时序参数(单位:秒)
+t_hold = 0.5 # 起始保持阶段
+t_swing1 = 1.0 # 第一段上摆
+t_swing2 = 1.0 # 第二段上摆
+
+# FSM 状态枚举
+FSM_HOLD = 0
+FSM_SWING1 = 1
+FSM_SWING2 = 2
+FSM_STOP = 3
+
+# 三段轨迹的目标点
+q_init = np.array([[-1.0], [0.0]]) # 初始下垂
+q_mid = np.array([[ 0.5], [-2.0]]) # 中间姿态
+q_end = np.array([[ 1.0], [0.0]]) # 末端顶部倒立
+
+t_init = t_hold
+t_mid = t_hold + t_swing1
+t_end = t_hold + t_swing1 + t_swing2
+
+def init_controller(model, data):
+ """初始化 FSM 状态,并预生成两段三次多项式轨迹。"""
+ global fsm_state, a_swing1, a_swing2
+ fsm_state = FSM_HOLD
+ a_swing1 = generate_trajectory(t_init, t_mid, q_init, q_mid)
+ a_swing2 = generate_trajectory(t_mid, t_end, q_mid, q_end)
+
+def controller(model, data):
+ """FSM + PD 轨迹跟踪控制器。"""
+ global fsm_state, a_swing1, a_swing2
+ time = data.time
+
+ # 状态机转移(仅基于时间)
+ if fsm_state == FSM_HOLD and time >= t_hold:
+ fsm_state = FSM_SWING1
+ elif fsm_state == FSM_SWING1 and time >= t_mid:
+ fsm_state = FSM_SWING2
+ elif fsm_state == FSM_SWING2 and time >= t_end:
+ fsm_state = FSM_STOP
+
+ # 各状态下的参考轨迹
+ if fsm_state == FSM_HOLD:
+ q_ref = q_init
+ dq_ref = np.zeros((2, 1))
+ elif fsm_state == FSM_SWING1:
+ a = a_swing1
+ q_ref = a[0] + a[1]*time + a[2]*time**2 + a[3]*time**3
+ dq_ref = a[1] + 2*a[2]*time + 3*a[3]*time**2
+ elif fsm_state == FSM_SWING2:
+ a = a_swing2
+ q_ref = a[0] + a[1]*time + a[2]*time**2 + a[3]*time**3
+ dq_ref = a[1] + 2*a[2]*time + 3*a[3]*time**2
+ elif fsm_state == FSM_STOP:
+ q_ref = q_end
+ dq_ref = np.zeros((2, 1))
+
+ # PD 控制(增益比 No.4 大 5 倍)
+ kp, kv = 500, 50
+ torque = kp * (q_ref[:, 0] - data.qpos) + kv * (dq_ref[:, 0] - data.qvel)
+
+ # 清空所有 6 个 ctrl 通道(motor+servo 共享索引空间)
+ for i in range(6):
+ data.ctrl[i] = 0
+ # 写入两个 motor 通道
+ data.ctrl[0] = torque[0] # pin → torque
+ data.ctrl[3] = torque[1] # pin2 → torque2
+
+def generate_trajectory(t0, tf, q0, qf):
+ """
+ 三次多项式轨迹:q(t) = a0 + a1*t + a2*t^2 + a3*t^3
+ 满足边界条件:q(t0)=q0, q(tf)=qf, dq(t0)=0, dq(tf)=0
+ """
+ tf_t0_3 = (tf - t0)**3
+ a0 = (qf*(t0**2)*(3*tf - t0) + q0*(tf**2)*(tf - 3*t0)) / tf_t0_3
+ a1 = (6*t0*tf*(q0 - qf)) / tf_t0_3
+ a2 = (3*(t0 + tf)*(qf - q0)) / tf_t0_3
+ a3 = (2*(q0 - qf)) / tf_t0_3
+ return a0, a1, a2, a3
+```
+
+### 3.1 有限状态机(FSM)架构
+
+```
+时间 ─────────────────────────────────────────────────▶
+ │
+ ├─ t ∈ [0, 0.5) ├─ [0.5, 1.5) ├─ [1.5, 2.5) ├─ [2.5, ∞)
+ │ HOLD │ SWING1 │ SWING2 │ STOP
+ │ q_ref = q_init │ 三次曲线 │ 三次曲线 │ q_ref = q_end
+ │ dq_ref = 0 │ init→mid │ mid→end │ dq_ref = 0
+```
+
+**状态转移条件**:纯粹基于仿真时间 `data.time`,是一种**时间驱动的确定性 FSM**。
+
+| 状态 | 时长 | 目标位姿 | 控制目标 |
+|------|------|----------|----------|
+| `HOLD` | 0.5s | `q_init = [-1, 0]` rad | 保持下垂,等待起摆 |
+| `SWING1` | 1.0s | 沿三次曲线扫过 `q_init → q_mid` | 第一段上摆 |
+| `SWING2` | 1.0s | 沿三次曲线扫过 `q_mid → q_end` | 第二段上摆,到达顶部 |
+| `STOP` | 永久 | `q_end = [1, 0]` rad | 保持顶部倒立姿态 |
+
+### 3.2 三次多项式轨迹生成
+
+目标:在 `t0` 时刻处于 `q0`、在 `tf` 时刻处于 `qf`,且**起止速度均为 0**。这是最小jerk风格的边界条件。
+
+求解后得到 4 个系数 `a0, a1, a2, a3`(形状 `(2, 1)`,对应 2 个关节):
+
+```
+q(t) = a0 + a1·t + a2·t² + a3·t³
+dq(t) = a1 + 2·a2·t + 3·a3·t²
+```
+
+`generate_trajectory()` 在 `init_controller` 中**预先**计算好两段轨迹的系数,运行时只做多项式求值,避免每步反解线性方程组。
+
+### 3.3 PD 控制器(与 No.4 对比)
+
+| 控制器特性 | No.4 (反馈线性化) | No.5 (PD + FSM) |
+|------------|------------------|----------------|
+| 控制律 | `τ = M·ddqref + f` | `τ = kp·e + kv·ed` |
+| 增益 `kp` | `100·I` | **500**(5 倍) |
+| 增益 `kv` | `10·I` | **50**(5 倍) |
+| 需惯性矩阵 M | ✅ | ❌ |
+| 需补偿重力/科氏 | ✅(用 `qfrc_bias`) | ❌(靠大 kp/kv 隐式抑制) |
+| 驱动接口 | `data.qfrc_applied` | `data.ctrl`(actuator) |
+| 跟踪目标 | **固定位姿** `qref` | **时变轨迹** `q(t), dq(t)` |
+
+> 设计权衡:No.4 用模型做精确补偿 + 较小增益;No.5 用高增益 PD 暴力跟踪,不依赖模型知识,**更鲁棒于参数误差**但控制信号更"硬"。
+
+### 3.4 ctrl 通道的写入
+
+```python
+for i in range(6):
+ data.ctrl[i] = 0 # 清空(含预留的 servo 通道)
+data.ctrl[0] = torque[0] # pin 的 motor
+data.ctrl[3] = torque[1] # pin2 的 motor
+```
+
+由于 `pin` 的 motor 占 `ctrl[0]`,而 `pin` 的 position/velocity servo 占 `ctrl[1]/[2]`,所以必须**全部清零**再写 motor,否则 servo 通道的 kp=0 不会注入任何力,但保险起见仍清零。
+
+---
+
+## 四、no_5.py 与 doublependulum_fsm.py 对比
+
+| 模块 | no_5.py | doublependulum_fsm.py |
+|------|---------|----------------------|
+| import 风格 | `import time` 在最顶 | 同 |
+| 模型加载 | `doublependulum_fsm.xml` ✅ | 同 |
+| `init_controller` | ❌ | ✅(预生成轨迹) |
+| FSM 状态变量 | ❌ | ✅ |
+| controller 回调 | ❌ | ✅(FSM + PD) |
+| 轨迹生成器 | ❌ | ✅(三次多项式) |
+| keyboard 回调 | ❌ | ✅(Backspace 重置) |
+| mouse_button 回调 | ❌ | ✅ |
+| mouse_move 回调 | ❌ | ✅ |
+| scroll 回调 | ❌ | ✅ |
+| GLFW 窗口 | ❌ | ✅(1200×900) |
+| 仿真时长 | 无限(手动关闭) | 5 秒(`simend=5`) |
+| 帧率控制 | `time.sleep(1e-3)` | 内层 `1.0/60.0` 步进 + `glfw.swap_interval(1)` |
+
+> 注意:`doublependulum_fsm.py` 没有 `no_5.py` 中的 `time.sleep(1e-3)`,因为它用 GLFW 的 `swap_interval(1)`(v-sync)来限速在 60Hz。
+
+---
+
+## 五、运行方法
+
+在 `mujoco/No_5/` 目录下执行:
+
+```bash
+# 最小脚本(仅可视化双摆自由下落,没有 FSM 控制)
+mjpython no_5.py
+
+# 完整脚本(FSM + 轨迹跟踪,把双摆甩到顶部)
+mjpython doublependulum_fsm.py
+```
+
+> macOS 上必须用 `mjpython` 启动(含 MJPEG 编码器);Linux/Windows 可用普通 `python`。
+
+预期效果:
+- `no_5.py`:双摆从下垂自然摆动,没有控制输入。
+- `doublependulum_fsm.py`:
+ - `t ∈ [0, 0.5)`:保持下垂。
+ - `t ∈ [0.5, 1.5)`:第一关节大幅正向加速,把第二关节甩起。
+ - `t ∈ [1.5, 2.5)`:第二关节继续被驱动到顶部。
+ - `t > 2.5`:保持在 `q_end = [1, 0]` rad(顶部倒立)。
+
+---
+
+## 六、与 No.4 的整体对比总结
+
+### 功能特性对比
+
+| 特性 | No.4 (双摆 + 反馈线性化) | No.5 (双摆 + FSM) |
+|------|--------------------------|-------------------|
+| **模型** | 双摆,下垂初始 | 双摆,**下垂 + 翻转 180°** |
+| **驱动接口** | `qfrc_applied` | `data.ctrl`(actuator) |
+| **actuator** | 无 | 2× motor + 4× servo |
+| **控制目标** | 单点镇定 | **多段轨迹跟踪** |
+| **控制器** | 反馈线性化 | PD |
+| **状态机** | ❌ | ✅(4 状态) |
+| **轨迹生成** | ❌ | 三次多项式 |
+| **模型知识需求** | 需 M(q)、qfrc_bias | 无(高增益 PD) |
+| **增益** | kp=100, kv=10 | kp=500, kv=50 |
+| **仿真时长** | 50s | 5s |
+| **最小脚本** | 加载 `ball.xml`(bug) | 加载正确 XML(修复) |
+
+### 控制思想对比
+
+```
+No.4 的「反馈线性化」思维:
+ 已知 M(q), g(q) → 算出补偿项 → 用 M⁻¹ 精确跟踪参考加速度
+ 优点:理论精度高、增益需求小
+ 缺点:依赖模型精度,参数不准时性能下降
+
+No.5 的「FSM + 高增益 PD」思维:
+ 不知道精确模型 → 用大 kp/kv 暴力跟踪
+ 优点:鲁棒于参数误差、结构清晰、易扩展多阶段
+ 缺点:控制信号"硬"、可能激发未建模动力学
+```
+
+### 学习路径
+
+```
+No.1: 基础建模 + viewer 可视化(被动窗口)
+ ↓
+No.2: GLFW 窗口 + 鼠标交互 + 回调机制
+ ↓
+No.3: 单摆关节控制 + 传感器读取 + PD 闭环控制
+ ↓
+No.4: 双摆层级结构 + RK4 + 反馈线性化(单点镇定)
+ ↓
+No.5: 双摆 + actuator + FSM + 三次多项式轨迹(多段跟踪) ← 当前
+ ↓
+(未来)No.6: 接触 / 抓取 / 强化学习策略
+```
+
+### 代码复用情况
+
+| 代码模块 | No.4 → No.5 |
+|----------|-------------|
+| 鼠标状态变量 | 完全相同 |
+| keyboard / mouse / scroll 回调 | 完全相同 |
+| GLFW 初始化 / 可视化结构 | 完全相同 |
+| 关节定义(pin、pin2) | 完全相同 |
+| 几何体 | 完全相同 |
+| 积分器 / timestep | 完全相同 |
+| **驱动接口** | **完全不同**(`qfrc_applied` → `data.ctrl`) |
+| **控制器** | **完全不同**(反馈线性化 → FSM + PD) |
+| **轨迹生成** | **No.5 新增** |
+| **状态机** | **No.5 新增** |
+| **XML 初始位姿** | **完全不同**(无翻转 → 翻转 180°) |
+| **actuator 配置** | **No.5 新增** |
+
+---
+
+## 七、常见问题
+
+### 1. XML 报错 `unrecognized attribute: 'sensornoise'`
+
+**原因**:`` 是老版本 MuJoCo 的写法,新版已移除该属性。
+
+**解决**:删掉 `sensornoise="enable"`,噪声在 `` 元素中单独配置。
+
+### 2. `from_xml_path` 报 `ValueError: XML Error`
+
+**原因**:常见两种——
+- 传了 `.py` 文件(误用)
+- XML 里有 schema 不识别的属性
+
+**解决**:确认路径是 `.xml`,并检查 XML 中所有属性是否在 [MuJoCo XML 文档](https://mujoco.readthedocs.io/en/stable/XMLreference.html) 中。
+
+### 3. FSM 不切换状态
+
+**原因**:`init_controller` 未在 `set_mjcb_control` **之前**调用,导致 `fsm_state` 未初始化。
+
+**解决**:
+```python
+init_controller(model, data) # 必须在 set_mjcb_control 之前
+mj.set_mjcb_control(controller)
+```
+
+### 4. 摆杆抖动剧烈
+
+**原因**:
+- timestep 不够小
+- kp/kv 过大激发数值不稳定
+- actuator 的 ctrlrange 不够大
+
+**解决**:
+- 减小 `timestep`(当前 0.0001 已较小)
+- 适度降低 kp/kv(先各降 50% 试试)
+- 确认 `ctrlrange` 足够大(当前 ±100)
+
+### 5. `data.ctrl[i]` 写入但摆杆没反应
+
+**原因**:写入了非 motor 通道(如 `ctrl[1]` 是 position servo),而 servo 的 kp=0 不产生力。
+
+**解决**:只写 `ctrl[0]`(pin motor)和 `ctrl[3]`(pin2 motor),其余清零。
+
+### 6. `no_5.py` 看不出 FSM 效果
+
+**原因**:`no_5.py` 是最小脚本,**没有注册 controller**,所以双摆自由下落。
+
+**解决**:要观察 FSM 控制效果,必须运行 `mjpython doublependulum_fsm.py`。
\ No newline at end of file
diff --git a/docs/src/MuJoCo/No_6.md b/docs/src/MuJoCo/No_6.md
new file mode 100644
index 0000000..18f0da3
--- /dev/null
+++ b/docs/src/MuJoCo/No_6.md
@@ -0,0 +1,388 @@
+# No.6 双摆逆运动学(IK)
+
+本节介绍双摆的**逆运动学(Inverse Kinematics, IK)** 控制 —— **从「指挥关节」升级到「指挥末端执行器」**。核心思想是用 Jacobian 把「末端想去哪」翻译成「关节该怎么动」。
+
+---
+
+## 文件说明
+
+```
+mujoco/No_6/
+├── doublependulum.xml # MuJoCo XML 模型文件
+└── doublependulum_ik.py # 完整脚本:含 IK 控制器
+```
+
+> No.6 **没有**最小脚本(`no_6.py`)。要看效果必须跑 `doublependulum_ik.py`。
+
+---
+
+## 一、doublependulum.xml 详解(对比 No.5)
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### XML 配置对比表
+
+| 配置项 | No.5 (FSM) | No.6 (IK) |
+|--------|-----------|-----------|
+| 重力 | 默认开启 | **`gravity="0 0 0"`(关)** |
+| 朝向 | `euler="0 180 0"` | `euler="0 90 0"`(**关节在 x-z 平面**) |
+| 末端标记 | ❌ | **`` 新增** |
+| actuator | 2 motor + 4 servo | **2 position servo + 2 velocity servo** |
+| 增益 | 无(motor) | **kp=100, kv=10**(PD 内部由 MuJoCo 算) |
+| sensor | ❌ | **`framepos` + `framelinvel` 新增** |
+
+### 关键变更说明
+
+#### 1. `gravity="0 0 0"`:关掉重力
+
+**为什么**?IK 任务是「末端画圆」,**不需要**与重力对抗。**纯运动学演示**让控制更干净。
+
+#### 2. `euler="0 90 0"`:让运动平面是 (x, z)
+
+`axis="0 -1 0"` 配合 `euler="0 90 0"` 之后,关节旋转轴变成水平方向 → 两个关节的运动**全在 (x, z) 平面内**。这让 IK 任务**降维到 2D**(x、z 两个分量),Jacobian 变成 2×2 方阵**可逆**。
+
+#### 3. ``:定义「末端执行器」
+
+```xml
+
+```
+
+`site` 是 MuJoCo 里的**虚拟标记点**,附在 body 上,不参与碰撞。`pos="0 0 0.5"` 表示在第二根 body 的**末端**(局部 z=0.5)。`size="0.1"` 是可视化半径(橙色小球)。
+
+> **关键概念**:`site` 是「**逻辑上**的末端」,**不**是物理实体。它让控制器能问「末端在哪」「末端的 Jacobian 是什么」。
+
+#### 4. 位置伺服代替 motor
+
+No.5 的 `motor` 是**力矩输入**(要自己写 PD)。No.6 的 `position` servo 是**目标关节角**(MuJoCo 内部帮你做 PD):
+
+```
+motor + 自己写 PD = τ (低层)
+position servo = q_target (高层)
+```
+
+**控制层级上移**:你只关心**目标位置**,不关心**怎么到位**。
+
+#### 5. 传感器:暴露末端信息
+
+```xml
+ → sensordata[0:3] = (x, y, z)
+ → sensordata[3:6] = (vx, vy, vz)
+```
+
+`framepos` 把末端**世界坐标**暴露给 Python。这是「**仿真器作弊**」——它直接把答案告诉你(见下文)。
+
+---
+
+## 二、controller 详解:逆运动学核心
+
+### 2.1 任务定义
+
+让末端在 (x, z) 平面**画一个圆**:
+
+```python
+# 在主循环初始化时算:
+mj.mj_forward(model, data) # 算当前末端位置
+
+x_0 = data.sensordata[0] - r # 圆心 x = 当前末端 x - 0.5
+z_0 = data.sensordata[2] # 圆心 z = 当前末端 z
+
+# 在 controller 里:
+x_target = x_0 + r * cos(t) # 圆参数化(周期 2π)
+z_target = z_0 + r * sin(t)
+```
+
+### 2.2 完整 controller 代码
+
+```python
+def controller(model, data):
+ # 1. 读末端当前位置(来自 )
+ end_eff_pos = data.sensordata[:3]
+
+ # 2. 算末端 Jacobian(3D 位置 × 2 关节)
+ jacp = np.zeros((3, 2))
+ mj.mj_jac(model, data, jacp, None, end_eff_pos, 2) # 2 = body id
+
+ # 3. 切到 (x, z) 子空间(忽略 y),变 2×2 方阵
+ J = jacp[[0, 2], :]
+
+ # 4. 末端位置误差
+ dx = np.array([
+ [x_0 + r * np.cos(data.time) - data.sensordata[0]],
+ [z_0 + r * np.sin(data.time) - data.sensordata[2]]
+ ])
+
+ # 5. 逆运动学:Δq = J⁻¹ · Δx
+ dq = inv(J) @ dx
+
+ # 6. 命令位置伺服:让关节去 q + Δq
+ data.ctrl[0] = data.qpos[0] + dq[0, 0] # pin
+ data.ctrl[2] = data.qpos[1] + dq[1, 0] # pin2
+```
+
+### 2.3 核心公式:Δq = J⁻¹ · Δx
+
+**Jacobian J 是「关节速度 → 末端速度」的线性映射**:
+
+```
+ẋ_末端 = J(q) · q̇_关节
+ (3D) (3×2) (2D)
+```
+
+**反解**:给定末端期望速度 → 求关节速度
+
+```
+q̇ = J⁻¹ · ẋ
+```
+
+No.6 取 J 的 x、z 两行 → 2×2 **方阵**可逆。
+
+### 2.4 公式链
+
+```
+目标末端位置: x*(t) = (x_0 + r·cos t, z_0 + r·sin t)
+末端误差: Δx = x*(t) - x_current
+关节修正: Δq = J⁻¹ · Δx
+目标关节角: q_target = q_current + Δq
+ctrl[0] = q_target_pin
+ctrl[2] = q_target_pin2
+内部 kp=100 → MuJoCo 帮你做 PD 到位
+```
+
+---
+
+## 三、`mj_jac` 的两个非显然参数
+
+```python
+mj.mj_jac(model, data, jacp, None, end_eff_pos, 2)
+```
+
+| 参数位置 | 含义 | 这个值 |
+|---------|------|--------|
+| `jacp` | 位置 Jacobian(输出) | 3×2 零数组(被填) |
+| 第 4 参数 | 旋转 Jacobian | `None`(不要) |
+| `end_eff_pos` | 算 Jacobian 的**世界坐标点** | 末端当前世界坐标 |
+| `2` | 这个点**附在哪个 body** | body id 2(第二根杆) |
+
+> **易错点**:`end_eff_pos` 必须是**世界坐标**(不是 body 局部坐标);`2` 是 body 在 MuJoCo 内部的 id(按 XML 声明顺序:world=0, 第一根杆=1, 第二根杆=2)。
+
+---
+
+## 四、初始化的精妙之处
+
+```python
+data.qpos[0] = -0.5
+data.qpos[1] = 1.0
+mj.mj_forward(model, data) # ← 关键
+
+x_0 = data.sensordata[0] - r # 圆心 = 当前末端 x - 0.5
+z_0 = data.sensordata[2] # 圆心 z = 当前末端 z
+```
+
+### 为什么 `mj_forward`?
+
+`data.sensordata` **不是**赋值 `qpos` 后立即更新的。要算「当前末端在哪」必须**先** `mj_forward`(不消耗时间的前向计算)。
+
+### 圆心为什么这么算?
+
+```
+圆方程: x(t) = x_0 + r·cos(t), z(t) = z_0 + r·sin(t)
+t=0 时: x(0) = x_0 + r, z(0) = z_0
+ = 当前末端位置 = 当前末端位置
+```
+
+所以**圆心 = 末端当前位置向左移 r**。这样画出来的圆**从末端当前位置出发**,自然过渡。
+
+---
+
+## 五、仿真器「作弊」的两处
+
+### 5.1 第一处:直接读 sensordata
+
+```python
+end_eff_pos = data.sensordata[:3] # ← 偷懒:直接拿答案
+```
+
+真实代码应该**自己算正运动学 (FK)**:
+
+```python
+# 自己写 FK
+L1, L2 = 0.5, 0.5 # 杆长
+x_ee = L1 * cos(q[0]) + L2 * cos(q[0] + q[1])
+z_ee = L1 * sin(q[0]) + L2 * sin(q[0] + q[1])
+end_eff_pos = np.array([x_ee, 0, z_ee])
+```
+
+**效果完全一样**(因为 sensor 内部就是这个 FK),但**不依赖 MuJoCo 内部状态**。
+
+### 5.2 第二处:mj_jac 算 Jacobian
+
+```python
+mj.mj_jac(model, data, jacp, None, end_eff_pos, 2) # ← MuJoCo 自动求导
+```
+
+真实代码应该**手写 Jacobian 解析式**(FK 的导数):
+
+```python
+# 自己写 Jacobian
+J = np.array([
+ [-L1·sin(q1) - L2·sin(q1+q2), -L2·sin(q1+q2)],
+ [ L1·cos(q1) + L2·cos(q1+q2), L2·cos(q1+q2)]
+])
+J = J[[0, 1], :] # 提取 x, z 行
+```
+
+> **关键洞察**:**「不知道末端位置」是假问题**。知道 `q` 就能算出 `x`(FK 是确定性函数)。代码里用 `sensordata` 和 `mj_jac` 只是「**仿真器帮你算好**」的便利,**真实部署**时把这两步换成自己的 FK 和 J 即可。
+
+---
+
+## 六、跟 No.4/5 的本质区别
+
+| 维度 | No.4 反馈线性化 | No.5 PD+FSM | **No.6 IK** |
+|------|----------------|------------|-------------|
+| **控制空间** | 关节空间 q | 关节空间 q | **任务空间 x(末端位置)** |
+| **目标** | 固定点 qref | 时变轨迹 q(t) | **空间几何曲线** x(t)(圆) |
+| **核心数学** | `τ = M·v + f` | FSM + PD | **`Δq = J⁻¹·Δx`** |
+| **驱动** | 力矩(qfrc) | 力矩(motor) | **位置(servo)** |
+| **需要模型吗** | 需要 M | 不需要 | **需要 J**(比 M 简单) |
+
+### 控制思想的演进
+
+```
+No.4: 已知模型 → 用模型「抵消」非线性
+No.5: 不知道模型 → 用大增益「硬追」
+No.6: 不知道关节该怎么动 → 问 Jacobian(局部线性映射)
+```
+
+**No.6 的核心价值**:**你不用关心关节怎么动,只告诉系统「末端应该到哪」**。
+
+---
+
+## 七、运行方法
+
+```bash
+cd mujoco/No_6/
+mjpython doublependulum_ik.py
+```
+
+预期效果:末端**绕圆心逆时针画圆**(周期 2π ≈ 6.28 秒),双臂在 (x, z) 平面内优雅地摆动。
+
+---
+
+## 八、常见问题
+
+### 1. 末端画的不是圆
+
+**原因**:Jacobian 奇异点(`det(J) = 0`)。两个杆完全伸直或完全折叠时,末端速度被「锁死」,`J⁻¹` 数值爆炸。
+
+**解决**:
+- 减小圆半径 r(让运动范围远离奇异点)
+- 用 **DLS**(阻尼最小二乘):`J⁺ = Jᵀ(JJᵀ + λ²I)⁻¹`,牺牲精度换稳定
+
+### 2. `mj_jac` 报 body id 错误
+
+**原因**:body id 不是 `2`。
+
+**检查**:
+```python
+print("body 0 =", model.body(0).name) # world
+print("body 1 =", model.body(1).name) # 第一根杆
+print("body 2 =", model.body(2).name) # 第二根杆(含 site)
+```
+
+### 3. 末端从一开始就不在预期位置
+
+**原因**:初始化时没 `mj_forward`,sensordata 还是上一步的旧值。
+
+**解决**:在 `data.qpos = ...` 之后**立即** `mj.mj_forward(model, data)`。
+
+### 4. `dx` 永远不收敛到 0
+
+**原因**:
+- IK 公式是「**一步**修正」(不是积分),理论上**单步**就能闭合误差
+- 但因为 `data.qpos` 在 `mj_step` 过程中变化了,**单帧内** dx 不会完全为 0
+- 控制器每物理步跑 10000 次(因为 timestep=0.0001),所以**视觉上**会很快收敛
+
+### 5. 没有重力,物理意义是什么?
+
+No.6 是**纯运动学演示**:测试 IK 数学是否正确,**不**关心物理真实性。
+
+如果要恢复物理真实性:移除 `gravity="0 0 0"`,并把 `position servo` 换成 `motor + 自己写 PD`。
+
+### 6. 跟 No.7 LQR 怎么选?
+
+| 场景 | 用 IK | 用 LQR |
+|------|-------|--------|
+| 末端走几何路径 | ✅ 直观 | ❌ 麻烦 |
+| 关节镇定到平衡点 | ❌ 不自然 | ✅ 经典用法 |
+| 避障 | ✅ 显式规划 | ❌ 难 |
+| 抗扰 | ❌(单步 IK 不抗扰)| ✅ 闭环稳定 |
+
+**简单规则**:控制**末端**用 IK,**镇定关节**用 LQR。
+
+---
+
+## 九、整体公式对应
+
+```
+───────── 任务(任务空间)─────────
+x*(t) = (x_0 + r·cos t, z_0 + r·sin t) ← 圆参数化
+
+───────── 误差(任务空间)─────────
+Δx = x* - x_current ← sensordata[:3]
+
+───────── 逆运动学(任务空间→关节空间)──
+J = ∂fk/∂q ← 3×2
+J = J[[0, 2], :] ← 切到 2×2
+Δq = J⁻¹ · Δx ← IK 核心
+
+───────── 执行(关节空间)─────────
+ctrl[0] = qpos[0] + Δq[0] ← pin 的目标角
+ctrl[2] = qpos[1] + Δq[1] ← pin2 的目标角
+
+───────── 物理(mujoco)─────────
+position servo (kp=100, kv=10)
+ → 让关节实际到达 q_target
+ → 末端到达 x*
+```
+
+---
+
+## 十、一句话总结
+
+> **No.6 = 「指挥末端而不是关节」**。核心是 Jacobian 逆 `Δq = J⁻¹·Δx` —— 把几何意图翻译成关节命令。`site` + `framepos` 让控制器能问「末端在哪」,`mj_jac` 让它能问「关节动一点末端会怎么动」。**这是运动规划的基础**。
\ No newline at end of file
diff --git a/docs/src/MuJoCo/No_7.md b/docs/src/MuJoCo/No_7.md
new file mode 100644
index 0000000..17c4c94
--- /dev/null
+++ b/docs/src/MuJoCo/No_7.md
@@ -0,0 +1,551 @@
+# No.7 双摆 LQR 最优控制(含系统线性化)
+
+本节介绍在 No.4–No.6 已有控制器的基础上,引入**最优控制**与**在线性化**技术。核心思路:
+1. **线性化**:用有限差分把非线性双摆模型在平衡点附近**线性化**为 `ẋ = Ax + Bu`
+2. **LQR 设计**:解连续代数 Riccati 方程 (CARE),得到**最优反馈增益** `K`
+3. **状态反馈**:控制律 `u = -Kx`(这里 K 已含负号,代码里直接 `+K@state`)
+4. **鲁棒性测试**:往第一个关节注入高斯噪声,看 LQR 能否镇定
+
+---
+
+## 文件说明
+
+```
+mujoco/No_7/
+├── doublependulum.xml # MuJoCo XML 模型文件
+└── doublependulum_lqr.py # 完整脚本:含线性化、LQR 设计、噪声注入
+```
+
+> No.7 **没有**最小脚本(`no_7.py`)。要看效果必须跑 `doublependulum_lqr.py`。
+
+---
+
+## 一、doublependulum.xml 详解(对比 No.6)
+
+### No.7 doublependulum.xml 完整代码
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### XML 配置对比表
+
+| 配置项 | No.6 (IK) | No.7 (LQR) |
+|--------|-----------|-----------|
+| 关节定义 | `pin`, `pin2`(关节在杆底) | `pin`, `pin2`(**关节在杆顶**) |
+| 初始 body 位置 | `(0, 0, 1.25)` | `(0, 0, 2.5)` |
+| 朝向 | `euler="0 90 0"` | `euler="0 180 0"`(朝下) |
+| `` 块 | ❌(用默认) | ✅(**显式**指定 `mass=1`,`diaginertia=0.1 0.1 0.1`) |
+| timestep | 0.0001 | **0.001**(粗 10 倍) |
+| actuator | 4 个(2 motor + 2 servo) | **1 个**(只驱动 pin2) |
+| sensor | `framepos` + `framelinvel` | ❌(**没有**) |
+| `` | ❌ | **有**(但**该属性已废弃**,见 FAQ) |
+| 重力 | `gravity="0 0 0"`(关) | **默认开启** |
+
+### 关键变更说明
+
+#### 1. 关节位置 `pos="0 0 0.5"`(No.6 是 `-0.5`)
+
+No.6 中关节在 body 的**底部**(局部 z=-0.5),第二根 body 接在第一根底部。
+No.7 改成关节在 body **顶部**(局部 z=0.5),第二根 body 接在第一根顶部 `pos="0 0 -1"`。
+
+**为什么改?** 因为 LQR 是基于**线性化模型**的,关节位置的不同会让平衡点附近的 A、B 矩阵**完全不同**。No.7 的设计选择是「杆朝下、关节在顶、像倒挂的钟摆」。
+
+#### 2. 显式 `` 块
+
+```xml
+
+```
+
+`diaginertia="0.1 0.1 0.1"` 是对角惯量张量。No.4/5/6 用默认值(MuJoCo 根据 cylinder 几何自动算),No.7 显式指定**让惯量与几何无关**——便于 LQR 线性化时数值稳定。
+
+#### 3. 只驱动 pin2
+
+```xml
+
+
+```
+
+**只控第二个关节**。第一个关节是**被动**的(受重力、噪声扰动,但无控制输入)。
+
+> **设计哲学**:让 LQR 「**只用一个执行器稳定整个 2 自由度系统**」——这是控制理论的经典挑战。系统是**欠驱动**(underactuated)的。
+
+#### 4. timestep 变粗(0.0001 → 0.001)
+
+**为什么**?LQR 在仿真里**不需要那么小的步长**——它设计时就考虑了「在平衡点附近有界」。粗 10 倍 = 仿真快 10 倍。
+
+---
+
+## 二、核心:状态空间与线性化(最重要的新概念)
+
+### 2.1 状态向量
+
+No.7 第一次明确把系统表示成**状态空间**形式:
+
+```python
+state = [q1, dq1, q2, dq2]ᵀ # 4 维列向量
+ # 两个关节角 + 两个关节角速度
+```
+
+控制输入:
+
+```python
+u = data.ctrl[0] # 1 维标量(pin2 的力矩)
+```
+
+### 2.2 状态导数函数 `get_dx`
+
+```python
+def get_dx(inputs):
+ """
+ inputs = [q1, dq1, q2, dq2, u] (5 维)
+ outputs = [dq1, ddq1, dq2, ddq2] (4 维)
+ """
+ data.qpos[0] = inputs[0]
+ data.qvel[0] = inputs[1]
+ data.qpos[1] = inputs[2]
+ data.qvel[1] = inputs[3]
+ data.ctrl[0] = inputs[4]
+
+ mj.mj_forward(model, data)
+
+ dq1 = data.qvel[0]
+ dq2 = data.qvel[1]
+
+ M = np.zeros((2, 2))
+ mj.mj_fullM(model, M, data.qM)
+
+ f = np.array([
+ [0 - data.qfrc_bias[0]],
+ [data.ctrl[0] - data.qfrc_bias[1]]
+ ])
+
+ ddq = inv(M) @ f
+
+ return np.array([dq1, ddq[0, 0], dq2, ddq[1, 0]])
+```
+
+**这就是 `ẋ = f(x, u)` 的「真值」**——给定当前 `x` 和 `u`,算出状态导数 `ẋ`。
+
+| 输入分量 | 物理含义 | MuJoCo 字段 |
+|---------|---------|------------|
+| `inputs[0]` | 关节 1 角 | `data.qpos[0]` |
+| `inputs[1]` | 关节 1 角速度 | `data.qvel[0]` |
+| `inputs[2]` | 关节 2 角 | `data.qpos[1]` |
+| `inputs[3]` | 关节 2 角速度 | `data.qvel[1]` |
+| `inputs[4]` | pin2 力矩 | `data.ctrl[0]` |
+
+| 输出分量 | 物理含义 |
+|---------|---------|
+| `dq1` | 关节 1 角速度(`= inputs[1]`,恒等) |
+| `ddq1` | 关节 1 角加速度(从 `M @ d̈q = f` 解出) |
+| `dq2` | 关节 2 角速度 |
+| `ddq2` | 关节 2 角加速度 |
+
+> **关键洞察**:`dq/dt` = 角速度本身,`d(dq)/dt` = 角加速度。这俩是**不同的物理量**,但 `get_dx` 把它们一起算出来构成完整的 ẋ。
+
+### 2.3 为什么第一关节的 `f[0]` 是 `0 - qfrc_bias[0]`?
+
+```python
+f = [[0 - qfrc_bias[0]], # pin 没有力矩输入(motor 被注释)
+ [data.ctrl[0] - qfrc_bias[1]]] # pin2 收到 ctrl[0] - 偏置
+```
+
+MuJoCo 的动力学方程是:
+
+```
+M(q) q̈ = τ_applied + τ_bias_constraint - τ_bias
+```
+
+整理成 `M q̈ = f` 形式:
+
+```
+f = τ_applied - qfrc_bias
+```
+
+| 关节 | τ_applied | f = τ_applied - qfrc_bias |
+|------|-----------|--------------------------|
+| pin | 0(无 motor)| `0 - qfrc_bias[0]` |
+| pin2 | `data.ctrl[0]` | `data.ctrl[0] - qfrc_bias[1]` |
+
+然后 `q̈ = M⁻¹ f` 解出加速度。
+
+> **注意**:这里的 `qfrc_bias` 含重力,**所以 f 自动「扣除」了重力**——这跟 No.4 的反馈线性化一个思路。
+
+### 2.4 数值线性化 `linearization`
+
+```python
+def linearization(pert=0.001):
+ f0 = get_dx(np.zeros(5)) # 在 x=0, u=0 处求 ẋ
+
+ Jacobians = []
+ for i in range(5):
+ inputs_i = np.zeros(5)
+ inputs_i[i] = pert # 扰动第 i 个分量
+ jac = (get_dx(inputs_i) - f0) / pert # 有限差分
+ Jacobians.append(jac[:, np.newaxis])
+
+ A = np.concatenate(Jacobians[:4], axis=1) # 4×4(对 x 导数)
+ B = Jacobians[-1] # 4×1(对 u 导数)
+
+ return A, B
+```
+
+这是**有限差分法求 Jacobian**——把 `get_dx` 当黑箱,**数值**地求 `∂ẋ/∂x` 和 `∂ẋ/∂u`:
+
+```
+A[i, j] = (get_dx(x + pert·eⱼ, u) - get_dx(x, u))[i] / pert
+B[i, 0] = (get_dx(x, u + pert) - get_dx(x, u))[i] / pert
+```
+
+**为什么在 `x=0, u=0` 处线性化?** 因为双摆**自然下垂**(`q=0`)是稳定平衡点。`ẋ₀ = 0`(不动),这就是**线性化的基准点**。
+
+### 2.5 线性化结果:状态空间方程
+
+```
+ẋ ≈ A·x + B·u (4 维)
+ = [A]·[q1, dq1, q2, dq2]ᵀ + [B]·u
+```
+
+| 矩阵 | 形状 | 含义 |
+|------|------|------|
+| A | 4×4 | 状态转移:x 变化 → ẋ 变化 |
+| B | 4×1 | 控制响应:u 变化 → ẋ 变化 |
+
+**这是 LQR 设计的前提**:把非线性系统「假装」成线性系统,再设计线性控制器。
+
+---
+
+## 三、LQR 设计(核心算法)
+
+### 3.1 LQR 想干什么?
+
+LQR 找**最优增益 K**,使下面这个**二次型代价函数最小**:
+
+```
+J = ∫₀^∞ ( xᵀ Q x + uᵀ R u ) dt
+ \_______________/ \_______/
+ 状态误差代价 控制代价
+```
+
+- **Q 大** → 重视状态误差(快速收敛,但控制信号大)
+- **R 大** → 重视控制代价(节能,但响应慢)
+- Q、R 选得不同 → 不同 K,**没有"正确"答案**
+
+### 3.2 代码实现
+
+```python
+Q = np.diag([10, 10, 10, 10]) # 状态权重(q1, dq1, q2, dq2 各 10)
+R = np.diag([0.1]) # 控制权重(u 一个分量 0.1)
+
+P = solve_continuous_are(A, B, Q, R) # ① 解 Riccati 方程
+K = -inv(B.T @ P @ B + R) @ B.T @ P @ A # ② 计算最优增益
+```
+
+#### 步骤 ①:解连续代数 Riccati 方程 (CARE)
+
+```
+Aᵀ P + P A - P B R⁻¹ Bᵀ P + Q = 0
+```
+
+这是关于矩阵 P 的**非线性矩阵方程**。`scipy.linalg.solve_continuous_are` 用迭代法(schur decomposition)解。
+
+#### 步骤 ②:LQR 增益公式
+
+```
+K = -R⁻¹ Bᵀ P (连续 LQR 公式)
+ = -(Bᵀ P B + R)⁻¹ Bᵀ P A (代码里的等价形式,避免求 R⁻¹)
+```
+
+**注意负号**:`K` 已经是「带负号」的形式了,所以控制律是 `u = K @ x`(不是 `u = -K @ x`)。
+
+### 3.3 控制律
+
+```python
+def controller(model, data):
+ state = np.array([
+ [data.qpos[0]],
+ [data.qvel[0]],
+ [data.qpos[1]],
+ [data.qvel[1]],
+ ])
+ data.ctrl[0] = (K @ state)[0, 0] # u = K @ x
+
+ # 噪声注入(鲁棒性测试)
+ noise = mj.mju_standardNormal(0.0)
+ data.qfrc_applied[0] = noise
+```
+
+**线性状态反馈**:把 `x` 直接乘以 `K` 矩阵就得到控制量。
+
+### 3.4 噪声注入:鲁棒性测试
+
+```python
+noise = mj.mju_standardNormal(0.0) # 标准正态 N(0, 1)
+data.qfrc_applied[0] = noise # 注入到 pin 关节
+```
+
+`mj.mju_standardNormal` 是 MuJoCo 自带的伪随机数生成器(基于 Box-Muller)。注入到**第一个关节**(pin,是被动的)——模拟**外部扰动**。
+
+**为什么这样测?** 因为 LQR 在「无扰动、模型精确」下一定稳。**有扰动还稳**才证明控制器**鲁棒**。这是控制理论的标配实验。
+
+> **特别说明**:噪声注入 `qfrc_applied[0]` 是**叠加**在系统上的,跟 LQR 控制无关。LQR 不需要知道这个噪声,靠反馈把它「压」回去。
+
+---
+
+## 四、与 No.4–No.6 的本质区别
+
+| 维度 | No.4 反馈线性化 | No.5 PD+FSM | No.6 IK | **No.7 LQR** |
+|------|----------------|------------|---------|--------------|
+| **控制律** | `τ = M·v + f` | FSM 切换的 PD | `Δq = J⁻¹·Δx` | `u = K·x` |
+| **设计方法** | 手算 + 调参 | 手算 + 调参 | 手算 + IK 公式 | **算法求解**(CARE) |
+| **需要模型吗** | ✅ M, qfrc_bias | ❌ | ✅ J | ✅ A, B(要**线性化**) |
+| **需要全状态吗** | 部分(q, q̇) | 部分 | 是 | **是**(x = [q, q̇, q, q̇]) |
+| **稳定性保证** | 局部(精确模型时) | 无 | 局部(远离奇异点) | **全局**(线性化点附近,理论上) |
+| **最优性** | ❌ | ❌ | ❌ | ✅(最小化二次代价) |
+| **鲁棒性** | 差(依赖 M) | 中(大增益) | 中 | **可调**(Q/R 调节) |
+| **计算复杂度** | O(n²) | O(1) | O(n³) | O(n³)(一次性离线) |
+
+### 控制思想的演进
+
+```
+No.4: 已知模型 → 用模型「抵消」非线性
+No.5: 不知道模型 → 用大增益「硬追」
+No.6: 不知道关节该怎么动 → 问 Jacobian(局部线性映射)
+No.7: 知道模型 → 把模型「线性化」→ 用最优控制理论设计 K
+```
+
+---
+
+## 五、controller / get_dx / linearization 三函数协作图
+
+```
+线性化(仅在启动时跑一次) LQR 控制(每物理步)
+───────────────────── ─────────────────
+linearization() controller(model, data)
+ │ │
+ ├─ get_dx(0,0,0,0,0) ├─ 读 qpos, qvel → state (4,1)
+ │ └─ 设 qpos/qvel/ctrl │
+ │ └─ mj_forward ├─ u = K @ state ← 一次矩阵乘
+ │ └─ 算 M, qfrc_bias │
+ │ └─ q̈ = M⁻¹ f └─ data.ctrl[0] = u
+ │ └─ return ẋ
+ │ └─ 注入噪声(鲁棒性测试)
+ ├─ 扰动每个输入 → 重复 5 次 qfrc_applied[0] = N(0,1)
+ ├─ 有限差分 → A (4×4), B (4×1)
+ │
+ └─ Q, R 选权重
+ P = solve CARE
+ K = -R⁻¹ Bᵀ P
+```
+
+---
+
+## 六、关键设计参数与调参指南
+
+| 参数 | 当前值 | 含义 | 怎么调 |
+|------|--------|------|--------|
+| `Q = diag(10, 10, 10, 10)` | 全 10 | 4 个状态分量同等重视 | **增大** → 收敛更快但 u 更大 |
+| `R = 0.1` | 0.1 | 控制代价 | **增大** → u 更小但跟踪变慢 |
+| `Q/R` 比 | 100 | 关键比例 | **越大越激进**,**越小越保守** |
+| `timestep` | 0.001 | 仿真步长 | 线性化**假设连续时间**,dt 不影响 K,但太大会让数字不稳 |
+| `pert` | 0.001 | 有限差分步长 | 太小 → 数值误差;太大 → 截断误差;**~1e-3 ~ 1e-5 都行** |
+
+### 调参实验建议
+
+| 想看什么 | 改什么 |
+|---------|--------|
+| 收敛更快 | `Q` 整体乘 2 |
+| 控制更平缓 | `R` 乘 10 |
+| 位置精度更高 | 角分量 `Q[0,0]`、`Q[2,2]` 调大 |
+| 速度更小 | 角速度分量 `Q[1,1]`、`Q[3,3]` 调大 |
+| 测试鲁棒性边界 | 噪声系数放大 10 倍 |
+
+---
+
+## 七、运行方法
+
+```bash
+cd mujoco/No_7/
+mjpython doublependulum_lqr.py
+```
+
+预期效果:
+- **无噪声情况下**:双摆稳定在自然下垂(q=0),不摆动。
+- **有噪声注入**:双摆**仍然稳定**在 q=0 附近,但会有小幅抖动(被 LQR 抑制)。
+
+> ⚠️ 启动时控制台会打印 `solve_continuous_are` 的解算过程(如果用 verbose 模式)。这是正常的——Riccati 求解是离线一次性计算,**不**影响仿真速度。
+
+---
+
+## 八、跟 No.4–No.6 的学习路径
+
+```
+No.1: 基础建模 + viewer 可视化
+ ↓
+No.2: GLFW + 鼠标交互
+ ↓
+No.3: 单摆 PD 闭环
+ ↓
+No.4: 双摆 + 反馈线性化(需 M)
+ ↓
+No.5: 双摆 + actuator + FSM + 三次多项式轨迹
+ ↓
+No.6: 双摆 + 任务空间 IK(需 J)
+ ↓
+No.7: 双摆 + 线性化 + LQR(需 A, B) ← 当前
+ ↓
+(未来)No.8: MPC / 强化学习 / 接触 / 抓取
+```
+
+### 控制器「知识需求」演进
+
+| 节 | 需要预先知道 |
+|---|------------|
+| No.4 | 动力学 M、qfrc_bias |
+| No.5 | 轨迹生成、FSM 切换 |
+| No.6 | 雅可比 J 的几何意义 |
+| **No.7** | **状态空间、线性化、Riccati 方程** |
+
+---
+
+## 九、常见问题
+
+### 1. XML 报错 `unrecognized attribute: 'sensornoise'`
+
+**原因**:`sensornoise="enable"` 是老版本 MuJoCo 的写法,新版已移除该属性。
+
+**解决**:删除 `` 中的 `sensornoise="enable"`,或整个 flag 元素简化为 ``。
+
+### 2. `solve_continuous_are` 报错 / 数值不稳定
+
+**原因**:
+- (A, B) 不可控(controllable)→ Riccati 无解
+- Q、R 选得不合理(如 R=0)
+
+**检查**:
+```python
+import numpy as np
+controllability = np.concatenate([B, A @ B, A @ A @ B, A @ A @ A @ B], axis=1)
+print("rank:", np.linalg.matrix_rank(controllability)) # 应为 4(满秩)
+```
+
+### 3. 双摆剧烈震荡不收敛
+
+**可能原因**:
+- K 算错了(行/列顺序错)
+- 状态向量顺序跟 K 不匹配
+- 噪声太大盖过控制
+
+**调试**:
+```python
+print("K =", K)
+print("state =", state.flatten())
+print("u =", (K @ state)[0, 0])
+```
+
+### 4. 第一关节(pin)完全不动
+
+**原因**:这是**预期行为**。pin 没有 motor,只有 `qfrc_applied` 注入噪声。
+
+**意图**:让 LQR 「**只用 1 个执行器稳定 2 自由度欠驱动系统**」——经典控制难题。
+
+### 5. 噪声注入在哪一行生效?
+
+```python
+data.qfrc_applied[0] = noise # 注入到 pin
+data.ctrl[0] = (K @ state)[0] # LQR 控 pin2
+```
+
+`qfrc_applied` 是**叠加**到系统上的力,不经过 actuator。**LQR 不需要知道这个力**,靠反馈自然抑制。
+
+### 6. 为什么 `get_dx` 里第一行写 `0 - qfrc_bias[0]`?
+
+因为 pin 没有 motor(τ_applied = 0)。代入 `M q̈ = τ_applied - qfrc_bias`:
+```
+f[0] = 0 - qfrc_bias[0]
+```
+
+### 7. 跟 No.4 反馈线性化的本质区别
+
+| | No.4 反馈线性化 | No.7 LQR |
+|---|---|---|
+| 取消非线性的方式 | 实时算 `M(q)` 并用它做补偿 | 离线线性化成 A, B,不再算 M |
+| 控制律 | `τ = M·v + f`(实时)| `u = K·x`(**一次矩阵乘**)|
+| 计算成本 | 每步 O(n³) | 每步 **O(n²)** |
+| 全局最优? | ❌ | ✅(线性化点附近) |
+
+### 8. LQR 适用范围
+
+LQR **只在线性化点附近**有效。**远离平衡点**性能会急剧下降(因为 `A·x + B·u` 不再近似 `f(x, u)`)。
+
+**解决办法**:
+- 在多个平衡点设计 LQR,**切换**(类似 No.5 的 FSM)
+- 用 **LTV-LQR**(时变 LQR,每步重新线性化)
+- 用 **MPC**(模型预测控制,No.8 可能涉及)
+
+---
+
+## 十、整体公式对应
+
+```
+────────────── 系统(物理)────────────────
+M(q) q̈ + C(q, q̇) + g(q) = τ
+ ← 真实非线性动力学
+────────────── 线性化(get_dx + 数值 Jacobian)────
+ẋ = A x + B u ← 在 x=0, u=0 处的线性近似
+ x = [q1, q̇1, q2, q̇2]ᵀ (4×1)
+ u = pin2 力矩 (1×1)
+ A: 4×4, B: 4×1
+────────────── LQR 设计(离线)─────────────
+min ∫(xᵀQx + uᵀRu)dt
+ s.t. ẋ = Ax + Bu
+ → 解 CARE: AᵀP + PA − PBR⁻¹BᵀP + Q = 0
+ → 增益 K = -R⁻¹BᵀP
+────────────── 实时控制(每步)────────────
+u = K @ x
+data.ctrl[0] = u[0]
+data.qfrc_applied[0] = noise # 鲁棒性测试
+────────────── 物理执行(mj_step)─────────
+更新 qpos, qvel, x → 回到第一步
+```
+
+---
+
+## 十一、一句话总结
+
+> **No.7 = 「离线线性化 + LQR 最优控制 + 噪声鲁棒性测试」**。跟前几节比,最大跃迁是**把控制器设计从「手算调参」升级到「算法求解」**——Q、R 选好,自动算出最优 K。理论上的保证更强了(全局最优、闭环稳定),代价是**只在线性化点附近有效**。
\ No newline at end of file
diff --git a/docs/src/MuJoCo/No_8.md b/docs/src/MuJoCo/No_8.md
new file mode 100644
index 0000000..7e439ef
--- /dev/null
+++ b/docs/src/MuJoCo/No_8.md
@@ -0,0 +1,463 @@
+# No.8 双摆约束力「移交」仿真
+
+本节介绍一个**混合仿真**(hybrid simulation)技巧:用**约束反作用力**作为「虚拟连杆」,在两个**没有真实机械连接**的 body 之间传递力,并在适当时机「释放」。这是把 `connect` equality 约束当作**传感器**用的典型例子。
+
+---
+
+## 文件说明
+
+```
+mujoco/No_8/
+├── pendulum.xml # MuJoCo XML 模型文件
+└── hybrid_pendulum.py # 完整脚本:含约束力读取、FSM 状态机
+```
+
+> No.8 **没有**最小脚本(`no_8.py`)。要看效果必须跑 `hybrid_pendulum.py`。
+
+---
+
+## 一、pendulum.xml 详解(对比 No.7)
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### XML 配置对比表
+
+| 配置项 | No.7 (LQR) | No.8 (Hybrid) |
+|--------|-----------|---------------|
+| body 数量 | 2(嵌套双摆)| 2(**独立**两个 body) |
+| body 关系 | 父子嵌套 | **互不连接** |
+| 关节类型 | 2 个 hinge | 6 个(3 slide + 3 hinge)|
+| DOF 总数 | 2 | **6** |
+| 自由度 | `nv=2` | `nv=6` |
+| actuator | 1 motor | 1 motor + 2 servo(servo kp/kv=0) |
+| equality | ❌ | **`` 新增**(3D 约束)|
+| 重力 | 默认 | 默认(开启)|
+| 约束求解 | 无 | **有**(connect 是硬约束)|
+| timestep | 0.001 | 0.001 |
+
+### 关键变更说明
+
+#### 1. 两个 body 没有机械连接
+
+No.7 的两个 body 是**父子嵌套**(自动连成一根杆)。No.8 的 `pole` 和 `pole2` 是**两个独立的 body** —— 它们**没有真实的关节相连**。
+
+#### 2. `` 等式约束
+
+```xml
+
+```
+
+含义:把 `pole` 上**局部坐标** `(0, 0, 0.5)` 这个点,**钉死**在世界的 `(0, 0, 0.5)` 上。MuJoCo 内部用**拉格朗日乘子**强制这个约束成立。
+
+**约束维度**:3(x, y, z 三个分量)。所以 `nefc = 3`(活跃约束数 = 3)。
+
+#### 3. 6 个 DOF
+
+| DOF | 关节 | 物理含义 | 是否被 connect 约束 |
+|-----|------|---------|-------------------|
+| 0 | `x` | pole 沿 x 平移 | ✅ |
+| 1 | `z` | pole 沿 z 平移 | ✅ |
+| 2 | `pin`| pole 绕 y 旋转 | ✅ |
+| 3 | `x2` | pole2 沿 x 平移 | ❌ 自由 |
+| 4 | `z2` | pole2 沿 z 平移 | ❌ 自由 |
+| 5 | `pin2`| pole2 绕 y 旋转 | ❌ 自由 |
+
+> **关键事实**:`pole` 实际**只剩 0 个自由 DOF**(被 connect 全部钉死)。`pole2` 才有 3 个真正自由的 DOF。
+
+---
+
+## 二、核心概念:connect 等式约束与约束 Jacobian
+
+### 2.1 约束的本质
+
+```xml
+
+```
+
+这是一个**硬约束**:在仿真**每一步**,MuJoCo 都要求:
+
+```
+position_of_anchor_on_pole_in_world == (0, 0, 0.5) (3D 等式)
+```
+
+**不满足就施加拉格朗日乘子力**让它满足。
+
+### 2.2 约束 Jacobian(efc_J)
+
+`efc_J` 是约束对各 DOF 的**偏导数矩阵**:
+
+```
+[约束违反量] = J · qvel
+[3×1] [3×6][6×1]
+```
+
+每一行对应一个约束维度(x, y, z 的位置误差),每一列对应一个 DOF。
+
+| 索引 | 形状 | 含义 |
+|------|------|------|
+| `data.efc_J` | `(nefc*nv,)` 1D 平铺 | 约束 Jacobian(**MuJoCo 3.x 是 1D**) |
+| `data.efc_force` | `(nefc,)` 1D | 约束反作用力(拉格朗日乘子) |
+| `data.nefc` | int | 当前活跃约束数(connect 给 3) |
+| `model.nv` | int | 系统自由度数(这里 = 6) |
+
+### 2.3 约束反作用力(efc_force)
+
+`efc_force` 是**保持约束所需的力**(3D 笛卡尔力):
+
+```
+F0 = efc_force[:3] # (3,) ← x, y, z 三个方向的约束反力
+```
+
+物理含义:把 `pole` 的 anchor 钉在 (0, 0, 0.5) **需要的力有多大**。重力 + 摆动 + 一切外力产生的「偏离趋势」都由这个力抵消。
+
+---
+
+## 三、controller 详解:FSM + 力移交
+
+### 3.1 完整代码
+
+```python
+def controller(model, data):
+ global fsm
+
+ # 1. 读约束 Jacobian(reshape 后切片)
+ J = data.efc_J[:data.nefc * model.nv].reshape((data.nefc, model.nv))[:3, :3]
+
+ # 2. 读约束反作用力
+ F0 = data.efc_force[:3][:, np.newaxis] # (3, 1)
+
+ # 3. 把约束力「投影」到 pole 的 3 个 DOF
+ JT_F = J.T @ F0 # (3, 1)
+
+ # 4. 状态转移:pin2 旋转 > 1.0 rad → 释放
+ if fsm == FSM_SWING and data.qpos[5] > 1.0:
+ fsm = FSM_FREE
+
+ # 5. SWING 阶段:驱动 pin + 移交约束力到 pole2
+ if fsm == FSM_SWING:
+ data.qfrc_applied[2] = -1 * (data.qvel[2] - 5.0) # pin 速度控制
+ data.qfrc_applied[3] = JT_F[0, 0] # x2 ← 约束力 x
+ data.qfrc_applied[4] = JT_F[1, 0] # z2 ← 约束力 y
+ data.qfrc_applied[5] = JT_F[2, 0] + data.qfrc_applied[2] # pin2 ← 约束力 z + pin 力矩
+
+ # 6. FREE 阶段:pole2 自由飞
+ elif fsm == FSM_FREE:
+ data.qfrc_applied[3] = 0.0
+ data.qfrc_applied[4] = 0.0
+ data.qfrc_applied[5] = 0.0
+```
+
+### 3.2 状态机
+
+```
+ pin2 旋转 > 1.0 rad
+ ┌───────────────────────────────────────────┐
+ │ │
+ ▼ │
+ ┌─────────┐ ┌─────────┐
+ │ FSM_SWING│ │ FSM_FREE│
+ │ 摆动 + │ ──────────────────────────────▶ │ 释放 │
+ │ 力移交 │ │ 自由飞行 │
+ └─────────┘ └─────────┘
+ │ │
+ └───────────────────────────────────────────┘
+ 持续仿真直到 simend
+```
+
+| 状态 | 条件 | 行为 |
+|------|------|------|
+| `FSM_SWING` | 初始 / pin2 角度 ≤ 1.0 | 驱动 pin 加速到 5 rad/s + 把约束力移交到 pole2 |
+| `FSM_FREE` | pin2 角度 > 1.0 rad | pole2 自由,**不再受约束力** |
+
+### 3.3 关键公式链
+
+```
+约束力 (3D 笛卡尔): F0 = efc_force[:3] (3, 1)
+约束 Jacobian: J = efc_J reshape → (3, 6) (3, 6)
+pole 的子块: J_pole = J[:, :3] (3, 3)
+pole DOF 上的力: f_pole = J_pole.T @ F0 (3, 1)
+ = (3, 3).T @ (3, 1) = (3, 1)
+```
+
+**核心思想**:`f_pole` 是**本来作用在 pole 上的力**,现在**直接写到 pole2 的 DOF**(`qfrc_applied[3, 4, 5]`)。
+
+这就是「**力移交**」—— pole 被约束钉住,约束反力被「偷」过来,**转手**给 pole2。
+
+---
+
+## 四、「力移交」机制详解
+
+### 4.1 为什么要做力移交?
+
+因为 `pole` 和 `pole2` **没有真实关节连接**,但你**想让它们互动**。三种方案:
+
+| 方案 | 实现 | 优缺点 |
+|------|------|--------|
+| 真实关节 | 在 XML 加 `` | 永久连接,不能分离 |
+| 距离约束 | `` 等式 | 软连接,可调刚度 |
+| **力移交(No.8 方案)** | 读约束力 + 写到另一 body | **可释放**,FSM 控制 |
+
+### 4.2 为什么 `pin2` 旋转会让 pole2 受力?
+
+直觉:
+
+1. `pole` 被 connect 钉死在 (0, 0, 0.5)。
+2. 你**强行**给 `pin` 加速(`qvel[2] → 5`),但 `pin` 旋转会**试图**让 anchor 偏离 (0, 0, 0.5)。
+3. MuJoCo 算出「保持约束」需要的 3D 力 F0。
+4. **`F0` 不是真的在 anchor 点上**——它在每个 DOF 上的**投影**才是 pole 实际感受到的。
+5. 把这个投影**写**到 pole2 上,pole2 就「**好像被连着**」了。
+
+### 4.3 释放时会发生什么?
+
+```python
+if fsm == FSM_SWING and data.qpos[5] > 1.0:
+ fsm = FSM_FREE
+```
+
+当 `pin2` 旋转超过 1.0 rad(约 57°),FSM 切换到 `FSM_FREE`:
+
+```python
+elif fsm == FSM_FREE:
+ data.qfrc_applied[3] = 0.0
+ data.qfrc_applied[4] = 0.0
+ data.qfrc_applied[5] = 0.0
+```
+
+**不再写力给 pole2**。此时 pole2 已经在旋转+平移中获得动能,**带着这个动量飞出去**。pole 仍然被 connect 钉住(如果 MuJoCo 的约束**没有**被释放)。
+
+> **注意**:代码里**只**移除了 `qfrc_applied` 的力,**没有**移除 `` 约束本身(XML 没改)。所以 pole 仍然被钉,pole2 自由飞。
+
+---
+
+## 五、MuJoCo 版本陷阱:efc_J 形状
+
+### 5.1 历史变化
+
+| 版本 | `data.efc_J` 形状 | 索引方式 |
+|------|------------------|---------|
+| < 3.0 | `(nefc, nv)` 2D 密集 | `J[i, j]` |
+| **≥ 3.0(当前 3.8.0)** | `(njmax * nv,)` 1D 平铺 | `J[i*nv + j]` |
+
+老代码 `data.efc_J[:3, :3]` **直接报错**(`IndexError: too many indices`)。
+
+### 5.2 正确写法(已修复)
+
+```python
+J = data.efc_J[:data.nefc * model.nv].reshape((data.nefc, model.nv))[:3, :3]
+```
+
+**解释**:
+- `data.nefc` = 3(connect 给 3 个约束)
+- `model.nv` = 6(总 DOF)
+- `data.efc_J[:18]` 取前 18 个元素(3×6)
+- `.reshape((3, 6))` 变回 2D
+- `[:3, :3]` 保留原作者的语义(取 pole 的 3×3 子块)
+
+### 5.3 如果想知道完整的 Jacobian
+
+```python
+J_full = data.efc_J[:data.nefc * model.nv].reshape((data.nefc, model.nv))
+# 形状 (3, 6): 行=约束维度(x,y,z), 列=DOF
+# [:, :3] = pole 的 3 个 DOF
+# [:, 3:] = pole2 的 3 个 DOF
+```
+
+---
+
+## 六、跟 No.4–No.7 的本质区别
+
+| 维度 | No.4 反馈线性化 | No.5 PD+FSM | No.6 IK | No.7 LQR | **No.8 力移交** |
+|------|----------------|------------|---------|----------|----------------|
+| **物理模型** | 串联双摆 | 串联双摆 | 串联双摆 | 串联双摆 | **两个独立 body** |
+| **连接方式** | 真实关节 | 真实关节 | 真实关节 | 真实关节 | **connect 等式约束** |
+| **控制律** | `τ = M·v + f` | FSM + PD | `Δq = J⁻¹·Δx` | `u = K·x` | **读约束力 + 写到另一 body** |
+| **需要模型吗** | 需要 M | 否 | 需要 J | 需要 A, B | **需要 efc_J, efc_force** |
+| **可释放?** | ❌ | ❌(FSM 切状态) | ❌ | ❌ | **✅(FSM_FREE 切走)** |
+| **核心数学** | 矩阵运算 | 时序逻辑 | Jacobian 逆 | Riccati 方程 | **约束 Jacobian 转置** |
+
+### 控制思想的演进
+
+```
+No.4: 已知模型 → 用模型「抵消」非线性
+No.5: 不知道模型 → 用大增益「硬追」
+No.6: 不知道关节怎么动 → 问 Jacobian(末端映射)
+No.7: 已知模型 → 线性化 + 最优控制
+No.8: 已知约束 → 读约束反力 → 「借」力给另一 body
+```
+
+**No.8 的核心价值**:**不修改 XML 就能在两个 body 之间建立可释放的「虚拟连接」**。
+
+---
+
+## 七、运行方法
+
+```bash
+cd mujoco/No_8/
+mjpython hybrid_pendulum.py
+```
+
+预期效果:
+- **前段(FSM_SWING)**:pole 在原地摆动(被钉住),pole2 跟着动(接收约束反力)。
+- **pin2 转过 1.0 rad**(约 1-2 秒后):FSM 切到 FREE。
+- **后段(FSM_FREE)**:pole2 自由飞行,pole 仍被钉。
+
+---
+
+## 八、跟 No.5 FSM 的对比
+
+| | No.5 FSM | No.8 FSM |
+|---|---------|---------|
+| 状态数 | 4(HOLD/SWING1/SWING2/STOP) | 2(SWING/FREE) |
+| 切换条件 | 时间触发 | 状态触发(`qpos[5] > 1.0`) |
+| 切换逻辑 | 时间表 | 物理量阈值 |
+| 状态行为 | 切 PD 参考 | 切是否施加约束反力 |
+
+**No.5 是「按时间表跑任务」,No.8 是「按物理事件触发」**。
+
+---
+
+## 九、常见问题
+
+### 1. `IndexError: too many indices for array: array is 1-dimensional`
+
+**原因**:MuJoCo 3.x 把 `efc_J` 改成 1D 平铺了。
+
+**解决**:
+```python
+J = data.efc_J[:data.nefc * model.nv].reshape((data.nefc, model.nv))
+```
+
+### 2. 约束力算出来是 0
+
+**原因**:`` 的 `body2="world"` 让 anchor 钉在**世界**。如果 pole 不动(重力被某物平衡),约束力为 0。
+
+**检查**:
+```python
+print("efc_force =", data.efc_force)
+print("nefc =", data.nefc)
+```
+
+### 3. pole2 飞得不对(方向/角度错)
+
+**可能原因**:力移交的**几何不严格**。代码把约束力**直接当 pole2 的关节力矩**用,**忽略了力臂**。
+
+**严格做法**:
+- 在 pole2 上定义一个等效 anchor(用 ``)
+- 算这个 anchor 的 Jacobian `J_anchor2`
+- `qfrc_applied_pole2 = J_anchor2.T @ F0`
+
+### 4. 怎么验证「力移交」真的发生了?
+
+```python
+# 在 controller 里加:
+print("F0 =", F0.flatten())
+print("JT_F =", JT_F.flatten())
+print("qfrc_applied[3:6] =", data.qfrc_applied[3:6])
+```
+
+F0 非零 + JT_F 非零 → 力确实在传。
+
+### 5. 怎么「真的」释放 pole 本身?
+
+代码只移除了 `qfrc_applied` 的力,**没**移除 `` 约束(XML 里的 `body1="pole"` 还在)。
+
+要真释放,**得在仿真中改 XML**(用 `mj_deleteConnection` 或等价的 Python API)—— 这是 MuJoCo 的另一个大话题(**动态模型修改**)。
+
+### 6. 状态变量 `qpos[5]` 是哪个?
+
+是 **pole2 的 pin2 角度**(DOF 5)。`qpos` 顺序按 XML 声明:
+```
+qpos[0] = pole.x
+qpos[1] = pole.z
+qpos[2] = pole.pin
+qpos[3] = pole2.x2
+qpos[4] = pole2.z2
+qpos[5] = pole2.pin2 ← FSM 用这个
+```
+
+### 7. 不用约束力,直接加 `` 连两个 body 行不行?
+
+**可以** —— 但那就不是「**可释放**」的连接了。No.8 的精妙之处就是**用约束当传感器**,**不**用真实关节,**保留「释放」的能力**。
+
+### 8. 跟强化学习里的「虚拟力」有什么关系?
+
+No.8 是把**约束反力**当作 RL 里的**奖励塑形信号**或**辅助控制**。MuJoCo 里这种「**借约束力**」的技巧也常用于**接触感知**(`efc_force` 反映接触力大小)。
+
+---
+
+## 十、整体公式对应
+
+```
+──────── 系统定义(XML)──────
+两个独立 body (pole, pole2) + connect 等式约束
+nefc = 3 (x, y, z), nv = 6 (3+3 个 DOF)
+
+──────── 约束求解(mj_step)──────
+约束 Jacobian: J (3×6) = ∂constraint/∂q
+约束反力: F0 (3×1) = 拉格朗日乘子
+保持 anchor 钉在 (0, 0, 0.5) 所需要的力
+
+──────── 力移交(controller)──────
+J_pole = J[:, :3] (3×3)
+f_pole = J_pole.T @ F0 (3×1) ← pole 上要施加的力
+data.qfrc_applied[3:6] = f_pole ← 改写到 pole2 的 DOF
+
+──────── FSM 状态机 ──────
+FSM_SWING: 驱动 pin + 移交力 + 检查 pin2 > 1.0
+FSM_FREE: pole2 自由,不再移交
+
+──────── 物理(mj_step)──────
+重力 + 约束反力 + 用户的 qfrc_applied → 更新 qpos, qvel
+```
+
+---
+
+## 十一、一句话总结
+
+> **No.8 = 「读约束反力 + 写给另一 body + FSM 释放」**。这是把 MuJoCo 的等式约束**当传感器用**的经典技巧 —— 不修改 XML 也能在两个 body 之间建立**可释放**的虚拟连接。**核心数学**是 `f_pole = J_pole.T @ F0`,**核心代码**是 FSM 切换 `qfrc_applied` 的写入。
diff --git a/docs/src/MuJoCo/No_9.md b/docs/src/MuJoCo/No_9.md
new file mode 100644
index 0000000..a784693
--- /dev/null
+++ b/docs/src/MuJoCo/No_9.md
@@ -0,0 +1,553 @@
+# No.9 单腿跳跃机器人(Hopper)4 状态 FSM
+
+本节介绍一个**单腿跳跃机器人**控制:4 状态 FSM 模拟完整的「空中 → 落地 → 压缩 → 蹬地 → 再次腾空」跳跃循环,**运行时切换 PD 增益**实现不同的刚度/阻尼需求。这是 Raibert hopper 风格的经典控制范式,也是强化学习 locomotion 任务的 baseline controller。
+
+---
+
+## 文件说明
+
+```
+mujoco/No_9/
+├── hopper.xml # MuJoCo XML 模型文件
+└── hopper.py # 完整脚本:4 状态 FSM + 动态增益切换
+```
+
+> No.9 **没有**最小脚本(`no_9.py`)。要看效果必须跑 `hopper.py`。
+
+---
+
+## 一、hopper.xml 详解
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 关键设计说明
+
+#### 1. 机器人形态(4 DOF)
+
+| DOF 索引 | 关节名 | 类型 | 物理含义 | 范围 |
+|---------|--------|------|---------|------|
+| 0 | `x` | slide | 躯干前后平移 | 无界 |
+| 1 | `z` | slide | 躯干上下平移 | 无界 |
+| 2 | `hip`| hinge | 髋关节旋转 | ±π |
+| 3 | `knee`| slide | 膝关节伸缩 | 无界 |
+
+**特点**:**没有「前进/后退」的主动驱动** —— 跳跃产生的反作用力是**唯一**的水平推力来源。
+
+#### 2. 4 个 actuator 通道:每个关节 position + velocity servo
+
+```xml
+
+
+
+
+```
+
+每个关节有**两个 actuator**(一个 P、一个 D),它们**叠加**出力:
+
+```
+F_total = F_position_servo + F_velocity_servo
+ = kp · (ctrl[0] - q) + kv · (ctrl[1] - qd)
+```
+
+> **技巧**:通过运行时修改 kp/kv,可以**同一个 actuator 在不同状态下表现得像弹簧、阻尼器、刚性关节**。
+
+#### 3. ``
+
+```xml
+
+
+
+```
+
+启用**头灯**(跟随相机移动的虚拟光源),让画面有立体感。`ambient` 是环境光强度。
+
+#### 4. foot 的双 geom 设计
+
+```xml
+
+
+```
+
+一个轻量球(mass=0.1)做**接触碰撞**,一个零质量圆柱做**视觉装饰**。这是机器人模型常用的「**质量集中**」技巧。
+
+---
+
+## 二、核心:4 状态 FSM(跳跃循环)
+
+### 2.1 状态定义
+
+```python
+FSM_AIR1 = 0 # 空中,下落中
+FSM_STANCE1= 1 # 刚落地,压缩中
+FSM_STANCE2= 2 # 蹬地,向上加速
+FSM_AIR2 = 3 # 离地后再腾空
+```
+
+### 2.2 状态转移图
+
+```
+ 脚高度 > 0.05
+ ┌────────────────────────────────────────┐
+ │ │
+ ▼ │
+┌────────┐ 脚高<0.05 ┌────────┐ vz>0 ┌────────┐
+│ AIR1 │ ──────────────▶│ STANCE1│ ───────▶│ STANCE2│
+│ 下落 │ │ 压缩 │ │ 蹬地 │
+└────────┘ └────────┘ └────────┘
+ ▲ │
+ │ vz<0 │
+ │ ┌──────────────────────────┐ │
+ │ │ │
+ │ │ 脚高>0.05 ▼ │
+ │ ┌────────┐ │
+ │ │ AIR2 │ │
+ │ │ 腾空 │ ─────────────────────────────────┘
+ │ └────────┘
+```
+
+### 2.3 状态转移条件详解
+
+```python
+body_no = 3
+z_foot = data.xpos[body_no, 2] # 脚的世界坐标 z
+vz_torso = data.qvel[1] # 躯干 z 方向速度
+
+# AIR1 → STANCE1:脚触地
+if fsm == FSM_AIR1 and z_foot < 0.05:
+ fsm = FSM_STANCE1
+
+# STANCE1 → STANCE2:躯干开始上升(压缩反弹)
+if fsm == FSM_STANCE1 and vz_torso > 0.0:
+ fsm = FSM_STANCE2
+
+# STANCE2 → AIR2:脚离开地面
+if fsm == FSM_STANCE2 and z_foot > 0.05:
+ fsm = FSM_AIR2
+
+# AIR2 → AIR1:躯干开始下落(完成一个跳跃周期)
+if fsm == FSM_AIR2 and vz_torso < 0.0:
+ fsm = FSM_AIR1
+ step_no += 1
+```
+
+| 转移 | 触发条件 | 物理含义 |
+|------|---------|---------|
+| AIR1 → STANCE1 | `z_foot < 0.05` | 脚接触地面(脚 z 坐标小于 5cm) |
+| STANCE1 → STANCE2 | `vz_torso > 0.0` | 躯干开始反弹上升 |
+| STANCE2 → AIR2 | `z_foot > 0.05` | 脚蹬离地面 |
+| AIR2 → AIR1 | `vz_torso < 0.0` | 到达最高点,开始下落 |
+
+> **这是事件驱动 FSM**(跟 No.5 的时间驱动、No.8 的单事件触发都不同)—— 转移条件是**物理量**而不是时间或单一标志位。
+
+### 2.4 body_no = 3 是怎么定的?
+
+MuJoCo 按 XML 声明顺序给 body 编号:
+
+| 索引 | body | 来源 |
+|------|------|------|
+| 0 | worldbody | 隐含 |
+| 1 | torso | XML 第一个 `` |
+| 2 | leg | torso 内嵌的 `` |
+| 3 | foot | leg 内嵌的 `` |
+
+所以 `body_no = 3` 是 foot。**这是个脆弱的硬编码**,XML 一改就错。
+
+---
+
+## 三、动态增益切换:每个状态不同刚度/阻尼
+
+### 3.1 状态-增益对照表
+
+| 状态 | pservo-hip kp | vservo-hip kv | pservo-knee kp | vservo-knee kv | ctrl[0] |
+|------|-------------|---------------|----------------|----------------|---------|
+| **AIR1** | 100 | 10 | 100 | 10 | 0 |
+| **STANCE1** | 1000 | 0 | 1000 | 0 | 0 |
+| **STANCE2** | 1000 | 0 | 1000 | 0 | **-0.2** |
+| **AIR2** | 100 | 10 | 100 | 10 | 0 |
+
+**规律**:
+- **空中**(AIR1, AIR2):kp=100, kv=10 —— **软弹簧 + 阻尼**,落地不冲击
+- **着地**(STANCE1, STANCE2):kp=1000, kv=0 —— **硬弹簧、无阻尼**,存储弹性势能
+- **蹬地**(STANCE2):髋关节目标设为 -0.2 rad —— **腿向后摆**,把身体「弹」出去
+
+### 3.2 增益函数
+
+```python
+def set_position_servo(actuator_no, kp):
+ model.actuator_gainprm[actuator_no, 0] = kp
+ model.actuator_biasprm[actuator_no, 1] = -kp
+
+def set_velocity_servo(actuator_no, kv):
+ model.actuator_gainprm[actuator_no, 0] = kv
+ model.actuator_biasprm[actuator_no, 2] = -kv
+```
+
+### 3.3 运行时改增益的机制
+
+`model.actuator_gainprm` 和 `model.actuator_biasprm` 是**模型参数**,但 MuJoCo **允许运行时修改**。
+
+MuJoCo 通用 actuator 模型:
+
+```
+actuator_force = gainprm[0] · ctrl + biasprm[0]
+ + biasprm[1] · (actuated_quantity) ← 位置反馈
+ + biasprm[2] · (actuated_velocity) ← 速度反馈
+ + biasprm[3] · ctrl² ← 二阶项(一般不用)
+```
+
+对于**位置伺服**(`actuator_gainprm[0] = kp, biasprm[1] = -kp`):
+
+```
+F = kp · ctrl + (-kp) · q
+ = kp · (ctrl - q)
+```
+
+对于**速度伺服**(`actuator_gainprm[0] = kv, biasprm[2] = -kv`):
+
+```
+F = kv · ctrl + (-kv) · qd
+ = kv · (ctrl - qd)
+```
+
+> **关键设计**:`gainprm[0]` 既可能是 P 也可能是 D 的系数,靠 `biasprm` 的索引区分作用位置。这是个**非常紧凑的通用模型**。
+
+### 3.4 为什么 STANCE 阶段 kv=0?
+
+蹬地时**不**要阻尼 —— 你想让弹簧**完全弹性**地释放能量。如果有阻尼,弹性势能会**被消耗**而不是**全部转化为动能**。
+
+| 阶段 | kp | kv | 行为 |
+|------|-----|-----|------|
+| 空中 | 100 | 10 | 软着地、摆动平稳 |
+| 落地 | 1000 | 0 | **储能**(弹簧压缩) |
+| 蹬地 | 1000 | 0 | **释能**(弹簧反弹) |
+
+**没有阻尼**意味着「**完全弹性碰撞**」—— 落到地面的动能全部存进弹簧,弹起时全部释放。这就是 Raibert hopper 的精髓。
+
+---
+
+## 四、视觉:headlight + 跟随相机
+
+### 4.1 headlight
+
+```xml
+
+
+
+```
+
+注释掉了两个 `` 元素。headlight 是**相机跟随的虚拟光源**,自然地随相机移动,照亮场景。
+
+### 4.2 跟随相机
+
+```python
+cam.lookat[0] = data.qpos[0] # 相机 lookat 的 x 跟随躯干 x
+```
+
+```python
+# 在主循环里(注意:原始代码里是在 mj_step 之后)
+cam.lookat[0] = data.qpos[0]
+mj.mjv_updateScene(model, data, opt, None, cam, ...)
+```
+
+**效果**:相机**永远盯着躯干**,hopper 跳到哪,相机就看到哪。
+
+> **小 bug**:`cam.lookat` 是 numpy 数组,但 `cam.lookat[0] = data.qpos[0]` 这种**属性赋值**在某些 MuJoCo 版本里**不会真正更新到 MjrRect**。可能需要 `cam.lookat = np.array([data.qpos[0], 0, 1.5])` 重新赋值。
+
+---
+
+## 五、init_controller / controller 分工
+
+### 5.1 init_controller(启动时调一次)
+
+```python
+def init_controller(model, data):
+ set_position_servo(0, 100) # pservo-hip
+ set_velocity_servo(1, 10) # vservo-hip
+ set_position_servo(2, 1000) # pservo-knee
+ set_velocity_servo(3, 0) # vservo-knee
+```
+
+**默认值**:髋软、膝硬。然后 controller 会根据状态重新设。
+
+### 5.2 controller(每物理步调一次)
+
+```python
+if fsm == FSM_AIR1:
+ set_position_servo(2, 100) # 膝软
+ set_velocity_servo(3, 10) # 膝阻尼
+
+if fsm == FSM_STANCE1:
+ set_position_servo(2, 1000) # 膝硬
+ set_velocity_servo(3, 0) # 膝无阻尼
+
+if fsm == FSM_STANCE2:
+ set_position_servo(2, 1000) # 膝硬
+ set_velocity_servo(3, 0) # 膝无阻尼
+ data.ctrl[0] = -0.2 # 髋目标 -0.2 rad(腿后摆)
+
+if fsm == FSM_AIR2:
+ set_position_servo(2, 100) # 膝软
+ set_velocity_servo(3, 10) # 膝阻尼
+ data.ctrl[0] = 0.0 # 髋目标归零
+```
+
+**只有 pservo-hip(ctrl[0])在 STANCE2 期间被显式设目标** —— 用来在蹬地瞬间**把腿向后甩**。其他时候 ctrl[0]=0,意思是「**腿保持竖直**」。
+
+---
+
+## 六、跟 No.5/No.8 FSM 的对比
+
+| 维度 | No.5 FSM | No.8 FSM | **No.9 FSM** |
+|------|---------|---------|--------------|
+| 状态数 | 4 | 2 | **4** |
+| 触发条件 | 时间 | 单一物理量(`qpos[5] > 1.0`)| **多个物理量**(z_foot, vz_torso)|
+| 触发类型 | 时间驱动 | 事件驱动 | **多事件驱动** |
+| 切换内容 | 切 PD 参考 | 切 `qfrc_applied` | **切 kp/kv + 切 ctrl[0]** |
+| 是否运行时改模型 | ❌ | ❌ | **✅ 改 `actuator_gainprm`** |
+| 应用域 | 关节空间 | 约束力 | **完整运动周期** |
+
+### 控制思想对比
+
+```
+No.5: 时间表 → 切任务(HOLD / SWING1 / SWING2 / STOP)
+No.8: 单事件 → 切物理交互(SWING / FREE)
+No.9: 多事件 + 动态增益 → 切运动阶段(AIR / STANCE / 蹬地)
+```
+
+**No.9 是第一个把「**运行时调整模型参数**」作为控制手段的例**。
+
+---
+
+## 七、整体控制流程图
+
+```
+启动: init_controller(model, data)
+ ├─ 设默认增益: pservo-hip kp=100, vservo-hip kv=10
+ ├─ 设默认增益: pservo-knee kp=1000, vservo-knee kv=0
+ └─ mj.set_mjcb_control(controller)
+
+主循环 (60Hz) ─────────────────────────────────────
+ 内层 1000Hz: mj_step → 每步自动调 controller():
+ │
+ │ 读 z_foot = data.xpos[3, 2]
+ │ 读 vz_torso = data.qvel[1]
+ │
+ │ ┌─ 状态转移(多条件检查)─┐
+ │ │ AIR1 + z_foot<0.05 → STANCE1
+ │ │ STANCE1 + vz>0 → STANCE2
+ │ │ STANCE2 + z_foot>0.05 → AIR2
+ │ │ AIR2 + vz<0 → AIR1
+ │ └────────────────────────┘
+ │
+ │ ┌─ 状态-增益映射 ─┐
+ │ │ AIR*: kp=100, kv=10 (软着地)
+ │ │ STANCE: kp=1000, kv=0 (储能/释能)
+ │ │ STANCE2: ctrl[0] = -0.2 (腿后摆)
+ │ └────────────────┘
+ │
+ │ set_position_servo(2, kp) ← 改 model 参数
+ │ set_velocity_servo(3, kv) ← 改 model 参数
+ │ data.ctrl[0] = target ← 改控制目标
+ │
+ └─ 物理: mj_step 应用所有力,更新 qpos, qvel
+ 外层: 渲染 + cam.lookat[0] = data.qpos[0] (跟随)
+```
+
+---
+
+## 八、运行方法
+
+```bash
+cd mujoco/No_9/
+mjpython hopper.py
+```
+
+预期效果:
+- Hopper **原地(或缓慢前进)跳跃**
+- 大约 0.5-1 秒一跳,`step_no` 累计
+- `simend = 20` 秒应该看到 **15-25 跳**
+- 相机**自动跟随**(`lookat[0] = qpos[0]`)
+
+---
+
+## 九、调试 / 验证方法
+
+### 1. 打印状态和步数
+
+```python
+def controller(model, data):
+ global fsm, step_no
+ body_no = 3
+ z_foot = data.xpos[body_no, 2]
+ vz_torso = data.qvel[1]
+ print(f"t={data.time:.2f} fsm={fsm} z_foot={z_foot:.3f} vz={vz_torso:.2f} step={step_no}")
+ # ... 原有代码
+```
+
+### 2. 验证增益确实被改了
+
+```python
+def controller(model, data):
+ print(f"hip kp={model.actuator_gainprm[0, 0]:.0f} "
+ f"hip kv={model.actuator_gainprm[1, 0]:.0f} "
+ f"knee kp={model.actuator_gainprm[2, 0]:.0f} "
+ f"knee kv={model.actuator_gainprm[3, 0]:.0f}")
+```
+
+### 3. 调参方向
+
+| 想改 | 改什么 |
+|------|--------|
+| 跳得更高 | 增大 `pservo-knee` 的 kp,或延长 STANCE 阶段 |
+| 跳得更稳 | 增大 `vservo-knee` 在 AIR 阶段的 kv |
+| 跳得更远 | STANCE2 时设 `data.ctrl[0] = -0.5`(腿更向后摆)|
+| 跳得更快 | 把状态转移阈值 `0.05` 改小(更快检测着地/离地)|
+
+---
+
+## 十、常见问题
+
+### 1. Hopper 跳不起来
+
+**原因**:STANCE 阶段 kp 太小,没有储能。
+
+**解决**:
+- 增大 `pservo-knee` 在 STANCE 的 kp(从 1000 试到 2000)
+- 检查 `vz_torso` 是不是真的能 > 0
+- 试着增加躯干质量(gravity 改小也行)
+
+### 2. Hopper 触地后「粘在地上」
+
+**原因**:蹬地力度不够,弹不起来。
+
+**解决**:
+- 检查 STANCE2 状态有没有真的进入(看 `z_foot > 0.05`)
+- `data.ctrl[0] = -0.2` 改大(比如 -0.5)
+- vservo 在 STANCE 阶段必须 `kv=0`,不然会**消耗弹性势能**
+
+### 3. `body_no = 3` 报错 / 不对
+
+**原因**:XML 改了,body 顺序变了。
+
+**解决**:用名字查 id 而不是硬编码:
+```python
+foot_id = mj.mj_name2id(model, mj.mjtObj.mjOBJ_BODY, "foot")
+z_foot = data.xpos[foot_id, 2]
+```
+
+### 4. `data.xpos[body_no, 2]` 是脚的哪点?
+
+是 foot body **质心**的 z 坐标(不是脚底)。脚底可能比质心低 0.05m 左右,所以阈值 `0.05` 实际上对应**脚底刚刚触地**。
+
+### 5. `cam.lookat[0] = data.qpos[0]` 没生效
+
+**原因**:某些 MuJoCo 版本对 `MjvCamera.lookat` 的**元素赋值**不更新底层 C 结构。
+
+**解决**:
+```python
+cam.lookat = np.array([data.qpos[0], 0.0, 1.5]) # 整体赋值
+```
+
+### 6. 运行时改 `actuator_gainprm` 会有性能影响吗?
+
+**没有**。这是直接修改内存里的 float 值,下一步 `mj_step` 就用新值。**对仿真速度零影响**。
+
+但有**限制**:必须**每步**改才能持续生效(虽然 `model` 是持久的,但如果你重置 `data` 会保留 `model` 的修改)。
+
+### 7. 为什么没有 LQR、IK、反馈线性化这些高级控制?
+
+**因为跳跃是高度非线性 + 不连续的**(着地瞬间)。这些方法都基于「**线性化点附近**」假设,跳跃时**严重偏离**任何平衡点。
+
+FSM + 动态增益是**事件驱动**的简单方案,**鲁棒**且**可解释**,是经典 Raibert hopper 的做法。
+
+### 8. 跟强化学习里的 locomotion 任务什么关系?
+
+这是**经典控制**(model-based FSM),RL 的 PPO/SAC 是**学习控制**(model-free)。RL baseline 通常也是 4 状态 FSM + 简单 PD,但**学习**状态转移的精确时机和增益。
+
+---
+
+## 十一、整体公式对应
+
+```
+─────── 机器人形态 ───────
+torso (sphere, mass=1) + leg (cylinder, mass=1) + foot (sphere, mass=0.1)
+nv = 4 (x, z, hip, knee)
+4 个 actuator: 2 关节 × (position + velocity) servo
+
+─────── 状态机 ───────
+fsm ∈ {AIR1, STANCE1, STANCE2, AIR2}
+转移条件: 脚高度 / 躯干速度 阈值
+
+─────── 控制律 ───────
+每个状态一对增益 (kp, kv):
+ F_joint = kp · (ctrl_target - q) + kv · (ctrl_vel_target - qd)
+运行时改 model.actuator_gainprm / biasprm
+
+─────── 周期 ───────
+AIR1 (0.4s) → STANCE1 (0.05s)
+ → STANCE2 (0.05s) [data.ctrl[0]=-0.2 腿后摆]
+ → AIR2 (0.4s) [step_no++]
+ → AIR1
+```
+
+---
+
+## 十二、一句话总结
+
+> **No.9 = 「4 状态事件驱动 FSM + 运行时动态增益切换」**。这是 Raibert hopper 风格的经典控制范式 —— 跳跃周期用**物理事件**(脚高度、躯干速度)分段,每段用**不同的 PD 增益**实现软着地 / 硬储能 / 完全弹性反弹。**核心创新**是 `set_position_servo / set_velocity_servo` 运行时改 `actuator_gainprm`,让同一个 actuator 在不同时刻扮演弹簧/阻尼器/刚性关节。
diff --git a/docs/src/MuJoCo/demo_collect.md b/docs/src/MuJoCo/demo_collect.md
new file mode 100644
index 0000000..6acc7c8
--- /dev/null
+++ b/docs/src/MuJoCo/demo_collect.md
@@ -0,0 +1,722 @@
+# 统一 Demo:移动操作机器人 + 数据采集(No.1–No.13 融合)
+
+本节把 No.1–No.13 全部概念融合到一个文件 `demo_collect.py` 中:一辆带 4-DOF 机械臂的小车,通过 **9 状态 FSM** 自动完成"捡地上的盒子 → 运回来 → 放下",并**同步采集训练数据**。
+
+---
+
+## 文件说明
+
+```
+mujoco/Chenlong_Robot/
+├── car.xml # MuJoCo 模型(小车 + 2× 臂 + 双指夹爪 + 28 路传感器)
+├── demo_collect.py # 统一 Demo + 数据采集(~800 行)
+└── episodes/ # 采集数据:ep_*.npz + summary_*.png
+```
+
+运行:
+
+```bash
+python3 demo_collect.py # GUI,默认 5 轮
+python3 demo_collect.py --headless --episodes 10 # 无头,10 轮
+```
+
+---
+
+## 一、car.xml 模型详解
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 臂几何参数
+
+| 段 | 长度 | 半径 | 关节 | 范围 |
+|----|------|------|------|------|
+| 上臂 | 0.50m | 0.065m | shoulder_lift | ±360° |
+| 前臂 | 0.40m | 0.055m | elbow | ±360° |
+| 手腕 | 0.16m | 0.045m | wrist_pitch | ±360° |
+| 夹爪 | — | — | finger_l/r slide | 0→0.04m |
+| **总臂展** | **1.06m** | | | |
+
+---
+
+## 二、demo_collect.py 逐段详解
+
+### 2.1 常量与几何参数(第 59–92 行)
+
+```python
+# 盒子放在地上 (z=0.04 是盒子半高,底部刚好贴地)
+TARGET_POS = np.array([1.5, 0.0, 0.04])
+PLACE_POS = np.array([0.25, 0.0, 0.55]) # 放到原点前方
+
+# 臂长 —— 必须与 car.xml 一致
+L_UPPER = 0.50
+L_FOREARM = 0.40
+L_WRIST = 0.16
+L_GRIPPER = 0.08
+L2_EFF = L_FOREARM + L_WRIST + L_GRIPPER # = 0.64,用于 2 连杆 IK
+
+# 控制参数
+DRIVE_SPEED = 0.5 # 轮子速度
+REACH_TOL = 0.18 # 末端到达容差(18cm)
+TRAJ_DURATION = 0.6 # 三次轨迹时长(秒)
+NUM_EPISODES = 5 # 默认运行 5 轮
+
+# 夹爪位置
+STOWED = [0.0, 0.5, -1.0, 0.0, 0.04, 0.04] # [pan, lift, elbow, wrist, f_l, f_r]
+# 手指=0.04 → 张开
+
+# 关节限位(必须与 XML 中 range 一致)
+Q_MIN = np.array([-6.28, -6.28, -6.28, -6.28]) # ±360°
+Q_MAX = np.array([ 6.28, 6.28, 6.28, 6.28])
+```
+
+---
+
+### 2.2 模型 ID 缓存(第 109–124 行)
+
+```python
+def _cache_ids(m):
+ """一次性查表,存下所有 body/site/joint 的 ID,后续用整数寻址比字符串快。"""
+ for name in ("car", "arm_base", "target_box"):
+ _ids[name] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_BODY, name)
+ _ids["ee"] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_SITE, "end_effector")
+ _ids["gripper"] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_BODY, "gripper_palm")
+
+ arm_names = ["shoulder_pan", "shoulder_lift", "elbow", "wrist_pitch",
+ "finger_l_j", "finger_r_j"]
+ _arm_qpos_adr = [m.jnt_qposadr[m.joint(n).id] for n in arm_names]
+ _arm_dof_adr = [m.jnt_dofadr[m.joint(n).id] for n in arm_names]
+ # jnt_qposadr → 关节在 qpos 数组中的起始下标
+ # jnt_dofadr → 关节在 qvel 数组中的起始下标
+```
+
+**为什么需要 `_arm_qpos_adr`?** 因为模型中还有 target_box freejoint、car freejoint 在 qpos 前面,不能直接假设下标。通过 `jnt_qposadr` 查表才是正确的。
+
+---
+
+### 2.3 状态读取器(第 129–134 行)
+
+```python
+def _car_pos(d): return d.xpos[_ids["car"]] # (3,) 世界坐标
+def _arm_base_pos(d): return d.xpos[_ids["arm_base"]] # (3,)
+def _target_pos(d): return d.xpos[_ids["target_box"]] # (3,)
+def _ee_pos(d): return d.site_xpos[_ids["ee"]] # (3,) site 的世界坐标
+def _arm_qpos(d): return np.array([d.qpos[a] for a in _arm_qpos_adr]) # (6,)
+def _car_quat(d): return d.xquat[_ids["car"]].copy() # (4,) w,x,y,z
+```
+
+**`xpos` vs `qpos`**:`xpos` 是笛卡尔世界坐标(已通过正运动学计算),`qpos` 是关节空间原始值。用 `xpos` 读位置不需要手工做坐标系变换。
+
+---
+
+### 2.4 解析 2D IK(第 140–155 行)—— No.6 核心
+
+```python
+def _analytical_ik_2d(x, z, L1=L_UPPER, L2=L2_EFF):
+ """2 连杆平面 IK:已知目标 (x,z),求 (lift, elbow, wrist)。"""
+ D = np.hypot(x, z) # 目标距臂基座的距离
+ max_r = L1 + L2 # 最大臂展
+ if D > max_r * 0.95: # 太远 → 缩放到 95% 最大范围
+ s = max_r * 0.95 / D
+ x, z = x * s, z * s
+
+ # 余弦定理求肘关节角
+ D2 = x*x + z*z
+ cos_q2 = np.clip((D2 - L1*L1 - L2*L2) / (2*L1*L2), -1.0, 1.0)
+ q2_int = np.arccos(cos_q2) # 肘部内角(0=伸直, π=完全折叠)
+
+ alpha = np.arctan2(z, x) # 目标方向角
+ beta = np.arctan2(L2 * np.sin(q2_int), L1 + L2 * np.cos(q2_int))
+ q1 = -(alpha - beta) # shoulder_lift(负号来自 MuJoCo 轴方向)
+ q2 = -(np.pi - q2_int) # elbow
+ q3 = -(q1 + q2) # wrist_pitch(保持末端水平)
+ return q1, q2, q3
+```
+
+**几何意义**:
+
+```
+ L1 L2
+ ●─────────●─────────● (目标)
+ arm_base elbow EE
+
+已知 arm_base → EE 的距离 D,用余弦定理算出 elbow 折叠角 q2_int,
+再通过 α(目标方向)和 β(上臂相对目标线的偏角)合成 q1。
+```
+
+---
+
+### 2.5 数值 IK 精调(第 164–211 行)—— No.11 核心
+
+```python
+def numerical_ik(target_world, arm_base_pos, q_guess=None,
+ max_iter=40, tol=0.015, alpha=0.5, gripper_cmd=0.0):
+ m, d = model, _fk_data
+
+ # Step 1: 解析 IK 给出初值
+ local = target_world - arm_base_pos
+ r_xy = np.hypot(local[0], local[1])
+ pan0 = float(np.clip(np.arctan2(local[1], local[0]), -1.57, 1.57))
+ lift0, elbow0, wrist0 = _analytical_ik_2d(r_xy, local[2])
+ q = np.clip([pan0, lift0, elbow0, wrist0], Q_MIN, Q_MAX)
+
+ # Step 2: 把主仿真状态拷贝到独立的 FK data 中(不污染主数据)
+ d.qpos[:] = data.qpos[:]
+ d.qvel[:] = data.qvel[:]
+ mj.mj_fwdPosition(m, d)
+
+ # Step 3: Jacobian 伪逆迭代
+ for _ in range(max_iter):
+ # 把当前关节角写入 FK data 并计算正运动学
+ for adr, val in zip(_arm_qpos_adr,
+ [q[0], q[1], q[2], q[3], gripper_cmd, gripper_cmd]):
+ d.qpos[adr] = val
+ mj.mj_fwdPosition(m, d)
+ ee = d.site_xpos[_ids["ee"]]
+ err = target_world - ee[:3] # 位置误差(世界坐标)
+
+ if np.linalg.norm(err) < tol:
+ break # 收敛
+
+ # 计算末端位置雅可比(3×nv),提取 4 个臂关节列
+ jacp = np.zeros((3, m.nv))
+ mj.mj_jac(m, d, jacp, None, ee[:3], _ids["gripper"])
+ J = np.zeros((3, 4))
+ for i, adr in enumerate(_arm_dof_adr[:4]):
+ J[:, i] = jacp[:, adr] # 只取 pan/lift/elbow/wrist 四列
+
+ # 阻尼伪逆:Δq = (JᵀJ + λI)⁻¹ Jᵀ · err · α
+ lam = 0.05
+ dq = np.linalg.solve(J.T @ J + lam * np.eye(4), J.T @ err) * alpha
+ q = np.clip(q + dq, Q_MIN, Q_MAX)
+ q[3] = float(np.clip(-(q[1] + q[2]), -1.57, 1.57))
+ # ↑ 强制 wrist = -(lift+elbow),保持末端水平
+
+ return np.array([q[0], q[1], q[2], q[3], gripper_cmd, gripper_cmd])
+```
+
+**为什么用独立 FK data(No.12 模式)**:迭代过程中的 `mj_fwdPosition` 是纯数学计算,不能影响主仿真状态。独立的 `MjData` 保证隔离。
+
+**阻尼 λ 的作用**:当 J 接近奇异(臂伸直)时,JᵀJ 不可逆。λ=0.05 的阻尼项保证始终有解。
+
+---
+
+### 2.6 三次轨迹插值(第 217–225 行)—— No.5 核心
+
+```python
+def _cubic_coeffs(q0, qf, T):
+ """q(t) = a₀ + a₂t² + a₃t³,边界速度=0"""
+ return np.array([q0, 0.0, 3*(qf - q0)/(T*T), -2*(qf - q0)/(T*T*T)])
+
+def _eval_cubic(c, t):
+ """求值:返回 (pos, vel)"""
+ pos = c[0] + c[1]*t + c[2]*t*t + c[3]*t*t*t
+ vel = c[1] + 2*c[2]*t + 3*c[3]*t*t
+ return pos, vel
+```
+
+**为什么用三次多项式?** 给定起点 q₀、终点 q_f、起止速度=0,三次多项式是唯一满足这 4 个约束的最低次多项式。比直接跳变平滑,不会激发机械臂振动。
+
+---
+
+### 2.7 动力学提取(第 231–238 行)—— No.4 核心
+
+```python
+def _log_mass_matrix_diag():
+ """每次 FSM 切换时,提取并打印质量矩阵对角线。"""
+ M = np.zeros((model.nv, model.nv))
+ mj.mj_fullM(model, M, data.qM) # 从稀疏 qM 构建稠密 M
+ print(f"[No.4 Dynamics] mass-matrix diagonal (nv={model.nv}): ...")
+```
+
+质量矩阵对角线反映了各关节的等效惯量。数值大 → 该关节"重",加速慢。
+
+---
+
+### 2.8 抓取管理(第 243–250 行)—— No.8 核心
+
+```python
+def _grasp(activate):
+ """激活/停用 grasp_weld 约束。"""
+ eq = model.eq("grasp_weld")
+ eq.active0[0] = 1 if activate else 0
+```
+
+weld 是 MuJoCo 的 equality constraint,把两个 body 刚性连接。处理方式比纯摩擦力可靠。
+
+---
+
+### 2.9 DataCollector 数据采集器(第 255–330 行)
+
+```python
+class DataCollector:
+ def maybe_record(self, t, fsm, ee, target, last_action):
+ """10Hz 采样:状态、动作、传感器数据"""
+ if t - self._last_coll_t < 0.1: # 10Hz = 0.1s 间隔
+ return
+ self.joint_states.append(np.concatenate([
+ _car_pos(data), _car_quat(data), _arm_qpos(data)])) # 7+6=13
+ self.sensordata.append(data.sensordata.copy()) # 28 维全部传感器
+ self.actions.append(last_action.copy()) # 4轮+6臂=10
+
+ def save(self):
+ """保存为 .npz"""
+ np.savez_compressed(path,
+ joint_states=..., ee_position=..., target_position=...,
+ actions=..., sensordata=..., fsm_state=..., timestamps=...)
+```
+
+---
+
+### 2.10 FSM 状态机(第 334–458 行)—— No.5 + No.9 核心
+
+**9 个状态**:
+
+```
+DRIVE(0) → REACH(1) → LOWER(2) → GRASP(3) → LIFT(4)
+ → DRIVE_BACK(5) → PLACE(6) → RELEASE(7) → DONE(8)
+```
+
+**状态转换函数**:
+
+```python
+def _check_transitions(st):
+ car_x = _car_pos(data)[0]
+ ee = _ee_pos(data)
+ elap = data.time - st.fsm_enter
+
+ # 超时保护:防止某状态永远达不到条件
+ if elap > FSM_TIMEOUT.get(st.fsm, float("inf")):
+ return 下一个状态
+
+ if fsm == FSM_DRIVE:
+ if car_x > DRIVE_TARGET_X: # 车开到 x>1.05 就停
+ return FSM_REACH
+
+ elif fsm == FSM_REACH:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ if np.linalg.norm(ee - goal) < REACH_TOL: # EE 到目标上方 18cm 内
+ return FSM_LOWER
+
+ elif fsm == FSM_LOWER:
+ goal[2] = max(TARGET_POS[2], box_z + 0.10)
+ if np.linalg.norm(ee - goal) < REACH_TOL: # EE 到盒子附近
+ return FSM_GRASP
+
+ elif fsm == FSM_GRASP:
+ if elap > 1.0: # 等 1 秒让手指夹紧
+ return FSM_LIFT
+
+ elif fsm == FSM_LIFT:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ if np.linalg.norm(ee - goal) < REACH_TOL: # 抬起到安全高度
+ return FSM_DRIVE_BACK
+
+ elif fsm == FSM_DRIVE_BACK:
+ if car_x < 0.30: # 开回原点附近
+ return FSM_PLACE
+
+ elif fsm == FSM_PLACE:
+ if np.linalg.norm(ee - PLACE_POS) < REACH_TOL: # EE 到放置点
+ return FSM_RELEASE
+
+ elif fsm == FSM_RELEASE:
+ if elap > 0.5: # 等 0.5 秒手指张开
+ return FSM_DONE
+```
+
+**FSM 切换时做的事**(在 controller 中):
+
+```python
+if nxt != fsm:
+ if nxt == FSM_GRASP:
+ _grasp(True) # 激活 weld,锁住盒子
+ if nxt == FSM_RELEASE:
+ _grasp(False) # 释放 weld
+ state.fsm = nxt
+ state.fsm_enter = data.time
+ _compute_arm_target(nxt, state) # 为新状态算一次 IK(只算一次!)
+ # 启动三次轨迹:从当前 q → 新目标,TRAJ_DURATION 秒内平滑过渡
+ state._traj_coeffs = np.array([_cubic_coeffs(cur_q[j],
+ state._cached_target[j], TRAJ_DURATION) for j in range(6)])
+ _log_mass_matrix_diag() # No.4 动力学快照
+```
+
+---
+
+### 2.11 controller 控制回调(第 465–557 行)
+
+每步仿真(1000Hz)调用一次,是整个系统的"大脑":
+
+```python
+def controller(m, d):
+ fsm = state.fsm
+
+ # ── ① 车控制 ──
+ if fsm == FSM_DRIVE:
+ # 比例速度:离目标越近越慢
+ dist = max(0, DRIVE_TARGET_X - car_x)
+ speed = min(DRIVE_SPEED, 0.8 * dist + 0.15)
+ d.ctrl[0:4] = max(0.1, speed) # 最低 0.1 克服静摩擦
+ elif fsm == FSM_DRIVE_BACK:
+ dist = max(0, car_x - 0.3)
+ speed = min(DRIVE_SPEED, 0.8 * dist + 0.15)
+ d.ctrl[0:4] = -max(0.1, speed) # 反向
+ else:
+ # 刹车:用速度反馈抵抗剩余速度
+ for i in range(4):
+ wv = d.qvel[wheel_dof_start + i]
+ d.ctrl[i] = -np.sign(wv) * BRAKE_TORQUE if abs(wv) > 0.01 else 0.0
+
+ # ── ② 臂控制 ──
+ # 每 0.5 秒刷新一次 IK(车刹停后可能还在漂移)
+ if fsm in (FSM_REACH, FSM_LOWER, FSM_LIFT, FSM_PLACE):
+ if data.time - state._last_ik_time > 0.5:
+ _compute_arm_target(fsm, state)
+ # 如果新目标和旧目标差距 > 0.03 rad,重新启动轨迹
+ if np.linalg.norm(new - old) > 0.03:
+ # 生成新三次轨迹
+ state._traj_coeffs = ...
+
+ # 轨迹插值:在轨迹期间用三次多项式,结束后直接 hold
+ if state._traj_coeffs active:
+ arm_cmd = eval_cubic(traj, data.time) # 平滑过渡
+ else:
+ arm_cmd = state._cached_target # 保持在目标位置
+
+ for i in range(6):
+ d.ctrl[4 + i] = arm_cmd[i] # 写入 position servo
+
+ # ── ③ 数据记录 ──
+ collector.maybe_record(data.time, fsm, ee, target, last_action)
+
+ # ── ④ FSM 转换 ──
+ nxt = _check_transitions(state)
+ if nxt != fsm:
+ # 抓取/释放管理 + 重新算IK + 启动新轨迹
+```
+
+**ctrl 数组布局**:
+
+| 下标 | 内容 | 驱动方式 |
+|------|------|---------|
+| 0–3 | 4 轮 motor | 速度指令 |
+| 4 | shoulder_pan | position servo (PD) |
+| 5 | shoulder_lift | position servo |
+| 6 | elbow | position servo |
+| 7 | wrist_pitch | position servo |
+| 8 | finger_l | position servo |
+| 9 | finger_r | position servo |
+
+---
+
+### 2.12 GLFW 鼠标/键盘(第 562–609 行)—— No.2 模式
+
+```python
+# Backspace → 重置仿真
+mj.mj_resetData(model, data)
+mj.mj_forward(model, data)
+_grasp(False)
+state = SimState() # 重新创建 FSM 状态
+
+# S → 手动保存数据
+collector.save()
+
+# Q/Esc → 退出
+glfw.set_window_should_close(window, True)
+
+# 鼠标拖拽 → 旋转/平移/缩放
+mj.mjv_moveCamera(model, action, dx, dy, scene, cam)
+```
+
+---
+
+### 2.13 main 主循环(第 671–793 行)—— No.1 + No.2
+
+```python
+def main(headless=False):
+ # 加载模型
+ model = mj.MjModel.from_xml_path(XML_PATH)
+ data = mj.MjData(model)
+ _cache_ids(model)
+ _fk_data = mj.MjData(model) # 独立的 FK 数据(No.12)
+
+ if headless:
+ # 无头模式:纯 mj_step 循环,N 轮自动重置
+ for ep in range(NUM_EPISODES):
+ while data.time < simend and state.fsm != FSM_DONE:
+ mj.mj_step(model, data)
+ mj.mj_resetData(model, data) # 轮间重置
+ collector.save()
+ return
+
+ # GUI 模式:
+ # ① 创建 GLFW 窗口
+ # ② 设置鼠标/键盘回调
+ # ③ 渲染循环:
+ while not glfw.window_should_close(window):
+ # 每 1/60 秒渲染一帧,期间跑多个 mj_step
+ while data.time - t_start < 1/60:
+ mj.mj_step(model, data)
+
+ # FSM 完成后自动重置进入下一轮
+ if state.fsm == FSM_DONE:
+ ep_count += 1
+ mj.mj_resetData(model, data)
+ state = SimState()
+
+ # 相机跟踪小车
+ cam.lookat = 0.5*(car + TARGET) # 中点
+
+ # 渲染
+ mj.mjv_updateScene(...)
+ mj.mjr_render(...)
+ glfw.swap_buffers(window)
+
+ # 结束:保存数据 + matplotlib 总结图
+ collector.save()
+ _plot_summary()
+```
+
+**渲染 vs 仿真频率**:仿真跑 1000Hz(timestep=0.001),渲染跑 60Hz。每帧之间跑 ~16 个 `mj_step`。
+
+---
+
+## 三、数据流总览
+
+```
+mj_step (1000Hz)
+ └── controller (每步)
+ ├── 车: 比例速度 / 刹车
+ ├── 臂: 读缓存目标 → 三次轨迹插值 → 写 ctrl[4:10]
+ ├── 记录: collector.maybe_record (10Hz)
+ └── FSM: 检查转换条件 → (切换?) → 算 IK → 启动新轨迹
+ └── GRASP 时激活 weld, RELEASE 时释放
+
+渲染循环 (60Hz)
+ └── mj_step × ~16 → updateScene → render → swapBuffers
+
+结束后
+ └── collector.save() → ep_*.npz
+ └── matplotlib → summary_*.png
+```
+
+---
+
+## 四、No.1–No.13 概念对照
+
+| No. | 概念 | 在 demo_collect.py 中的位置 |
+|-----|------|---------------------------|
+| No.1 | 基础仿真循环 | `main()` — `mj_step` + render loop |
+| No.2 | GLFW 渲染 | `main()` — GLFW window + camera + scene + 鼠标回调 |
+| No.3 | 位置伺服 PD | `controller` — `data.ctrl[4:10]` 写 position servo 目标 |
+| No.4 | 动力学提取 | `_log_mass_matrix_diag` — `mj_fullM` 提取 M 矩阵对角线 |
+| No.5 | FSM + 三次轨迹 | `_check_transitions` 9状态机 + `_cubic_coeffs` 轨迹插值 |
+| No.6 | Jacobian IK | `numerical_ik` — `mj_jac` 算雅可比 + 伪逆 |
+| No.7 | 状态反馈控制 | `controller` — 比例速度驱动 + 速度反馈刹车 |
+| No.8 | 约束管理 | `_grasp` — 激活/停用 weld equality |
+| No.9 | 位置触发 | `_check_transitions` — car_x/EE距离触发状态切换 |
+| No.11 | 数值优化 | `numerical_ik` — 阻尼伪逆迭代 |
+| No.12 | 独立 FK + 绘图 | `_fk_data` 独立 MjData + `_plot_summary` matplotlib |
+| No.13 | 姿态估计 | `_car_quat` — `xquat` 读车身四元数 + 相机跟踪 |
diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md
index 20099a6..0c39409 100644
--- a/docs/src/SUMMARY.md
+++ b/docs/src/SUMMARY.md
@@ -26,3 +26,12 @@
- [No.2 交互式仿真与鼠标控制](MuJoCo/No_2.md)
- [No.3 单摆控制仿真](MuJoCo/No_3.md)
- [No.4 双摆控制仿真](MuJoCo/No_4.md)
+ - [No.5 双摆有限状态机(FSM)轨迹跟踪](MuJoCo/No_5.md)
+ - [No.6 双摆逆运动学(IK)](MuJoCo/No_6.md)
+ - [No.7 双摆 LQR 最优控制(含系统线性化)](MuJoCo/No_7.md)
+ - [No.8 双摆约束力「移交」仿真](MuJoCo/No_8.md)
+ - [No.9 单腿跳跃机器人(Hopper)4 状态 FSM](MuJoCo/No_9.md)
+ - [No.11 抛射体轨迹优化(NLopt 非线性规划)](MuJoCo/No_11.md)
+ - [No.12 双摆 Lemniscate 数值逆运动学](MuJoCo/No_12.md)
+ - [No.13 双足步行机器人(Biped)—— 3 状态机并行控制](MuJoCo/No_13.md)
+ - [统一 Demo:移动操作机器人 + 数据采集](MuJoCo/demo_collect.md)
diff --git a/mujoco/Chenlong_Robot/car.xml b/mujoco/Chenlong_Robot/car.xml
new file mode 100644
index 0000000..d30ccdd
--- /dev/null
+++ b/mujoco/Chenlong_Robot/car.xml
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/Chenlong_Robot/demo_collect.py b/mujoco/Chenlong_Robot/demo_collect.py
new file mode 100644
index 0000000..dc6906b
--- /dev/null
+++ b/mujoco/Chenlong_Robot/demo_collect.py
@@ -0,0 +1,796 @@
+#!/usr/bin/env python3
+"""
+demo_collect.py — Mobile Manipulator: All-in-One Demo + Data Collection
+========================================================================
+A single, self-contained file that covers MuJoCo concepts No.1 through
+No.13 and performs automatic pick-and-place with imitation-learning data
+collection.
+
+Concepts exercised (one file, 12 lessons):
+ No.1 : Model loading + basic simulation loop (MjModel, MjData, mj_step)
+ No.2 : GLFW rendering pipeline (camera, scene, context, mouse interaction)
+ No.3 : Position servos — PD control on each arm joint via actuators
+ No.4 : Dynamics extraction — mj_fullM + qfrc_bias for mass-matrix diagnostics
+ No.5 : Finite state machine + cubic polynomial trajectory interpolation
+ No.6 : Jacobian-based inverse kinematics (mj_jac → pseudo-inverse)
+ No.7 : State-feedback control — linearize arm targets around current pose
+ No.8 : Equality-constraint management — activate/deactivate grasp_weld
+ No.9 : Contact / position-triggered FSM transitions
+ No.11 : Numerical IK via damped Jacobian pseudo-inverse iteration
+ No.12 : Separate FK model instance + matplotlib post-simulation summary
+ No.13 : Quaternion-based state estimation + camera tracking
+
+Task FSM (8 active states):
+ DRIVE_TO_TARGET → REACH → LOWER → GRASP → LIFT → DRIVE_BACK → PLACE → RELEASE → DONE
+
+Data collection:
+ Saves all episodes as a single timestamped .npz file under ./episodes/
+ Keys: joint_states, ee_position, target_position, actions, fsm_state, timestamps
+ Sampling: 10 Hz
+
+Run:
+ python3 demo_collect.py # GUI window + data collection (macOS)
+ mjpython demo_collect.py # Linux / non-macOS
+ python3 demo_collect.py --headless # no window, collect data only
+
+Controls:
+ Mouse drag rotate / pan / zoom
+ Backspace reset simulation (restarts task)
+ S save accumulated data immediately
+ Q / Esc quit
+"""
+
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+import os
+import sys
+import time as _time
+
+# ── Optional imports ──────────────────────────────────────────────────────────
+try:
+ import matplotlib
+ matplotlib.use("Agg") # non-interactive — renders to file, avoids macOS Tk crash
+ import matplotlib.pyplot as plt
+ HAVE_MPL = True
+except ImportError:
+ HAVE_MPL = False
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Constants & geometry (must match car.xml)
+# ═══════════════════════════════════════════════════════════════════════════════
+HERE = os.path.dirname(os.path.abspath(__file__))
+XML_PATH = os.path.join(HERE, "car.xml")
+OUT_DIR = os.path.join(HERE, "episodes")
+os.makedirs(OUT_DIR, exist_ok=True)
+
+# World positions
+TARGET_POS = np.array([1.5, 0.0, 0.04]) # box on ground at (1.5, 0, 0.04)
+PLACE_POS = np.array([0.25, 0.0, 0.55]) # where to put it (in front of origin)
+
+# Arm link lengths (meters) — must match car.xml (2× scale)
+L_UPPER = 0.50 # upper_arm length
+L_FOREARM = 0.40 # forearm length
+L_WRIST = 0.16 # wrist length
+L_GRIPPER = 0.08 # gripper half-extent
+L2_EFF = L_FOREARM + L_WRIST + L_GRIPPER # 0.64 — effective forearm+wrist+gripper
+
+# Control gains
+DRIVE_SPEED = 0.5 # wheel ctrl while driving
+BRAKE_TORQUE = 3.0 # braking torque in non-driving states
+REACH_TOL = 0.18 # EE position tolerance (m)
+TRAJ_DURATION = 0.6 # cubic trajectory duration (s)
+DRIVE_TARGET_X = 1.05 # car x position that triggers arm reach
+COLLECT_HZ = 10 # data sampling rate
+NUM_EPISODES = 5 # number of task repetitions (use --episodes N to override)
+
+# Home / stowed arm pose (joint angles, radians)
+STOWED = np.array([0.0, 0.5, -1.0, 0.0, 0.04, 0.04]) # [pan, lift, elbow, wrist, finger_l, finger_r]
+
+# Joint limits (radians / meters for gripper slide) — must match car.xml joint ranges
+Q_MIN = np.array([-6.28, -6.28, -6.28, -6.28]) # ±360° (matches car.xml)
+Q_MAX = np.array([ 6.28, 6.28, 6.28, 6.28])
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# FSM state machine (No.5: FSM pattern No.9: position-triggered transitions)
+# ═══════════════════════════════════════════════════════════════════════════════
+(FSM_DRIVE, FSM_REACH, FSM_LOWER, FSM_GRASP, FSM_LIFT,
+ FSM_DRIVE_BACK, FSM_PLACE, FSM_RELEASE, FSM_DONE) = range(9)
+
+FSM_NAMES = ["DRIVE", "REACH", "LOWER", "GRASP", "LIFT",
+ "DRIVE_BACK", "PLACE", "RELEASE", "DONE"]
+
+# Safety timeouts — prevent indefinite hangs
+FSM_TIMEOUT = {FSM_DRIVE: 15, FSM_REACH: 8, FSM_LOWER: 8, FSM_GRASP: 3,
+ FSM_LIFT: 8, FSM_DRIVE_BACK: 15, FSM_PLACE: 5, FSM_RELEASE: 3,
+ FSM_DONE: float("inf")}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Cached model IDs (populated once in main)
+# ═══════════════════════════════════════════════════════════════════════════════
+_ids = {} # string → id: car_body, arm_base_body, target_body, ee_site, gripper_body
+_arm_qpos_adr = [] # 5 ints: qpos addresses for pan/lift/elbow/wrist/gripper
+_arm_dof_adr = [] # 5 ints: dof addresses for the same joints
+
+def _cache_ids(m):
+ """One-time lookup of all body/site/joint IDs. (No.1: model introspection)"""
+ for name in ("car", "arm_base", "target_box"):
+ _ids[name] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_BODY, name)
+ _ids["ee"] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_SITE, "end_effector")
+ _ids["gripper"] = mj.mj_name2id(m, mj.mjtObj.mjOBJ_BODY, "gripper_palm")
+
+ arm_names = ["shoulder_pan", "shoulder_lift", "elbow", "wrist_pitch",
+ "finger_l_j", "finger_r_j"]
+ global _arm_qpos_adr, _arm_dof_adr
+ _arm_qpos_adr = [m.jnt_qposadr[m.joint(n).id] for n in arm_names]
+ _arm_dof_adr = [m.jnt_dofadr[m.joint(n).id] for n in arm_names]
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# State accessors (No.13: position-based state estimation)
+# ═══════════════════════════════════════════════════════════════════════════════
+def _car_pos(d): return d.xpos[_ids["car"]]
+def _arm_base_pos(d): return d.xpos[_ids["arm_base"]]
+def _target_pos(d): return d.xpos[_ids["target_box"]]
+def _ee_pos(d): return d.site_xpos[_ids["ee"]]
+def _arm_qpos(d): return np.array([d.qpos[a] for a in _arm_qpos_adr])
+def _car_quat(d): return d.xquat[_ids["car"]].copy() # (w, x, y, z) (No.13: quaternion state)
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.6 : Analytical 2D IK (deterministic, always converges when reachable)
+# Used as seed for the numerical IK to guarantee fast convergence.
+# ═══════════════════════════════════════════════════════════════════════════════
+def _analytical_ik_2d(x, z, L1=L_UPPER, L2=L2_EFF):
+ """2-link IK in the (x, z) plane. Returns (lift, elbow, wrist)."""
+ D = np.hypot(x, z)
+ max_r = L1 + L2
+ if D > max_r * 0.95:
+ s = max_r * 0.95 / D
+ x, z = x * s, z * s
+ D2 = x*x + z*z
+ cos_q2 = np.clip((D2 - L1*L1 - L2*L2) / (2*L1*L2), -1.0, 1.0)
+ q2_int = np.arccos(cos_q2)
+ alpha = np.arctan2(z, x)
+ beta = np.arctan2(L2 * np.sin(q2_int), L1 + L2 * np.cos(q2_int))
+ q1 = -(alpha - beta)
+ q2 = -(np.pi - q2_int)
+ q3 = -(q1 + q2)
+ return q1, q2, q3
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.6 + No.11 : Jacobian-based numerical IK
+# Uses a separate MjData for side-effect-free FK queries (No.12 pattern).
+# Seeded by analytical 2D IK for fast, reliable convergence.
+# ═══════════════════════════════════════════════════════════════════════════════
+_fk_data = None # separate MjData for IK queries
+
+def numerical_ik(target_world, arm_base_pos, q_guess=None, max_iter=40,
+ tol=0.015, alpha=0.5, gripper_cmd=0.0):
+ """4-DOF arm IK: analytical 2D seed + Jacobian refinement (No.6 + No.11).
+
+ Returns [pan, lift, elbow, wrist, gripper_cmd].
+ Falls back gracefully to analytical IK if numerical refinement is unavailable.
+ """
+ m, d = model, _fk_data
+ local = target_world - arm_base_pos
+ r_xy = np.hypot(local[0], local[1])
+ pan0 = float(np.clip(np.arctan2(local[1], local[0]), -1.57, 1.57))
+ lift0, elbow0, wrist0 = _analytical_ik_2d(r_xy, local[2])
+ q = np.clip([pan0, lift0, elbow0, wrist0], Q_MIN, Q_MAX)
+
+ if q_guess is not None:
+ q = np.clip(0.3 * np.array(q_guess[:4]) + 0.7 * q, Q_MIN, Q_MAX)
+
+ # If FK model unavailable, return analytical result directly
+ if m is None or d is None:
+ return np.array([q[0], q[1], q[2], q[3], gripper_cmd, gripper_cmd])
+
+ # Jacobian refinement (No.11: numerical optimisation)
+ d.qpos[:] = data.qpos[:]
+ d.qvel[:] = data.qvel[:]
+ mj.mj_fwdPosition(m, d)
+
+ for _ in range(max_iter):
+ for adr, val in zip(_arm_qpos_adr, [q[0], q[1], q[2], q[3], gripper_cmd, gripper_cmd]):
+ d.qpos[adr] = val
+ mj.mj_fwdPosition(m, d)
+ ee = d.site_xpos[_ids["ee"]]
+ err = target_world - ee[:3]
+ if np.linalg.norm(err) < tol:
+ break
+
+ jacp = np.zeros((3, m.nv))
+ mj.mj_jac(m, d, jacp, None, ee[:3], _ids["gripper"])
+ J = np.zeros((3, 4))
+ for i, adr in enumerate(_arm_dof_adr[:4]):
+ J[:, i] = jacp[:, adr]
+
+ lam = 0.05
+ dq = np.linalg.solve(J.T @ J + lam * np.eye(4), J.T @ err) * alpha
+ q = np.clip(q + dq, Q_MIN, Q_MAX)
+ q[3] = float(np.clip(-(q[1] + q[2]), -1.57, 1.57))
+
+ q[3] = float(np.clip(-(q[1] + q[2]), -1.57, 1.57))
+ return np.array([q[0], q[1], q[2], q[3], gripper_cmd, gripper_cmd])
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.5 : Cubic polynomial trajectory generation
+# q(t) = a0 + a1·t + a2·t² + a3·t³ with dq(0)=0, dq(T)=0
+# ═══════════════════════════════════════════════════════════════════════════════
+def _cubic_coeffs(q0, qf, T):
+ """Return [a0, a1, a2, a3] for one joint."""
+ return np.array([q0, 0.0, 3*(qf - q0)/(T*T), -2*(qf - q0)/(T*T*T)])
+
+def _eval_cubic(c, t):
+ """Evaluate cubic at time t; returns (position, velocity)."""
+ pos = c[0] + c[1]*t + c[2]*t*t + c[3]*t*t*t
+ vel = c[1] + 2*c[2]*t + 3*c[3]*t*t
+ return pos, vel
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.4 : Dynamics extraction (diagnostic only)
+# Extracts the 15×15 mass matrix and bias forces once per phase change.
+# ═══════════════════════════════════════════════════════════════════════════════
+def _log_mass_matrix_diag():
+ """Log the diagonal of the joint-space inertia matrix. (No.4 concept)"""
+ M = np.zeros((model.nv, model.nv))
+ mj.mj_fullM(model, M, data.qM)
+ diag = np.diag(M)
+ print(f"[No.4 Dynamics] mass-matrix diagonal (nv={model.nv}): "
+ f"{np.array2string(diag, precision=2, max_line_width=120)}")
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.8 : Equality-constraint grasp management
+# Activates / deactivates the grasp_weld defined in car.xml.
+# ═══════════════════════════════════════════════════════════════════════════════
+def _grasp(activate):
+ """Toggle the weld equality between gripper and target_box."""
+ eq = model.eq("grasp_weld")
+ if eq is None:
+ print("[GRASP] ERROR: 'grasp_weld' not found in model")
+ return
+ eq.active0[0] = 1 if activate else 0
+ print(f"[No.8 Constraint] grasp_weld {'ACTIVATED' if activate else 'RELEASED'}")
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# DataCollector — records images + states + actions at 10 Hz
+# ═══════════════════════════════════════════════════════════════════════════════
+class DataCollector:
+ """Buffers simulation data and saves as .npz on completion."""
+
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.joint_states = [] # list of (13,) float
+ self.ee_positions = [] # list of (3,) float
+ self.target_positions = [] # list of (3,) float
+ self.actions = [] # list of (10,) float
+ self.sensordata = [] # list of (nsensordata,) float — raw sensor readings
+ self.fsm_states = [] # list of int
+ self.timestamps = [] # list of float
+ self._last_coll_t = -0.2
+
+ def maybe_record(self, t, fsm, ee, target, last_action):
+ """Record a frame if the collection interval has elapsed."""
+ if t - self._last_coll_t < 1.0 / COLLECT_HZ - 1e-6:
+ return
+ self._last_coll_t = t
+ # Store car pose (3D pos + 4D quat) + arm qpos (5D) = 12D
+ car_pose = np.concatenate([_car_pos(data), _car_quat(data)])
+ self.joint_states.append(np.concatenate([car_pose, _arm_qpos(data)]))
+ self.ee_positions.append(ee.copy())
+ self.target_positions.append(target.copy())
+ self.actions.append(last_action.copy())
+ self.sensordata.append(data.sensordata.copy())
+ self.fsm_states.append(fsm)
+ self.timestamps.append(t)
+
+ @property
+ def frame_count(self):
+ return len(self.timestamps)
+
+ def save(self):
+ """Write accumulated data to a timestamped .npz file."""
+ if self.frame_count == 0:
+ print("[COLLECT] No data to save.")
+ return None
+ ts = _time.strftime("%Y%m%d_%H%M%S")
+ path = os.path.join(OUT_DIR, f"ep_{ts}.npz")
+ np.savez_compressed(
+ path,
+ joint_states=np.array(self.joint_states, dtype=np.float32),
+ ee_position=np.array(self.ee_positions, dtype=np.float32),
+ target_position=np.array(self.target_positions, dtype=np.float32),
+ actions=np.array(self.actions, dtype=np.float32),
+ sensordata=np.array(self.sensordata, dtype=np.float32),
+ fsm_state=np.array(self.fsm_states, dtype=np.int8),
+ timestamps=np.array(self.timestamps, dtype=np.float32),
+ )
+ size_mb = os.path.getsize(path) / 1e6
+ print(f"\n[COLLECT] Saved {self.frame_count} frames ({size_mb:.1f} MB) → {path}")
+ return path
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# SimState — mutable state shared between controller and main loop
+# ═══════════════════════════════════════════════════════════════════════════════
+class SimState:
+ def __init__(self):
+ self.fsm = FSM_DRIVE
+ self.fsm_enter = 0.0
+ self.last_print = -1.0
+ self.frozen_arm = None # cached arm pose during DRIVE_BACK
+ self.last_action = np.zeros(10, dtype=float)
+ # Cubic trajectory state (No.5)
+ self._traj_coeffs = None # (5, 4) array or None
+ self._traj_t0 = 0.0
+ self._traj_T = TRAJ_DURATION
+ self._cached_target = STOWED.copy() # cached IK result — recompute on FSM change only
+ self._last_ik_time = -1.0 # last IK recompute time (for periodic refresh)
+
+# Global singletons (set up in main)
+model = data = None
+state = SimState()
+collector = DataCollector()
+_main_cam_handle = None
+_main_opt = _main_scene = _main_context = None
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.7 : Arm-target computation (state-feedback)
+# Computes desired arm joint angles for the current FSM phase.
+# Called ONLY on FSM transitions; result is cached and smoothed by cubic traj.
+# ═══════════════════════════════════════════════════════════════════════════════
+def _compute_arm_target(fsm, st):
+ """Compute and cache the IK arm target for this FSM state (expensive — call once)."""
+ arm_base = _arm_base_pos(data)
+ cur_q = _arm_qpos(data)
+
+ if fsm == FSM_DRIVE:
+ st._cached_target = STOWED.copy()
+ return
+
+ if fsm == FSM_REACH:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ st._cached_target = numerical_ik(goal, arm_base, cur_q, gripper_cmd=0.04) # open fingers
+ return
+
+ if fsm in (FSM_LOWER, FSM_GRASP):
+ goal = TARGET_POS.copy(); goal[2] = max(TARGET_POS[2], _target_pos(data)[2] + 0.10)
+ st._cached_target = numerical_ik(goal, arm_base, cur_q, gripper_cmd=0.0) # close fingers
+ return
+
+ if fsm == FSM_LIFT:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ st._cached_target = numerical_ik(goal, arm_base, cur_q, gripper_cmd=0.0) # hold
+ return
+
+ if fsm == FSM_DRIVE_BACK:
+ if st.frozen_arm is None:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ st.frozen_arm = numerical_ik(goal, arm_base, cur_q, gripper_cmd=0.0).copy()
+ st._cached_target = st.frozen_arm
+ return
+
+ if fsm == FSM_PLACE:
+ st._cached_target = numerical_ik(PLACE_POS, arm_base, cur_q, gripper_cmd=0.0) # hold
+ return
+
+ if fsm == FSM_RELEASE:
+ st._cached_target = numerical_ik(PLACE_POS, arm_base, cur_q, gripper_cmd=0.04) # open
+ return
+
+ st._cached_target = STOWED.copy()
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# FSM transition evaluation (No.9 + No.13)
+# ═══════════════════════════════════════════════════════════════════════════════
+def _check_transitions(st):
+ """Evaluate FSM transition conditions; return next state (may be same)."""
+ car_x = _car_pos(data)[0]
+ ee = _ee_pos(data)
+ elap = data.time - st.fsm_enter
+ fsm = st.fsm
+
+ # Timeout fallback (forces progress)
+ if elap > FSM_TIMEOUT.get(fsm, float("inf")):
+ print(f"[TIMEOUT] {FSM_NAMES[fsm]} exceeded {FSM_TIMEOUT[fsm]:.0f}s — advancing")
+ order = {FSM_DRIVE: FSM_REACH, FSM_REACH: FSM_LOWER, FSM_LOWER: FSM_GRASP,
+ FSM_GRASP: FSM_LIFT, FSM_LIFT: FSM_DRIVE_BACK,
+ FSM_DRIVE_BACK: FSM_PLACE, FSM_PLACE: FSM_RELEASE, FSM_RELEASE: FSM_DONE}
+ return order.get(fsm, fsm + 1)
+
+ if fsm == FSM_DRIVE:
+ if car_x > DRIVE_TARGET_X:
+ return FSM_REACH
+
+ elif fsm == FSM_REACH:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ if np.linalg.norm(ee - goal) < REACH_TOL:
+ return FSM_LOWER
+
+ elif fsm == FSM_LOWER:
+ goal = TARGET_POS.copy(); goal[2] = max(TARGET_POS[2], _target_pos(data)[2] + 0.10)
+ if np.linalg.norm(ee - goal) < REACH_TOL:
+ return FSM_GRASP
+
+ elif fsm == FSM_GRASP:
+ if elap > 1.0:
+ return FSM_LIFT
+
+ elif fsm == FSM_LIFT:
+ goal = TARGET_POS.copy(); goal[2] += 0.15
+ if np.linalg.norm(ee - goal) < REACH_TOL:
+ st.frozen_arm = None
+ return FSM_DRIVE_BACK
+
+ elif fsm == FSM_DRIVE_BACK:
+ # Stop before overshooting origin
+ if car_x < 0.30:
+ return FSM_PLACE
+
+ elif fsm == FSM_PLACE:
+ if np.linalg.norm(ee - PLACE_POS) < REACH_TOL:
+ return FSM_RELEASE
+
+ elif fsm == FSM_RELEASE:
+ if elap > 0.5:
+ return FSM_DONE
+
+ return fsm
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Controller callback — called every mj_step (No.2 + No.3 + No.5 + No.7)
+# Heavy IK computation only happens on FSM transitions (cached).
+# Cubic trajectory interpolation provides smooth motion between waypoints.
+# ═══════════════════════════════════════════════════════════════════════════════
+def controller(m, d):
+ global state
+ fsm = state.fsm
+
+ # ── Car: proportional drive toward target, brake in non-driving states ──
+ car_x = _car_pos(data)[0]
+ if fsm == FSM_DRIVE:
+ # Proportional speed: slow down as we approach the target
+ dist = max(0, DRIVE_TARGET_X - car_x)
+ speed = min(DRIVE_SPEED, 0.8 * dist + 0.15)
+ d.ctrl[0:4] = max(0.1, speed) # minimum drive to overcome friction
+ elif fsm == FSM_DRIVE_BACK:
+ dist = max(0, car_x - 0.3) # distance to origin
+ speed = min(DRIVE_SPEED, 0.8 * dist + 0.15)
+ d.ctrl[0:4] = -max(0.1, speed)
+ else:
+ # Active braking via velocity feedback (No.7: state-feedback pattern)
+ wheel_dof_start = _arm_dof_adr[-1] + 1 # 6 (car free) + 5 (arm) = 11
+ for i in range(4):
+ wv = d.qvel[wheel_dof_start + i]
+ d.ctrl[i] = -np.sign(wv) * BRAKE_TORQUE if abs(wv) > 0.01 else 0.0
+
+ # ── Arm: track cached target via cubic trajectory (No.5) ─────────────
+ # Periodically recompute IK during arm-movement states so the arm stays
+ # on-target even as the car drifts after braking (No.7: state-feedback)
+ if fsm in (FSM_REACH, FSM_LOWER, FSM_LIFT, FSM_PLACE):
+ if data.time - state._last_ik_time > 0.5:
+ old_target = state._cached_target.copy()
+ _compute_arm_target(fsm, state)
+ state._last_ik_time = data.time
+ # If target changed noticeably, restart cubic trajectory
+ if np.linalg.norm(state._cached_target - old_target) > 0.03:
+ cur_q = _arm_qpos(data)
+ state._traj_coeffs = np.array([_cubic_coeffs(
+ cur_q[j], state._cached_target[j], TRAJ_DURATION) for j in range(6)])
+ state._traj_t0 = data.time
+ state._traj_T = TRAJ_DURATION
+
+ cur_q = _arm_qpos(data)
+
+ # Evaluate trajectory or hold at target
+ if (state._traj_coeffs is not None and
+ data.time - state._traj_t0 < state._traj_T):
+ t = data.time - state._traj_t0
+ arm_cmd = np.array([_eval_cubic(state._traj_coeffs[j], t)[0] for j in range(6)])
+ else:
+ arm_cmd = state._cached_target
+ state._traj_coeffs = None
+
+ # Write arm position-servo targets (No.3: PD via actuators)
+ for i in range(6):
+ d.ctrl[4 + i] = arm_cmd[i]
+
+ # Store action for data collection
+ state.last_action = np.concatenate([d.ctrl[:4], arm_cmd])
+
+ # ── Logging ───────────────────────────────────────────────────────────
+ if data.time - state.last_print > 1.0:
+ state.last_print = data.time
+ car_p = _car_pos(data)
+ ee_p = _ee_pos(data)
+ tgt_p = _target_pos(data)
+ traj = " [traj]" if (state._traj_coeffs is not None and
+ data.time - state._traj_t0 < state._traj_T) else ""
+ print(f"[{data.time:5.2f}s] {FSM_NAMES[fsm]:<12} "
+ f"car_x={car_p[0]:+.2f} ee=({ee_p[0]:+.2f},{ee_p[1]:+.2f},{ee_p[2]:+.2f}) "
+ f"box_z={tgt_p[2]:+.2f}{traj}")
+
+ # ── Data collection ───────────────────────────────────────────────────
+ collector.maybe_record(data.time, fsm, _ee_pos(data), _target_pos(data),
+ state.last_action)
+
+ # ── FSM transition ────────────────────────────────────────────────────
+ nxt = _check_transitions(state)
+ if nxt != fsm:
+ print(f"\n>>> [{data.time:5.2f}s] {FSM_NAMES[fsm]} → {FSM_NAMES[nxt]}\n")
+ if nxt == FSM_GRASP:
+ _grasp(True)
+ if nxt == FSM_RELEASE:
+ _grasp(False)
+ state.fsm = nxt
+ state.fsm_enter = data.time
+ # Compute new IK target for this phase (expensive — once per phase)
+ _compute_arm_target(nxt, state)
+ # Start cubic trajectory from current qpos toward new target (No.5)
+ cur_q = _arm_qpos(data)
+ state._traj_coeffs = np.array([_cubic_coeffs(cur_q[j],
+ state._cached_target[j], TRAJ_DURATION)
+ for j in range(6)])
+ state._traj_t0 = data.time
+ state._traj_T = TRAJ_DURATION
+ # Log dynamics once per phase change (No.4)
+ _log_mass_matrix_diag()
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# GLFW callbacks (No.2: keyboard + mouse interaction)
+# ═══════════════════════════════════════════════════════════════════════════════
+_button_left = _button_right = _button_middle = False
+_last_x = _last_y = 0
+
+def _key_cb(window, key, scancode, act, mods):
+ if act != glfw.PRESS:
+ return
+ if key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+ _grasp(False) # release any active weld
+ global state, collector
+ state = SimState()
+ collector.reset()
+ print("[SIM] Reset — restarting task")
+ elif key == glfw.KEY_S:
+ collector.save()
+ elif key in (glfw.KEY_Q, glfw.KEY_ESCAPE):
+ glfw.set_window_should_close(window, True)
+
+def _mouse_button_cb(window, button, act, mods):
+ global _button_left, _button_right, _button_middle
+ _button_left = glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS
+ _button_right = glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS
+ _button_middle = glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS
+ glfw.get_cursor_pos(window)
+
+def _mouse_move_cb(window, xpos, ypos):
+ global _last_x, _last_y
+ if _last_x == 0 and _last_y == 0:
+ _last_x, _last_y = xpos, ypos
+ dx = xpos - _last_x; dy = ypos - _last_y
+ _last_x, _last_y = xpos, ypos
+ if not (_button_left or _button_right or _button_middle):
+ return
+ w, h = glfw.get_window_size(window)
+ shift = (glfw.get_key(window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS or
+ glfw.get_key(window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS)
+ if _button_right:
+ act = mj.mjtMouse.mjMOUSE_MOVE_H if shift else mj.mjtMouse.mjMOUSE_MOVE_V
+ elif _button_left:
+ act = mj.mjtMouse.mjMOUSE_ROTATE_H if shift else mj.mjMOUSE_ROTATE_V
+ else:
+ act = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, act, dx / h, dy / h, _main_scene, _main_cam_handle)
+
+def _scroll_cb(window, xoff, yoff):
+ mj.mjv_moveCamera(model, mj.mjtMouse.mjMOUSE_ZOOM, 0.0,
+ -0.05 * yoff, _main_scene, _main_cam_handle)
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# No.12 : post-simulation matplotlib summary
+# ═══════════════════════════════════════════════════════════════════════════════
+def _plot_summary():
+ if not HAVE_MPL or collector.frame_count < 2:
+ print("[PLOT] matplotlib unavailable or no data — skipping plots")
+ return
+
+ t = np.array(collector.timestamps)
+ ee = np.array(collector.ee_positions)
+ js = np.array(collector.joint_states)
+ tg = np.array(collector.target_positions)
+ cp = np.array([s[:3] for s in js]) # car xyz from joint_states
+ fsm = np.array(collector.fsm_states)
+
+ fig, axes = plt.subplots(2, 2, figsize=(13, 10))
+ fig.suptitle("Mobile Manipulator — Task Summary", fontsize=14, fontweight="bold")
+
+ # (1) EE trajectory XZ, color-coded by FSM
+ ax = axes[0, 0]
+ for s in np.unique(fsm):
+ msk = fsm == s
+ ax.plot(ee[msk, 0], ee[msk, 2], '.', label=FSM_NAMES[s], markersize=4)
+ ax.plot(TARGET_POS[0], TARGET_POS[2], 'r*', ms=12, label="Target")
+ ax.plot(PLACE_POS[0], PLACE_POS[2], 'g*', ms=12, label="Place")
+ ax.set_xlabel("EE X (m)"); ax.set_ylabel("EE Z (m)")
+ ax.set_title("End-Effector Trajectory (XZ)")
+ ax.legend(fontsize=7); ax.grid(True, alpha=0.3); ax.set_aspect("equal")
+
+ # (2) Joint angles
+ ax = axes[0, 1]
+ names = ["pan", "lift", "elbow", "wrist", "finger_l", "finger_r"]
+ for j in range(6):
+ ax.plot(t, js[:, 7+j], label=names[j])
+ ax.set_xlabel("Time (s)"); ax.set_ylabel("Angle (rad)")
+ ax.set_title("Arm Joint Angles"); ax.legend(fontsize=7); ax.grid(True, alpha=0.3)
+
+ # (3) Box Z
+ ax = axes[1, 0]
+ ax.plot(t, tg[:, 2], 'b-', lw=2)
+ ax.axhline(TARGET_POS[2], color='gray', ls='--', alpha=0.5, label="Target Z")
+ ax.axhline(PLACE_POS[2], color='green', ls='--', alpha=0.5, label="Place Z")
+ ax.set_xlabel("Time (s)"); ax.set_ylabel("Box Z (m)")
+ ax.set_title("Target Box Height"); ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
+
+ # (4) Car X
+ ax = axes[1, 1]
+ ax.plot(t, cp[:, 0], 'b-', lw=2)
+ ax.set_xlabel("Time (s)"); ax.set_ylabel("Car X (m)")
+ ax.set_title("Car Forward Position"); ax.grid(True, alpha=0.3)
+
+ plt.tight_layout()
+ out_path = os.path.join(OUT_DIR, f"summary_{_time.strftime('%Y%m%d_%H%M%S')}.png")
+ plt.savefig(out_path, dpi=120)
+ plt.close()
+ print(f"[No.12 Plot] Summary saved → {out_path}")
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Main (No.1: basic loop No.2: GLFW rendering)
+# ═══════════════════════════════════════════════════════════════════════════════
+def main(headless=False):
+ global model, data, state, collector
+ global _main_cam_handle, _main_opt, _main_scene, _main_context
+ global _fk_data
+
+ # ── Load model (No.1) ─────────────────────────────────────────────────
+ model = mj.MjModel.from_xml_path(XML_PATH)
+ data = mj.MjData(model)
+ _cache_ids(model)
+
+ # Separate data for FK queries (No.12: auxiliary MjData)
+ _fk_data_obj = mj.MjData(model)
+ globals()["_fk_data"] = _fk_data_obj
+
+ state = SimState()
+ collector = DataCollector()
+
+ # ── Headless mode: run N episodes, no window ──────────────────────────
+ if headless:
+ print("=" * 60)
+ print(f"Headless data-collection mode ×{NUM_EPISODES} episodes")
+ print(f"Target: {TARGET_POS} Place: {PLACE_POS}")
+ print("=" * 60)
+ mj.set_mjcb_control(controller)
+ for ep in range(NUM_EPISODES):
+ print(f"\n── Episode {ep+1}/{NUM_EPISODES} ──")
+ simend = data.time + 60.0
+ while data.time < simend and state.fsm != FSM_DONE:
+ mj.mj_step(model, data)
+ print(f"Episode {ep+1} done. t={data.time:.2f}s state={FSM_NAMES[state.fsm]}")
+ if ep < NUM_EPISODES - 1:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+ _grasp(False)
+ state = SimState()
+ print(f"\nDone. {NUM_EPISODES} episodes, {collector.frame_count} frames total")
+ collector.save()
+ _plot_summary()
+ return
+
+ # ── GLFW window (No.2) ────────────────────────────────────────────────
+ if not glfw.init():
+ raise RuntimeError("GLFW init failed")
+ window = glfw.create_window(1200, 900, "Mobile Manipulator — Demo + Collect", None, None)
+ glfw.make_context_current(window)
+ glfw.swap_interval(1)
+
+ glfw.set_key_callback(window, _key_cb)
+ glfw.set_mouse_button_callback(window, _mouse_button_cb)
+ glfw.set_cursor_pos_callback(window, _mouse_move_cb)
+ glfw.set_scroll_callback(window, _scroll_cb)
+
+ # Render state (No.2)
+ cam = mj.MjvCamera()
+ opt = mj.MjvOption()
+ scn = mj.MjvScene(model, maxgeom=10000)
+ ctx = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+ mj.mjv_defaultCamera(cam)
+ mj.mjv_defaultOption(opt)
+ cam.azimuth = 130; cam.elevation = -22; cam.distance = 3.5
+ cam.lookat = np.array([0.7, 0.0, 0.5])
+ opt.flags[mj.mjtVisFlag.mjVIS_JOINT] = 1
+ # opt.flags[mj.mjtVisFlag.mjVIS_HEADLIGHT] = 0 # (not available in this MuJoCo version)
+
+ _main_cam_handle = cam
+ _main_opt = opt
+ _main_scene = scn
+ _main_context = ctx
+
+ mj.set_mjcb_control(controller)
+
+ # ── Header ────────────────────────────────────────────────────────────
+ print("=" * 64)
+ print("Mobile Manipulator — All-in-One Demo (No.1–No.13) + Data Collection")
+ print("=" * 64)
+ print(f" IK: Jacobian damped pseudo-inverse (4-DOF)")
+ print(f" Traj: cubic polynomial, {TRAJ_DURATION:.1f}s per waypoint")
+ print(f" Grasp: equality-constraint weld")
+ print(f" Data: {COLLECT_HZ} Hz → {OUT_DIR}/ep_*.npz")
+ print(f" Episodes: {NUM_EPISODES} (auto-repeat)")
+ print(f" Mouse: drag=rotate right-drag=pan scroll=zoom")
+ print(f" Keys: Backspace=reset S=save Q=quit")
+ print("=" * 64)
+ _log_mass_matrix_diag()
+
+ # ── Render loop (multi-episode) ───────────────────────────────────────
+ ep_count = 0
+ frame_no = 0
+ simend = data.time + 60.0
+ try:
+ while not glfw.window_should_close(window) and ep_count < NUM_EPISODES:
+ t_start = data.time
+ while data.time - t_start < 1.0 / 60.0:
+ mj.mj_step(model, data)
+
+ if data.time >= simend or state.fsm == FSM_DONE:
+ ep_count += 1
+ print(f"\n── Episode {ep_count}/{NUM_EPISODES} done ({collector.frame_count} frames) ──")
+ if ep_count >= NUM_EPISODES:
+ break
+ # Reset for next episode (global state already declared at top of main)
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+ _grasp(False)
+ state = SimState()
+ simend = data.time + 60.0
+ print(f"── Episode {ep_count+1}/{NUM_EPISODES} starting... ──\n")
+ continue
+
+ # Camera tracks car ↔ target midpoint (No.13: camera tracking)
+ car_p = _car_pos(data)
+ cam.lookat[0] = 0.5 * (car_p[0] + TARGET_POS[0])
+ cam.lookat[1] = car_p[1]
+ cam.lookat[2] = max(0.4, car_p[2] + 0.3)
+ cam.distance = 3.0 + abs(car_p[0]) * 0.3
+
+ vp = mj.MjrRect(0, 0, 1200, 900)
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scn)
+ mj.mjr_render(vp, scn, ctx)
+ glfw.swap_buffers(window)
+ glfw.poll_events()
+ frame_no += 1
+ except Exception as e:
+ print(f"\n[WARN] Render loop error: {e}")
+ finally:
+ glfw.terminate()
+
+ # ── Cleanup ───────────────────────────────────────────────────────────
+ _grasp(False)
+
+ print("\n" + "=" * 64)
+ print(f"Task ended episodes={ep_count} t={data.time:.2f}s state={FSM_NAMES[state.fsm]}")
+ print(f"Frames collected: {collector.frame_count}")
+ print("=" * 64)
+
+ collector.save()
+ _plot_summary()
+
+# ═══════════════════════════════════════════════════════════════════════════════
+if __name__ == "__main__":
+ headless = "--headless" in sys.argv
+ for i, a in enumerate(sys.argv):
+ if a == "--episodes" and i + 1 < len(sys.argv):
+ NUM_EPISODES = int(sys.argv[i+1])
+ main(headless=headless)
diff --git a/mujoco/Chenlong_Robot/episodes/.gitignore b/mujoco/Chenlong_Robot/episodes/.gitignore
new file mode 100644
index 0000000..2fb4bc0
--- /dev/null
+++ b/mujoco/Chenlong_Robot/episodes/.gitignore
@@ -0,0 +1,2 @@
+*.npz
+*.png
diff --git a/mujoco/Chenlong_Robot/test.py b/mujoco/Chenlong_Robot/test.py
new file mode 100644
index 0000000..f9f6ff8
--- /dev/null
+++ b/mujoco/Chenlong_Robot/test.py
@@ -0,0 +1,12 @@
+import time
+
+import mujoco.viewer
+
+model = mujoco.MjModel.from_xml_path('car.xml')
+data = mujoco.MjData(model)
+
+with mujoco.viewer.launch_passive(model, data) as viewer:
+ while viewer.is_running():
+ mujoco.mj_step(model, data)
+ viewer.sync()
+ time.sleep(1/500) # ~60 Hz real-time
\ No newline at end of file
diff --git a/mujoco/Hw4_MuJoCo.pdf b/mujoco/Hw4_MuJoCo.pdf
new file mode 100644
index 0000000..ddb86d8
Binary files /dev/null and b/mujoco/Hw4_MuJoCo.pdf differ
diff --git a/mujoco/Hw5_MuJoCo.pdf b/mujoco/Hw5_MuJoCo.pdf
new file mode 100644
index 0000000..4b057ad
Binary files /dev/null and b/mujoco/Hw5_MuJoCo.pdf differ
diff --git a/mujoco/No_11/ball.xml b/mujoco/No_11/ball.xml
new file mode 100644
index 0000000..ddc77a6
--- /dev/null
+++ b/mujoco/No_11/ball.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_11/projectile_opt.py b/mujoco/No_11/projectile_opt.py
new file mode 100644
index 0000000..349918d
--- /dev/null
+++ b/mujoco/No_11/projectile_opt.py
@@ -0,0 +1,257 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+import os
+NLOPT_IMPORTED = True
+#NOTE: If this gives numpy error try, pip install numpy --upgrade
+try:
+ import nlopt
+except ImportError:
+ print("nlopt not imported, switching to pre-computed solution")
+ NLOPT_IMPORTED = False
+
+xml_path = 'ball.xml'
+simend = 5
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ #put the controller here
+ pass
+
+def simulator(x):
+ v, theta, time_of_flight = x[0], x[1], x[2]
+
+ data.qvel[0] = v * np.cos(theta)
+ data.qvel[2] = v * np.sin(theta)
+
+ while data.time < time_of_flight:
+ # Step simulation environment
+ mj.mj_step(model, data)
+
+ # Get position
+ pos = np.array([data.qpos[0], data.qpos[2]])
+
+ # Reset Data
+ mj.mj_resetData(model, data)
+
+ return pos
+
+def init_controller(model,data):
+
+ # Set initial guess
+ v = 10.0
+ theta = np.pi / 4
+ time_of_flight = 2.0
+
+ if NLOPT_IMPORTED:
+ sol = optimize_ic(np.array([v, theta, time_of_flight]))
+ else:
+ sol = np.array([9.398687489285555, 1.2184054599970882, 1.5654456340479144])
+
+ v_sol, theta_sol = sol[0], sol[1]
+ simend = sol[2] + 2
+
+ data.qvel[0] = v_sol * np.cos(theta_sol)
+ data.qvel[2] = v_sol * np.sin(theta_sol)
+
+def cost_func(x, grad):
+ cost = 0.0
+ return cost
+
+def equality_constraints(result, x, grad):
+ """
+ For details of the API please refer to:
+ https://nlopt.readthedocs.io/en/latest/NLopt_Python_Reference/#:~:text=remove_inequality_constraints()%0Aopt.remove_equality_constraints()-,Vector%2Dvalued%20constraints,-Just%20as%20for
+ Note: Please open the link in Chrome
+ """
+ pos = simulator(x)
+ result[0] = pos[0] - 5.0
+ result[1] = pos[1] - 2.1
+
+def optimize_ic(x):
+ """
+ Optimization problem is
+
+ min_X 0
+ subject to 0.1 ≤ v ≤ ∞
+ 0.1 ≤ θ ≤ π/2
+ 0.1 ≤ T ≤ ∞
+ x(T) = x^*
+ z(T) = z^*
+
+ with X = [v, θ, T]
+ """
+ # Define optimization problem
+ opt = nlopt.opt(nlopt.LN_COBYLA, 3)
+
+ # Define lower and upper bounds
+ opt.set_lower_bounds([0.1, 0.1, 0.1])
+ opt.set_upper_bounds([10000.0, np.pi / 2 - 0.1, 10000.0])
+
+ # Set objective funtion
+ opt.set_min_objective(cost_func)
+
+ # Define equality constraints
+ tol = [1e-4, 1e-4]
+ opt.add_equality_mconstraint(equality_constraints, tol)
+
+ # Set relative tolerance on optimization parameters
+ opt.set_xtol_rel(1e-4)
+
+ # Solve problem
+ sol = opt.optimize(x)
+
+ return sol
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set camera configuration
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+# # Set initial guess
+# v = 10.0
+# theta = np.pi / 4
+# time_of_flight = 2.0
+#
+# if NLOPT_IMPORTED:
+# sol = optimize_ic(np.array([v, theta, time_of_flight]))
+# else:
+# sol = np.array([9.398687489285555, 1.2184054599970882, 1.5654456340479144])
+#
+# v_sol, theta_sol = sol[0], sol[1]
+# simend = sol[2] + 2
+#
+# data.qvel[0] = v_sol * np.cos(theta_sol)
+# data.qvel[2] = v_sol * np.sin(theta_sol)
+
+#initialize the controller
+init_controller(model,data)
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ cam.lookat[0] = data.qpos[0] #camera moves with the ball
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_12/manipulator.xml b/mujoco/No_12/manipulator.xml
new file mode 100644
index 0000000..65996da
--- /dev/null
+++ b/mujoco/No_12/manipulator.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_12/manipulator_ik.py b/mujoco/No_12/manipulator_ik.py
new file mode 100644
index 0000000..9dd349a
--- /dev/null
+++ b/mujoco/No_12/manipulator_ik.py
@@ -0,0 +1,326 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import nlopt
+import numpy as np
+import os
+
+mpl.rcParams['text.usetex'] = True
+mpl.rcParams['text.latex.preamble'] = r'\usepackage{amsmath}'
+plt.rcParams["font.size"] = 16
+
+xml_path = 'manipulator.xml'
+
+omega = 0.4
+a = 0.25
+simend = 0.25 + 2 * np.pi / omega
+center_x = None
+center_z = None
+X_target = None #np.array([0,0]);
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ # Get reference end-effector position
+ global X_target
+
+ X_target = get_lemniscate_ref(data.time)
+
+ # Use current joint angles as the initial guess
+ qpos = np.array([data.qpos[0], data.qpos[1]])
+
+ # Solve for the joint angles
+ sol = inverse_kinematics(qpos)
+
+ # Apply control
+ data.ctrl[0] = sol[0]
+ data.ctrl[2] = sol[1]
+
+def forward_kinematics(self, q):
+ data_sim.qpos[0] = q[0]
+ data_sim.qpos[1] = q[1]
+ data_sim.ctrl[0] = data_sim.qpos[0]
+ data_sim.ctrl[2] = data_sim.qpos[1]
+
+ mj.mj_forward(model, data_sim)
+
+ end_eff_pos = np.array([
+ data_sim.sensordata[0],
+ data_sim.sensordata[2]
+ ])
+
+ return end_eff_pos
+
+def init_controller(model, data):
+ global center_x
+ global center_z
+ global X_target
+
+ # Get center of Lemniscate
+ end_eff_pos = forward_kinematics([-0.5, 1.0])
+ center_x = end_eff_pos[0] - 0.25
+ center_z = end_eff_pos[1]
+
+ # Get initial joint angles
+ q_guess = np.array([-0.5, 1.0])
+ X_target = get_lemniscate_ref(0.0)
+ q_pos = inverse_kinematics(q_guess)
+
+ data.qpos[0] = q_pos[0]
+ data.qpos[1] = q_pos[1]
+
+def forward_kinematics(q):
+ data_sim.qpos[0] = q[0]
+ data_sim.qpos[1] = q[1]
+ data_sim.ctrl[0] = data_sim.qpos[0]
+ data_sim.ctrl[2] = data_sim.qpos[1]
+
+ mj.mj_forward(model, data_sim)
+
+ end_eff_pos = np.array([
+ data_sim.sensordata[0],
+ data_sim.sensordata[2]
+ ])
+
+ return end_eff_pos
+
+def cost_func(x, grad):
+ cost = 0.0
+ return cost
+
+def equality_constraints(result, x, grad):
+ global X_target
+
+ end_eff_pos = forward_kinematics(x)
+ result[0] = end_eff_pos[0] - X_target[0]
+ result[1] = end_eff_pos[1] - X_target[1]
+
+def inverse_kinematics(x):
+ # Define optimization problem
+ opt = nlopt.opt(nlopt.LN_COBYLA, 2)
+
+ # Define lower and upper bounds
+ opt.set_lower_bounds([-np.pi, -np.pi])
+ opt.set_upper_bounds([np.pi, np.pi])
+
+ # Set objective funtion
+ opt.set_min_objective(cost_func)
+
+ # Define equality constraints
+ tol = [1e-4, 1e-4]
+ opt.add_equality_mconstraint(equality_constraints, tol)
+
+ # Set relative tolerance on optimization parameters
+ opt.set_xtol_rel(1e-4)
+
+ # Solve problem
+ sol = opt.optimize(x)
+
+ return sol
+
+def get_lemniscate_ref(t):
+ global center_x
+ global center_z
+ global omega
+ global a
+
+ wt = omega * t
+ denominator = 1 + np.sin(wt) * np.sin(wt)
+
+ x = center_x + (a * np.cos(wt)) / denominator
+ z = center_z + (a * np.sin(wt) * np.cos(wt)) / denominator
+
+ ref_pos = np.array([x, z])
+
+ return ref_pos
+
+def graph():
+ # Measured motion trajectory
+ global end_eff_pos
+ end_eff_pos_arr = np.concatenate(end_eff_pos, axis=1)
+
+ # Get reference trajectory
+ wt = omega * np.linspace(0.0, simend, 500)
+ denominator = 1 + np.sin(wt) * np.sin(wt)
+ leminiscate_x = center_x + (a * np.cos(wt)) / denominator
+ leminiscate_z = center_z + \
+ (a * np.sin(wt) * np.cos(wt)) / denominator
+
+ # Visualization
+ fig, ax = plt.subplots(1, 1, figsize=(8, 5))
+
+ ax.plot(
+ end_eff_pos_arr[0, :],
+ end_eff_pos_arr[1, :],
+ color="cornflowerblue",
+ linewidth=4,
+ zorder=-2,
+ label=r"$\textbf{Inverse Kinematics}$"
+ )
+ ax.plot(
+ leminiscate_x,
+ leminiscate_z,
+ color="darkorange",
+ linewidth=1,
+ zorder=-1,
+ label=r"$\textbf{Reference}$"
+ )
+
+ ax.grid()
+ ax.set_aspect("equal")
+ ax.legend(frameon=False, ncol=2, loc="lower center",
+ bbox_to_anchor=(0.5, -0.4))
+
+
+ #plt.show()
+ plt.show(block=False)
+ plt.pause(5)
+ plt.close()
+ # plt.savefig("imgs/manipulator_ik.png", dpi=200,
+ # transparent=False, bbox_inches="tight")
+
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+data_sim = mj.MjData(model) #data structure for forward/inverse kinematics
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set camera configuration
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+init_controller(model,data)
+# Create list to store data
+end_eff_pos = []
+
+#set the controller
+# mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 0.1): # Use 1/60 = 0.01667 for slower animation
+ controller(model, data)
+ mj.mj_step(model, data)
+
+ end_eff_pos_temp = np.array([
+ data.sensordata[0],
+ data.sensordata[2]])
+ end_eff_pos.append(end_eff_pos_temp[:, np.newaxis])
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
+graph()
diff --git a/mujoco/No_13/biped.py b/mujoco/No_13/biped.py
new file mode 100644
index 0000000..4c6a1a8
--- /dev/null
+++ b/mujoco/No_13/biped.py
@@ -0,0 +1,241 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+from numpy.linalg import inv
+from scipy.spatial.transform import Rotation as R
+import numpy as np
+import os
+
+xml_path = 'biped.xml'
+simend = 30
+
+step_no = 0;
+
+FSM_LEG1_SWING = 0
+FSM_LEG2_SWING = 1
+
+FSM_KNEE1_STANCE = 0
+FSM_KNEE1_RETRACT = 1
+
+FSM_KNEE2_STANCE = 0
+FSM_KNEE2_RETRACT = 1
+
+fsm_hip = FSM_LEG2_SWING
+fsm_knee1 = FSM_KNEE1_STANCE
+fsm_knee2 = FSM_KNEE2_STANCE
+
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ """
+ This function implements a controller that
+ mimics the forces of a fixed joint before release
+ """
+ global fsm_hip
+ global fsm_knee1
+ global fsm_knee2
+ global step_no
+
+ # State Estimation
+ quat_leg1 = data.xquat[1, :]
+ euler_leg1 = quat2euler(quat_leg1)
+ abs_leg1 = -euler_leg1[1]
+ pos_foot1 = data.xpos[2, :]
+
+ quat_leg2 = data.xquat[3, :]
+ euler_leg2 = quat2euler(quat_leg2)
+ abs_leg2 = -euler_leg2[1]
+ pos_foot2 = data.xpos[4, :]
+
+ # Transition check
+ if fsm_hip == FSM_LEG2_SWING and pos_foot2[2] < 0.05 and abs_leg1 < 0.0:
+ fsm_hip = FSM_LEG1_SWING
+ if fsm_hip == FSM_LEG1_SWING and pos_foot1[2] < 0.05 and abs_leg2 < 0.0:
+ fsm_hip = FSM_LEG2_SWING
+
+ if fsm_knee1 == FSM_KNEE1_STANCE and pos_foot2[2] < 0.05 and abs_leg1 < 0.0:
+ fsm_knee1 = FSM_KNEE1_RETRACT
+ if fsm_knee1 == FSM_KNEE1_RETRACT and abs_leg1 > 0.1:
+ fsm_knee1 = FSM_KNEE1_STANCE
+
+ if fsm_knee2 == FSM_KNEE2_STANCE and pos_foot1[2] < 0.05 and abs_leg2 < 0.0:
+ fsm_knee2 = FSM_KNEE2_RETRACT
+ if fsm_knee2 == FSM_KNEE2_RETRACT and abs_leg2 > 0.1:
+ fsm_knee2 = FSM_KNEE2_STANCE
+
+ # Control
+ if fsm_hip == FSM_LEG1_SWING:
+ data.ctrl[0] = -0.5
+ if fsm_hip == FSM_LEG2_SWING:
+ data.ctrl[0] = 0.5
+
+ if fsm_knee1 == FSM_KNEE1_STANCE:
+ data.ctrl[2] = 0.0
+ if fsm_knee1 == FSM_KNEE1_RETRACT:
+ data.ctrl[2] = -0.25
+
+ if fsm_knee2 == FSM_KNEE2_STANCE:
+ data.ctrl[4] = 0.0
+ if fsm_knee2 == FSM_KNEE2_RETRACT:
+ data.ctrl[4] = -0.25
+
+def init_controller(model,data):
+ data.qpos[4] = 0.5
+ data.ctrl[0] = data.qpos[4]
+
+def quat2euler(quat):
+ # SciPy defines quaternion as [x, y, z, w]
+ # MuJoCo defines quaternion as [w, x, y, z]
+ _quat = np.concatenate([quat[1:], quat[:1]])
+ r = R.from_quat(_quat)
+
+ # roll-pitch-yaw is the same as rotating w.r.t
+ # the x, y, z axis in the world frame
+ euler = r.as_euler('xyz', degrees=False)
+
+ return euler
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set camera configuration
+cam.azimuth = 120.89 # 89.608063
+cam.elevation = -15.81 # -11.588379
+cam.distance = 8.0 # 5.0
+cam.lookat = np.array([0.0, 0.0, 2.0])
+
+#turn the direction of gravity to simulate a ramp
+model.opt.gravity[0] = 9.81 * np.sin(0.1)
+model.opt.gravity[2] = -9.81 * np.cos(0.1)
+
+init_controller(model,data)
+
+#set the controller
+#mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ #simulation step
+ mj.mj_step(model, data)
+ # Apply control
+ controller(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Show joint frames
+ opt.flags[mj.mjtVisFlag.mjVIS_JOINT] = 1
+
+ # Update scene and render
+ cam.lookat[0] = data.qpos[0] #camera follows the robot
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_13/biped.xml b/mujoco/No_13/biped.xml
new file mode 100644
index 0000000..5778c85
--- /dev/null
+++ b/mujoco/No_13/biped.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_5/doublependulum_fsm.py b/mujoco/No_5/doublependulum_fsm.py
new file mode 100644
index 0000000..72a2c2e
--- /dev/null
+++ b/mujoco/No_5/doublependulum_fsm.py
@@ -0,0 +1,252 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+import os
+
+xml_path = 'doublependulum_fsm.xml'
+simend = 5
+
+t_hold = 0.5
+t_swing1 = 1.0
+t_swing2 = 1.0
+
+FSM_HOLD = 0
+FSM_SWING1 = 1
+FSM_SWING2 = 2
+FSM_STOP = 3
+
+# fsm_state = FSM_HOLD;
+
+# Define setpoints
+q_init = np.array([[-1.0], [0.0]])
+q_mid = np.array([[0.5], [-2.0]])
+q_end = np.array([[1.0], [0.0]])
+
+# Define setpoint times
+t_init = t_hold
+t_mid = t_hold + t_swing1
+t_end = t_hold + t_swing1 + t_swing2
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def init_controller(model,data):
+
+ global fsm_state
+ global a_swing1, a_swing2
+ fsm_state = FSM_HOLD;
+
+ a_swing1 = generate_trajectory(
+ t_init, t_mid, q_init, q_mid)
+
+ a_swing2 = generate_trajectory(
+ t_mid, t_end, q_mid, q_end)
+
+def controller(model, data):
+ """
+ This function implements a PD controller for tracking
+ the reference motion.
+ """
+ global fsm_state
+ global a_swing1, a_swing2
+ time = data.time
+
+ # Check for state change
+ if fsm_state == FSM_HOLD and time >= t_hold:
+ fsm_state = FSM_SWING1
+ elif fsm_state == FSM_SWING1 and time >= t_mid:
+ fsm_state = FSM_SWING2
+ elif fsm_state == FSM_SWING2 and time >= t_end:
+ fsm_state = FSM_STOP
+
+ # Get reference joint position & velocity
+ if fsm_state == FSM_HOLD:
+ q_ref = q_init
+ dq_ref = np.zeros((2, 1))
+ elif fsm_state == FSM_SWING1:
+ q_ref = a_swing1[0] + a_swing1[1]*time + \
+ a_swing1[2]*(time**2) + a_swing1[3]*(time**3)
+ dq_ref = a_swing1[1] + 2 * a_swing1[2] * \
+ time + 3 * a_swing1[3]*(time**2)
+ elif fsm_state == FSM_SWING2:
+ q_ref = a_swing2[0] + a_swing2[1]*time + \
+ a_swing2[2]*(time**2) + a_swing2[3]*(time**3)
+ dq_ref = a_swing2[1] + 2 * a_swing2[2] * \
+ time + 3 * a_swing2[3]*(time**2)
+ elif fsm_state == FSM_STOP:
+ q_ref = q_end
+ dq_ref = np.zeros((2, 1))
+
+ # Define PD gains
+ kp = 500
+ kv = 50
+
+ # Compute PD control
+ torque = kp * (q_ref[:, 0] - data.qpos) + \
+ kv * (dq_ref[:, 0] - data.qvel)
+
+ for i in range(0,6):
+ data.ctrl[i]=0;
+
+ data.ctrl[0] = torque[0];
+ data.ctrl[3] = torque[1];
+
+
+def generate_trajectory(t0, tf, q0, qf):
+ """
+ Generates a trajectory
+ q(t) = a0 + a1t + a2t^2 + a3t^3
+ which satisfies the boundary condition
+ q(t0) = q0, q(tf) = qf, dq(t0) = 0, dq(tf) = 0
+ """
+ tf_t0_3 = (tf - t0)**3
+ a0 = qf*(t0**2)*(3*tf-t0) + q0*(tf**2)*(tf-3*t0)
+ a0 = a0 / tf_t0_3
+
+ a1 = 6 * t0 * tf * (q0 - qf)
+ a1 = a1 / tf_t0_3
+
+ a2 = 3 * (t0 + tf) * (qf - q0)
+ a2 = a2 / tf_t0_3
+
+ a3 = 2 * (q0 - qf)
+ a3 = a3 / tf_t0_3
+
+ return a0, a1, a2, a3
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set initial condition
+data.qpos[0] = -1
+
+# Set camera configuration
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+#initialize the controller
+init_controller(model,data);
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_5/doublependulum_fsm.xml b/mujoco/No_5/doublependulum_fsm.xml
new file mode 100644
index 0000000..02f3724
--- /dev/null
+++ b/mujoco/No_5/doublependulum_fsm.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_5/no_5.py b/mujoco/No_5/no_5.py
new file mode 100644
index 0000000..f8b01ff
--- /dev/null
+++ b/mujoco/No_5/no_5.py
@@ -0,0 +1,13 @@
+import time
+
+import mujoco
+import mujoco.viewer
+
+model = mujoco.MjModel.from_xml_path('doublependulum_fsm.xml')
+data = mujoco.MjData(model)
+
+with mujoco.viewer.launch_passive(model, data) as viewer:
+ while viewer.is_running():
+ mujoco.mj_step(model, data)
+ viewer.sync()
+ time.sleep(1e-3)
\ No newline at end of file
diff --git a/mujoco/No_6/doublependulum.xml b/mujoco/No_6/doublependulum.xml
new file mode 100644
index 0000000..f81828a
--- /dev/null
+++ b/mujoco/No_6/doublependulum.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_6/doublependulum_ik.py b/mujoco/No_6/doublependulum_ik.py
new file mode 100644
index 0000000..435268b
--- /dev/null
+++ b/mujoco/No_6/doublependulum_ik.py
@@ -0,0 +1,179 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+from numpy.linalg import inv
+import os
+
+xml_path = 'doublependulum.xml'
+simend = 10
+
+r = 0.5
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ """
+ This function implements a P controller for tracking
+ the reference motion.
+ """
+ # End-effector position
+ end_eff_pos = data.sensordata[:3]
+
+ # Compute end-effector Jacobian
+ jacp = np.zeros((3, 2))
+ mj.mj_jac(model, data, jacp, None, end_eff_pos, 2)
+
+ # Δq = Jinv * Δx
+ J = jacp[[0, 2], :]
+ dx = np.array([
+ [x_0 + r * np.cos(data.time) - data.sensordata[0]],
+ [z_0 + r * np.sin(data.time) - data.sensordata[2]]
+ ])
+ dq = inv(J) @ dx
+
+ # Target position is q + Δq
+ data.ctrl[0] = data.qpos[0] + dq[0, 0]
+ data.ctrl[2] = data.qpos[1] + dq[1, 0]
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set initial angle of pendulum
+data.qpos[0] = -0.5
+data.qpos[1] = 1.0
+
+# Set camera configuration
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+#use forward kinematics to set the position
+mj.mj_forward(model, data)
+
+## center of drawn circle
+x_0 = data.sensordata[0] - r
+z_0 = data.sensordata[2]
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_7/doublependulum.xml b/mujoco/No_7/doublependulum.xml
new file mode 100644
index 0000000..7d188df
--- /dev/null
+++ b/mujoco/No_7/doublependulum.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_7/doublependulum_lqr.py b/mujoco/No_7/doublependulum_lqr.py
new file mode 100644
index 0000000..a0dab3d
--- /dev/null
+++ b/mujoco/No_7/doublependulum_lqr.py
@@ -0,0 +1,219 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+from numpy.linalg import inv
+from scipy.linalg import solve_continuous_are
+import os
+
+xml_path = 'doublependulum.xml'
+simend = 10
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def get_dx(inputs):
+ """
+ The state is [q1, dq1, q2, dq2]
+ The inputs are [q1, dq1, q2, dq2, u]
+
+ The function outputs [dq1, ddq1, dq2, ddq2]
+ """
+ # Apply inputs
+ data.qpos[0] = inputs[0]
+ data.qvel[0] = inputs[1]
+ data.qpos[1] = inputs[2]
+ data.qvel[1] = inputs[3]
+ data.ctrl[0] = inputs[4]
+
+ mj.mj_forward(model, data)
+
+ # Record outputs
+ dq1 = data.qvel[0]
+ dq2 = data.qvel[1]
+
+ # Convert sparse inertia matrix M into full (i.e. dense) matrix.
+ # M is filled with the data from data.qM
+ M = np.zeros((2, 2))
+ mj.mj_fullM(model, M, data.qM)
+
+ # Calculate f = ctrl - qfrc_bias
+ f = np.array([
+ [0 - data.qfrc_bias[0]],
+ [data.ctrl[0] - data.qfrc_bias[1]]
+ ])
+
+ # Calculate qacc
+ ddq = inv(M) @ f
+
+ outputs = np.array([dq1, ddq[0, 0], dq2, ddq[1, 0]])
+
+ return outputs
+
+def linearization(pert=0.001):
+ f0 = get_dx(np.zeros(5))
+
+ Jacobians = []
+ for i in range(5):
+ inputs_i = np.zeros(5)
+ inputs_i[i] = pert
+ jac = (get_dx(inputs_i) - f0) / pert
+ Jacobians.append(jac[:, np.newaxis])
+
+ A = np.concatenate(Jacobians[:4], axis=1)
+ B = Jacobians[-1]
+
+ return A, B
+
+def controller(model, data):
+ """
+ This function implements a LQR controller for balancing.
+ """
+ state = np.array([
+ [data.qpos[0]],
+ [data.qvel[0]],
+ [data.qpos[1]],
+ [data.qvel[1]],
+ ])
+ data.ctrl[0] = (K @ state)[0, 0]
+
+ # Apply noise to shoulder
+ noise = mj.mju_standardNormal(0.0)
+ data.qfrc_applied[0] = noise
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+#set the camera
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 2.5])
+
+# Compute LQR gain
+A, B = linearization()
+Q = np.diag([10, 10, 10, 10])
+R = np.diag([0.1])
+P = solve_continuous_are(A, B, Q, R)
+K = -inv(B.T @ P @ B + R) @ B.T @ P @ A
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_8/hybrid_pendulum.py b/mujoco/No_8/hybrid_pendulum.py
new file mode 100644
index 0000000..06dd05c
--- /dev/null
+++ b/mujoco/No_8/hybrid_pendulum.py
@@ -0,0 +1,181 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+from numpy.linalg import inv
+import os
+
+xml_path = 'pendulum.xml'
+simend = 5
+
+FSM_SWING = 0
+FSM_FREE = 1
+
+fsm = FSM_SWING
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ """
+ This function implements a controller that
+ mimics the forces of a fixed joint before release
+ """
+ global fsm
+
+ # Get constraint Jacobian (MuJoCo 3.x: efc_J is 1D, need reshape)
+ J = data.efc_J[:data.nefc * model.nv].reshape((data.nefc, model.nv))[:3, :3]
+
+ # Get constraint force
+ F0 = data.efc_force[:3][:, np.newaxis]
+
+ # Get constrained joint torque
+ JT_F = J.T @ F0
+
+ # Release condition check
+ if fsm == FSM_SWING and data.qpos[5] > 1.0:
+ fsm = FSM_FREE
+
+ if fsm == FSM_SWING:
+ data.qfrc_applied[2] = -1 * (data.qvel[2] - 5.0)
+ data.qfrc_applied[3] = JT_F[0, 0]
+ data.qfrc_applied[4] = JT_F[1, 0]
+ data.qfrc_applied[5] = JT_F[2, 0] + data.qfrc_applied[2]
+ elif fsm == FSM_FREE:
+ data.qfrc_applied[3] = 0.0
+ data.qfrc_applied[4] = 0.0
+ data.qfrc_applied[5] = 0.0
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+# Set initial configuration
+data.qpos[2] = -np.pi/2
+data.qpos[5] = -np.pi/2
+
+# Set camera configuration
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 7.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_8/no_8.py b/mujoco/No_8/no_8.py
new file mode 100644
index 0000000..b81e850
--- /dev/null
+++ b/mujoco/No_8/no_8.py
@@ -0,0 +1,13 @@
+import time
+
+import mujoco
+import mujoco.viewer
+
+model = mujoco.MjModel.from_xml_path('pendulum.xml')
+data = mujoco.MjData(model)
+
+with mujoco.viewer.launch_passive(model, data) as viewer:
+ while viewer.is_running():
+ mujoco.mj_step(model, data)
+ viewer.sync()
+ time.sleep(1e-3)
\ No newline at end of file
diff --git a/mujoco/No_8/pendulum.xml b/mujoco/No_8/pendulum.xml
new file mode 100644
index 0000000..0674612
--- /dev/null
+++ b/mujoco/No_8/pendulum.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/No_9/hopper.py b/mujoco/No_9/hopper.py
new file mode 100644
index 0000000..1cf92a5
--- /dev/null
+++ b/mujoco/No_9/hopper.py
@@ -0,0 +1,221 @@
+import mujoco as mj
+from mujoco.glfw import glfw
+import numpy as np
+from numpy.linalg import inv
+import os
+
+xml_path = 'hopper.xml'
+simend = 20
+
+step_no = 0
+
+FSM_AIR1 = 0
+FSM_STANCE1 = 1
+FSM_STANCE2 = 2
+FSM_AIR2 = 3
+
+fsm = FSM_AIR1
+
+# For callback functions
+button_left = False
+button_middle = False
+button_right = False
+lastx = 0
+lasty = 0
+
+def controller(model, data):
+ """
+ This function implements a controller that
+ mimics the forces of a fixed joint before release
+ """
+ global fsm
+ global step_no
+
+ body_no = 3
+ z_foot = data.xpos[body_no, 2]
+ vz_torso = data.qvel[1]
+
+ # Lands on the ground
+ if fsm == FSM_AIR1 and z_foot < 0.05:
+ fsm = FSM_STANCE1
+
+ # Moving upward
+ if fsm == FSM_STANCE1 and vz_torso > 0.0:
+ fsm = FSM_STANCE2
+
+ # Take off
+ if fsm == FSM_STANCE2 and z_foot > 0.05:
+ fsm = FSM_AIR2
+
+ # Moving downward
+ if fsm == FSM_AIR2 and vz_torso < 0.0:
+ fsm = FSM_AIR1
+ step_no += 1
+
+ if fsm == FSM_AIR1:
+ set_position_servo(2, 100)
+ set_velocity_servo(3, 10)
+
+ if fsm == FSM_STANCE1:
+ set_position_servo(2, 1000)
+ set_velocity_servo(3, 0)
+
+ if fsm == FSM_STANCE2:
+ set_position_servo(2, 1000)
+ set_velocity_servo(3, 0)
+ data.ctrl[0] = -0.2
+
+ if fsm == FSM_AIR2:
+ set_position_servo(2, 100)
+ set_velocity_servo(3, 10)
+ data.ctrl[0] = 0.0
+
+def init_controller(model,data):
+ # pservo-hip
+ set_position_servo(0, 100)
+
+ # vservo-hip
+ set_velocity_servo(1, 10)
+
+ # pservo-knee
+ set_position_servo(2, 1000)
+
+ # vservo-knee
+ set_velocity_servo(3, 0)
+
+def set_position_servo(actuator_no, kp):
+ model.actuator_gainprm[actuator_no, 0] = kp
+ model.actuator_biasprm[actuator_no, 1] = -kp
+
+def set_velocity_servo(actuator_no, kv):
+ model.actuator_gainprm[actuator_no, 0] = kv
+ model.actuator_biasprm[actuator_no, 2] = -kv
+
+def keyboard(window, key, scancode, act, mods):
+ if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
+ mj.mj_resetData(model, data)
+ mj.mj_forward(model, data)
+
+def mouse_button(window, button, act, mods):
+ # update button state
+ button_left = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
+ button_middle = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
+ button_right = (glfw.get_mouse_button(
+ window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)
+
+ # update mouse position
+ glfw.get_cursor_pos(window)
+
+def mouse_move(window, xpos, ypos):
+ # compute mouse displacement, save
+ global lastx
+ global lasty
+ dx = xpos - lastx
+ dy = ypos - lasty
+ lastx = xpos
+ lasty = ypos
+
+ # no buttons down: nothing to do
+ if (not button_left) and (not button_middle) and (not button_right):
+ return
+
+ # get current window size
+ width, height = glfw.get_window_size(window)
+
+ # get shift key state
+ PRESS_LEFT_SHIFT = glfw.get_key(
+ window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS
+ PRESS_RIGHT_SHIFT = glfw.get_key(
+ window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS
+ mod_shift = (PRESS_LEFT_SHIFT or PRESS_RIGHT_SHIFT)
+
+ # determine action based on mouse button
+ if button_right:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_MOVE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_MOVE_V
+ elif button_left:
+ if mod_shift:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_H
+ else:
+ action = mj.mjtMouse.mjMOUSE_ROTATE_V
+ else:
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+
+ mj.mjv_moveCamera(model, action, dx/height,
+ dy/height, scene, cam)
+
+def scroll(window, xoffset, yoffset):
+ action = mj.mjtMouse.mjMOUSE_ZOOM
+ mj.mjv_moveCamera(model, action, 0.0, -0.05 *
+ yoffset, scene, cam)
+
+#get the full path
+dirname = os.path.dirname(__file__)
+abspath = os.path.join(dirname + "/" + xml_path)
+xml_path = abspath
+
+# MuJoCo data structures
+model = mj.MjModel.from_xml_path(xml_path) # MuJoCo model
+data = mj.MjData(model) # MuJoCo data
+cam = mj.MjvCamera() # Abstract camera
+opt = mj.MjvOption() # visualization options
+
+# Init GLFW, create window, make OpenGL context current, request v-sync
+glfw.init()
+window = glfw.create_window(1200, 900, "Demo", None, None)
+glfw.make_context_current(window)
+glfw.swap_interval(1)
+
+# initialize visualization data structures
+mj.mjv_defaultCamera(cam)
+mj.mjv_defaultOption(opt)
+scene = mj.MjvScene(model, maxgeom=10000)
+context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)
+
+# install GLFW mouse and keyboard callbacks
+glfw.set_key_callback(window, keyboard)
+glfw.set_cursor_pos_callback(window, mouse_move)
+glfw.set_mouse_button_callback(window, mouse_button)
+glfw.set_scroll_callback(window, scroll)
+
+cam.azimuth = 89.608063
+cam.elevation = -11.588379
+cam.distance = 5.0
+cam.lookat = np.array([0.0, 0.0, 1.5])
+
+init_controller(model,data)
+
+#set the controller
+mj.set_mjcb_control(controller)
+
+while not glfw.window_should_close(window):
+ simstart = data.time
+
+ while (data.time - simstart < 1.0/60.0):
+ mj.mj_step(model, data)
+
+ if (data.time>=simend):
+ break;
+
+ # get framebuffer viewport
+ viewport_width, viewport_height = glfw.get_framebuffer_size(
+ window)
+ viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)
+
+ # Update scene and render
+ cam.lookat[0] = data.qpos[0] #camera will follow qpos
+ mj.mjv_updateScene(model, data, opt, None, cam,
+ mj.mjtCatBit.mjCAT_ALL.value, scene)
+ mj.mjr_render(viewport, scene, context)
+
+ # swap OpenGL buffers (blocking call due to v-sync)
+ glfw.swap_buffers(window)
+
+ # process pending GUI events, call GLFW callbacks
+ glfw.poll_events()
+
+glfw.terminate()
diff --git a/mujoco/No_9/hopper.xml b/mujoco/No_9/hopper.xml
new file mode 100644
index 0000000..2e00c80
--- /dev/null
+++ b/mujoco/No_9/hopper.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mujoco/car_arm.xml b/mujoco/car_arm.xml
deleted file mode 100644
index 735866e..0000000
--- a/mujoco/car_arm.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/mujoco/demo/demo.py b/mujoco/demo/demo.py
new file mode 100644
index 0000000..95156ec
--- /dev/null
+++ b/mujoco/demo/demo.py
@@ -0,0 +1,39 @@
+"""Demo: sites + sensors on a simple pendulum."""
+import time
+import numpy as np
+import mujoco
+import mujoco.viewer
+
+model = mujoco.MjModel.from_xml_path("demo.xml")
+data = mujoco.MjData(model)
+
+# Get site ID — used for fast lookup
+site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, "tip")
+
+# Give it a push
+data.qvel[0] = 5.0
+
+with mujoco.viewer.launch_passive(model, data) as viewer:
+ t = 0
+ while viewer.is_running():
+ mujoco.mj_step(model, data)
+
+ # --- Reading sensors ---
+ # sensordata layout: [hinge_vel(1), tip_pos(3), tip_vel(3)]
+ hinge_vel = data.sensordata[0] # joint velocity (rad/s)
+ tip_pos = data.sensordata[1:4] # tip world xyz
+ tip_vel = data.sensordata[4:7] # tip world velocity
+
+ # site_xpos — same as framepos sensor (no noise)
+ tip_world = data.site_xpos[site_id]
+
+ # Print every 0.5s sim time
+ if t % 250 == 0:
+ print(f"t={data.time:.2f}s "
+ f"angle={np.degrees(data.qpos[0]):+6.1f}° "
+ f"vel={hinge_vel:+6.2f} rad/s "
+ f"tip=({tip_world[0]:+.2f},{tip_world[1]:+.2f},{tip_world[2]:+.2f})")
+
+ viewer.sync()
+ t += 1
+ time.sleep(1/500)
diff --git a/mujoco/demo/demo.xml b/mujoco/demo/demo.xml
new file mode 100644
index 0000000..af97a8d
--- /dev/null
+++ b/mujoco/demo/demo.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 04e86ef..8dee93a 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -8,12 +8,15 @@
"name": "ui",
"version": "0.0.0",
"dependencies": {
+ "@mujoco/mujoco": "^3.8.1",
+ "@types/three": "^0.184.1",
"ky": "^1.14.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
+ "three": "^0.184.0",
"zustand": "^5.0.12"
},
"devDependencies": {
@@ -328,6 +331,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "license": "Apache-2.0"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1029,6 +1038,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mujoco/mujoco": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/@mujoco/mujoco/-/mujoco-3.8.1.tgz",
+ "integrity": "sha512-uIIUcdAHG48N2UBmL/iLuBoAxH1xbTs/HaJthQd5euLvk44p9ivJ5wCc+IsnqgstpCdmCZAgx2l1WGwFyxErTQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1663,6 +1678,12 @@
"tailwindcss": "4.2.1"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1752,6 +1773,32 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.184.1",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
+ "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -2688,6 +2735,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3308,6 +3361,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "license": "MIT"
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3776,6 +3835,12 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/three": {
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
diff --git a/ui/package.json b/ui/package.json
index cfec7ed..abca67f 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -10,12 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
+ "@mujoco/mujoco": "^3.8.1",
+ "@types/three": "^0.184.1",
"ky": "^1.14.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
+ "three": "^0.184.0",
"zustand": "^5.0.12"
},
"devDependencies": {
diff --git a/ui/src/api/socket.ts b/ui/src/api/socket.ts
index eae5cc5..ea6bb46 100644
--- a/ui/src/api/socket.ts
+++ b/ui/src/api/socket.ts
@@ -25,7 +25,6 @@ export function createSocket(namespace: string = "/"): Socket {
// 创建 Sim 和 Real 页面专用的 socket 实例
export const simSocket = createSocket("/sim");
export const realSocket = createSocket("/real");
-export const mujocoSocket = createSocket("/mujoco");
// 为了向后兼容,保留默认的全局 socket(使用 /sim 命名空间)
export const socket = simSocket;
@@ -134,36 +133,3 @@ export const onTrainingProgress = (socket: Socket, callback: (data: {
socket.on('training_progress', callback);
return () => socket.off('training_progress', callback);
};
-
-// ============ MuJoCo Socket 函数 ============
-
-export const sendMujocoAction = (socket: Socket, yaw: number, pitch: number, roll: number) => {
- socket.emit('mujoco_action', { yaw, pitch, roll });
-}
-
-export const sendMujocoCarAction = (socket: Socket, velLeft: number, velRight: number) => {
- socket.emit('mujoco_car_action', { vel_left: velLeft, vel_right: velRight });
-}
-
-export const sendMujocoCameraMove = (socket: Socket, deltaAzimuth: number, deltaElevation: number) => {
- socket.emit('mujoco_camera_move', { delta_azimuth: deltaAzimuth, delta_elevation: deltaElevation });
-}
-
-export const sendMujocoCameraZoom = (socket: Socket, delta: number) => {
- socket.emit('mujoco_camera_zoom', { delta });
-}
-
-export const requestMujocoState = (socket: Socket) => {
- socket.emit('get_mujoco_state');
-}
-
-export const onMujocoStateUpdate = (
- socket: Socket,
- callback: (data: { topdown: string; firstperson: string; state: unknown }) => void
-) => {
- socket.on('mujoco_state_update', callback);
- const cleanup = () => {
- socket.off('mujoco_state_update', callback);
- };
- return cleanup;
-};
diff --git a/ui/src/pages/MujocoPage/CameraViews.tsx b/ui/src/pages/MujocoPage/CameraViews.tsx
deleted file mode 100644
index 8e19c41..0000000
--- a/ui/src/pages/MujocoPage/CameraViews.tsx
+++ /dev/null
@@ -1,239 +0,0 @@
-import {useEffect, useState, useRef, useCallback} from "react";
-import {mujocoSocket, sendMujocoAction, sendMujocoCarAction, sendMujocoCameraMove, sendMujocoCameraZoom, onMujocoStateUpdate} from "../../api/socket";
-
-interface MujocoState {
- car_pos?: number[];
- car_quat?: number[];
- arm_qpos?: number[];
- arm_qvel?: number[];
-}
-
-export const TopDownView = () => {
- const [image, setImage] = useState("");
- const [dragging, setDragging] = useState(false);
- const lastPos = useRef<{x: number; y: number} | null>(null);
-
- useEffect(() => {
- const unsubscribe = onMujocoStateUpdate(mujocoSocket, (data) => {
- setImage(`data:image/jpeg;base64,${data.topdown}`);
- });
- return unsubscribe;
- }, []);
-
- const handleMouseDown = useCallback((e: React.MouseEvent) => {
- setDragging(true);
- lastPos.current = {x: e.clientX, y: e.clientY};
- }, []);
-
- const handleMouseMove = useCallback((e: React.MouseEvent) => {
- if (!dragging || !lastPos.current) return;
- const dx = e.clientX - lastPos.current.x;
- const dy = e.clientY - lastPos.current.y;
- lastPos.current = {x: e.clientX, y: e.clientY};
- sendMujocoCameraMove(mujocoSocket, dx, dy);
- }, [dragging]);
-
- const handleMouseUp = useCallback(() => {
- setDragging(false);
- lastPos.current = null;
- }, []);
-
- const handleWheel = useCallback((e: React.WheelEvent) => {
- sendMujocoCameraZoom(mujocoSocket, e.deltaY > 0 ? 1 : -1);
- }, []);
-
- return (
-
-
-
- 俯视视角 (Top-Down)
-
-
- {image ? (
-

- ) : (
-
- 加载中...
-
- )}
-
-
- );
-};
-
-export const FirstPersonView = () => {
- const [image, setImage] = useState("");
-
- useEffect(() => {
- const unsubscribe = onMujocoStateUpdate(mujocoSocket, (data) => {
- setImage(`data:image/jpeg;base64,${data.firstperson}`);
- });
- return unsubscribe;
- }, []);
-
- return (
-
-
-
- 第一人称视角 (First-Person)
-
-
- {image ? (
-

- ) : (
-
- 加载中...
-
- )}
-
-
- );
-};
-
-export const ArmControl = () => {
- const [yaw, setYaw] = useState(0);
- const [pitch, setPitch] = useState(0);
- const [roll, setRoll] = useState(0);
- const [state, setState] = useState({});
-
- useEffect(() => {
- const unsubscribe = onMujocoStateUpdate(mujocoSocket, (data) => {
- setState(data.state as MujocoState);
- });
- return unsubscribe;
- }, []);
-
- const handleArmAction = () => {
- sendMujocoAction(mujocoSocket, yaw, pitch, roll);
- };
-
- const armNames = ["Yaw", "Pitch", "Roll"];
- const armQpos = state.arm_qpos || [];
- const armQvel = state.arm_qvel || [];
-
- return (
-
- {/* 机械臂控制 */}
-
-
- {/* 小车控制 */}
-
-
- {/* 关节状态显示 */}
-
-
关节状态
-
- {armNames.map((name, i) => (
-
- {name}:
-
- pos={armQpos[i]?.toFixed(3) ?? "N/A"}, vel={armQvel[i]?.toFixed(3) ?? "N/A"}
-
-
- ))}
-
-
-
- );
-};
-
-export const CarControl = () => {
- const [velLeft, setVelLeft] = useState(0);
- const [velRight, setVelRight] = useState(0);
-
- const handleCarAction = () => {
- sendMujocoCarAction(mujocoSocket, velLeft, velRight);
- };
-
- return (
-
-
小车控制
-
-
-
- setVelLeft(parseFloat(e.target.value))}
- className="w-full"
- />
-
-
-
- setVelRight(parseFloat(e.target.value))}
- className="w-full"
- />
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/ui/src/pages/MujocoPage/ControlPanel.tsx b/ui/src/pages/MujocoPage/ControlPanel.tsx
new file mode 100644
index 0000000..f8da201
--- /dev/null
+++ b/ui/src/pages/MujocoPage/ControlPanel.tsx
@@ -0,0 +1,70 @@
+interface Props {
+ isLoaded: boolean;
+ reset: () => void;
+ showJointOverlay: boolean;
+ setShowJointOverlay: (v: boolean) => void;
+ driveSpeed: number;
+ turnSpeed: number;
+ onDriveSpeedChange: (v: number) => void;
+ onTurnSpeedChange: (v: number) => void;
+}
+
+export function ControlPanel({
+ isLoaded,
+ reset,
+ showJointOverlay,
+ setShowJointOverlay,
+ driveSpeed,
+ turnSpeed,
+ onDriveSpeedChange,
+ onTurnSpeedChange,
+}: Props) {
+ if (!isLoaded) return null;
+
+ return (
+
+
+
Drive Settings
+
+
+ onDriveSpeedChange(parseFloat(e.target.value))}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
+ />
+
+
+
+ onTurnSpeedChange(parseFloat(e.target.value))}
+ className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/MujocoPage/MujocoRenderer.tsx b/ui/src/pages/MujocoPage/MujocoRenderer.tsx
new file mode 100644
index 0000000..9017540
--- /dev/null
+++ b/ui/src/pages/MujocoPage/MujocoRenderer.tsx
@@ -0,0 +1,670 @@
+import { useEffect, useRef, useCallback } from "react";
+import * as THREE from "three";
+import type { MainModule, MjModel, MjData } from "@mujoco/mujoco";
+
+const MJ_GEOM_PLANE = 0;
+const MJ_GEOM_SPHERE = 2;
+const MJ_GEOM_CYLINDER = 5;
+const MJ_GEOM_BOX = 6;
+
+const SUBSTEPS = 5;
+
+// Coordinate conversion: MuJoCo (x,y,z) → Three.js (x, z, -y)
+function mjPosToThree(buf: Float64Array, index: number, target: THREE.Vector3): THREE.Vector3 {
+ return target.set(buf[index * 3 + 0], buf[index * 3 + 2], -buf[index * 3 + 1]);
+}
+
+// Quaternion conversion: MuJoCo [w,x,y,z] → Three.js (-x, -z, y, -w)
+function mjQuatToThree(buf: Float64Array, index: number, target: THREE.Quaternion): THREE.Quaternion {
+ return target.set(-buf[index * 4 + 1], -buf[index * 4 + 3], buf[index * 4 + 2], -buf[index * 4 + 0]);
+}
+
+function createGeomMesh(
+ type: number,
+ size: Float64Array,
+ rgba: Float64Array,
+): THREE.Object3D {
+ let mesh: THREE.Object3D;
+
+ const alpha = rgba.length >= 4 ? rgba[3] : 1;
+ const color = new THREE.Color(rgba[0], rgba[1], rgba[2]);
+ const transparent = alpha < 0.99;
+
+ switch (type) {
+ case MJ_GEOM_PLANE: {
+ const geometry = new THREE.PlaneGeometry(20, 20);
+ geometry.rotateX(-Math.PI / 2);
+ const material = new THREE.MeshStandardMaterial({
+ color,
+ side: THREE.DoubleSide,
+ roughness: 0.9,
+ transparent,
+ opacity: alpha,
+ });
+ mesh = new THREE.Mesh(geometry, material);
+ break;
+ }
+ case MJ_GEOM_SPHERE: {
+ const geometry = new THREE.SphereGeometry(size[0], 32, 32);
+ const material = new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.4,
+ transparent,
+ opacity: alpha,
+ });
+ mesh = new THREE.Mesh(geometry, material);
+ break;
+ }
+ case MJ_GEOM_CYLINDER: {
+ const radius = size[0];
+ const halfHeight = size[1];
+
+ const cylGeom = new THREE.CylinderGeometry(radius, radius, halfHeight * 2, 32);
+
+ const cylMat = new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.5,
+ metalness: 0.3,
+ transparent,
+ opacity: alpha,
+ });
+ const cylMesh = new THREE.Mesh(cylGeom, cylMat);
+ cylMesh.castShadow = true;
+ cylMesh.receiveShadow = true;
+
+ const group = new THREE.Group();
+ group.add(cylMesh);
+
+ // Cross-spokes only for wheel-like cylinders (radius > halfHeight)
+ if (radius >= halfHeight) {
+ const spokeHalfLen = radius * 0.9;
+ const spokeThick = halfHeight * 0.3;
+ const spokeGeomX = new THREE.BoxGeometry(spokeHalfLen * 2, spokeThick, spokeThick);
+ const spokeGeomZ = new THREE.BoxGeometry(spokeThick, spokeThick, spokeHalfLen * 2);
+ const spokeMat = new THREE.MeshStandardMaterial({
+ color: 0x333333,
+ roughness: 0.5,
+ metalness: 0.4,
+ });
+ const spokeX = new THREE.Mesh(spokeGeomX, spokeMat);
+ const spokeZ = new THREE.Mesh(spokeGeomZ, spokeMat);
+ spokeX.castShadow = true;
+ spokeZ.castShadow = true;
+ group.add(spokeX);
+ group.add(spokeZ);
+ }
+
+ mesh = group;
+ break;
+ }
+ case MJ_GEOM_BOX: {
+ const geometry = new THREE.BoxGeometry(size[0] * 2, size[2] * 2, size[1] * 2);
+ const material = new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.5,
+ metalness: 0.2,
+ transparent,
+ opacity: alpha,
+ });
+ mesh = new THREE.Mesh(geometry, material);
+ break;
+ }
+ default: {
+ const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
+ const material = new THREE.MeshStandardMaterial({ color: 0xff00ff });
+ mesh = new THREE.Mesh(geometry, material);
+ }
+ }
+
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ return mesh;
+}
+
+function toUint8Array(buf: unknown): Uint8Array | null {
+ if (typeof buf === "string") {
+ return new TextEncoder().encode(buf);
+ }
+ if (buf instanceof ArrayBuffer) {
+ return new Uint8Array(buf);
+ }
+ if (ArrayBuffer.isView(buf)) {
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ }
+ return null;
+}
+
+function resolveName(names: unknown, addr: number): string {
+ const buf = toUint8Array(names);
+ if (!buf) return "";
+ let end = addr;
+ while (end < buf.length && buf[end] !== 0) end++;
+ return new TextDecoder().decode(buf.slice(addr, end));
+}
+
+function makeLabelSprite(text: string, color: string, scale: number = 0.4) {
+ const canvas = document.createElement("canvas");
+ canvas.width = 64;
+ canvas.height = 64;
+ const ctx = canvas.getContext("2d")!;
+ ctx.fillStyle = color;
+ ctx.font = "bold 48px Arial";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(text, 32, 32);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ const material = new THREE.SpriteMaterial({ map: texture, depthTest: false });
+ const sprite = new THREE.Sprite(material);
+ sprite.scale.set(scale, scale, 1);
+ return sprite;
+}
+
+function resolveCameraIndices(model: MjModel) {
+ let fpIdx = -1;
+ let tdIdx = -1;
+ const names = model.names;
+ for (let i = 0; i < model.ncam; i++) {
+ const name = resolveName(names, model.name_camadr[i]);
+ if (name === "firstperson") fpIdx = i;
+ else if (name === "topdown") tdIdx = i;
+ }
+ return { fpIdx, tdIdx };
+}
+
+interface JointOverlay {
+ cylinder: THREE.Mesh;
+ sprite: THREE.Sprite;
+ jointIdx: number;
+ name: string;
+ qposAdr: number;
+}
+
+interface Props {
+ mujoco: React.RefObject;
+ model: React.RefObject;
+ data: React.RefObject;
+ isLoaded: boolean;
+ onStep: () => void;
+ showJointOverlay: boolean;
+}
+
+export default function MujocoRenderer({
+ mujoco,
+ model,
+ data,
+ isLoaded,
+ onStep,
+ showJointOverlay,
+}: Props) {
+ const containerRef = useRef(null);
+ const fpContainerRef = useRef(null);
+ const axesContainerRef = useRef(null);
+ const sceneRef = useRef(null);
+ const orbitCameraRef = useRef(null);
+ const fpCameraRef = useRef(null);
+ const axesCameraRef = useRef(null);
+ const axesSceneRef = useRef(null);
+ const axesGroupRef = useRef(null);
+ const mainRendererRef = useRef(null);
+ const fpRendererRef = useRef(null);
+ const axesRendererRef = useRef(null);
+ const bodyGroupsRef = useRef([]);
+ const animRef = useRef(0);
+ const draggingRef = useRef(false);
+ const lastMouseRef = useRef({ x: 0, y: 0 });
+ const orbitStateRef = useRef({
+ azimuth: 0.5,
+ elevation: 0.4,
+ distance: 6,
+ target: new THREE.Vector3(0, 0.3, 0),
+ });
+ const fpCamIdxRef = useRef(-1);
+ const fpCamBodyIdRef = useRef(-1);
+ const jointGroupRef = useRef(null);
+ const jointOverlaysRef = useRef([]);
+ const jointFrameCountRef = useRef(0);
+ const showJointOverlayRef = useRef(showJointOverlay);
+ showJointOverlayRef.current = showJointOverlay;
+
+ const setupScene = useCallback(() => {
+ const container = containerRef.current;
+ const fpContainer = fpContainerRef.current;
+ if (!container || !fpContainer) return;
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x2a2a4e);
+
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ const orbitCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
+
+ const fpW = fpContainer.clientWidth || 320;
+ const fpH = fpContainer.clientHeight || 210;
+ const fpCamera = new THREE.PerspectiveCamera(110, fpW / fpH, 0.1, 100);
+
+ const mainRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
+ mainRenderer.setClearColor(0x2a2a4e, 1);
+ mainRenderer.setSize(w, h);
+ mainRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ mainRenderer.shadowMap.enabled = true;
+ mainRenderer.domElement.style.position = "absolute";
+ mainRenderer.domElement.style.top = "0";
+ mainRenderer.domElement.style.left = "0";
+ container.appendChild(mainRenderer.domElement);
+
+ const fpRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
+ fpRenderer.setClearColor(0x2a2a4e, 1);
+ fpRenderer.setSize(fpW, fpH);
+ fpRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ fpRenderer.shadowMap.enabled = true;
+ fpContainer.appendChild(fpRenderer.domElement);
+
+ // Axes gizmo (bottom-left)
+ const axesContainer = axesContainerRef.current;
+ let axesScene: THREE.Scene | null = null;
+ let axesCamera: THREE.PerspectiveCamera | null = null;
+ let axesRenderer: THREE.WebGLRenderer | null = null;
+ if (axesContainer) {
+ axesScene = new THREE.Scene();
+ const axesGroup = new THREE.Group();
+ axesGroup.add(new THREE.AxesHelper(1.0));
+ axesGroup.add(makeLabelSprite("X", "#ff4444").translateX(1.15));
+ axesGroup.add(makeLabelSprite("Z", "#4444ff").translateY(1.15));
+ axesGroup.add(makeLabelSprite("Y", "#44ff44").translateZ(1.15));
+ axesScene.add(axesGroup);
+ axesGroupRef.current = axesGroup;
+ const size = 120;
+ const halfSize = 1.3;
+ axesCamera = new THREE.OrthographicCamera(-halfSize, halfSize, halfSize, -halfSize, 0.1, 10);
+ axesCamera.position.set(0, 0, 3);
+ axesRenderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
+ axesRenderer.setClearColor(0x1a1a2e, 1);
+ axesRenderer.setSize(size, size);
+ axesRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ axesContainer.appendChild(axesRenderer.domElement);
+ }
+ axesCameraRef.current = axesCamera;
+ axesSceneRef.current = axesScene;
+ axesRendererRef.current = axesRenderer;
+
+ const ambient = new THREE.AmbientLight(0xffffff, 1.0);
+ scene.add(ambient);
+
+ const dir = new THREE.DirectionalLight(0xffffff, 1.5);
+ dir.position.set(5, 8, 3);
+ dir.castShadow = true;
+ dir.shadow.mapSize.set(1024, 1024);
+ scene.add(dir);
+
+ const hemi = new THREE.HemisphereLight(0x87ceeb, 0x3a3a3a, 0.7);
+ scene.add(hemi);
+
+ const grid = new THREE.GridHelper(10, 20, 0x444466, 0x222244);
+ scene.add(grid);
+
+ const axes = new THREE.AxesHelper(2);
+ scene.add(axes);
+
+ sceneRef.current = scene;
+ orbitCameraRef.current = orbitCamera;
+ fpCameraRef.current = fpCamera;
+ mainRendererRef.current = mainRenderer;
+ fpRendererRef.current = fpRenderer;
+ }, []);
+
+ const updateOrbitCamera = useCallback(() => {
+ const camera = orbitCameraRef.current;
+ const cs = orbitStateRef.current;
+ if (!camera) return;
+
+ const az = cs.azimuth;
+ const el = cs.elevation;
+ const d = cs.distance;
+ const t = cs.target;
+
+ camera.position.set(
+ t.x + d * Math.cos(el) * Math.sin(az),
+ t.y + d * Math.sin(el),
+ t.z + d * Math.cos(el) * Math.cos(az),
+ );
+ camera.lookAt(t);
+ }, []);
+
+ useEffect(() => {
+ setupScene();
+ const handleResize = () => {
+ const container = containerRef.current;
+ const fpContainer = fpContainerRef.current;
+ const mainRenderer = mainRendererRef.current;
+ const fpRenderer = fpRendererRef.current;
+ const orbitCamera = orbitCameraRef.current;
+ const fpCamera = fpCameraRef.current;
+ if (!container || !mainRenderer || !orbitCamera) return;
+
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ mainRenderer.setSize(w, h);
+ orbitCamera.aspect = w / h;
+ orbitCamera.updateProjectionMatrix();
+
+ if (fpContainer && fpRenderer && fpCamera) {
+ const fpW = fpContainer.clientWidth;
+ const fpH = fpContainer.clientHeight;
+ fpRenderer.setSize(fpW, fpH);
+ fpCamera.aspect = fpW / fpH;
+ fpCamera.updateProjectionMatrix();
+ }
+ };
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ cancelAnimationFrame(animRef.current);
+ mainRendererRef.current?.domElement?.remove();
+ mainRendererRef.current?.dispose();
+ fpRendererRef.current?.domElement?.remove();
+ fpRendererRef.current?.dispose();
+ axesRendererRef.current?.domElement?.remove();
+ axesRendererRef.current?.dispose();
+ };
+ }, [setupScene]);
+
+ const prevLoadedRef = useRef(false);
+ useEffect(() => {
+ if (!isLoaded || !model.current || prevLoadedRef.current) return;
+ prevLoadedRef.current = true;
+
+ const m = model.current!;
+ const scene = sceneRef.current;
+ if (!scene) return;
+
+ // Remove old body groups
+ bodyGroupsRef.current.forEach((g) => scene.remove(g));
+ bodyGroupsRef.current = [];
+
+ // Create body groups, all as direct children of world (body 0)
+ // We use world positions from d.xpos, so no parent-child body hierarchy needed
+ const bodyGroups: THREE.Group[] = [];
+ for (let b = 0; b < m.nbody; b++) {
+ const group = new THREE.Group();
+ const nameAddr = m.name_bodyadr?.[b] ?? -1;
+ group.name = nameAddr >= 0 ? resolveName(m.names, nameAddr) : `body_${b}`;
+ bodyGroups.push(group);
+ if (b === 0) {
+ scene.add(group);
+ } else {
+ bodyGroups[0].add(group);
+ }
+ }
+
+ // Create geom meshes and attach to body groups with LOCAL pos/quat
+ for (let i = 0; i < m.ngeom; i++) {
+ const type = m.geom_type[i];
+ const size = m.geom_size.slice(i * 3, i * 3 + 3) as Float64Array;
+ const rgba = m.geom_rgba.slice(i * 4, i * 4 + 4) as Float64Array;
+ const bodyId = m.geom_bodyid[i];
+
+ const mesh = createGeomMesh(type, size, rgba);
+ mjPosToThree(m.geom_pos, i, mesh.position);
+ if (type !== MJ_GEOM_PLANE) {
+ mjQuatToThree(m.geom_quat, i, mesh.quaternion);
+ }
+ bodyGroups[bodyId].add(mesh);
+ }
+
+ bodyGroupsRef.current = bodyGroups;
+
+ const { fpIdx, tdIdx } = resolveCameraIndices(m);
+ fpCamIdxRef.current = fpIdx;
+ fpCamBodyIdRef.current = fpIdx >= 0 ? (m.cam_bodyid?.[fpIdx] ?? -1) : -1;
+
+ const orbitCam = orbitCameraRef.current;
+ const fpCam = fpCameraRef.current;
+ if (tdIdx >= 0 && orbitCam) {
+ orbitCam.fov = m.cam_fovy[tdIdx];
+ orbitCam.updateProjectionMatrix();
+ }
+ if (fpIdx >= 0 && fpCam) {
+ fpCam.fov = m.cam_fovy[fpIdx];
+ fpCam.updateProjectionMatrix();
+ }
+
+ // Joint overlay group
+ const jointGroup = new THREE.Group();
+ jointGroup.visible = showJointOverlay;
+ scene.add(jointGroup);
+ jointGroupRef.current = jointGroup;
+ const overlays: JointOverlay[] = [];
+
+ for (let i = 0; i < m.njnt; i++) {
+ const jtype = m.jnt_type[i];
+ if (jtype !== 3) continue; // only hinge joints for now
+
+ const name = resolveName(m.names, m.name_jntadr[i]);
+
+ const qposAdr = m.jnt_qposadr[i];
+
+ // axis cylinder - use MeshBasicMaterial for guaranteed visibility
+ const cylGeo = new THREE.CylinderGeometry(0.04, 0.04, 0.5, 8);
+ const cylMat = new THREE.MeshBasicMaterial({
+ color: 0xff4444,
+ depthTest: false,
+ depthWrite: false,
+ });
+ const cylinder = new THREE.Mesh(cylGeo, cylMat);
+ cylinder.renderOrder = 999;
+
+ // label sprite
+ const canvas = document.createElement("canvas");
+ canvas.width = 256;
+ canvas.height = 64;
+ const ctx = canvas.getContext("2d")!;
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.LinearFilter;
+ const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false });
+ const sprite = new THREE.Sprite(spriteMat);
+ sprite.scale.set(0.6, 0.15, 1);
+
+ jointGroup.add(cylinder);
+ jointGroup.add(sprite);
+ overlays.push({ cylinder, sprite, jointIdx: i, name, qposAdr });
+ }
+ jointOverlaysRef.current = overlays;
+
+ updateOrbitCamera();
+ }, [isLoaded, model, updateOrbitCamera]);
+
+ useEffect(() => {
+ let running = true;
+
+ function loop() {
+ if (!running) return;
+
+ const m = mujoco.current;
+ const d = data.current;
+ const scene = sceneRef.current;
+ const mainRenderer = mainRendererRef.current;
+ const fpRenderer = fpRendererRef.current;
+ const orbitCamera = orbitCameraRef.current;
+ const fpCamera = fpCameraRef.current;
+
+ if (m && d && scene) {
+ onStep();
+
+ for (let s = 1; s < SUBSTEPS; s++) {
+ m.mj_step(model.current!, data.current!);
+ }
+
+ // Update body group transforms from MuJoCo world data
+ const bodyGroups = bodyGroupsRef.current;
+ for (let b = 0; b < bodyGroups.length; b++) {
+ mjPosToThree(d.xpos, b, bodyGroups[b].position);
+ mjQuatToThree(d.xquat, b, bodyGroups[b].quaternion);
+ }
+
+ // Track car body position for orbit camera target
+ const carBodyId = 1;
+ const carPos3 = new THREE.Vector3();
+ mjPosToThree(d.xpos, carBodyId, carPos3);
+ orbitStateRef.current.target.set(carPos3.x, carPos3.y + 0.3, carPos3.z);
+ updateOrbitCamera();
+
+ // First-person camera: reuse body group transform (same proven quaternion path)
+ const fpIdx = fpCamIdxRef.current;
+ const camBodyId = fpCamBodyIdRef.current;
+ if (fpCamera && fpIdx >= 0 && camBodyId >= 0 && bodyGroups[camBodyId]) {
+ fpCamera.position.copy(bodyGroups[camBodyId].position);
+ fpCamera.quaternion.copy(bodyGroups[camBodyId].quaternion);
+ }
+
+ // Joint overlay update
+ const jointGroup = jointGroupRef.current;
+ const overlays = jointOverlaysRef.current;
+ if (jointGroup) {
+ const show = showJointOverlayRef.current;
+ jointGroup.visible = show;
+ if (show && overlays.length > 0) {
+ jointFrameCountRef.current++;
+ const updateLabels = jointFrameCountRef.current % 30 === 0;
+ for (const ov of overlays) {
+ const ji = ov.jointIdx;
+ const jnt = d.jnt(ji);
+
+ const anchorRaw = jnt.xanchor;
+ const axisRaw = jnt.xaxis;
+ const anchor = (anchorRaw instanceof Float64Array || ArrayBuffer.isView(anchorRaw))
+ ? new Float64Array(anchorRaw.buffer, anchorRaw.byteOffset, 3)
+ : (Array.isArray(anchorRaw) ? anchorRaw : [0, 0, 0]) as unknown as Float64Array;
+ const axis = (axisRaw instanceof Float64Array || ArrayBuffer.isView(axisRaw))
+ ? new Float64Array(axisRaw.buffer, axisRaw.byteOffset, 3)
+ : (Array.isArray(axisRaw) ? axisRaw : [0, 1, 0]) as unknown as Float64Array;
+
+ const posT = new THREE.Vector3(anchor[0], anchor[2], -anchor[1]);
+ const axT = new THREE.Vector3(axis[0], axis[2], -axis[1]).normalize();
+
+ ov.cylinder.position.copy(posT);
+ ov.cylinder.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axT);
+
+ ov.sprite.position.copy(posT).add(new THREE.Vector3(0, 0.25, 0));
+
+ if (updateLabels) {
+ const qposVal = d.qpos[ov.qposAdr]?.toFixed(3) ?? "?";
+ const canvas = (ov.sprite.material as THREE.SpriteMaterial).map?.image as HTMLCanvasElement;
+ if (canvas) {
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = "rgba(0,0,0,0.75)";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = "#ffffff";
+ ctx.font = "20px monospace";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(`${ov.name}: ${qposVal}`, 128, 32);
+ (ov.sprite.material as THREE.SpriteMaterial).map!.needsUpdate = true;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (mainRenderer && orbitCamera && scene) {
+ mainRenderer.render(scene, orbitCamera);
+ }
+ if (fpRenderer && fpCamera && scene) {
+ fpRenderer.render(scene, fpCamera);
+ }
+
+ // Axes gizmo: rotate the axes group to match orbit camera view
+ const axesCamera = axesCameraRef.current;
+ const axesScene = axesSceneRef.current;
+ const axesRenderer = axesRendererRef.current;
+ const axesGroup = axesGroupRef.current;
+ if (axesCamera && axesScene && axesRenderer && axesGroup && orbitCamera) {
+ axesGroup.quaternion.copy(orbitCamera.quaternion).invert();
+ axesRenderer.render(axesScene, axesCamera);
+ }
+
+ animRef.current = requestAnimationFrame(loop);
+ }
+
+ if (isLoaded) {
+ loop();
+ }
+
+ return () => {
+ running = false;
+ cancelAnimationFrame(animRef.current);
+ };
+ }, [isLoaded, mujoco, data, onStep]);
+
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
+ draggingRef.current = true;
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ }, []);
+
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!draggingRef.current) return;
+ const dx = e.clientX - lastMouseRef.current.x;
+ const dy = e.clientY - lastMouseRef.current.y;
+ lastMouseRef.current = { x: e.clientX, y: e.clientY };
+
+ orbitStateRef.current.azimuth -= dx * 0.005;
+ orbitStateRef.current.elevation += dy * 0.005;
+ orbitStateRef.current.elevation = Math.max(
+ -1.5,
+ Math.min(1.5, orbitStateRef.current.elevation),
+ );
+ updateOrbitCamera();
+ },
+ [updateOrbitCamera],
+ );
+
+ const handlePointerUp = useCallback(() => {
+ draggingRef.current = false;
+ }, []);
+
+ const handleWheel = useCallback(
+ (e: React.WheelEvent) => {
+ orbitStateRef.current.distance += e.deltaY * 0.01;
+ orbitStateRef.current.distance = Math.max(
+ 1,
+ Math.min(30, orbitStateRef.current.distance),
+ );
+ updateOrbitCamera();
+ },
+ [updateOrbitCamera],
+ );
+
+ return (
+
+ {!isLoaded && (
+
+
Loading MuJoCo WASM...
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/src/pages/MujocoPage/carArmXml.ts b/ui/src/pages/MujocoPage/carArmXml.ts
new file mode 100644
index 0000000..3351a20
--- /dev/null
+++ b/ui/src/pages/MujocoPage/carArmXml.ts
@@ -0,0 +1,50 @@
+import { MAZE_XML } from './mazeXml';
+
+export const CAR_ARM_XML = `
+
+
+
+
+
+
+
+
+ ${MAZE_XML}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
\ No newline at end of file
diff --git a/ui/src/pages/MujocoPage/index.tsx b/ui/src/pages/MujocoPage/index.tsx
index c3f689a..d01f32c 100644
--- a/ui/src/pages/MujocoPage/index.tsx
+++ b/ui/src/pages/MujocoPage/index.tsx
@@ -1,57 +1,151 @@
-import {useEffect, useState} from "react";
-import {mujocoSocket} from "../../api/socket";
-import {TopDownView, FirstPersonView} from "./CameraViews";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useMujoco } from "./useMujoco";
+import MujocoRenderer from "./MujocoRenderer";
+import { ControlPanel } from "./ControlPanel";
+
+const DRIVE_KEYS = [
+ "KeyW", "KeyA", "KeyS", "KeyD",
+ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
+ "Space",
+];
export default function MujocoPage() {
- const [connected, setConnected] = useState(false);
-
- useEffect(() => {
- mujocoSocket.on("connect", () => {
- setConnected(true);
- });
- mujocoSocket.on("disconnect", () => {
- setConnected(false);
- });
-
- return () => {
- mujocoSocket.off("connect");
- mujocoSocket.off("disconnect");
- };
- }, []);
-
- return (
-
- {/* 顶部标题栏 */}
-
-
-
- MJC
-
-
-
MuJoCo 小车+机械臂
-
3D Physics Simulation
-
-
-
-
-
- {connected ? 'Connected' : 'Disconnected'}
-
-
-
-
- {/* 主内容区 */}
-
- {/* 中间 - 俯视视角 */}
-
-
-
-
- {/* 右侧 - 第一人称视角 */}
-
-
-
-
+ const { isLoaded, mujoco, model, data, step, setControl, reset } =
+ useMujoco();
+ const keysRef = useRef
>(new Set());
+ const [showJointOverlay, setShowJointOverlay] = useState(false);
+ const [driveSpeed, setDriveSpeed] = useState(5);
+ const [turnSpeed, setTurnSpeed] = useState(3);
+ const [fps, setFps] = useState(0);
+ const fpsFramesRef = useRef(0);
+ const fpsLastTimeRef = useRef(performance.now());
+
+ const applyDrive = useCallback(() => {
+ let leftVel = 0;
+ let rightVel = 0;
+
+ const k = keysRef.current;
+
+ if (k.has("KeyW") || k.has("ArrowUp")) {
+ leftVel += driveSpeed;
+ rightVel += driveSpeed;
+ }
+ if (k.has("KeyS") || k.has("ArrowDown")) {
+ leftVel -= driveSpeed;
+ rightVel -= driveSpeed;
+ }
+ if (k.has("KeyA") || k.has("ArrowLeft")) {
+ leftVel -= turnSpeed;
+ rightVel += turnSpeed;
+ }
+ if (k.has("KeyD") || k.has("ArrowRight")) {
+ leftVel += turnSpeed;
+ rightVel -= turnSpeed;
+ }
+ if (k.has("Space")) {
+ leftVel = 0;
+ rightVel = 0;
+ }
+
+ setControl("motor_wheel_fl", leftVel);
+ setControl("motor_wheel_rl", leftVel);
+ setControl("motor_wheel_fr", rightVel);
+ setControl("motor_wheel_rr", rightVel);
+ }, [setControl, driveSpeed, turnSpeed]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (DRIVE_KEYS.includes(e.code)) {
+ e.preventDefault();
+ e.stopPropagation();
+ keysRef.current.add(e.code);
+ }
+ };
+ const handleKeyUp = (e: KeyboardEvent) => {
+ keysRef.current.delete(e.code);
+ };
+ // capture:true ensures we intercept before the browser scrolls
+ window.addEventListener("keydown", handleKeyDown, { capture: true });
+ window.addEventListener("keyup", handleKeyUp);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown, { capture: true });
+ window.removeEventListener("keyup", handleKeyUp);
+ };
+ }, []);
+
+ // Apply drive controls every frame before mj_step, with real FPS
+ const stepWithDrive = useCallback(() => {
+ applyDrive();
+
+ // Real FPS counter
+ fpsFramesRef.current++;
+ const now = performance.now();
+ if (now - fpsLastTimeRef.current >= 1000) {
+ setFps(fpsFramesRef.current);
+ fpsFramesRef.current = 0;
+ fpsLastTimeRef.current = now;
+ }
+
+ step();
+ }, [applyDrive, step]);
+
+ return (
+
+
+
+
+ MJC
+
+
+
+ MuJoCo WASM + Three.js
+
+
Browser-native simulation
+
+
+
+
+ FPS: {fps || "--"}
+
+
+
+
+ {isLoaded ? "Running" : "Loading..."}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WASD / Arrows: drive · Space: brake
+
- );
-}
\ No newline at end of file
+
+
+ );
+}
diff --git a/ui/src/pages/MujocoPage/mazeXml.ts b/ui/src/pages/MujocoPage/mazeXml.ts
new file mode 100644
index 0000000..76435e0
--- /dev/null
+++ b/ui/src/pages/MujocoPage/mazeXml.ts
@@ -0,0 +1,33 @@
+export const MAZE_XML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
\ No newline at end of file
diff --git a/ui/src/pages/MujocoPage/useDataCollection.ts b/ui/src/pages/MujocoPage/useDataCollection.ts
new file mode 100644
index 0000000..5887673
--- /dev/null
+++ b/ui/src/pages/MujocoPage/useDataCollection.ts
@@ -0,0 +1,152 @@
+import { useRef, useCallback, useState } from "react";
+import { io, Socket } from "socket.io-client";
+import type { MjData } from "@mujoco/mujoco";
+
+const CAPTURE_INTERVAL_MS = 100; // 10 FPS
+const USER_ID = "sim_user";
+
+interface Frame {
+ timestamp: number;
+ image: string; // base64 JPEG
+ action: [number, number, number]; // [left, right, gripper]
+ state: { vel_left: number; vel_right: number };
+}
+
+export function useDataCollection(
+ dataRef: React.RefObject,
+ fpCanvasRef: React.RefObject,
+) {
+ const [isRecording, setIsRecording] = useState(false);
+ const [frameCount, setFrameCount] = useState(0);
+ const [episodeId, setEpisodeId] = useState(1);
+ const [socketStatus, setSocketStatus] = useState("disconnected");
+
+ const socketRef = useRef(null);
+ const recordingRef = useRef(false);
+ const lastCaptureRef = useRef(0);
+ const episodeIdRef = useRef(1);
+ const leftVelRef = useRef(0);
+ const rightVelRef = useRef(0);
+
+ // Connect to backend
+ const connect = useCallback(() => {
+ if (socketRef.current?.connected) return;
+ const s = io("http://localhost:8000", {
+ transports: ["websocket"],
+ namespace: "/sim",
+ });
+ s.on("connect", () => setSocketStatus("connected"));
+ s.on("disconnect", () => setSocketStatus("disconnected"));
+ s.on("connect_error", () => setSocketStatus("error"));
+ s.on("episode_started", (data: { episode_id: number }) => {
+ setEpisodeId(data.episode_id);
+ episodeIdRef.current = data.episode_id;
+ });
+ s.on("collection_count", (data: { count: number }) => {
+ setFrameCount(data.count);
+ });
+ socketRef.current = s;
+ return s;
+ }, []);
+
+ const disconnect = useCallback(() => {
+ socketRef.current?.disconnect();
+ socketRef.current = null;
+ setSocketStatus("disconnected");
+ }, []);
+
+ // Start recording
+ const startRecording = useCallback(() => {
+ const s = connect();
+ recordingRef.current = true;
+ setIsRecording(true);
+ setFrameCount(0);
+ lastCaptureRef.current = 0;
+
+ s.emit("start_episode", {
+ user_id: USER_ID,
+ episode_id: episodeIdRef.current,
+ task_name: "maze_driving",
+ });
+ }, [connect]);
+
+ // Stop recording
+ const stopRecording = useCallback(() => {
+ recordingRef.current = false;
+ setIsRecording(false);
+ const s = socketRef.current;
+ if (s) {
+ s.emit("end_episode", {
+ user_id: USER_ID,
+ episode_id: episodeIdRef.current,
+ });
+ episodeIdRef.current += 1;
+ }
+ }, []);
+
+ // Set current action values (called from drive loop)
+ const setAction = useCallback((left: number, right: number) => {
+ leftVelRef.current = left;
+ rightVelRef.current = right;
+ }, []);
+
+ // Capture frame + send data (called each physics step)
+ const captureFrame = useCallback(() => {
+ if (!recordingRef.current) return;
+
+ const now = performance.now();
+ if (now - lastCaptureRef.current < CAPTURE_INTERVAL_MS) return;
+ lastCaptureRef.current = now;
+
+ // Capture FPV canvas
+ const fpCanvas = fpCanvasRef.current;
+ if (!fpCanvas) return;
+ const image = fpCanvas.toDataURL("image/jpeg", 0.7);
+
+ // Read wheel velocities from MuJoCo data
+ const d = dataRef.current;
+ let velLeft = 0;
+ let velRight = 0;
+ if (d) {
+ // Wheel joints are at indices 0-3 (fl, fr, rl, rr)
+ // Joint velocity = d.qvel[joint_dof_adr]
+ const getJointVel = (name: string) => {
+ // Simplified: use the first 4 hinge joints
+ // wheel_fl=0, wheel_fr=1, wheel_rl=2, wheel_rr=3
+ const idx = ["wheel_fl", "wheel_fr", "wheel_rl", "wheel_rr"].indexOf(name);
+ if (idx >= 0 && d.qvel.length > idx) return d.qvel[idx];
+ return 0;
+ };
+ velLeft = (getJointVel("wheel_fl") + getJointVel("wheel_rl")) / 2;
+ velRight = (getJointVel("wheel_fr") + getJointVel("wheel_rr")) / 2;
+ }
+
+ const action: [number, number, number] = [
+ leftVelRef.current,
+ rightVelRef.current,
+ 0, // gripper (not used for car)
+ ];
+
+ socketRef.current?.emit("collect_data", {
+ user_id: USER_ID,
+ image: image.replace(/^data:image\/jpeg;base64,/, ""),
+ dataset_name: "maze",
+ timestamp: now,
+ state: { vel_left: velLeft, vel_right: velRight },
+ action,
+ });
+ }, [dataRef, fpCanvasRef]);
+
+ return {
+ isRecording,
+ frameCount,
+ episodeId,
+ socketStatus,
+ connect,
+ disconnect,
+ startRecording,
+ stopRecording,
+ setAction,
+ captureFrame,
+ };
+}
diff --git a/ui/src/pages/MujocoPage/useMujoco.ts b/ui/src/pages/MujocoPage/useMujoco.ts
new file mode 100644
index 0000000..a9b9eee
--- /dev/null
+++ b/ui/src/pages/MujocoPage/useMujoco.ts
@@ -0,0 +1,93 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import type { MainModule, MjModel, MjData } from "@mujoco/mujoco";
+import wasmUrl from "@mujoco/mujoco/mujoco.wasm?url";
+import { CAR_ARM_XML } from "./carArmXml";
+
+export function useMujoco() {
+ const [isLoaded, setIsLoaded] = useState(false);
+ const mujocoRef = useRef(null);
+ const modelRef = useRef(null);
+ const dataRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function init() {
+ const { default: loadMujoco } = await import("@mujoco/mujoco");
+ const mujoco = await loadMujoco({
+ locateFile: (path: string) => {
+ if (path.endsWith(".wasm")) return wasmUrl;
+ return path;
+ },
+ });
+ if (cancelled) return;
+
+ mujocoRef.current = mujoco;
+
+ const model = (mujoco.MjModel as unknown as { from_xml_string: (xml: string) => MjModel }).from_xml_string(CAR_ARM_XML);
+ const data = new mujoco.MjData(model);
+
+ modelRef.current = model;
+ dataRef.current = data;
+ setIsLoaded(true);
+ }
+
+ init();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const step = useCallback(() => {
+ const m = mujocoRef.current;
+ const model = modelRef.current;
+ const data = dataRef.current;
+ if (!m || !model || !data) return;
+ m.mj_step(model, data);
+ }, []);
+
+ const setControl = useCallback((name: string, value: number) => {
+ const model = modelRef.current;
+ const data = dataRef.current;
+ if (!model || !data) return;
+ const names = model.names;
+ let buf: Uint8Array;
+ if (typeof names === "string") {
+ buf = new TextEncoder().encode(names);
+ } else if (names instanceof ArrayBuffer) {
+ buf = new Uint8Array(names);
+ } else if (ArrayBuffer.isView(names)) {
+ buf = new Uint8Array(names.buffer, names.byteOffset, names.byteLength);
+ } else {
+ return;
+ }
+ const decoder = new TextDecoder();
+ for (let i = 0; i < model.nu; i++) {
+ const addr = model.name_actuatoradr[i];
+ let end = addr;
+ while (end < buf.length && buf[end] !== 0) end++;
+ if (decoder.decode(buf.slice(addr, end)) === name) {
+ data.ctrl[i] = value;
+ return;
+ }
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ const m = mujocoRef.current;
+ const model = modelRef.current;
+ const data = dataRef.current;
+ if (!m || !model || !data) return;
+ m.mj_resetData(model, data);
+ }, []);
+
+ return {
+ isLoaded,
+ mujoco: mujocoRef,
+ model: modelRef,
+ data: dataRef,
+ step,
+ setControl,
+ reset,
+ };
+}