Merge pull request 'Cen' (#3) from Cen into main
build / setup (compute matrix) (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled
build / setup (compute matrix) (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
Generated
+110
@@ -9,9 +9,12 @@
|
||||
"version": "1.0.0-alpha.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mujoco/mujoco": "^3.11.0",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^0.555.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pyodide": "^0.29.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zustand": "^5.0.15"
|
||||
@@ -931,6 +934,29 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/loader": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"state-local": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@monaco-editor/react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@monaco-editor/loader": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"monaco-editor": ">= 0.25.0 < 1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mujoco/mujoco": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@mujoco/mujoco/-/mujoco-3.11.0.tgz",
|
||||
@@ -1472,6 +1498,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1550,6 +1582,13 @@
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
@@ -2542,6 +2581,15 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -3801,6 +3849,18 @@
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
@@ -3887,6 +3947,16 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4398,6 +4468,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pyodide": {
|
||||
"version": "0.29.4",
|
||||
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.29.4.tgz",
|
||||
"integrity": "sha512-tCseTsqU3kSxZIjkue5zXxTMNEwrKZwOIIEQRBA/VzHxFN1hoCxe4w41phfCdHd9it9RcCNQb5K/Re0InqMgvA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.4",
|
||||
"ws": "^8.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -4682,6 +4765,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
@@ -5558,6 +5647,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -56,9 +56,12 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mujoco/mujoco": "^3.11.0",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^0.555.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pyodide": "^0.29.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zustand": "^5.0.15"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Web 仿真控制脚本
|
||||
|
||||
该目录存放可由 MuJoCo Web 平台“控制 → Python 控制器”直接导入的可信本地脚本。
|
||||
|
||||
## Go2-W 平衡控制
|
||||
|
||||
文件:`go2_w_balance.py`
|
||||
|
||||
使用条件:
|
||||
|
||||
1. 可导入 `/home/cen/Embodied_Workspace/unitree_ros/robots/go2w_description/` 文件夹/ZIP,或 Unitree 官方 `/home/cen/Embodied_Workspace/unitree_mujoco/unitree_robots/go2w/` MJCF 工程;
|
||||
2. 使用 URDF 时选择“转换为 MJCF”和“浮动基座”;
|
||||
3. 使用 URDF 时勾选“为关节添加驱动器”和“添加传感器”,平台会在浮动基座注入6轴 IMU;
|
||||
4. 模型加载后进入“控制 → Python 控制器”,导入本文件;
|
||||
5. 点击“加载脚本”或直接导入后,点击“启用”,再播放仿真。
|
||||
|
||||
控制器兼容两套命名:
|
||||
|
||||
- Body:官方 MJCF 的 `base_link` 或 URDF 转换模型的 `base`
|
||||
- 腿关节:`FL/FR/RL/RR_{hip,thigh,calf}_joint`
|
||||
- 轮关节:官方 `*_wheel_joint` 或 URDF 转换后的 `*_foot_joint`
|
||||
- actuator:官方 `FL_hip`/`FL_wheel` 风格,或平台生成的 `<joint-name>_motor`
|
||||
- 6轴 IMU:`imu_gyro`(三轴角速度)和 `imu_acc`(三轴加速度)
|
||||
|
||||
脚本在原地站立和 IMU roll/pitch 调平基础上,实现平台 Python SDK 的标准基本移动指令:停止、前进、后退、左转、右转和原地起跳。加载并启用脚本后,可在“基本移动指令”面板直接操作。前后移动采用四轮速度闭环;转向采用前轮主导、后轮限幅的左右差速,减小后腿 hip 关节摆幅;起跳采用“下蹲—爆发伸腿—腾空收腿—落地准备—恢复”的一次性轨迹。
|
||||
|
||||
控制脚本可选定义同步函数 `command(name, state)` 接收指令。当前标准指令名为:
|
||||
|
||||
- `stop`
|
||||
- `forward`
|
||||
- `backward`
|
||||
- `turn_left`
|
||||
- `turn_right`
|
||||
- `jump`
|
||||
|
||||
其中移动/转向指令会持续生效,直到收到另一条移动指令或 `stop`。`stop` 会在轮速降为零后继续执行带积分补偿的站立姿态恢复,使机身自动回正。`jump` 是一次性原地起跳指令,会先切换到 `stop`。控制器在下蹲阶段保持完整重心补偿,爆发阶段以小腿竖直推力和轮毂反作用力矩抑制后仰,并记录起跳点;落地接触后会沿机身前向轴自动收回水平漂移。控制器必须确认位置、机身高度、姿态和角速度重新稳定,才会执行下一次已排队的起跳,避免连续起跳逐步后仰。不同接触参数、质量或初始姿态下,可调整 `NOMINAL`、`KP_FINAL`、`KD`、`DRIVE_SPEED`、`DRIVE_ACCELERATION`、`TURN_RATE`、`TURN_ACCELERATION`、`WHEEL_VELOCITY_KP` 和 `MAX_WHEEL_TORQUE`。速度指令采用斜坡限制,避免轮毂力矩阶跃导致机身后仰;起跳会等待轮速和重心稳定,随后以小腿满力矩爆发伸展,并在腾空阶段主动收腿和持续控制俯仰。
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Unitree Go2-W 原地站立平衡控制器(纯 MuJoCo/Python 版本)。
|
||||
|
||||
兼容两种模型:
|
||||
|
||||
* unitree_mujoco/unitree_robots/go2w/go2w.xml;
|
||||
* Web 平台由 unitree_ros/go2w_description.urdf 转换出的浮动基座 MJCF。
|
||||
|
||||
控制策略参考 unitree_mujoco/example/python/stand_go2.py:站立关节角、kp=50、
|
||||
kd=3.5,并在浏览器中直接计算 motor torque。姿态由基座6轴 IMU 的角速度与
|
||||
重力加速度互补滤波获得,不包含 DDS、CRC、实机电机模式或 sim2real 逻辑。
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
NAME = "Unitree Go2-W 原地平衡控制"
|
||||
CONTROL_HZ = 200
|
||||
|
||||
LEG_NAMES = ("FL", "FR", "RL", "RR")
|
||||
|
||||
# 来自 unitree_mujoco/example/python/stand_go2.py 的 stand_up_joint_pos。
|
||||
HIP_TARGET = {"FL": 0.00571868, "FR": -0.00571868,
|
||||
"RL": 0.00571868, "RR": -0.00571868}
|
||||
NOMINAL = {"thigh": 0.608813, "calf": -1.21763}
|
||||
HIP_DOWN = {"FL": 0.0473455, "FR": -0.0473455,
|
||||
"RL": 0.0473455, "RR": -0.0473455}
|
||||
STAND_DOWN = {"thigh": 1.22187, "calf": -2.44375}
|
||||
|
||||
# SDK 示例使用 kp=50、kd=3.5。力矩上限按 Go2-W URDF/MJCF 保守取值。
|
||||
KP_FINAL = 50.0
|
||||
KP_INITIAL = 20.0
|
||||
KD = 3.5
|
||||
EFFORT_LIMIT = {"hip": 23.7, "thigh": 23.7, "calf": 35.55}
|
||||
RAMP_SECONDS = 1.2
|
||||
WHEEL_DAMPING = 0.12
|
||||
MAX_WHEEL_TORQUE = 6.0
|
||||
WHEEL_RADIUS = 0.065
|
||||
TRACK_HALF_WIDTH = 0.23
|
||||
DRIVE_SPEED = 0.35
|
||||
DRIVE_ACCELERATION = 0.25
|
||||
TURN_RATE = 1.4
|
||||
TURN_ACCELERATION = 0.7
|
||||
WHEEL_VELOCITY_KP = 0.45
|
||||
TURN_VELOCITY_KP = 0.90
|
||||
# 后轮仅承担部分差速转向,降低侧向轮胎力造成的后腿 hip 大幅摆动。
|
||||
REAR_TURN_SCALE = 0.40
|
||||
REAR_TURN_MAX_TORQUE = 3.0
|
||||
WHEEL_FEEDFORWARD = 0.25
|
||||
JUMP_CROUCH_SECONDS = 0.28
|
||||
JUMP_EXTEND_SECONDS = 0.08
|
||||
JUMP_TUCK_SECONDS = 0.18
|
||||
JUMP_LANDING_SECONDS = 0.22
|
||||
JUMP_RECOVER_SECONDS = 0.35
|
||||
# 轮毂反作用力矩用于抵消伸腿时的后仰角动量,不用于驱动水平位移。
|
||||
JUMP_PITCH_WHEEL_TORQUE = -6.0
|
||||
|
||||
|
||||
def _clamp(value, lower, upper):
|
||||
return max(lower, min(upper, value))
|
||||
|
||||
|
||||
def _move_toward(value, target, max_delta):
|
||||
return value + _clamp(target - value, -max_delta, max_delta)
|
||||
|
||||
|
||||
def _accel_attitude(acceleration):
|
||||
"""由 Body 局部系中的重力方向估计横滚角和俯仰角。"""
|
||||
ax, ay, az = acceleration
|
||||
return math.atan2(ay, az), math.atan2(-ax, math.sqrt(ay * ay + az * az))
|
||||
|
||||
|
||||
def _resolve(api, kind, names):
|
||||
"""依次尝试官方 MJCF 与 URDF 转换模型的命名。"""
|
||||
resolver = getattr(api, kind)
|
||||
last_error = None
|
||||
for name in names:
|
||||
try:
|
||||
return resolver(name)
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
raise RuntimeError(f"无法解析 {kind},候选名称:{', '.join(names)}") from last_error
|
||||
|
||||
|
||||
def init(api):
|
||||
legs = []
|
||||
for prefix in LEG_NAMES:
|
||||
joints = {}
|
||||
actuators = {}
|
||||
for part in ("hip", "thigh", "calf"):
|
||||
joint_name = f"{prefix}_{part}_joint"
|
||||
joints[part] = _resolve(api, "joint", (joint_name,))
|
||||
actuators[part] = _resolve(
|
||||
api, "actuator", (f"{prefix}_{part}", f"{joint_name}_motor")
|
||||
)
|
||||
|
||||
wheel_joint = _resolve(
|
||||
api, "joint", (f"{prefix}_wheel_joint", f"{prefix}_foot_joint")
|
||||
)
|
||||
wheel_actuator = _resolve(
|
||||
api,
|
||||
"actuator",
|
||||
(f"{prefix}_wheel", f"{prefix}_wheel_joint_motor", f"{prefix}_foot_joint_motor"),
|
||||
)
|
||||
legs.append({
|
||||
"name": prefix,
|
||||
"joints": joints,
|
||||
"actuators": actuators,
|
||||
"wheel_joint": wheel_joint,
|
||||
"wheel_actuator": wheel_actuator,
|
||||
"side": 1.0 if prefix.endswith("L") else -1.0,
|
||||
"fore": 1.0 if prefix.startswith("F") else -1.0,
|
||||
})
|
||||
|
||||
return {
|
||||
"base": _resolve(api, "body", ("base_link", "base")),
|
||||
"imu_gyro": _resolve(api, "sensor", ("imu_gyro", "__platform_imu_gyro__")),
|
||||
"imu_acc": _resolve(api, "sensor", ("imu_acc", "__platform_imu_acc__")),
|
||||
"legs": legs,
|
||||
"started": False,
|
||||
"start_time": 0.0,
|
||||
"estimated_roll": 0.0,
|
||||
"estimated_pitch": 0.0,
|
||||
"filtered_droll": 0.0,
|
||||
"filtered_dpitch": 0.0,
|
||||
"unstable_duration": 0.0,
|
||||
"motion": "stop",
|
||||
"linear_speed": 0.0,
|
||||
"yaw_rate": 0.0,
|
||||
"jump_requested": False,
|
||||
"jump_started": None,
|
||||
"posture_recovering": False,
|
||||
"stable_duration": 0.0,
|
||||
"recovery_roll_integral": 0.0,
|
||||
"recovery_pitch_integral": 0.0,
|
||||
"last_base_position": None,
|
||||
"base_velocity_x": 0.0,
|
||||
"base_velocity_y": 0.0,
|
||||
"jump_anchor": None,
|
||||
"jump_forward": None,
|
||||
}
|
||||
|
||||
|
||||
def _initialize(ctx, state, acceleration):
|
||||
state["started"] = True
|
||||
state["start_time"] = ctx.time
|
||||
state["estimated_roll"], state["estimated_pitch"] = _accel_attitude(acceleration)
|
||||
|
||||
|
||||
def step(ctx, state):
|
||||
gyro = ctx.sensor(state["imu_gyro"])
|
||||
acceleration = ctx.sensor(state["imu_acc"])
|
||||
base_position = ctx.body_position(state["base"])
|
||||
if not state["started"]:
|
||||
_initialize(ctx, state, acceleration)
|
||||
|
||||
dt = max(1.0e-4, ctx.dt)
|
||||
previous_position = state["last_base_position"]
|
||||
if previous_position is not None:
|
||||
velocity_alpha = 0.25
|
||||
state["base_velocity_x"] += velocity_alpha * (
|
||||
(base_position[0] - previous_position[0]) / dt
|
||||
- state["base_velocity_x"]
|
||||
)
|
||||
state["base_velocity_y"] += velocity_alpha * (
|
||||
(base_position[1] - previous_position[1]) / dt
|
||||
- state["base_velocity_y"]
|
||||
)
|
||||
state["last_base_position"] = list(base_position)
|
||||
rate_alpha = 0.25
|
||||
state["filtered_droll"] += rate_alpha * (gyro[0] - state["filtered_droll"])
|
||||
state["filtered_dpitch"] += rate_alpha * (gyro[1] - state["filtered_dpitch"])
|
||||
accel_roll, accel_pitch = _accel_attitude(acceleration)
|
||||
fusion = 0.015
|
||||
state["estimated_roll"] = (1.0 - fusion) * (
|
||||
state["estimated_roll"] + state["filtered_droll"] * dt
|
||||
) + fusion * accel_roll
|
||||
state["estimated_pitch"] = (1.0 - fusion) * (
|
||||
state["estimated_pitch"] + state["filtered_dpitch"] * dt
|
||||
) + fusion * accel_pitch
|
||||
roll, pitch = state["estimated_roll"], state["estimated_pitch"]
|
||||
|
||||
forward_error = 0.0
|
||||
forward_velocity = 0.0
|
||||
if state["jump_anchor"] is not None:
|
||||
forward_x, forward_y = state["jump_forward"]
|
||||
forward_error = (
|
||||
(base_position[0] - state["jump_anchor"][0]) * forward_x
|
||||
+ (base_position[1] - state["jump_anchor"][1]) * forward_y
|
||||
)
|
||||
forward_velocity = (
|
||||
state["base_velocity_x"] * forward_x
|
||||
+ state["base_velocity_y"] * forward_y
|
||||
)
|
||||
|
||||
if state["posture_recovering"]:
|
||||
state["recovery_roll_integral"] = _clamp(
|
||||
state["recovery_roll_integral"] + roll * dt, -0.6, 0.6
|
||||
)
|
||||
state["recovery_pitch_integral"] = _clamp(
|
||||
state["recovery_pitch_integral"] + pitch * dt, -0.6, 0.6
|
||||
)
|
||||
posture_stable = (
|
||||
abs(roll) < 0.06
|
||||
and abs(pitch) < 0.06
|
||||
and abs(state["filtered_droll"]) < 0.15
|
||||
and abs(state["filtered_dpitch"]) < 0.15
|
||||
and abs(forward_error) < 0.015
|
||||
and abs(forward_velocity) < 0.05
|
||||
and base_position[2] > 0.38
|
||||
)
|
||||
state["stable_duration"] = (
|
||||
state["stable_duration"] + dt if posture_stable else 0.0
|
||||
)
|
||||
if state["stable_duration"] > 0.5:
|
||||
# 保留积分得到的静态姿态补偿;清零会让机身再次回到带偏差的平衡点。
|
||||
state["posture_recovering"] = False
|
||||
state["stable_duration"] = 0.0
|
||||
state["jump_anchor"] = None
|
||||
state["jump_forward"] = None
|
||||
|
||||
elapsed = max(0.0, ctx.time - state["start_time"])
|
||||
ramp = 1.0 if elapsed >= 3.0 else math.tanh(elapsed / RAMP_SECONDS)
|
||||
kp = KP_INITIAL + (KP_FINAL - KP_INITIAL) * ramp
|
||||
|
||||
motion = state["motion"]
|
||||
target_linear_speed = (
|
||||
DRIVE_SPEED
|
||||
if motion == "forward"
|
||||
else -DRIVE_SPEED if motion == "backward" else 0.0
|
||||
)
|
||||
target_yaw_rate = (
|
||||
TURN_RATE
|
||||
if motion == "turn_left"
|
||||
else -TURN_RATE if motion == "turn_right" else 0.0
|
||||
)
|
||||
state["linear_speed"] = _move_toward(
|
||||
state["linear_speed"], target_linear_speed, DRIVE_ACCELERATION * ctx.dt
|
||||
)
|
||||
state["yaw_rate"] = _move_toward(
|
||||
state["yaw_rate"], target_yaw_rate, TURN_ACCELERATION * ctx.dt
|
||||
)
|
||||
|
||||
jump_offset_thigh = 0.0
|
||||
jump_offset_calf = 0.0
|
||||
jump_launching = False
|
||||
jump_balance_scale = 1.6
|
||||
jump_centering = state["jump_anchor"] is not None and state["jump_started"] is None
|
||||
ready_to_jump = (
|
||||
abs(state["linear_speed"]) < 0.05 and abs(state["yaw_rate"]) < 0.1
|
||||
)
|
||||
if (
|
||||
state["jump_requested"]
|
||||
and ramp > 0.98
|
||||
and ready_to_jump
|
||||
and not state["posture_recovering"]
|
||||
and state["jump_started"] is None
|
||||
):
|
||||
state["jump_started"] = ctx.time
|
||||
state["jump_requested"] = False
|
||||
quat = ctx.body_quat(state["base"])
|
||||
qw, qx, qy, qz = quat
|
||||
forward_x = 1.0 - 2.0 * (qy * qy + qz * qz)
|
||||
forward_y = 2.0 * (qx * qy + qw * qz)
|
||||
forward_norm = max(1.0e-6, math.hypot(forward_x, forward_y))
|
||||
state["jump_anchor"] = [base_position[0], base_position[1]]
|
||||
state["jump_forward"] = [
|
||||
forward_x / forward_norm,
|
||||
forward_y / forward_norm,
|
||||
]
|
||||
if state["jump_started"] is not None:
|
||||
jump_elapsed = ctx.time - state["jump_started"]
|
||||
launch_end = JUMP_CROUCH_SECONDS + JUMP_EXTEND_SECONDS
|
||||
tuck_end = launch_end + JUMP_TUCK_SECONDS
|
||||
landing_end = tuck_end + JUMP_LANDING_SECONDS
|
||||
recover_end = landing_end + JUMP_RECOVER_SECONDS
|
||||
if jump_elapsed < JUMP_CROUCH_SECONDS:
|
||||
phase = jump_elapsed / JUMP_CROUCH_SECONDS
|
||||
jump_offset_thigh = 0.34 * phase
|
||||
jump_offset_calf = -0.64 * phase
|
||||
elif jump_elapsed < launch_end:
|
||||
# 以小腿为主提供竖直爆发力,避免大腿推力把机身向后推出。
|
||||
jump_offset_thigh = -0.22
|
||||
jump_offset_calf = 0.30
|
||||
jump_launching = True
|
||||
jump_balance_scale = 0.28
|
||||
elif jump_elapsed < tuck_end:
|
||||
# 离地后主动收腿,提高轮端离地高度并减小腿部转动惯量。
|
||||
jump_offset_thigh = 0.22
|
||||
jump_offset_calf = -0.40
|
||||
jump_balance_scale = 0.4
|
||||
elif jump_elapsed < landing_end:
|
||||
# 触地前逐渐伸腿,避免保持收腿姿态直接撞击地面。
|
||||
phase = (jump_elapsed - tuck_end) / JUMP_LANDING_SECONDS
|
||||
jump_offset_thigh = 0.22 * (1.0 - phase) - 0.05 * phase
|
||||
jump_offset_calf = -0.40 * (1.0 - phase) + 0.08 * phase
|
||||
jump_balance_scale = 0.4 + 0.8 * phase
|
||||
elif jump_elapsed < recover_end:
|
||||
jump_centering = True
|
||||
phase = (jump_elapsed - landing_end) / JUMP_RECOVER_SECONDS
|
||||
jump_offset_thigh = -0.05 * (1.0 - phase)
|
||||
jump_offset_calf = 0.08 * (1.0 - phase)
|
||||
jump_balance_scale = 1.2 + 0.4 * phase
|
||||
else:
|
||||
state["jump_started"] = None
|
||||
state["posture_recovering"] = True
|
||||
state["stable_duration"] = 0.0
|
||||
state["recovery_roll_integral"] = 0.0
|
||||
state["recovery_pitch_integral"] = 0.0
|
||||
|
||||
# SDK 示例的核心:12 个腿关节平滑进入站立姿态并保持 PD 闭环。
|
||||
motion_level = max(
|
||||
abs(state["linear_speed"]) / DRIVE_SPEED,
|
||||
abs(state["yaw_rate"]) / TURN_RATE,
|
||||
)
|
||||
for leg in state["legs"]:
|
||||
for part in ("hip", "thigh", "calf"):
|
||||
up = HIP_TARGET[leg["name"]] if part == "hip" else NOMINAL[part]
|
||||
down = HIP_DOWN[leg["name"]] if part == "hip" else STAND_DOWN[part]
|
||||
desired = down + ramp * (up - down)
|
||||
if part == "calf" and ramp > 0.8:
|
||||
# 通过左右/前后轮腿长度差调平车身:高侧缩短,低侧伸长。
|
||||
if state["jump_started"] is not None:
|
||||
balance_scale = jump_balance_scale
|
||||
elif motion == "stop":
|
||||
balance_scale = 1.6
|
||||
else:
|
||||
balance_scale = 1.0
|
||||
roll_term = balance_scale * (
|
||||
0.34 * roll
|
||||
+ 0.025 * state["filtered_droll"]
|
||||
+ 0.20 * state["recovery_roll_integral"]
|
||||
)
|
||||
target_pitch = 0.0 if motion == "stop" else (
|
||||
0.12 * state["linear_speed"] / DRIVE_SPEED
|
||||
+ 0.08 * abs(state["yaw_rate"]) / TURN_RATE
|
||||
)
|
||||
pitch_error = pitch - target_pitch
|
||||
pitch_term = balance_scale * (
|
||||
0.50 * pitch_error
|
||||
+ 0.035 * state["filtered_dpitch"]
|
||||
+ 0.28 * state["recovery_pitch_integral"]
|
||||
)
|
||||
desired -= leg["side"] * roll_term
|
||||
desired += leg["fore"] * pitch_term
|
||||
desired -= 0.18 * motion_level
|
||||
desired += jump_offset_calf
|
||||
elif part == "thigh":
|
||||
desired += 0.10 * motion_level + jump_offset_thigh
|
||||
target = desired
|
||||
position = ctx.qpos(leg["joints"][part])
|
||||
velocity = ctx.qvel(leg["joints"][part])
|
||||
damping = 1.0 if jump_launching else KD
|
||||
torque = kp * (target - position) - damping * velocity
|
||||
limit = EFFORT_LIMIT[part]
|
||||
if jump_launching and part == "calf":
|
||||
torque = limit
|
||||
elif jump_launching and part == "thigh":
|
||||
# 爆发阶段由小腿提供主要竖直推力;大腿推力会引入向后冲量。
|
||||
torque = 0.0
|
||||
ctx.set_control(leg["actuators"][part], _clamp(torque, -limit, limit))
|
||||
|
||||
# 四轮速度闭环:斜坡限速避免瞬时驱动力矩使机身后仰。
|
||||
if motion == "stop":
|
||||
balance_torque = 3.0 * pitch + 0.25 * state["filtered_dpitch"]
|
||||
else:
|
||||
balance_torque = 1.4 * pitch + 0.10 * state["filtered_dpitch"]
|
||||
# 记录起跳点在机身前向轴上的位置,落地接触后用轮子收回水平漂移。
|
||||
centering_speed = 0.0
|
||||
if jump_centering and base_position[2] < 0.45:
|
||||
centering_speed = _clamp(
|
||||
-4.0 * forward_error - 0.8 * forward_velocity,
|
||||
-0.30,
|
||||
0.30,
|
||||
)
|
||||
for leg in state["legs"]:
|
||||
target_velocity = (state["linear_speed"] + centering_speed) / WHEEL_RADIUS
|
||||
turn_scale = 1.0 if leg["fore"] > 0 else REAR_TURN_SCALE
|
||||
target_velocity -= (
|
||||
leg["side"]
|
||||
* state["yaw_rate"]
|
||||
* TRACK_HALF_WIDTH
|
||||
* turn_scale
|
||||
/ WHEEL_RADIUS
|
||||
)
|
||||
wheel_velocity = ctx.qvel(leg["wheel_joint"])
|
||||
velocity_kp = (
|
||||
TURN_VELOCITY_KP
|
||||
if abs(state["yaw_rate"]) > 0.1 or abs(centering_speed) > 0.02
|
||||
else WHEEL_VELOCITY_KP
|
||||
)
|
||||
wheel_torque = balance_torque + velocity_kp * (
|
||||
target_velocity - wheel_velocity
|
||||
)
|
||||
if abs(target_velocity) > 0.1:
|
||||
wheel_torque += math.copysign(WHEEL_FEEDFORWARD, target_velocity)
|
||||
if motion == "stop":
|
||||
wheel_torque -= WHEEL_DAMPING * wheel_velocity
|
||||
if jump_launching:
|
||||
wheel_torque = JUMP_PITCH_WHEEL_TORQUE
|
||||
elif state["jump_started"] is not None and base_position[2] > 0.47:
|
||||
# 腾空期间利用四个轮子的反作用角动量持续把机身俯仰拉回零。
|
||||
# 接近地面后恢复轮速闭环,避免姿态力矩转化为水平冲量。
|
||||
wheel_torque = (
|
||||
12.0 * pitch + 1.5 * state["filtered_dpitch"]
|
||||
)
|
||||
wheel_limit = (
|
||||
REAR_TURN_MAX_TORQUE
|
||||
if abs(state["yaw_rate"]) > 0.1 and leg["fore"] < 0
|
||||
else MAX_WHEEL_TORQUE
|
||||
)
|
||||
ctx.set_control(
|
||||
leg["wheel_actuator"],
|
||||
_clamp(wheel_torque, -wheel_limit, wheel_limit),
|
||||
)
|
||||
|
||||
# 持续失稳时主动停止,避免倒地后控制器继续输出饱和力矩。
|
||||
unstable = ramp > 0.95 and (
|
||||
base_position[2] < 0.16 or abs(roll) > 1.0 or abs(pitch) > 1.0
|
||||
)
|
||||
state["unstable_duration"] = state["unstable_duration"] + dt if unstable else 0.0
|
||||
if state["unstable_duration"] > 0.65:
|
||||
raise RuntimeError(
|
||||
f"Go2-W 已失稳:z={base_position[2]:.3f} m, "
|
||||
f"roll={roll:.3f} rad, pitch={pitch:.3f} rad;请重置后检查模型接触参数"
|
||||
)
|
||||
|
||||
|
||||
def command(name, state):
|
||||
"""接收 Web Python SDK 的标准基本移动指令。"""
|
||||
if name in ("forward", "backward", "turn_left", "turn_right"):
|
||||
state["motion"] = name
|
||||
state["posture_recovering"] = False
|
||||
state["stable_duration"] = 0.0
|
||||
state["recovery_roll_integral"] = 0.0
|
||||
state["recovery_pitch_integral"] = 0.0
|
||||
state["jump_anchor"] = None
|
||||
state["jump_forward"] = None
|
||||
return
|
||||
if name == "stop":
|
||||
state["motion"] = "stop"
|
||||
state["jump_requested"] = False
|
||||
state["posture_recovering"] = True
|
||||
state["stable_duration"] = 0.0
|
||||
state["recovery_roll_integral"] = 0.0
|
||||
state["recovery_pitch_integral"] = 0.0
|
||||
return
|
||||
if name == "jump":
|
||||
state["motion"] = "stop"
|
||||
if state["jump_started"] is None:
|
||||
# 每次起跳都先重新确认重心稳定;移动中触发时不会直接带着惯性伸腿。
|
||||
state["jump_requested"] = True
|
||||
state["posture_recovering"] = True
|
||||
state["stable_duration"] = 0.0
|
||||
return
|
||||
raise ValueError(f"不支持的移动指令:{name}")
|
||||
|
||||
|
||||
def reset(state):
|
||||
state["started"] = False
|
||||
state["estimated_roll"] = 0.0
|
||||
state["estimated_pitch"] = 0.0
|
||||
state["filtered_droll"] = 0.0
|
||||
state["filtered_dpitch"] = 0.0
|
||||
state["unstable_duration"] = 0.0
|
||||
state["motion"] = "stop"
|
||||
state["linear_speed"] = 0.0
|
||||
state["yaw_rate"] = 0.0
|
||||
state["jump_requested"] = False
|
||||
state["jump_started"] = None
|
||||
state["posture_recovering"] = False
|
||||
state["stable_duration"] = 0.0
|
||||
state["recovery_roll_integral"] = 0.0
|
||||
state["recovery_pitch_integral"] = 0.0
|
||||
state["last_base_position"] = None
|
||||
state["base_velocity_x"] = 0.0
|
||||
state["base_velocity_y"] = 0.0
|
||||
state["jump_anchor"] = None
|
||||
state["jump_forward"] = None
|
||||
|
||||
|
||||
def dispose(state):
|
||||
pass
|
||||
@@ -6,11 +6,13 @@
|
||||
|
||||
- 导入单个/多个 MJCF(XML)、URDF 和关联资源
|
||||
- 兼容常见 ROS URDF:规范化重复 material、解析 `package://` 工程内资源路径
|
||||
- URDF 导入时可选为 hinge/slide 关节生成 motor 驱动器,并可用 kp/kv 调整对应 MJCF 关节刚度与阻尼,并将可调位置/朝向的摄像头固连到指定机器人 Body
|
||||
- 导入保留相对路径的文件夹或 ZIP 工程
|
||||
- 多模型入口选择、中文加载和编译错误
|
||||
- Three.js primitive、mesh、材质/贴图显示与对象选择
|
||||
- 播放、暂停、单步、重置、0.25×–4× 速度
|
||||
- actuator 滑杆、hinge/slide 关节拖动、动态 body 外力拖拽
|
||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||
- FPS、物理耗时和主线程步进预算提示
|
||||
|
||||
## 开发
|
||||
@@ -52,6 +54,13 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist
|
||||
- XML 的 `include`、mesh 和贴图路径必须相对于入口/编译器配置可解析。
|
||||
- 路径穿越、绝对路径、加密 ZIP、重复路径会被拒绝。
|
||||
- 默认限制:2000 个文件、单文件 128 MiB、总解压大小 512 MiB、ZIP 文件 128 MiB。
|
||||
- 文件夹或 ZIP 中的 `.py` 会显示在“控制 → Python 控制器”;也可以在加载模型后单独导入不超过 1 MiB 的 `.py`。
|
||||
|
||||
## Python 控制器
|
||||
|
||||
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。
|
||||
|
||||
当前 Python 与 MuJoCo 都运行在主线程,以保证闭环调用严格位于 `mj_step` 前。仅运行可信脚本;死循环仍可能阻塞页面。Pyodide 及 Python 标准库由 npm 包随生产构建离线发布,不从 CDN 下载;暂不支持第三方 Python 包、`pip` 或多文件 import。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -59,6 +68,7 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist
|
||||
|
||||
- `mjcf_include/`:MJCF include、OBJ/STL mesh 和 PNG texture;
|
||||
- `urdf_mesh/`:引用 OBJ 的 URDF;
|
||||
- `python_controller/`:倒立摆模型及 `balance.py` PD 控制器;
|
||||
- `invalid.xml`:无效模型;
|
||||
- `missing-resource.xml`:缺失资源错误示例。
|
||||
|
||||
@@ -68,7 +78,7 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist
|
||||
|
||||
- 仅面向桌面版 Chrome、Edge、Firefox;未适配手机和平板。
|
||||
- 物理运行在主线程、单线程 WASM。超出每帧预算时限制追帧并提示。
|
||||
- 不支持 Xacro、XML 在线编辑、热重载、导出、账号或云端保存。
|
||||
- 不支持 Xacro、账号或云端保存;Python 控制器暂不支持第三方包和不可信代码隔离。
|
||||
- 关节拖动只支持 hinge/slide;ball/free joint 只读。
|
||||
- MuJoCo WASM 本身不支持 DAE mesh。平台会移除 DAE visual,并以 collision 几何显示;DAE collision 会替换为半径 0.05 m 的占位球体并在界面警告。高精度仿真应先将 DAE 转为 OBJ/STL 或改为 URDF primitive。
|
||||
- 导入工程只存在当前页面内存,刷新页面后需重新导入。
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# MuJoCo Web Platform V0.1 能力总结
|
||||
|
||||
## 1. 浏览器内模型导入
|
||||
|
||||
支持以下模型和资源格式:
|
||||
|
||||
- MJCF/XML
|
||||
- URDF
|
||||
- ZIP 工程包
|
||||
- 文件夹工程
|
||||
- OBJ、STL、DAE Mesh
|
||||
- PNG、JPG 等纹理资源
|
||||
- MJCF `include` 多文件工程
|
||||
- URDF `package://` 资源路径
|
||||
|
||||
导入工程仅保存在当前浏览器会话和 WASM 文件系统中,不会修改或删除本地文件。
|
||||
|
||||
## 2. 工程资源管理
|
||||
|
||||
- 按真实目录层级展示工程文件。
|
||||
- 目录优先并按文件名排序。
|
||||
- 标记和高亮当前模型入口。
|
||||
- 导入 URDF 时,只自动展开选中 URDF 所在的目录路径。
|
||||
- 其他目录默认折叠,仍可手动展开。
|
||||
- 支持从当前会话中移除整个工程。
|
||||
|
||||
## 3. URDF 兼容处理
|
||||
|
||||
提供两种加载方式:
|
||||
|
||||
- **转换为 MJCF(推荐)**
|
||||
- **MuJoCo 原生 URDF**
|
||||
|
||||
自动兼容处理包括:
|
||||
|
||||
- 将 `package://` 地址改写为工程相对路径。
|
||||
- 在浏览器内将 DAE 转换为 OBJ。
|
||||
- 保留 DAE 单位、坐标轴和节点变换。
|
||||
- 保留 URDF visual mesh 和 Body 层级。
|
||||
- 处理 MuJoCo 不支持的重复 material。
|
||||
- DAE 转换失败时提供降级几何。
|
||||
- 自动添加 `z=0` 物理地面。
|
||||
- 计算模型最低几何点并对齐到地面。
|
||||
|
||||
## 4. 固定与浮动基座
|
||||
|
||||
URDF 转 MJCF 时可以选择:
|
||||
|
||||
- **浮动基座**
|
||||
- 自动添加根 `freejoint`。
|
||||
- 支持重力、碰撞和外力运动。
|
||||
- **固定基座**
|
||||
- 根 Body 与世界固连。
|
||||
|
||||
切换基座类型后会自动重新转换并加载模型。
|
||||
|
||||
## 5. Three.js 模型可视化
|
||||
|
||||
- 渲染 MuJoCo Body、Geom 和 Mesh。
|
||||
- 支持 Box、Sphere、Capsule、Cylinder、Ellipsoid、Plane 和 Mesh。
|
||||
- 正确解析 MuJoCo `mjvGeom.dataid` 的 Mesh/凸包编码。
|
||||
- 支持材质颜色、透明度和基础纹理。
|
||||
- 默认显示 visual 并隐藏 collision。
|
||||
- 可切换显示碰撞几何。
|
||||
- 自动计算相机中心和观察范围。
|
||||
- 无限地面不会影响相机包围范围。
|
||||
- 支持相机旋转、缩放、平移和复位。
|
||||
|
||||
## 6. XYZ 方向指示器
|
||||
|
||||
- 右下角提供固定的 XYZ 方向指示器。
|
||||
- X、Y、Z 分别使用红、绿、蓝色。
|
||||
- 指示器随相机方向实时旋转。
|
||||
- 不参与模型拾取和交互。
|
||||
|
||||
## 7. 模型结构树
|
||||
|
||||
左侧工程文件树下方显示 MuJoCo 模型结构:
|
||||
|
||||
- 按 Body 父子关系组织。
|
||||
- 显示每个 Body 所属的 Joint。
|
||||
- 支持层级展开和折叠。
|
||||
- 鼠标悬停或键盘聚焦关节时:
|
||||
- 高亮关节所属 Body。
|
||||
- 在关节锚点显示黄色标记。
|
||||
- 标记随仿真运动实时更新。
|
||||
|
||||
## 8. 仿真控制
|
||||
|
||||
支持:
|
||||
|
||||
- 播放和暂停。
|
||||
- 单步运行。
|
||||
- 完整重置。
|
||||
- `0.25×`~`4×` 仿真速度。
|
||||
- 实时仿真时间和物理耗时。
|
||||
- 主线程步进预算及追帧限制。
|
||||
- 播放中重置后仍可正常再次播放和暂停。
|
||||
|
||||
快捷键:
|
||||
|
||||
- `Space`:播放/暂停。
|
||||
- `R`:重置。
|
||||
- `1/2/3`:切换交互模式。
|
||||
|
||||
## 9. 关节控制
|
||||
|
||||
- 使用滑块调整旋转和滑动关节。
|
||||
- 只读显示 Free、Ball 等不适合单滑块编辑的关节。
|
||||
- 调整关节时自动暂停仿真。
|
||||
- 支持重置所有可编辑关节。
|
||||
- 重置时恢复初始位置并清零对应速度。
|
||||
- 支持忽略 MuJoCo 关节限位。
|
||||
- 恢复限位时自动将超限值钳制回合法范围。
|
||||
- 高级模式显示原始上下限和“已忽略”状态。
|
||||
- 旋转关节支持弧度制和角度制切换。
|
||||
- 滑动关节保持使用米制单位。
|
||||
|
||||
## 10. Actuator 与交互工具
|
||||
|
||||
- 显示模型 Actuator。
|
||||
- 根据 `ctrlrange` 提供控制滑块。
|
||||
- 提供三种视口交互模式:
|
||||
- 物体选择。
|
||||
- 关节拖动。
|
||||
- 外力施加。
|
||||
- 外力强度可调。
|
||||
- 松开鼠标后自动清除外力。
|
||||
- 显示当前选中的 Body、Geom、类型和世界位置。
|
||||
|
||||
## 11. 界面与主题
|
||||
|
||||
- 中文用户界面。
|
||||
- 左右面板支持显示、隐藏和宽度调整。
|
||||
- 支持白天与黑夜主题切换。
|
||||
- 主题选择保存到浏览器本地。
|
||||
- UI、视口、网格和 XYZ 指示器同步换色。
|
||||
- 使用约 `420 ms` 的平滑主题切换动画。
|
||||
- 支持系统“减少动态效果”设置。
|
||||
|
||||
## 12. 状态与诊断
|
||||
|
||||
实时显示:
|
||||
|
||||
- 仿真时间。
|
||||
- FPS。
|
||||
- 物理步进耗时。
|
||||
- 浏览器内存使用情况。
|
||||
- WASM 加载状态。
|
||||
- Body、Joint、Geom、Actuator、qpos 和 qvel 数量。
|
||||
|
||||
错误分类包括:
|
||||
|
||||
- 导入失败。
|
||||
- ZIP 解析失败。
|
||||
- 模型编译失败。
|
||||
- 渲染失败。
|
||||
|
||||
URDF 自动兼容处理会以中文列出具体修改内容。
|
||||
|
||||
## 13. 当前限制
|
||||
|
||||
- DAE 转 OBJ 主要保留几何、单位和节点变换,内嵌材质及复杂贴图不能完全保留。
|
||||
- 仿真和渲染目前运行在浏览器主线程。
|
||||
- 关节滑块主要面向 Hinge 和 Slide,Free、Ball Joint 暂不提供完整的多自由度编辑器。
|
||||
- 导入工程不会持久化,刷新页面后需要重新导入。
|
||||
@@ -1,5 +1,7 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {zipSync} from 'fflate';
|
||||
|
||||
const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
|
||||
|
||||
@@ -17,6 +19,8 @@ const SIMPLE_MODEL = `
|
||||
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
|
||||
</mujoco>`;
|
||||
|
||||
const SLIDE_DIRECTION_MODEL=`<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
|
||||
|
||||
const LARGE_MODEL = `
|
||||
<mujoco model="soak">
|
||||
<option timestep=".005"/>
|
||||
@@ -79,7 +83,7 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('tab',{name:'控制'}).click();
|
||||
await page.getByRole('button',{name:'Actuator'}).click();
|
||||
await expect(page.getByText('motor')).toBeVisible();
|
||||
await expect(page.getByText('motor',{exact:true})).toBeVisible();
|
||||
await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible();
|
||||
await page.getByRole('tab',{name:'模型结构'}).click();
|
||||
const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover();
|
||||
@@ -98,6 +102,24 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
|
||||
});
|
||||
|
||||
test('转换后的 MJCF 可编辑并重新载入', async ({page}) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await page.getByRole('button',{name:'源代码'}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible();
|
||||
await expect(dialog.getByText('转换后的 MJCF',{exact:true})).toBeVisible();
|
||||
const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}});
|
||||
await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"'));
|
||||
await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();
|
||||
await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
|
||||
await dialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await expect(page.getByRole('button',{name:'导出 URDF'})).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').first().setInputFiles([
|
||||
@@ -117,6 +139,10 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
|
||||
fixture('urdf_mesh/robot.urdf'),
|
||||
fixture('urdf_mesh/triangle.obj'),
|
||||
]);
|
||||
const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'});
|
||||
await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
|
||||
await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
|
||||
await options.getByRole('button',{name:'转换并加载'}).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await expect(page.getByText('2 个文件')).toBeVisible();
|
||||
await page.getByRole('button',{name:'URDF 处理方式'}).click();
|
||||
@@ -128,6 +154,44 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
|
||||
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
|
||||
await page.getByRole('button',{name:'源代码'}).click();
|
||||
const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
|
||||
await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');
|
||||
await sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible();await sourceDialog.getByRole('button',{name:'关闭源代码编辑器'}).click();
|
||||
});
|
||||
|
||||
test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译',async({page})=>{
|
||||
const urdf=`<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>`;
|
||||
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'jointed.urdf',mimeType:'application/xml',buffer:Buffer.from(urdf)});
|
||||
await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await page.getByRole('button',{name:'通知中心'}).click();
|
||||
const notifications=page.getByRole('dialog',{name:'通知中心'});
|
||||
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
|
||||
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
|
||||
await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();await page.getByRole('button',{name:'Actuator'}).click();
|
||||
await expect(page.getByText('shoulder_motor')).toBeVisible();await expect(page.getByText('关节:shoulder')).toBeVisible();await expect(page.getByText('N·m',{exact:true})).toBeVisible();
|
||||
await page.getByText('常用参数').click();const kp=page.getByLabel(/kp(MJCF stiffness/),kv=page.getByLabel(/kv(MJCF damping/);await kp.fill('150');await kp.press('Enter');await kv.fill('15');await kv.press('Enter');await expect(kp).toHaveValue('150');await expect(kv).toHaveValue('15');
|
||||
});
|
||||
|
||||
test('转换后的 MJCF 保存时保留 DAE 转换缓存资源',async({page})=>{
|
||||
const zip=zipSync({'robot/urdf/robot.urdf':new Uint8Array(readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf'))),'robot/dae/triangle.dae':new Uint8Array(readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')))});
|
||||
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'dae.zip',mimeType:'application/zip',buffer:Buffer.from(zip)});await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await page.getByRole('button',{name:'源代码'}).click();const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await dialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});await expect(page.getByText('WASM 已加载')).toBeVisible();await expect(page.getByText('模型编译失败')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加',async({page})=>{
|
||||
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'slide.xml',mimeType:'text/xml',buffer:Buffer.from(SLIDE_DIRECTION_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await page.getByRole('button',{name:'关节拖动'}).click();const canvas=page.locator('main canvas').first(),box=await canvas.boundingBox();expect(box).not.toBeNull();const x=box!.x+box!.width/2,y=box!.y+box!.height/2;await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+70,y,{steps:8});await page.mouse.up();
|
||||
await page.getByRole('tab',{name:'控制'}).click();const jointSection=page.getByRole('button',{name:'关节 1'});if(await jointSection.getAttribute('aria-expanded')==='false')await jointSection.click();const output=page.getByText('screen_x').locator('..').locator('output');await expect.poll(async()=>Number.parseFloat(await output.textContent()||'0')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('可导入并启用 Python 控制器',async({page})=>{
|
||||
await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000});
|
||||
await page.getByRole('tab',{name:'控制'}).click();const python=`NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`;
|
||||
await page.locator('input[accept=".py,text/x-python"]').setInputFiles({name:'balance.py',mimeType:'text/x-python',buffer:Buffer.from(python)});await expect(page.getByText('测试 PD 控制器',{exact:true})).toBeVisible({timeout:30_000});await expect(page.getByText('Python / Pyodide')).toBeVisible();await page.getByRole('button',{name:'启用',exact:true}).click();await expect(page.getByText('运行中')).toBeVisible();
|
||||
});
|
||||
|
||||
test('中等规模模型持续步进并可重复加载', async ({page}) => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""可信本地脚本示例:用 PD 控制让倒立摆保持竖直。"""
|
||||
|
||||
NAME = "倒立摆 PD 平衡控制"
|
||||
CONTROL_HZ = 100
|
||||
|
||||
|
||||
def init(api):
|
||||
return {
|
||||
"joint": api.joint("balance_hinge"),
|
||||
"actuator": api.actuator("balance_motor"),
|
||||
}
|
||||
|
||||
|
||||
def step(ctx, state):
|
||||
angle = ctx.qpos(state["joint"])
|
||||
angular_velocity = ctx.qvel(state["joint"])
|
||||
torque = -80.0 * angle - 12.0 * angular_velocity
|
||||
ctx.set_control(state["actuator"], torque)
|
||||
|
||||
|
||||
def reset(state):
|
||||
pass
|
||||
@@ -0,0 +1,13 @@
|
||||
<mujoco model="python_balance">
|
||||
<option timestep="0.002" gravity="0 0 -9.81"/>
|
||||
<worldbody>
|
||||
<geom type="plane" size="3 3 .1"/>
|
||||
<body name="pendulum" pos="0 0 0.08">
|
||||
<joint name="balance_hinge" type="hinge" axis="0 1 0" range="-1.4 1.4" damping="0.05"/>
|
||||
<geom name="rod" type="capsule" fromto="0 0 0 0 0 1" size="0.05" mass="1" rgba="0.2 0.55 0.95 1"/>
|
||||
</body>
|
||||
</worldbody>
|
||||
<actuator>
|
||||
<motor name="balance_motor" joint="balance_hinge" ctrlrange="-100 100"/>
|
||||
</actuator>
|
||||
</mujoco>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
|
||||
<library_geometries><geometry id="tetrahedron"><mesh><source id="positions"><float_array id="positions-array" count="12">0 0 0 1 0 0 0 1 0 0 0 1</float_array><technique_common><accessor source="#positions-array" count="4" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="4" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 2 1 0 1 3 0 3 2 1 2 3</p></triangles></mesh></geometry></library_geometries>
|
||||
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#tetrahedron"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<robot name="dae_fixture">
|
||||
<link name="base">
|
||||
<inertial><origin xyz="0 0 0"/><mass value="1"/><inertia ixx="0.1" ixy="0" ixz="0" iyy="0.1" iyz="0" izz="0.1"/></inertial>
|
||||
<visual><geometry><mesh filename="../dae/triangle.dae"/></geometry></visual>
|
||||
<collision><geometry><mesh filename="../dae/triangle.dae"/></geometry></collision>
|
||||
</link>
|
||||
</robot>
|
||||
@@ -1,10 +1,12 @@
|
||||
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
|
||||
import {Camera,ChevronLeft,ChevronRight,CircleHelp,Crosshair,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react';
|
||||
import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react';
|
||||
import type {ProjectManifest} from '../project/types';
|
||||
import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer';
|
||||
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||
import {filesFromDrop,importBrowserFiles,normalizeProjectPath,ProjectImportError} from '../project/importer';
|
||||
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfEnhancementOptions,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||
import type {ActuatorParameters} from '../simulation/SimulationSession';
|
||||
import type {ControllerCommand,ControllerStatus} from '../controller/types';
|
||||
import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer';
|
||||
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
|
||||
import {WorkbenchHeader} from './components/WorkbenchHeader';
|
||||
@@ -20,31 +22,40 @@ import {CommandPalette,type WorkbenchCommand} from './components/CommandPalette'
|
||||
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './components/NotificationCenter';
|
||||
import {SettingsDialog} from './components/SettingsDialog';
|
||||
import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './components/LayoutSettingsDialog';
|
||||
import {ConfirmDialog,IconButton} from '../components/ui';
|
||||
import {Button,ConfirmDialog,IconButton} from '../components/ui';
|
||||
import {DiagnosticsDrawer} from './components/DiagnosticsDrawer';
|
||||
import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu';
|
||||
import {SourceEditorDialog} from './components/SourceEditorDialog';
|
||||
import {UrdfImportOptionsDialog} from './components/UrdfImportOptionsDialog';
|
||||
import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles';
|
||||
|
||||
function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};}
|
||||
function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}}
|
||||
function convertedCachePath(entryPath:string):string{const slash=entryPath.lastIndexOf('/');return `${slash>=0?entryPath.slice(0,slash+1):''}.__converted_mjcf_cache__.xml`;}
|
||||
function urdfLinkNames(project:ProjectManifest|null,path:string|undefined):string[]{const file=path?project?.files.find(candidate=>candidate.path===path):undefined;if(!file)return[];const document=new DOMParser().parseFromString(new TextDecoder().decode(file.data),'application/xml');return Array.from(document.querySelectorAll('robot > link[name]')).map(link=>link.getAttribute('name')).filter((name):name is string=>Boolean(name));}
|
||||
|
||||
export function App(){
|
||||
const state=useAppStore();
|
||||
const manifest=useRef<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(null);
|
||||
const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>();
|
||||
const manifest=useRef<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(null),urdfEnhancementsRef=useRef<UrdfEnhancementOptions>({addActuators:true,addSensors:true,sensorType:'camera'});
|
||||
const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState<string>(),[generatedMjcfPath,setGeneratedMjcfPath]=useState<string>(),[pendingUrdfPath,setPendingUrdfPath]=useState<string>(),[pendingUrdfMounts,setPendingUrdfMounts]=useState<string[]>([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>(),[selectedControllerPath,setSelectedControllerPath]=useState<string>(),[controllerStatus,setControllerStatus]=useState<ControllerStatus>();
|
||||
const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf'),urdfModeRef=useRef<UrdfLoadMode>('mjcf');
|
||||
const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating'),baseModeRef=useRef<UrdfBaseMode>('floating');
|
||||
const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState<ViewerTheme>(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
|
||||
useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:error=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
|
||||
const [showCollision,setShowCollision]=useState(false),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState<ViewerTheme>(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
|
||||
useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot){state.setSnapshot(snapshot);setControllerStatus(snapshot.controller);if(snapshot.controller?.error)state.setPaused(true);}},onError:error=>state.setDiagnostic(diagnostic(error.message.includes('控制器')?'仿真':'渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
|
||||
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]);
|
||||
useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);
|
||||
useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);
|
||||
useEffect(()=>{viewer.current?.setShowSensorCamera(showSensorCamera);},[showSensorCamera]);
|
||||
useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 当前会话仍可切换 */}},[theme]);
|
||||
useEffect(()=>{const change=()=>setFullscreen(document.fullscreenElement===root.current);document.addEventListener('fullscreenchange',change);return()=>document.removeEventListener('fullscreenchange',change);},[]);
|
||||
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]);
|
||||
const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[loadEntry]);
|
||||
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);setControllerStatus(undefined);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current,urdfEnhancementsRef.current);const supportFiles=adapter.current.cachedSupportFiles();if(supportFiles.length&&manifest.current){manifest.current=mergeCachedFiles(manifest.current,supportFiles);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);}setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));setGeneratedMjcfPath(convertedCachePath(path));}catch(error){console.warn('[MuJoCo] 无法生成源码预览',error);}const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]);
|
||||
const requestLoadEntry=useCallback(async(path:string)=>{const entry=manifest.current?.entries.find(candidate=>candidate.path===path);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,path));setPendingUrdfPath(path);return;}await loadEntry(path);},[loadEntry]);
|
||||
const confirmUrdfOptions=(options:UrdfEnhancementOptions)=>{const path=pendingUrdfPath;if(!path)return;urdfEnhancementsRef.current=options;setPendingUrdfPath(undefined);setPendingUrdfMounts([]);void loadEntry(path);};
|
||||
const skipUrdfOptions=()=>confirmUrdfOptions({addActuators:false,addSensors:false,sensorType:'camera'});
|
||||
const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});manifest.current=next;setSelectedControllerPath(next.files.find(file=>/\.py$/i.test(file.path))?.path);state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await requestLoadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[requestLoadEntry]);
|
||||
const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);};
|
||||
const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();setRemoveConfirmOpen(false);};
|
||||
const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf')void loadEntry(entry.path,value);};
|
||||
const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;setGeneratedMjcf(undefined);setGeneratedMjcfPath(undefined);setPendingUrdfPath(undefined);setPendingUrdfMounts([]);setSelectedControllerPath(undefined);setControllerStatus(undefined);state.clearProject();setRemoveConfirmOpen(false);};
|
||||
const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format!=='urdf')return;if(value==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,entry.path));setPendingUrdfPath(entry.path);}else void loadEntry(entry.path,value);};
|
||||
const changeBaseMode=(value:UrdfBaseMode)=>{setBaseMode(value);baseModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf')void loadEntry(entry.path,'mjcf');};
|
||||
const changeFiles=(event:ChangeEvent<HTMLInputElement>)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';};
|
||||
const drop=(event:DragEvent)=>{event.preventDefault();if(state.loading||importInFlight.current)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取拖放文件',value:.05});void (async()=>{try{const files=await filesFromDrop(event.dataTransfer.items,event.dataTransfer.files);await ingest(files,true);}catch(error){importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);state.setDiagnostic(diagnostic('导入',error));}})();};
|
||||
@@ -56,7 +67,18 @@ export function App(){
|
||||
const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const setActuator=(id:number,value:number)=>{adapter.current.setActuator(id,value);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const setActuatorParameters=(id:number,parameters:ActuatorParameters)=>{if(!adapter.current.setActuatorParameters(id,parameters))return;state.setSnapshot(adapter.current.snapshot()??undefined);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));}catch(error){console.warn('[MuJoCo] 无法刷新驱动器参数源码',error);}};
|
||||
const setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const loadControllerSource=async(source:string,path:string)=>{state.setLoading(true);setImportProgress({label:'初始化 Python 运行时并加载控制器',value:.5});state.setDiagnostic(undefined);try{const status=await adapter.current.loadPythonController(source,path);setControllerStatus(status);state.setSnapshot(adapter.current.snapshot()??undefined);notify('Python 控制器已加载',`${status.name} · ${status.controlHz} Hz`);}catch(error){state.setDiagnostic(diagnostic('仿真',error,path));}finally{setImportProgress(undefined);state.setLoading(false);}};
|
||||
const loadControllerPath=(path:string)=>{const file=manifest.current?.files.find(candidate=>candidate.path===path);if(!file){state.setDiagnostic(diagnostic('仿真',new Error('工程中找不到控制脚本'),path));return;}setSelectedControllerPath(path);void loadControllerSource(new TextDecoder().decode(file.data),path);};
|
||||
const importController=(file:File)=>{void (async()=>{try{if(!/\.py$/i.test(file.name))throw new Error('请选择 .py 文件');if(file.size>1024*1024)throw new Error('Python 控制脚本不能超过 1 MiB');const path=normalizeProjectPath(file.name),data=new Uint8Array(await file.arrayBuffer());if(manifest.current){const index=manifest.current.files.findIndex(candidate=>candidate.path===path),files=manifest.current.files.slice(),entry={path,data,size:data.byteLength,source:'file' as const,mimeType:file.type||'text/x-python'};if(index>=0)files[index]=entry;else files.push(entry);manifest.current={...manifest.current,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};state.setProject(manifest.current.name,files.map(({path:filePath,size})=>({path:filePath,size})),manifest.current.entries,manifest.current.selectedEntry);state.setSnapshot(adapter.current.snapshot()??undefined);}setSelectedControllerPath(path);await loadControllerSource(new TextDecoder().decode(data),path);}catch(error){state.setDiagnostic(diagnostic('仿真',error,file.name));}})();};
|
||||
const toggleController=(enabled:boolean)=>{adapter.current.setControllerEnabled(enabled);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);};
|
||||
const sendControllerCommand=(command:ControllerCommand)=>{try{adapter.current.sendControllerCommand(command);const snapshot=adapter.current.snapshot()??undefined;setControllerStatus(snapshot?.controller);state.setSnapshot(snapshot);}catch(error){state.setDiagnostic(diagnostic('仿真',error,selectedControllerPath));}};
|
||||
const removeController=()=>{adapter.current.removeController();setControllerStatus(undefined);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const notify=(title:string,detail:string,tone:WorkbenchNotification['tone']='success')=>{const notice:WorkbenchNotification={id:++notificationId.current,title,detail,tone,at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);};
|
||||
const saveCachedSource=async(path:string,text:string)=>{if(!manifest.current)return;manifest.current=upsertCachedMjcf(manifest.current,path,text);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);notify('转换后的 MJCF 已保存到缓存',path);await loadEntry(path);};
|
||||
const exportUrdf=()=>{if(!manifest.current||selectedFormat!=='urdf'||!state.selectedEntry)return;const text=readCachedText(manifest.current,state.selectedEntry);downloadBytes(new TextEncoder().encode(text),exportedFileName(manifest.current.name,'urdf'));notify('URDF 已导出',state.selectedEntry);};
|
||||
const exportMjcf=()=>{try{const data=adapter.current.exportMjcf();downloadBytes(data,exportedFileName(manifest.current?.name??'model','xml'));notify('MJCF 已导出','导出内容来自当前已编译模型');}catch(error){state.setDiagnostic(diagnostic('模型编译',error,state.selectedEntry));}};
|
||||
const toggleFullscreen=()=>{if(document.fullscreenElement)void document.exitFullscreen().catch(()=>{});else if(root.current)void root.current.requestFullscreen().catch(()=>{});};
|
||||
const applyLayoutPreset=(preset:LayoutPreset)=>{if(preset==='viewport'){setLeftOpen(false);setRightOpen(false);dispatchLayoutWidths(288,288);}else if(preset==='project'){setLeftOpen(true);setRightOpen(false);dispatchLayoutWidths(384,288);}else if(preset==='control'){setLeftOpen(false);setRightOpen(true);dispatchLayoutWidths(288,384);}else{setLeftOpen(true);setRightOpen(true);dispatchLayoutWidths(288,288);}};
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(document.activeElement instanceof HTMLElement&&document.activeElement.closest('[role="dialog"]'))return;if((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k'){event.preventDefault();setCommandOpen(true);return;}if((event.target as HTMLElement).matches('input,select,button'))return;if(event.code==='Space'){event.preventDefault();togglePause();}if(event.key==='r')reset();if(event.key==='1')mode('select');if(event.key==='2')mode('joint');if(event.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
|
||||
@@ -68,6 +90,9 @@ export function App(){
|
||||
{id:'joint',label:'切换到关节拖动',group:'视口',icon:<Hand className="h-4 w-4"/>,shortcut:'2',run:()=>mode('joint')},
|
||||
{id:'force',label:'切换到外力施加',group:'视口',icon:<Crosshair className="h-4 w-4"/>,shortcut:'3',run:()=>mode('force')},
|
||||
{id:'camera',label:'复位相机',group:'视口',icon:<Camera className="h-4 w-4"/>,run:()=>viewer.current?.resetCamera()},
|
||||
{id:'source',label:'查看和修改缓存源代码',group:'工程',icon:<Code2 className="h-4 w-4"/>,disabled:!generatedMjcf,run:()=>setSourceOpen(true)},
|
||||
{id:'export-urdf',label:'导出 URDF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:selectedFormat!=='urdf',run:exportUrdf},
|
||||
{id:'export-mjcf',label:'导出 MJCF 文件',group:'工程',icon:<Download className="h-4 w-4"/>,disabled:!state.snapshot,run:exportMjcf},
|
||||
{id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?<ChevronLeft className="h-4 w-4"/>:<ChevronRight className="h-4 w-4"/>,run:()=>setLeftOpen(value=>!value)},
|
||||
{id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?<ChevronRight className="h-4 w-4"/>:<ChevronLeft className="h-4 w-4"/>,run:()=>setRightOpen(value=>!value)},
|
||||
{id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:<SunMoon className="h-4 w-4"/>,run:()=>setTheme(value=>value==='dark'?'light':'dark')},
|
||||
@@ -75,8 +100,8 @@ export function App(){
|
||||
{id:'help',label:'查看快捷键帮助',group:'帮助',icon:<CircleHelp className="h-4 w-4"/>,run:()=>setHelpOpen(true)},
|
||||
];
|
||||
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} onFiles={changeFiles} onFolder={changeFiles} onTogglePause={togglePause} onStep={singleStep} onReset={reset} onSpeed={changeSpeed} onToggleLeft={()=>setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={path=>void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&<EntrySelectionDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/></div>
|
||||
<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} hasProject={Boolean(generatedMjcf)} onFiles={changeFiles} onFolder={changeFiles} onOpenSource={()=>setSourceOpen(true)} onTogglePause={togglePause} onStep={singleStep} onReset={reset} onSpeed={changeSpeed} onToggleLeft={()=>setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={requestLoadEntry} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?<div aria-label="摄像头画面" className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-video w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"><div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white"><span className="flex items-center gap-1"><Camera className="h-3 w-3"/>摄像头</span><button type="button" className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60" onClick={()=>setShowSensorCamera(false)}>隐藏画面</button></div></div>:<Button className="absolute bottom-4 left-4 z-20" icon={<Camera className="h-3.5 w-3.5"/>} onClick={()=>setShowSensorCamera(true)}>显示摄像头画面</Button>)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} controllerPaths={state.files.filter(file=>/\.py$/i.test(file.path)).map(file=>file.path)} selectedControllerPath={selectedControllerPath} controllerStatus={controllerStatus} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale} onSelectControllerPath={setSelectedControllerPath} onLoadControllerPath={loadControllerPath} onImportController={importController} onToggleController={toggleController} onControllerCommand={sendControllerCommand} onRemoveController={removeController}/></div>
|
||||
{pendingUrdfPath&&<UrdfImportOptionsDialog open path={pendingUrdfPath} mountBodies={pendingUrdfMounts} onConfirm={confirmUrdfOptions} onSkip={skipUrdfOptions}/>}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&<SourceEditorDialog open code={generatedMjcf} filePath={generatedMjcfPath} theme={theme} onClose={()=>setSourceOpen(false)} onSave={saveCachedSource}/>}<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {ActuatorControl} from './SidebarPanel';
|
||||
import type {ActuatorInfo} from '../../simulation/SimulationSession';
|
||||
|
||||
const actuator:ActuatorInfo={id:0,name:'shoulder_motor',value:.5,min:-1,max:1,limited:true,jointId:0,jointName:'shoulder',jointType:3,unit:'N·m',kind:'motor',controlCount:1,gear:2,gain:1,kp:0,kv:0,ctrlLimited:true,ctrlMin:-1,ctrlMax:1,forceLimited:true,forceMin:-20,forceMax:20};
|
||||
|
||||
describe('ActuatorControl',()=>{
|
||||
it('显示对应关节和常用力矩单位',()=>{
|
||||
render(<ActuatorControl actuator={actuator} onControl={()=>{}} onParameters={()=>{}}/>);
|
||||
expect(screen.getByText('shoulder_motor')).toBeVisible();
|
||||
expect(screen.getByText('关节:shoulder')).toBeVisible();
|
||||
expect(screen.getByText('1.000 N·m')).toBeVisible();
|
||||
});
|
||||
|
||||
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{
|
||||
const onControl=vi.fn(),onParameters=vi.fn();
|
||||
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>);
|
||||
fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}});
|
||||
expect(onControl).toHaveBeenCalledWith(1);
|
||||
fireEvent.click(screen.getByText('常用参数'));
|
||||
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
|
||||
const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp);
|
||||
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true}));
|
||||
});
|
||||
|
||||
it('position 伺服使用角度目标并开放 kp、kv',()=>{
|
||||
const onParameters=vi.fn();
|
||||
render(<ActuatorControl actuator={{...actuator,name:'shoulder_servo',kind:'position',unit:'°',value:Math.PI/2,min:-Math.PI,max:Math.PI,gear:1,kp:100,kv:10,gain:100}} onControl={()=>{}} onParameters={onParameters}/>);
|
||||
expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数'));
|
||||
const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp);
|
||||
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10}));
|
||||
});
|
||||
|
||||
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{
|
||||
render(<ActuatorControl actuator={{...actuator,name:'custom',kind:'other',unit:'',value:.25}} onControl={()=>{}} onParameters={()=>{}}/>);
|
||||
expect(screen.getByText('0.250')).toBeVisible();
|
||||
expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {describe,expect,it,vi} from 'vitest';
|
||||
import {PythonControllerPanel} from './PythonControllerPanel';
|
||||
|
||||
const noop=()=>{};
|
||||
|
||||
describe('PythonControllerPanel',()=>{
|
||||
it('向支持 command 的已启用控制器发送基本移动指令',()=>{
|
||||
const onCommand=vi.fn();
|
||||
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:true,acceptsCommands:true,activeCommand:'stop',lastStepMs:.1}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={onCommand} onRemove={noop}/>);
|
||||
fireEvent.click(screen.getByRole('button',{name:'前进'}));
|
||||
fireEvent.click(screen.getByRole('button',{name:'左转'}));
|
||||
fireEvent.click(screen.getByRole('button',{name:'起跳'}));
|
||||
expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]);
|
||||
expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true');
|
||||
});
|
||||
|
||||
it('控制器未启用时禁用基本移动按钮',()=>{
|
||||
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:false,acceptsCommands:true,lastStepMs:0}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={noop} onRemove={noop}/>);
|
||||
expect(screen.getByRole('button',{name:'前进'})).toBeDisabled();
|
||||
expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useRef,type ChangeEvent} from 'react';
|
||||
import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react';
|
||||
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
|
||||
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
|
||||
|
||||
export interface PythonControllerPanelProps {
|
||||
paths:string[];
|
||||
selectedPath?:string;
|
||||
status?:ControllerStatus;
|
||||
loading:boolean;
|
||||
onSelectPath(path:string):void;
|
||||
onLoadPath(path:string):void;
|
||||
onImport(file:File):void;
|
||||
onToggle(enabled:boolean):void;
|
||||
onCommand(command:ControllerCommand):void;
|
||||
onRemove():void;
|
||||
}
|
||||
|
||||
export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){
|
||||
const input=useRef<HTMLInputElement>(null);
|
||||
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
|
||||
return <div>
|
||||
<input ref={input} className="hidden" type="file" accept=".py,text/x-python" onChange={importFile}/>
|
||||
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内脚本</span><Select aria-label="Python 控制脚本" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .py 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 .py</Button>
|
||||
<Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载脚本</Button>
|
||||
</div>
|
||||
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={status.path}>{status.name}</span><Badge>{status.enabled?'运行中':'已停止'}</Badge></div>
|
||||
<PropertyRow label="语言" value="Python / Pyodide"/><PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`}/>
|
||||
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>}
|
||||
{status.acceptsCommands&&<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p><div className="grid grid-cols-3 gap-1.5"><span/><Button aria-pressed={status.activeCommand==='forward'} disabled={!status.enabled} icon={<ArrowUp className="h-3.5 w-3.5"/>} onClick={()=>onCommand('forward')}>前进</Button><span/><Button aria-pressed={status.activeCommand==='turn_left'} disabled={!status.enabled} icon={<ArrowLeft className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_left')}>左转</Button><Button aria-label="移动停止" aria-pressed={status.activeCommand==='stop'} disabled={!status.enabled} icon={<Octagon className="h-3.5 w-3.5"/>} onClick={()=>onCommand('stop')}>停止</Button><Button aria-pressed={status.activeCommand==='turn_right'} disabled={!status.enabled} icon={<ArrowRight className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_right')}>右转</Button><span/><Button aria-pressed={status.activeCommand==='backward'} disabled={!status.enabled} icon={<ArrowDown className="h-3.5 w-3.5"/>} onClick={()=>onCommand('backward')}>后退</Button><Button disabled={!status.enabled} onClick={()=>onCommand('jump')}>起跳</Button></div></div>}
|
||||
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
|
||||
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react';
|
||||
import type {ModelEntry} from '../../project/types';
|
||||
import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree';
|
||||
import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree';
|
||||
import type {SimulationSnapshot} from '../../simulation/SimulationSession';
|
||||
import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
|
||||
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
|
||||
import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
|
||||
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
|
||||
import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui';
|
||||
import {TreeSearchField} from './TreeSearchField';
|
||||
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
|
||||
import {PythonControllerPanel} from './PythonControllerPanel';
|
||||
|
||||
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}><aside className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side==='left'?'border-r':'border-l'} border-border`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;}
|
||||
|
||||
@@ -17,18 +19,44 @@ export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot
|
||||
interface ModelControlsProps{
|
||||
snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean;
|
||||
urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number;
|
||||
controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus;
|
||||
onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void;
|
||||
onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void;
|
||||
onActuator:(id:number,value:number)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
|
||||
onActuator:(id:number,value:number)=>void;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
|
||||
onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void;
|
||||
}
|
||||
export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div></SidebarPanel>;
|
||||
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nu}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection>
|
||||
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nactuator}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection>
|
||||
{props.selectedFormat==='urdf'&&<CollapsibleSection title="URDF 处理方式" defaultOpen={false}><Select aria-label="URDF 处理方式" className="w-full" value={props.urdfMode} disabled={props.loading} onChange={event=>props.onUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></Select><label className="mt-3 block text-xs text-text-secondary"><span className="mb-1 block">基座类型</span><Select aria-label="URDF 基座类型" className="w-full" value={props.baseMode} disabled={props.loading||props.urdfMode==='native'} onChange={event=>props.onBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></Select></label><p className="mt-2 text-xs text-text-tertiary">MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。</p><Check label="显示碰撞几何" checked={props.showCollision} onChange={props.onShowCollision}/></CollapsibleSection>}
|
||||
<CollapsibleSection title="当前选择" defaultOpen>{props.selection?<div className="text-xs"><PropertyRow label="Body" value={props.selection.bodyName} action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称"/>}/><PropertyRow label="标识" value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`} action={<CopyButton value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`} label="复制标识"/>}/><PropertyRow label="位置" value={props.selection.position.map(value=>value.toFixed(3)).join(', ')} action={<CopyButton value={props.selection.position.join(', ')} label="复制位置"/>}/></div>:<p className="flex items-center gap-2 text-xs text-text-tertiary"><Info className="h-3.5 w-3.5"/>在视口中单击物体</p>}</CollapsibleSection></>;
|
||||
const controls=<><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ControlSlider key={actuator.id} label={actuator.name} value={actuator.value} min={actuator.min} max={actuator.max} onChange={value=>props.onActuator(actuator.id,value)}/>):<p className="text-xs text-text-tertiary">模型没有 actuator</p>}</CollapsibleSection>
|
||||
const controls=<><CollapsibleSection title="Python 控制器" defaultOpen badge={s.controller?<Badge>{s.controller.enabled?'运行':'停止'}</Badge>:undefined}><PythonControllerPanel paths={props.controllerPaths} selectedPath={props.selectedControllerPath} status={props.controllerStatus??s.controller} loading={props.loading} onSelectPath={props.onSelectControllerPath} onLoadPath={props.onLoadControllerPath} onImport={props.onImportController} onToggle={props.onToggleController} onCommand={props.onControllerCommand} onRemove={props.onRemoveController}/></CollapsibleSection><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ActuatorControl key={actuator.id} actuator={actuator} onControl={value=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):<p className="text-xs text-text-tertiary">模型没有驱动器</p>}</CollapsibleSection>
|
||||
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}><div className="mb-4 grid grid-cols-2 gap-2"><Button onClick={props.onResetJoints}>重置关节</Button><Button variant={props.ignoreJointLimits?'primary':'secondary'} aria-pressed={props.ignoreJointLimits} onClick={props.onToggleJointLimits}>忽略关节限位</Button><Button variant={props.jointAdvanced?'primary':'secondary'} aria-pressed={props.jointAdvanced} onClick={props.onToggleAdvanced}>高级</Button><Button variant={props.angleUnit==='deg'?'primary':'secondary'} aria-pressed={props.angleUnit==='deg'} onClick={props.onToggleAngleUnit}>{props.angleUnit==='rad'?'rad 弧度制':'° 角度制'}</Button></div>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return <ControlSlider key={joint.id} label={`${joint.name}${joint.editable?'':'(只读)'}`} value={joint.value*scale} min={joint.min*scale} max={joint.max*scale} unit={unit} advanced={props.jointAdvanced} limited={joint.limited} limitsIgnored={joint.limitsIgnored} limitMin={joint.limitMin*scale} limitMax={joint.limitMax*scale} disabled={!joint.editable} onChange={value=>props.onJoint(joint.id,value/scale)}/>;})}</CollapsibleSection>
|
||||
<CollapsibleSection title="外力强度" defaultOpen={false}><ControlSlider label={`${props.forceScale.toFixed(0)} N/屏幕单位`} value={props.forceScale} min={5} max={200} onChange={props.onForceScale}/><p className="text-xs text-text-tertiary">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></CollapsibleSection></>;
|
||||
return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><Tabs label="模型控制侧栏" value={tab} onValueChange={setTab} items={[{value:'properties',label:'属性',icon:<Info className="h-3.5 w-3.5"/>,content:properties},{value:'controls',label:'控制',icon:<SlidersHorizontal className="h-3.5 w-3.5"/>,content:controls}]}/></SidebarPanel>;
|
||||
}
|
||||
|
||||
export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){
|
||||
const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale;
|
||||
const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain;
|
||||
const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear;
|
||||
const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB);
|
||||
const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9);
|
||||
const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入';
|
||||
const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB);
|
||||
const update=(patch:Partial<ActuatorParameters>)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear;
|
||||
return <div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
|
||||
<div className="mb-2 flex min-w-0 items-start justify-between gap-2"><div className="min-w-0"><div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>{actuator.name}</div><div className="mt-0.5 truncate text-[10px] text-text-tertiary">{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}</div></div><Badge>{actuator.unit||'u'}</Badge></div>
|
||||
{actuator.controlCount===1?<ControlSlider label={outputLabel} value={outputValue} min={outputMin} max={outputMax} disabled={outputDisabled} unit={actuator.unit?` ${actuator.unit}`:''} onChange={value=>{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:<p className="mb-2 text-[10px] leading-4 text-text-tertiary">该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。</p>}
|
||||
{actuator.controlCount===1&&!actuator.ctrlLimited&&<div className="mb-2"><ParameterInput label={`${controlLabel}输入(不限幅)`} value={actuator.value*targetScale} onCommit={value=>onControl(value/targetScale)}/></div>}
|
||||
{editable?<details className="group border-t border-border pt-2"><summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">常用参数</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">{isPosition?<><ParameterInput label={`位置增益 kp(${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp*gearSquared} disabled={gearSquared<=1e-18} onCommit={kp=>update({kp:kp/gearSquared})}/><ParameterInput label={`速度增益 kv(${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv*gearSquared} disabled={gearSquared<=1e-18} onCommit={kv=>update({kv:kv/gearSquared})}/></>:<><ParameterInput label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp} onCommit={kp=>update({kp})}/><ParameterInput label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv} onCommit={kv=>update({kv})}/></>}</div>
|
||||
<ParameterToggle label={`限制输出${actuator.jointType===3?'力矩':'力'}${forceUnit?`(${forceUnit})`:''}`} checked={actuator.forceLimited} onChange={forceLimited=>update({forceLimited})}/>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2"><ParameterInput label="输出下限" value={jointForceMin} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/><ParameterInput label="输出上限" value={jointForceMax} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/></div>
|
||||
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">{isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。</p>
|
||||
</details>:<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。</p>}
|
||||
</div>;
|
||||
}
|
||||
function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block truncate">{label}</span><input key={value} type="number" step="any" defaultValue={Number.isFinite(value)?value:0} disabled={disabled} className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onBlur={event=>{const next=Number(event.currentTarget.value);if(Number.isFinite(next)&&next!==value)onCommit(next);else event.currentTarget.value=String(value);}} onKeyDown={event=>{if(event.key==='Enter')event.currentTarget.blur();}}/></label>;}
|
||||
function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary"><input type="checkbox" className="accent-accent" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
|
||||
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
|
||||
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>;}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import './monacoSetup';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
|
||||
import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react';
|
||||
import {downloadBytes} from '../../project/cachedFiles';
|
||||
import {Button,IconButton} from '../../components/ui';
|
||||
|
||||
function basename(path:string):string{return path.split('/').at(-1)??path;}
|
||||
function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
|
||||
function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;}
|
||||
|
||||
export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise<void>}){
|
||||
const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)}));
|
||||
const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dirty=code!==savedCode,problem=xmlProblem(code);
|
||||
const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]);
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,problem,save]);
|
||||
const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);};
|
||||
const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
|
||||
const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);};
|
||||
const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});};
|
||||
if(!open)return null;
|
||||
return <div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
|
||||
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={onClose}><X className="h-4 w-4"/></IconButton></header>
|
||||
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/></div>
|
||||
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]"><div className={problem?'truncate text-warning':'text-success'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
|
||||
|
||||
describe('UrdfImportOptionsDialog',()=>{
|
||||
it('默认选择关节驱动器和摄像头传感器',()=>{
|
||||
const onConfirm=vi.fn();
|
||||
render(<UrdfImportOptionsDialog open path="robot.urdf" mountBodies={['base','head_link']} onConfirm={onConfirm} onSkip={()=>{}}/>);
|
||||
expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
|
||||
expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
|
||||
expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link');
|
||||
fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}});
|
||||
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'}));
|
||||
});
|
||||
|
||||
it('允许分别关闭自动生成项',()=>{
|
||||
const onConfirm=vi.fn();
|
||||
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={()=>{}}/>);
|
||||
fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/}));
|
||||
fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/}));
|
||||
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'}));
|
||||
});
|
||||
|
||||
it('可以不添加组件并继续加载',()=>{
|
||||
const onSkip=vi.fn();
|
||||
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={()=>{}} onSkip={onSkip}/>);
|
||||
fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'}));
|
||||
expect(onSkip).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useState,type ReactNode} from 'react';
|
||||
import {Camera,Settings2} from 'lucide-react';
|
||||
import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf';
|
||||
import {Button,Dialog,Select} from '../../components/ui';
|
||||
|
||||
function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){
|
||||
return <label className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked?'border-accent/60 bg-accent/10':'border-border bg-surface hover:bg-element-hover'}`}>
|
||||
<input className="mt-0.5 h-4 w-4 accent-accent" type="checkbox" checked={checked} onChange={event=>onChange(event.target.checked)}/>
|
||||
<span className="mt-0.5 text-text-secondary" aria-hidden="true">{icon}</span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-sm font-medium text-text-primary">{title}</span><span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>{children}</span>
|
||||
</label>;
|
||||
}
|
||||
|
||||
export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){
|
||||
const [options,setOptions]=useState<UrdfEnhancementOptions>(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'}));
|
||||
const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};});
|
||||
return <Dialog open={open} onClose={onSkip} closable={false} title="配置 URDF 仿真组件" className="max-w-xl" footer={<div className="flex justify-end gap-2"><Button onClick={onSkip}>不添加,直接加载</Button><Button variant="primary" onClick={()=>onConfirm(options)}>转换并加载</Button></div>}>
|
||||
<p className="text-sm text-text-secondary">导入 <strong className="text-text-primary">{path}</strong> 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<OptionCard checked={options.addActuators} onChange={addActuators=>setOptions(value=>({...value,addActuators}))} icon={<Settings2 className="h-4 w-4"/>} title="为关节添加驱动器" description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"/>
|
||||
<OptionCard checked={options.addSensors} onChange={addSensors=>setOptions(value=>({...value,addSensors}))} icon={<Camera className="h-4 w-4"/>} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/>
|
||||
{options.addSensors&&<div className="rounded-lg border border-border bg-surface p-3"><div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div><label className="block text-[11px] text-text-secondary"><span className="mb-1 block">固连 Body</span><Select aria-label="摄像头固连 Body" className="w-full" value={options.cameraMountBody??''} onChange={event=>setOptions(value=>({...value,cameraMountBody:event.target.value||undefined}))}>{mountBodies.length?mountBodies.map(name=><option key={name} value={name}>{name}</option>):<option value="">自动选择头部/末端 Body</option>}</Select></label><div className="mt-3 grid grid-cols-3 gap-2">{(['X','Y','Z'] as const).map((axis,index)=><label key={axis} className="text-[11px] text-text-secondary"><span className="mb-1 block">位置 {axis}(m)</span><input aria-label={`摄像头位置 ${axis}`} className="field h-8 w-full px-2 text-xs" type="number" step="0.01" value={(options.cameraPosition??[.1,0,.05])[index]} onChange={event=>setPosition(index,Number(event.target.value))}/></label>)}</div><label className="mt-3 block text-[11px] text-text-secondary"><span className="mb-1 block">镜头朝向(Body 局部轴)</span><Select aria-label="摄像头朝向" className="w-full" value={options.cameraDirection??'+X'} onChange={event=>setOptions(value=>({...value,cameraDirection:event.target.value as CameraDirection}))}>{(['+X','-X','+Y','-Y','+Z','-Z'] as CameraDirection[]).map(direction=><option key={direction}>{direction}</option>)}</Select></label><p className="mt-2 text-[10px] leading-4 text-text-tertiary">位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。</p></div>}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {ChangeEvent,ReactNode} from 'react';
|
||||
import {CircleHelp,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react';
|
||||
import {CircleHelp,Code2,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react';
|
||||
import {Button,IconButton,Select} from '../../components/ui';
|
||||
const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
|
||||
export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,center,endActions,compactMenu,onFiles,onFolder,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/>打开文件<input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/>文件夹<input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,hasProject,center,endActions,compactMenu,onFiles,onFolder,onOpenSource,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;hasProject?:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>void;onOpenSource?:()=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/>打开文件<input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/>文件夹<input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {loader} from '@monaco-editor/react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution';
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
|
||||
type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}};
|
||||
(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()};
|
||||
loader.config({monaco});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {PyodideInterface} from 'pyodide';
|
||||
import type {PyCallable,PyDict} from 'pyodide/ffi';
|
||||
import type {ControllerBindings,ControllerCommand,ControllerStatus} from './types';
|
||||
|
||||
const DEFAULT_CONTROL_HZ=100;
|
||||
const MIN_CONTROL_HZ=1;
|
||||
const MAX_CONTROL_HZ=500;
|
||||
|
||||
let pyodidePromise:Promise<PyodideInterface>|undefined;
|
||||
|
||||
function pyodideIndexUrl():string {
|
||||
return new URL('pyodide/',document.baseURI).href;
|
||||
}
|
||||
|
||||
export function getPythonRuntime():Promise<PyodideInterface> {
|
||||
pyodidePromise??=import('pyodide').then(({loadPyodide})=>loadPyodide({indexURL:pyodideIndexUrl()}));
|
||||
return pyodidePromise;
|
||||
}
|
||||
|
||||
function destroyProxy(value:unknown):void {
|
||||
if(value&&typeof value==='object'&&'destroy' in value&&typeof (value as {destroy?:unknown}).destroy==='function'){
|
||||
(value as {destroy():void}).destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error:unknown):string {
|
||||
return error instanceof Error?error.message:String(error);
|
||||
}
|
||||
|
||||
/** 在主线程同步执行可信的单文件 Python 控制器,保证控制发生在 mj_step 之前。 */
|
||||
export class PythonControllerRuntime {
|
||||
private globals?:PyDict;
|
||||
private initFunction?:PyCallable;
|
||||
private stepFunction?:PyCallable;
|
||||
private resetFunction?:PyCallable;
|
||||
private commandFunction?:PyCallable;
|
||||
private disposeFunction?:PyCallable;
|
||||
private state?:unknown;
|
||||
private nextControlTime=0;
|
||||
private statusValue:ControllerStatus;
|
||||
|
||||
private constructor(private readonly bindings:ControllerBindings,path:string,name:string,controlHz:number){
|
||||
this.statusValue={language:'python',path,name,controlHz,loaded:true,enabled:false,acceptsCommands:false,lastStepMs:0};
|
||||
}
|
||||
|
||||
static async load(source:string,path:string,bindings:ControllerBindings):Promise<PythonControllerRuntime>{
|
||||
const pyodide=await getPythonRuntime();
|
||||
const globals=pyodide.runPython('dict()') as PyDict;
|
||||
globals.set('__name__','__mujoco_controller__');
|
||||
try{
|
||||
await pyodide.runPythonAsync(source,{globals});
|
||||
if(!globals.has('step'))throw new Error('Python 控制器必须定义 step(ctx, state)');
|
||||
const rawHz=globals.has('CONTROL_HZ')?Number(globals.get('CONTROL_HZ')):DEFAULT_CONTROL_HZ;
|
||||
const controlHz=Math.min(MAX_CONTROL_HZ,Math.max(MIN_CONTROL_HZ,Number.isFinite(rawHz)?rawHz:DEFAULT_CONTROL_HZ));
|
||||
const name=globals.has('NAME')?String(globals.get('NAME')):path.split('/').at(-1)??path;
|
||||
const runtime=new PythonControllerRuntime(bindings,path,name,controlHz);
|
||||
runtime.globals=globals;
|
||||
runtime.initFunction=globals.has('init')?globals.get('init') as PyCallable:undefined;
|
||||
runtime.stepFunction=globals.get('step') as PyCallable;
|
||||
runtime.resetFunction=globals.has('reset')?globals.get('reset') as PyCallable:undefined;
|
||||
runtime.commandFunction=globals.has('command')?globals.get('command') as PyCallable:undefined;
|
||||
runtime.statusValue.acceptsCommands=Boolean(runtime.commandFunction);
|
||||
runtime.disposeFunction=globals.has('dispose')?globals.get('dispose') as PyCallable:undefined;
|
||||
runtime.state=runtime.initFunction?.(bindings.model);
|
||||
if(runtime.state instanceof Promise)throw new Error('控制器函数必须同步执行');
|
||||
return runtime;
|
||||
}catch(error){
|
||||
globals.destroy();
|
||||
throw new Error(`Python 控制器加载失败(${path}):${errorMessage(error)}`,{cause:error});
|
||||
}
|
||||
}
|
||||
|
||||
status():ControllerStatus{return {...this.statusValue};}
|
||||
|
||||
setEnabled(enabled:boolean,currentTime:number):void {
|
||||
if(!this.statusValue.loaded)return;
|
||||
this.statusValue.enabled=enabled;
|
||||
this.statusValue.error=undefined;
|
||||
this.nextControlTime=currentTime;
|
||||
if(!enabled)this.statusValue.activeCommand=undefined;
|
||||
}
|
||||
|
||||
command(command:ControllerCommand):void {
|
||||
if(!this.statusValue.enabled)throw new Error('请先启用 Python 控制器');
|
||||
if(!this.commandFunction)throw new Error('当前 Python 控制器未定义 command(name, state)');
|
||||
try{
|
||||
const result=this.commandFunction(command,this.state);
|
||||
if(result instanceof Promise)throw new Error('command() 必须是同步函数');
|
||||
destroyProxy(result);
|
||||
this.statusValue.activeCommand=command==='jump'?'stop':command;
|
||||
this.statusValue.error=undefined;
|
||||
}catch(error){
|
||||
this.statusValue.error=errorMessage(error);
|
||||
throw new Error(`Python 控制指令失败:${this.statusValue.error}`,{cause:error});
|
||||
}
|
||||
}
|
||||
|
||||
stepIfDue(time:number):void {
|
||||
if(!this.statusValue.enabled||!this.stepFunction||time+1e-9<this.nextControlTime)return;
|
||||
const dt=1/this.statusValue.controlHz;
|
||||
const started=performance.now();
|
||||
try{
|
||||
const result=this.stepFunction(this.bindings.createStepApi(time,dt),this.state);
|
||||
if(result instanceof Promise)throw new Error('step() 必须是同步函数');
|
||||
destroyProxy(result);
|
||||
this.statusValue.lastStepMs=performance.now()-started;
|
||||
this.nextControlTime=time+dt;
|
||||
}catch(error){
|
||||
this.statusValue.lastStepMs=performance.now()-started;
|
||||
this.statusValue.enabled=false;
|
||||
this.statusValue.error=errorMessage(error);
|
||||
throw new Error(`Python 控制器运行失败:${this.statusValue.error}`,{cause:error});
|
||||
}
|
||||
}
|
||||
|
||||
reset(currentTime:number):void {
|
||||
this.nextControlTime=currentTime;
|
||||
this.statusValue.activeCommand=undefined;
|
||||
if(!this.resetFunction)return;
|
||||
try{const result=this.resetFunction(this.state);destroyProxy(result);}
|
||||
catch(error){this.statusValue.enabled=false;this.statusValue.error=errorMessage(error);throw error;}
|
||||
}
|
||||
|
||||
dispose():void {
|
||||
if(!this.statusValue.loaded)return;
|
||||
this.statusValue.loaded=false;
|
||||
this.statusValue.enabled=false;
|
||||
try{if(this.disposeFunction){const result=this.disposeFunction(this.state);destroyProxy(result);}}
|
||||
finally{
|
||||
destroyProxy(this.state);this.state=undefined;
|
||||
this.initFunction?.destroy();this.stepFunction?.destroy();this.resetFunction?.destroy();this.commandFunction?.destroy();this.disposeFunction?.destroy();this.globals?.destroy();
|
||||
this.initFunction=undefined;this.stepFunction=undefined;this.resetFunction=undefined;this.commandFunction=undefined;this.disposeFunction=undefined;this.globals=undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type ControllerCommand='stop'|'forward'|'backward'|'turn_left'|'turn_right'|'jump';
|
||||
|
||||
export interface ControllerStatus {
|
||||
language:'python';
|
||||
path:string;
|
||||
name:string;
|
||||
controlHz:number;
|
||||
loaded:boolean;
|
||||
enabled:boolean;
|
||||
acceptsCommands:boolean;
|
||||
activeCommand?:ControllerCommand;
|
||||
lastStepMs:number;
|
||||
error?:string;
|
||||
}
|
||||
|
||||
export interface ControllerModelApi {
|
||||
joint(name:string):number;
|
||||
actuator(name:string):number;
|
||||
sensor(name:string):number;
|
||||
body(name:string):number;
|
||||
}
|
||||
|
||||
export interface ControllerStepApi {
|
||||
readonly time:number;
|
||||
readonly dt:number;
|
||||
qpos(jointId:number):number;
|
||||
qvel(jointId:number):number;
|
||||
sensor(sensorId:number):number[];
|
||||
body_quat(bodyId:number):number[];
|
||||
body_position(bodyId:number):number[];
|
||||
set_control(actuatorId:number,value:number):void;
|
||||
}
|
||||
|
||||
export interface ControllerBindings {
|
||||
readonly model:ControllerModelApi;
|
||||
createStepApi(time:number,dt:number):ControllerStepApi;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {editableSourcePaths,exportedFileName,mergeCachedFiles,readCachedText,updateCachedText,upsertCachedMjcf} from './cachedFiles';
|
||||
import type {ProjectManifest} from './types';
|
||||
|
||||
const encoder=new TextEncoder();
|
||||
function fixture():ProjectManifest{const xml=encoder.encode('<mujoco/>'),png=new Uint8Array([1,2]);return {id:'p',name:'测试 工程.zip',files:[{path:'model.xml',data:xml,size:xml.byteLength,source:'zip',mimeType:'text/xml'},{path:'texture.png',data:png,size:png.byteLength,source:'zip',mimeType:'image/png'}],entries:[{path:'model.xml',format:'mjcf',label:'model'}],selectedEntry:'model.xml',totalBytes:xml.byteLength+png.byteLength};}
|
||||
|
||||
describe('cached source files',()=>{
|
||||
it('只列出可编辑文本并读取缓存',()=>{const manifest=fixture();expect(editableSourcePaths(manifest)).toEqual(['model.xml']);expect(readCachedText(manifest,'model.xml')).toBe('<mujoco/>');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
|
||||
it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','<mujoco model="edited"/>');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('<mujoco/>');expect(updated.totalBytes).toBe(updated.files.reduce((sum,file)=>sum+file.size,0));});
|
||||
it('合并转换生成的支持资源',()=>{const original=fixture(),obj={path:'mesh.mujoco.obj',data:encoder.encode('v 0 0 0'),size:7,source:'file' as const,mimeType:'text/plain'},updated=mergeCachedFiles(original,[obj]);expect(updated.files.map(file=>file.path)).toContain('mesh.mujoco.obj');expect(original.files.map(file=>file.path)).not.toContain('mesh.mujoco.obj');});
|
||||
it('创建可重新载入的 MJCF 缓存文件和入口',()=>{const updated=upsertCachedMjcf(fixture(),'.__converted_mjcf_cache__.xml','<mujoco model="cached"/>');expect(readCachedText(updated,'.__converted_mjcf_cache__.xml')).toContain('cached');expect(updated.entries.at(-1)).toMatchObject({path:'.__converted_mjcf_cache__.xml',format:'mjcf'});});
|
||||
it('生成安全的导出文件名',()=>{expect(exportedFileName('测试 工程.zip','urdf')).toBe('测试_工程.urdf');expect(exportedFileName('robot.xml','xml')).toBe('robot.xml');});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {ProjectManifest} from './types';
|
||||
|
||||
const TEXT_EXTENSIONS=/\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
|
||||
const decoder=new TextDecoder('utf-8',{fatal:false});
|
||||
const encoder=new TextEncoder();
|
||||
|
||||
export function isEditableSource(path:string):boolean{return TEXT_EXTENSIONS.test(path);}
|
||||
|
||||
export function editableSourcePaths(manifest:ProjectManifest):string[]{
|
||||
return manifest.files.filter(file=>isEditableSource(file.path)).map(file=>file.path).sort((a,b)=>a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function readCachedText(manifest:ProjectManifest,path:string):string{
|
||||
const file=manifest.files.find(candidate=>candidate.path===path);
|
||||
if(!file)throw new Error(`缓存中找不到文件:${path}`);
|
||||
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
return decoder.decode(file.data);
|
||||
}
|
||||
|
||||
/** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */
|
||||
export function mergeCachedFiles(manifest:ProjectManifest,additional:ProjectManifest['files']):ProjectManifest{
|
||||
if(!additional.length)return manifest;
|
||||
const byPath=new Map(manifest.files.map(file=>[file.path,file]));
|
||||
for(const file of additional)byPath.set(file.path,file);
|
||||
const files=Array.from(byPath.values());
|
||||
return {...manifest,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};
|
||||
}
|
||||
|
||||
export function upsertCachedMjcf(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
|
||||
const data=encoder.encode(text),index=manifest.files.findIndex(candidate=>candidate.path===path);
|
||||
const files=manifest.files.slice();
|
||||
const file={path,data,size:data.byteLength,source:'file' as const,mimeType:'application/xml'};
|
||||
if(index<0)files.push(file);else files[index]={...files[index],...file};
|
||||
const entries=manifest.entries.some(entry=>entry.path===path)?manifest.entries:[...manifest.entries,{path,format:'mjcf' as const,label:`${path} (MJCF 缓存)`}];
|
||||
return {...manifest,files,entries,totalBytes:files.reduce((total,item)=>total+item.size,0)};
|
||||
}
|
||||
|
||||
export function updateCachedText(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
|
||||
const index=manifest.files.findIndex(candidate=>candidate.path===path);
|
||||
if(index<0)throw new Error(`缓存中找不到文件:${path}`);
|
||||
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
const data=encoder.encode(text),files=manifest.files.slice();
|
||||
files[index]={...files[index],data,size:data.byteLength,mimeType:files[index].mimeType||'text/plain'};
|
||||
return {...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)};
|
||||
}
|
||||
|
||||
export function downloadBytes(data:Uint8Array,fileName:string,mimeType='application/xml'):void{
|
||||
const blob=new Blob([data as BlobPart],{type:`${mimeType};charset=utf-8`});
|
||||
const url=URL.createObjectURL(blob),anchor=document.createElement('a');
|
||||
anchor.href=url;anchor.download=fileName;anchor.style.display='none';document.body.append(anchor);anchor.click();anchor.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),0);
|
||||
}
|
||||
|
||||
export function exportedFileName(projectName:string,extension:'urdf'|'xml'):string{
|
||||
const stem=projectName.replace(/\.(?:zip|xml|urdf)$/i,'').replace(/[^\p{L}\p{N}._-]+/gu,'_')||'model';
|
||||
return `${stem}.${extension}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {groundConvertedMjcf} from './urdfToMjcf';
|
||||
import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf';
|
||||
|
||||
const encode=(value:string)=>new TextEncoder().encode(value);
|
||||
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
|
||||
@@ -20,3 +20,55 @@ describe('groundConvertedMjcf',()=>{
|
||||
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhanceConvertedMjcf',()=>{
|
||||
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint',()=>{
|
||||
const source='<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
|
||||
const result=enhanceConvertedMjcf(encode(source),{addActuators:true,addSensors:false,sensorType:'camera'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
expect(result.actuatorCount).toBe(1);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe('false');
|
||||
expect(document.querySelector('motor[joint="slider"]')?.hasAttribute('ctrlrange')).toBe(false);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe('slider_motor');
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe('-100 100');
|
||||
expect(document.querySelector('joint[name="slider"]')?.getAttribute('stiffness')).toBe('0');
|
||||
expect(document.querySelector('joint[name="slider"]')?.getAttribute('damping')).toBe('0');
|
||||
expect(document.querySelectorAll('[joint="shoulder"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]')).toHaveLength(1);
|
||||
expect(document.querySelector('[joint="ball"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数',()=>{
|
||||
const prefixes=['FL','FR','RL','RR'],parts=['hip','thigh','calf'];
|
||||
const joints=[...prefixes.flatMap(prefix=>parts.map(part=>`<joint name="${prefix}_${part}_joint"/>`)),...prefixes.map(prefix=>`<body name="${prefix}_foot"><joint name="${prefix}_foot_joint"/><geom group="0"/></body>`)].join('');
|
||||
const result=enhanceConvertedMjcf(encode(`<mujoco><worldbody><body name="base">${joints}</body></worldbody></mujoco>`),{addActuators:true,addSensors:false,sensorType:'camera'}),document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
expect(result.unitreeGo2wTuned).toBe(true);expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe('0.01');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe('0.1');expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe('-45.43 45.43');expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe('-15 15');expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6');
|
||||
});
|
||||
|
||||
it('将可调摄像头固连到指定机器人 body',()=>{
|
||||
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:true,sensorType:'camera',cameraMountBody:'head',cameraPosition:[.2,0,.1],cameraDirection:'+X'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
const camera=document.querySelector('body[name="head"] > camera');
|
||||
expect(result.cameraAdded).toBe(true);
|
||||
expect(result.imuAdded).toBe(true);
|
||||
expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({tagName:'site'});
|
||||
expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe('imu');
|
||||
expect(document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site')).toBe('imu');
|
||||
expect(camera?.getAttribute('mode')).toBe('fixed');
|
||||
expect(camera?.getAttribute('pos')).toBe('0.2 0 0.1');
|
||||
expect(camera?.getAttribute('xyaxes')).toBe('0 -1 0 0 0 1');
|
||||
expect(camera?.getAttribute('resolution')).toBe('640 480');
|
||||
const repeated=enhanceConvertedMjcf(result.data,{addActuators:false,addSensors:true,sensorType:'camera'});
|
||||
const repeatedDocument=new DOMParser().parseFromString(decode(repeated.data),'application/xml');
|
||||
expect(repeatedDocument.querySelectorAll('camera')).toHaveLength(1);
|
||||
expect(repeatedDocument.querySelectorAll('sensor > gyro')).toHaveLength(1);
|
||||
expect(repeatedDocument.querySelectorAll('sensor > accelerometer')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('关闭选项时不修改 actuator 和 camera',()=>{
|
||||
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:false,sensorType:'camera'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
expect(document.querySelector('actuator')).toBeNull();
|
||||
expect(document.querySelector('camera')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,78 @@ function numbers(value:string|undefined,count:number):number[]{
|
||||
}
|
||||
|
||||
export type UrdfBaseMode='floating'|'fixed';
|
||||
export type CameraDirection='+X'|'-X'|'+Y'|'-Y'|'+Z'|'-Z';
|
||||
export interface UrdfEnhancementOptions {addActuators:boolean;addSensors:boolean;sensorType:'camera';cameraMountBody?:string;cameraPosition?:[number,number,number];cameraDirection?:CameraDirection;}
|
||||
export interface UrdfEnhancementResult {data:Uint8Array;actuatorCount:number;cameraAdded:boolean;imuAdded:boolean;unitreeGo2wTuned:boolean;}
|
||||
|
||||
function uniqueName(document:Document,selector:string,base:string):string {
|
||||
const occupied=new Set(Array.from(document.querySelectorAll(selector)).map(element=>element.getAttribute('name')).filter(Boolean));
|
||||
if(!occupied.has(base))return base;
|
||||
for(let index=2;;index+=1)if(!occupied.has(`${base}_${index}`))return `${base}_${index}`;
|
||||
}
|
||||
|
||||
function tuneUnitreeGo2w(document:Document):boolean {
|
||||
const prefixes=['FL','FR','RL','RR'],legParts=['hip','thigh','calf'];
|
||||
const expected=[...prefixes.flatMap(prefix=>legParts.map(part=>`${prefix}_${part}_joint`)),...prefixes.map(prefix=>`${prefix}_foot_joint`)];
|
||||
const joints=new Map(Array.from(document.querySelectorAll('worldbody joint[name]')).map(joint=>[joint.getAttribute('name')!,joint]));
|
||||
if(!expected.every(name=>joints.has(name)))return false;
|
||||
const root=document.querySelector('mujoco');if(!root)return false;
|
||||
let option=Array.from(root.children).find(element=>element.tagName==='option');if(!option){option=document.createElement('option');root.prepend(option);}
|
||||
option.setAttribute('cone','elliptic');option.setAttribute('impratio','100');
|
||||
for(const name of expected){const joint=joints.get(name)!;joint.setAttribute('armature','0.01');joint.setAttribute('damping','0.1');joint.setAttribute('frictionloss','0.2');}
|
||||
for(const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))){const jointName=motor.getAttribute('joint')??'';if(!expected.includes(jointName))continue;const limit=jointName.includes('_calf_')?45.43:jointName.endsWith('_foot_joint')?15:23.7;motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange',`${-limit} ${limit}`);}
|
||||
for(const geom of Array.from(document.querySelectorAll('worldbody body geom'))){if(geom.getAttribute('contype')==='0'||geom.getAttribute('group')==='1')continue;geom.setAttribute('friction','0.4 0.005 0.0001');geom.setAttribute('margin','0.001');geom.setAttribute('condim','1');const body=geom.closest('body'),name=body?.getAttribute('name')??'';if(/_(?:foot|wheel)(?:_link)?$/i.test(name)){geom.setAttribute('friction','0.8 0.02 0.01');geom.setAttribute('condim','6');geom.setAttribute('priority','1');}}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 为转换后的 MJCF 按需补充可直接控制的关节电机和一台自动取景相机。 */
|
||||
export function enhanceConvertedMjcf(data:Uint8Array,options:UrdfEnhancementOptions):UrdfEnhancementResult {
|
||||
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
|
||||
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||
const root=document.querySelector('mujoco'),worldbody=document.querySelector('mujoco > worldbody');
|
||||
if(!root||!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||
let actuatorCount=0;
|
||||
if(options.addActuators){
|
||||
let actuator=Array.from(root.children).find(element=>element.tagName==='actuator');
|
||||
if(!actuator){actuator=document.createElement('actuator');root.append(actuator);}
|
||||
const driven=new Set(Array.from(actuator.children).flatMap(element=>[element.getAttribute('joint'),element.getAttribute('jointinparent')]).filter(Boolean));
|
||||
for(const joint of Array.from(worldbody.querySelectorAll('joint'))){
|
||||
const type=joint.getAttribute('type')??'hinge';
|
||||
if(type!=='hinge'&&type!=='slide')continue;
|
||||
let jointName=joint.getAttribute('name');
|
||||
if(!jointName){jointName=uniqueName(document,'joint[name]','__platform_joint__');joint.setAttribute('name',jointName);}
|
||||
if(driven.has(jointName))continue;
|
||||
const motor=document.createElement('motor');
|
||||
motor.setAttribute('name',uniqueName(document,'actuator > *[name]',`${jointName}_motor`));
|
||||
motor.setAttribute('joint',jointName);motor.setAttribute('ctrllimited','false');motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange','-100 100');
|
||||
if(!joint.hasAttribute('stiffness'))joint.setAttribute('stiffness','0');if(!joint.hasAttribute('damping'))joint.setAttribute('damping','0');
|
||||
actuator.append(motor);driven.add(jointName);actuatorCount+=1;
|
||||
}
|
||||
if(!actuator.children.length)actuator.remove();
|
||||
}
|
||||
const unitreeGo2wTuned=tuneUnitreeGo2w(document);
|
||||
let imuAdded=false;
|
||||
if(options.addSensors&&!document.querySelector('sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]')){
|
||||
const bodies=Array.from(worldbody.querySelectorAll('body')),mount=bodies.find(body=>/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name')??''))??Array.from(worldbody.children).find(element=>element.tagName==='body');
|
||||
if(mount){
|
||||
const site=document.createElement('site'),siteName=uniqueName(document,'site[name]','imu');site.setAttribute('name',siteName);site.setAttribute('pos','0 0 0');site.setAttribute('size','0.005');site.setAttribute('rgba','0.2 0.8 1 0.5');mount.append(site);
|
||||
let sensor=Array.from(root.children).find(element=>element.tagName==='sensor');if(!sensor){sensor=document.createElement('sensor');root.append(sensor);}
|
||||
const gyro=document.createElement('gyro');gyro.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_gyro'));gyro.setAttribute('site',siteName);gyro.setAttribute('noise','0');sensor.append(gyro);
|
||||
const accelerometer=document.createElement('accelerometer');accelerometer.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_acc'));accelerometer.setAttribute('site',siteName);accelerometer.setAttribute('noise','0');sensor.append(accelerometer);imuAdded=true;
|
||||
}
|
||||
}
|
||||
let cameraAdded=false;
|
||||
if(options.addSensors&&options.sensorType==='camera'&&!document.querySelector('camera[name^="__platform_camera__"]')){
|
||||
const bodies=Array.from(worldbody.querySelectorAll('body')),preferred=options.cameraMountBody?bodies.find(body=>body.getAttribute('name')===options.cameraMountBody):undefined;
|
||||
const mount=preferred??bodies.find(body=>/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name')??''))??bodies.at(-1);
|
||||
if(mount){
|
||||
const directions:Record<CameraDirection,string>={'+X':'0 -1 0 0 0 1','-X':'0 1 0 0 0 1','+Y':'1 0 0 0 0 1','-Y':'-1 0 0 0 0 1','+Z':'0 1 0 1 0 0','-Z':'0 -1 0 1 0 0'},position=options.cameraPosition??[.1,0,.05],direction=options.cameraDirection??'+X';
|
||||
const camera=document.createElement('camera');camera.setAttribute('name',uniqueName(document,'camera[name]','__platform_camera__'));camera.setAttribute('mode','fixed');
|
||||
camera.setAttribute('pos',position.map(value=>Number.isFinite(value)?value:0).join(' '));camera.setAttribute('xyaxes',directions[direction]);camera.setAttribute('fovy','45');camera.setAttribute('resolution','640 480');mount.append(camera);cameraAdded=true;
|
||||
}
|
||||
}
|
||||
return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded,imuAdded,unitreeGo2wTuned};
|
||||
}
|
||||
|
||||
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
|
||||
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import loadMujoco, {type MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from '../project/types';
|
||||
import type {ProjectFile,ProjectManifest} from '../project/types';
|
||||
import {prepareProjectForMujoco} from '../project/importer';
|
||||
import {groundConvertedMjcf,type UrdfBaseMode} from '../project/urdfToMjcf';
|
||||
import {enhanceConvertedMjcf,groundConvertedMjcf,type UrdfBaseMode,type UrdfEnhancementOptions} from '../project/urdfToMjcf';
|
||||
import {MemfsWorkspace} from '../project/workspace';
|
||||
import {SimulationSession, type FrameResult, type SimulationSnapshot} from './SimulationSession';
|
||||
import {SimulationSession,type ActuatorParameters,type FrameResult,type SimulationSnapshot} from './SimulationSession';
|
||||
import type {ControllerCommand,ControllerStatus} from '../controller/types';
|
||||
|
||||
export type UrdfLoadMode='mjcf'|'native';
|
||||
export type {UrdfBaseMode};
|
||||
export type {UrdfBaseMode,UrdfEnhancementOptions};
|
||||
|
||||
export interface PhysicsAdapter {
|
||||
load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode):Promise<SimulationSnapshot>;
|
||||
load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode,enhancements?:UrdfEnhancementOptions):Promise<SimulationSnapshot>;
|
||||
advance(now: number): FrameResult;
|
||||
snapshot(): SimulationSnapshot | null;
|
||||
setPaused(paused: boolean): void;
|
||||
@@ -17,11 +18,18 @@ export interface PhysicsAdapter {
|
||||
reset(): void;
|
||||
singleStep(): void;
|
||||
setActuator(id: number, value: number): void;
|
||||
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean;
|
||||
setJointPosition(id:number,value:number):boolean;
|
||||
resetJoints():void;
|
||||
setIgnoreJointLimits(ignore:boolean):void;
|
||||
setExternalForce(bodyId: number, force: [number, number, number]): void;
|
||||
clearExternalForce(): void;
|
||||
loadPythonController(source:string,path:string):Promise<ControllerStatus>;
|
||||
setControllerEnabled(enabled:boolean):void;
|
||||
sendControllerCommand(command:ControllerCommand):void;
|
||||
removeController():void;
|
||||
cachedSupportFiles():ProjectFile[];
|
||||
exportMjcf(): Uint8Array;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -40,9 +48,11 @@ export function getMujocoModule(): Promise<MainModule> {
|
||||
export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
session: SimulationSession | null = null;
|
||||
workspace: MemfsWorkspace | null = null;
|
||||
private supportFiles:ProjectFile[]=[];
|
||||
|
||||
async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating'):Promise<SimulationSnapshot> {
|
||||
async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating',enhancements:UrdfEnhancementOptions={addActuators:false,addSensors:false,sensorType:'camera'}):Promise<SimulationSnapshot> {
|
||||
this.releaseCurrent();const module=await getMujocoModule();const workspace=new MemfsWorkspace(module,manifest.id);const prepared=prepareProjectForMujoco(manifest,entryPath);
|
||||
this.supportFiles=prepared.manifest.files.filter(file=>!manifest.files.some(original=>original.path===file.path));
|
||||
try{
|
||||
console.info('[MuJoCo] 写入 MEMFS',prepared.manifest.files.length);workspace.mount(prepared.manifest);
|
||||
const entry=prepared.manifest.entries.find(candidate=>candidate.path===entryPath);let modelPath=workspace.path(entryPath);const warnings=[...prepared.warnings];
|
||||
@@ -51,8 +61,14 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
try{
|
||||
const minimumZ=intermediate.minimumGeometryZ();const slash=entryPath.lastIndexOf('/');const directory=slash>=0?entryPath.slice(0,slash+1):'';const convertedPath=`${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g,'_')}.xml`;
|
||||
if(module.mj_saveLastXML(workspace.path(convertedPath),intermediate.model)===0)throw new Error('MuJoCo 无法导出中间 MJCF');
|
||||
workspace.writeGenerated(convertedPath,groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode));modelPath=workspace.path(convertedPath);
|
||||
const grounded=groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode);
|
||||
const enhanced=enhanceConvertedMjcf(grounded,enhancements);
|
||||
workspace.writeGenerated(convertedPath,enhanced.data);modelPath=workspace.path(convertedPath);
|
||||
warnings.push(`URDF 已转换为 MJCF(${baseMode==='floating'?'浮动基座':'固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`);
|
||||
if(enhanced.actuatorCount)warnings.push(`已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`);
|
||||
if(enhanced.unitreeGo2wTuned)warnings.unshift('已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数');
|
||||
if(enhanced.imuAdded)warnings.unshift('已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)');
|
||||
if(enhanced.cameraAdded)warnings.push(`已将 640×480 摄像头固连到 ${enhancements.cameraMountBody||'自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition??[.1,0,.05]).join(' ')} m,朝向 ${enhancements.cameraDirection??'+X'}`);
|
||||
}finally{intermediate.dispose();}
|
||||
}
|
||||
console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings);
|
||||
@@ -68,11 +84,28 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
reset():void{this.session?.reset();}
|
||||
singleStep():void{this.session?.singleStep();}
|
||||
setActuator(id:number,value:number):void{this.session?.setActuator(id,value);}
|
||||
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean{return this.session?.setActuatorParameters(id,parameters)??false;}
|
||||
setJointPosition(id:number,value:number):boolean{return this.session?.setJointPosition(id,value)??false;}
|
||||
resetJoints():void {this.session?.resetJoints();}
|
||||
setIgnoreJointLimits(ignore:boolean):void {this.session?.setIgnoreJointLimits(ignore);}
|
||||
setExternalForce(bodyId:number,force:[number,number,number]):void{this.session?.setExternalForce(bodyId,force);}
|
||||
clearExternalForce():void{this.session?.clearExternalForce();}
|
||||
private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;}
|
||||
async loadPythonController(source:string,path:string):Promise<ControllerStatus>{if(!this.session)throw new Error('请先加载模型');return this.session.loadPythonController(source,path);}
|
||||
setControllerEnabled(enabled:boolean):void{this.session?.setControllerEnabled(enabled);}
|
||||
sendControllerCommand(command:ControllerCommand):void{this.session?.sendControllerCommand(command);}
|
||||
removeController():void{this.session?.removeController();}
|
||||
cachedSupportFiles():ProjectFile[]{return this.supportFiles.map(file=>({...file,data:file.data.slice()}));}
|
||||
exportMjcf():Uint8Array{
|
||||
if(!this.session||!this.workspace)throw new Error('尚未加载可导出的模型');
|
||||
const relative='.__platform_export__.xml';
|
||||
if(this.session.module.mj_saveLastXML(this.workspace.path(relative),this.session.model)===0)throw new Error('MuJoCo 无法生成 MJCF');
|
||||
const source=this.workspace.readText(relative),document=new DOMParser().parseFromString(source,'application/xml'),actuatorSection=document.querySelector('mujoco > actuator');
|
||||
if(document.querySelector('parsererror'))return new TextEncoder().encode(source);
|
||||
const snapshot=this.session.snapshot();
|
||||
if(actuatorSection){for(const info of snapshot.actuators){const element=Array.from(actuatorSection.children).find(candidate=>candidate.getAttribute('name')===info.name);if(!element)continue;element.setAttribute('ctrllimited',info.ctrlLimited?'true':'false');element.setAttribute('forcelimited',info.forceLimited?'true':'false');}}
|
||||
for(const info of snapshot.actuators){if(info.kind!=='motor'||!info.jointName)continue;const joint=Array.from(document.querySelectorAll('worldbody joint[name]')).find(candidate=>candidate.getAttribute('name')===info.jointName);if(joint){joint.setAttribute('stiffness',String(info.kp));joint.setAttribute('damping',String(info.kv));}}
|
||||
const output=new TextEncoder().encode(new XMLSerializer().serializeToString(document));this.workspace.writeGenerated(relative,output);return output;
|
||||
}
|
||||
private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;this.supportFiles=[];}
|
||||
dispose():void{this.releaseCurrent();}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco';
|
||||
import {meshIdFromSceneDataId} from './geometry';
|
||||
import {PythonControllerRuntime} from '../controller/PythonControllerRuntime';
|
||||
import type {ControllerBindings,ControllerCommand,ControllerStatus} from '../controller/types';
|
||||
|
||||
export interface ActuatorInfo {id: number; name: string; value: number; min: number; max: number; limited: boolean;}
|
||||
export interface ActuatorParameters {gear:number;gain:number;kp:number;kv:number;ctrlLimited:boolean;ctrlMin:number;ctrlMax:number;forceLimited:boolean;forceMin:number;forceMax:number;}
|
||||
export interface ActuatorInfo extends ActuatorParameters {id:number;name:string;value:number;min:number;max:number;limited:boolean;jointId?:number;jointName?:string;jointType?:number;unit:string;kind:'motor'|'position'|'velocity'|'other';controlCount:number;}
|
||||
export interface JointInfo {id:number;name:string;type:number;value:number;min:number;max:number;limitMin:number;limitMax:number;limited:boolean;limitsIgnored:boolean;editable:boolean;bodyId:number;axis:[number,number,number];}
|
||||
export interface BodyInfo {id:number;name:string;parentId:number;}
|
||||
export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; model: {nbody: number; njnt: number; ngeom: number; nu: number; nq: number; nv: number};}
|
||||
export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; controller?:ControllerStatus; model:{nbody:number;njnt:number;ngeom:number;ncam:number;nactuator:number;nu:number;nq:number;nv:number};}
|
||||
export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;}
|
||||
|
||||
export class SimulationSession {
|
||||
@@ -22,6 +25,8 @@ export class SimulationSession {
|
||||
private disposed=false;
|
||||
private ignoreJointLimits=false;
|
||||
private jointLimits:{limited:boolean;min:number;max:number;type:number}[]=[];
|
||||
private pythonController?:PythonControllerRuntime;
|
||||
private controllerLoadGeneration=0;
|
||||
|
||||
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
|
||||
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
|
||||
@@ -38,8 +43,8 @@ export class SimulationSession {
|
||||
|
||||
setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;}
|
||||
setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));}
|
||||
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();}
|
||||
singleStep(): void {this.applyForce(); this.module.mj_step(this.model, this.data);}
|
||||
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();this.data.ctrl.fill(0);this.pythonController?.reset(Number(this.data.time));}
|
||||
singleStep(): void {this.runController();this.applyForce();this.module.mj_step(this.model,this.data);}
|
||||
|
||||
advance(now: number): FrameResult {
|
||||
if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};}
|
||||
@@ -48,27 +53,78 @@ export class SimulationSession {
|
||||
this.accumulator += elapsed * this.speed;
|
||||
const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0;
|
||||
while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) {
|
||||
this.applyForce(); this.module.mj_step(this.model, this.data); this.accumulator -= dt; steps++;
|
||||
this.runController();this.applyForce();this.module.mj_step(this.model,this.data);this.accumulator-=dt;steps++;
|
||||
}
|
||||
const overBudget = this.accumulator >= dt;
|
||||
if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt);
|
||||
return {steps, stepMs: performance.now() - started, overBudget};
|
||||
}
|
||||
|
||||
async loadPythonController(source:string,path:string):Promise<ControllerStatus>{
|
||||
const generation=++this.controllerLoadGeneration;
|
||||
const runtime=await PythonControllerRuntime.load(source,path,this.controllerBindings());
|
||||
if(this.disposed||generation!==this.controllerLoadGeneration){runtime.dispose();throw new Error('模型已切换,控制器加载已取消');}
|
||||
this.pythonController?.dispose();this.pythonController=runtime;
|
||||
return runtime.status();
|
||||
}
|
||||
|
||||
setControllerEnabled(enabled:boolean):void {
|
||||
this.pythonController?.setEnabled(enabled,Number(this.data.time));
|
||||
if(!enabled)this.data.ctrl.fill(0);
|
||||
}
|
||||
|
||||
sendControllerCommand(command:ControllerCommand):void {this.pythonController?.command(command);}
|
||||
|
||||
removeController():void {this.controllerLoadGeneration+=1;this.pythonController?.dispose();this.pythonController=undefined;this.data.ctrl.fill(0);}
|
||||
|
||||
private runController():void {
|
||||
try{this.pythonController?.stepIfDue(Number(this.data.time));}
|
||||
catch(error){this.setPaused(true);this.data.ctrl.fill(0);throw error;}
|
||||
}
|
||||
|
||||
private controllerBindings():ControllerBindings {
|
||||
const joints=new Map<string,number>(),actuators=new Map<string,number>(),sensors=new Map<string,number>(),bodies=new Map<string,number>();
|
||||
for(let id=0;id<this.model.njnt;id+=1){const item=this.model.jnt(id);try{if(item.name)joints.set(item.name,id);}finally{item.delete();}}
|
||||
for(let id=0;id<this.model.nactuator;id+=1){const item=this.model.actuator(id);try{if(item.name)actuators.set(item.name,id);}finally{item.delete();}}
|
||||
for(let id=0;id<this.model.nsensor;id+=1){const item=this.model.sensor(id);try{if(item.name)sensors.set(item.name,id);}finally{item.delete();}}
|
||||
for(let id=0;id<this.model.nbody;id+=1){const item=this.model.body(id);try{if(item.name)bodies.set(item.name,id);}finally{item.delete();}}
|
||||
const resolve=(items:Map<string,number>,kind:string,name:string)=>{const id=items.get(name);if(id===undefined)throw new Error(`模型中找不到${kind}:${name}`);return id;};
|
||||
return {model:{joint:name=>resolve(joints,'关节',name),actuator:name=>resolve(actuators,'驱动器',name),sensor:name=>resolve(sensors,'传感器',name),body:name=>resolve(bodies,'Body',name)},createStepApi:(time,dt)=>({time,dt,qpos:(jointId)=>{const joint=this.model.jnt(jointId);try{const type=Number(joint.type);if(type!==2&&type!==3)throw new Error(`关节 ${jointId} 不是标量 hinge/slide 关节`);return Number(this.data.qpos[Number(joint.qposadr)]);}finally{joint.delete();}},qvel:(jointId)=>{const joint=this.model.jnt(jointId);try{return Number(this.data.qvel[Number(joint.dofadr)]);}finally{joint.delete();}},sensor:(sensorId)=>{if(sensorId<0||sensorId>=this.model.nsensor)throw new Error(`传感器 ID 无效:${sensorId}`);const adr=Number(this.model.sensor_adr[sensorId]),dim=Number(this.model.sensor_dim[sensorId]);return Array.from(this.data.sensordata.subarray(adr,adr+dim),Number);},body_quat:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*4;return [Number(this.data.xquat[adr]),Number(this.data.xquat[adr+1]),Number(this.data.xquat[adr+2]),Number(this.data.xquat[adr+3])];},body_position:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*3;return [Number(this.data.xpos[adr]),Number(this.data.xpos[adr+1]),Number(this.data.xpos[adr+2])];},set_control:(actuatorId,value)=>{if(!Number.isFinite(value))throw new Error(`控制输出不是有限数:${value}`);this.setActuator(actuatorId,value);}})};
|
||||
}
|
||||
|
||||
setActuator(id: number, value: number): void {
|
||||
if (id < 0 || id >= this.model.nu) return;
|
||||
if (id < 0 || id >= this.model.nactuator) return;
|
||||
const actuator = this.model.actuator(id);
|
||||
try {
|
||||
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu;if(nextAddress-address!==1)return;
|
||||
const limited = Boolean(actuator.ctrllimited);
|
||||
const min = limited ? Number(actuator.ctrlrange[0]) : -1;
|
||||
const max = limited ? Number(actuator.ctrlrange[1]) : 1;
|
||||
const address = Number(this.model.actuator_ctrladr[id] ?? id);
|
||||
const min = limited ? Number(actuator.ctrlrange[0]) : Number.NEGATIVE_INFINITY;
|
||||
const max = limited ? Number(actuator.ctrlrange[1]) : Number.POSITIVE_INFINITY;
|
||||
this.data.ctrl[address] = Math.min(max, Math.max(min, value));
|
||||
} finally {
|
||||
actuator.delete();
|
||||
}
|
||||
}
|
||||
|
||||
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean {
|
||||
if(id<0||id>=this.model.nactuator)return false;
|
||||
const finite=(value:number,fallback:number)=>Number.isFinite(value)?value:fallback;
|
||||
const ordered=(a:number,b:number,fallbackA:number,fallbackB:number):[number,number]=>{const first=finite(a,fallbackA),second=finite(b,fallbackB),lower=Math.min(first,second),upper=Math.max(first,second);return upper-lower>=1e-9?[lower,upper]:[lower,lower+1e-6];};
|
||||
const actuator=this.model.actuator(id);
|
||||
try{
|
||||
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu;
|
||||
const scalarJoint=nextAddress-address===1&&(Number(actuator.trntype)===0||Number(actuator.trntype)===1)&&Number(actuator.trnid[0])>=0,plainDynamics=Number(actuator.gaintype)===0&&Number(actuator.dyntype)===0;
|
||||
const motorLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===0,positionLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(Number(actuator.biasprm[1])+Number(actuator.gainprm[0]))<1e-6;if(!motorLike&&!positionLike)return false;
|
||||
const [ctrlMin,ctrlMax]=ordered(parameters.ctrlMin,parameters.ctrlMax,-1,1),[forceMin,forceMax]=ordered(parameters.forceMin,parameters.forceMax,-100,100);
|
||||
actuator.gear[0]=finite(parameters.gear,1);
|
||||
if(positionLike){const kp=Math.max(0,finite(parameters.kp,100)),kv=Math.max(0,finite(parameters.kv,10));actuator.gainprm[0]=kp;actuator.biasprm[1]=-kp;actuator.biasprm[2]=-kv;}else{actuator.gainprm[0]=finite(parameters.gain,1);const jointId=Number(actuator.trnid[0]);if(jointId>=0&&jointId<this.model.njnt){const joint=this.model.jnt(jointId);try{joint.stiffness=Math.max(0,finite(parameters.kp,0));joint.damping=Math.max(0,finite(parameters.kv,0));}finally{joint.delete();}}}
|
||||
actuator.ctrllimited=parameters.ctrlLimited?1:0;actuator.ctrlrange[0]=ctrlMin;actuator.ctrlrange[1]=ctrlMax;
|
||||
actuator.forcelimited=parameters.forceLimited?1:0;actuator.forcerange[0]=forceMin;actuator.forcerange[1]=forceMax;
|
||||
if(parameters.ctrlLimited)this.data.ctrl[address]=Math.min(ctrlMax,Math.max(ctrlMin,Number(this.data.ctrl[address])));
|
||||
this.module.mj_forward(this.model,this.data);return true;
|
||||
}finally{actuator.delete();}
|
||||
}
|
||||
|
||||
setJointPosition(id:number,value:number):boolean {
|
||||
if(id<0||id>=this.model.njnt)return false;const joint=this.model.jnt(id);
|
||||
try{const type=Number(joint.type);if(type!==2&&type!==3)return false;const original=this.jointLimits[id];const next=!this.ignoreJointLimits&&original.limited?Math.min(original.max,Math.max(original.min,value)):value;this.setPaused(true);this.data.qpos[Number(joint.qposadr)]=next;this.module.mj_forward(this.model,this.data);return true;}finally{joint.delete();}
|
||||
@@ -138,12 +194,17 @@ export class SimulationSession {
|
||||
}
|
||||
|
||||
snapshot(): SimulationSnapshot {
|
||||
const actuators = Array.from({length: this.model.nu}, (_, id): ActuatorInfo => {
|
||||
const actuators = Array.from({length: this.model.nactuator}, (_, id): ActuatorInfo => {
|
||||
const actuator = this.model.actuator(id);
|
||||
try {
|
||||
const limited = Boolean(actuator.ctrllimited);
|
||||
const address = Number(this.model.actuator_ctrladr[id] ?? id);
|
||||
return {id,name:actuator.name || `actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?Number(actuator.ctrlrange[0]):-1,max:limited?Number(actuator.ctrlrange[1]):1,limited};
|
||||
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu,controlCount=Math.max(1,nextAddress-address);
|
||||
const jointId=(Number(actuator.trntype)===0||Number(actuator.trntype)===1)&&Number(actuator.trnid[0])>=0?Number(actuator.trnid[0]):undefined;
|
||||
let jointName:string|undefined,jointType:number|undefined,jointStiffness=0,jointDamping=0;if(jointId!==undefined&&jointId<this.model.njnt){const joint=this.model.jnt(jointId);try{jointName=joint.name||`joint_${jointId}`;jointType=Number(joint.type);jointStiffness=Number(joint.stiffness);jointDamping=Number(joint.damping);}finally{joint.delete();}}
|
||||
const ctrlMin=Number(actuator.ctrlrange[0]),ctrlMax=Number(actuator.ctrlrange[1]),forceLimited=Boolean(actuator.forcelimited);
|
||||
const scalarJoint=controlCount===1&&jointId!==undefined,plainDynamics=Number(actuator.gaintype)===0&&Number(actuator.dyntype)===0,gain=Number(actuator.gainprm[0]),bias1=Number(actuator.biasprm[1]),bias2=Number(actuator.biasprm[2]);
|
||||
const kind:ActuatorInfo['kind']=scalarJoint&&plainDynamics&&Number(actuator.biastype)===0?'motor':scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(bias1+gain)<1e-6?'position':scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(bias1)<1e-9&&Math.abs(bias2+gain)<1e-6?'velocity':'other';
|
||||
return {id,name:actuator.name||`actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?ctrlMin:-100,max:limited?ctrlMax:100,limited,jointId,jointName,jointType,unit:kind==='motor'?(jointType===3?'N·m':jointType===2?'N':''):kind==='position'?(jointType===3?'°':jointType===2?'m':''):'',kind,controlCount,gear:Number(actuator.gear[0]),gain,kp:kind==='position'?gain:kind==='motor'?jointStiffness:0,kv:kind==='position'?Math.max(0,-bias2):kind==='velocity'?gain:kind==='motor'?jointDamping:0,ctrlLimited:limited,ctrlMin,ctrlMax,forceLimited,forceMin:Number(actuator.forcerange[0]),forceMax:Number(actuator.forcerange[1])};
|
||||
} finally {
|
||||
actuator.delete();
|
||||
}
|
||||
@@ -163,7 +224,7 @@ export class SimulationSession {
|
||||
try {return {id,name:body.name||`body_${id}`,parentId:Number(this.model.body_parentid[id])};}
|
||||
finally { body.delete(); }
|
||||
});
|
||||
return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
|
||||
return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,controller:this.pythonController?.status(),model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,ncam:this.model.ncam,nactuator:this.model.nactuator,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
|
||||
}
|
||||
dispose(): void {if(this.disposed)return; this.disposed=true; this.clearExternalForce(); this.perturb.delete(); this.data.delete(); this.model.delete();}
|
||||
dispose(): void {if(this.disposed)return;this.disposed=true;this.removeController();this.clearExternalForce();this.perturb.delete();this.data.delete();this.model.delete();}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ class CapsuleGeometry extends THREE.BufferGeometry {
|
||||
export class MuJoCoViewer {
|
||||
readonly scene = new THREE.Scene();
|
||||
readonly camera = new THREE.PerspectiveCamera(45,1,.01,2_000);
|
||||
private readonly sensorCamera=new THREE.PerspectiveCamera(45,16/9,.01,2_000);
|
||||
readonly renderer: THREE.WebGLRenderer;
|
||||
readonly controls: OrbitControls;
|
||||
mode: InteractionMode = 'select';
|
||||
forceScale = 50;
|
||||
private showCollision=false;
|
||||
private showSensorCamera=true;
|
||||
private sensorCameraId=-1;
|
||||
private session: SimulationSession | null = null;
|
||||
private option: MjvOption | null = null;
|
||||
private mjCamera: MjvCamera | null = null;
|
||||
@@ -44,9 +47,10 @@ export class MuJoCoViewer {
|
||||
this.frame=requestAnimationFrame(this.animate);
|
||||
}
|
||||
|
||||
attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768); this.fitCamera(session);}
|
||||
attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768);for(let id=0;id<session.model.ncam;id+=1){const camera=session.model.cam(id);try{if(this.sensorCameraId<0||camera.name.startsWith('__platform_camera__'))this.sensorCameraId=id;if(camera.name.startsWith('__platform_camera__'))break;}finally{camera.delete();}}this.fitCamera(session);}
|
||||
setMode(mode:InteractionMode):void {this.mode=mode; this.controls.enabled=mode==='select'; this.stopDrag();}
|
||||
setShowCollision(value:boolean):void {this.showCollision=value;this.applyGeomVisibility();}
|
||||
setShowSensorCamera(value:boolean):void {this.showSensorCamera=value;}
|
||||
setTheme(theme:ViewerTheme):void {const light=theme==='light';const background=this.scene.background instanceof THREE.Color?this.scene.background:new THREE.Color(0x0b1220);this.scene.background=background;this.themeTransition={started:performance.now(),fromBackground:background.clone(),toBackground:new THREE.Color(light?0xf8fafc:0x0b1220),fromGround:this.hemisphere.groundColor.clone(),toGround:new THREE.Color(light?0xcbd5e1:0x223344)};const next=new THREE.GridHelper(20,40,light?0x64748b:0x3b82f6,light?0xcbd5e1:0x253047).rotateX(Math.PI/2);this.scene.remove(this.grid);this.grid.geometry.dispose();const materials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of materials)material.dispose();this.grid=next;this.scene.add(this.grid);this.orientationGizmo.setTheme(theme);}
|
||||
private updateThemeTransition(now:number):void {const transition=this.themeTransition;if(!transition)return;const progress=Math.min(1,Math.max(0,(now-transition.started)/420)),eased=1-Math.pow(1-progress,3);(this.scene.background as THREE.Color).copy(transition.fromBackground).lerp(transition.toBackground,eased);this.hemisphere.groundColor.copy(transition.fromGround).lerp(transition.toGround,eased);if(progress===1)this.themeTransition=undefined;}
|
||||
private applyGeomVisibility():void {if(!this.option||!this.session)return;let hasVisual=false;for(let index=0;index<this.session.model.ngeom;index+=1)if(Number(this.session.model.geom_group[index])===1){hasVisual=true;break;}this.option.geomgroup[0]=!hasVisual||this.showCollision?1:0;this.option.geomgroup[1]=1;this.option.geomgroup[5]=0;}
|
||||
@@ -58,7 +62,10 @@ export class MuJoCoViewer {
|
||||
private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();}
|
||||
private resize():void {const w=Math.max(1,this.host.clientWidth),h=Math.max(1,this.host.clientHeight); this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix();}
|
||||
|
||||
private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.updateThemeTransition(now); this.controls.update();this.orientationGizmo.update(this.camera); if(this.session){this.updateMuJoCoScene();this.updateJointMarker();} this.renderer.render(this.scene,this.camera); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);};
|
||||
private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.updateThemeTransition(now); this.controls.update();this.orientationGizmo.update(this.camera); if(this.session){this.updateMuJoCoScene();this.updateJointMarker();} this.renderer.setScissorTest(false);this.renderer.render(this.scene,this.camera);if(this.showSensorCamera&&this.updateSensorCamera())this.renderSensorCamera(); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);};
|
||||
|
||||
private updateSensorCamera():boolean {if(!this.session||this.sensorCameraId<0||this.sensorCameraId>=this.session.model.ncam)return false;const id=this.sensorCameraId,p=id*3,m=id*9,data=this.session.data,model=this.session.model;this.sensorCamera.position.set(Number(data.cam_xpos[p]),Number(data.cam_xpos[p+1]),Number(data.cam_xpos[p+2]));const rotation=new THREE.Matrix4().set(Number(data.cam_xmat[m]),Number(data.cam_xmat[m+1]),Number(data.cam_xmat[m+2]),0,Number(data.cam_xmat[m+3]),Number(data.cam_xmat[m+4]),Number(data.cam_xmat[m+5]),0,Number(data.cam_xmat[m+6]),Number(data.cam_xmat[m+7]),Number(data.cam_xmat[m+8]),0,0,0,0,1);this.sensorCamera.quaternion.setFromRotationMatrix(rotation);this.sensorCamera.fov=Number(model.cam_fovy[id])||45;const extent=this.session.geometryBounds().extent;this.sensorCamera.near=Math.max(.001,extent/1000);this.sensorCamera.far=Math.max(100,extent*100);this.sensorCamera.updateProjectionMatrix();return true;}
|
||||
private renderSensorCamera():void {const size=this.renderer.getSize(new THREE.Vector2()),width=Math.max(120,Math.min(320,size.x*.32)),height=width*9/16,margin=16;this.sensorCamera.aspect=width/height;this.sensorCamera.updateProjectionMatrix();this.renderer.setViewport(margin,margin,width,height);this.renderer.setScissor(margin,margin,width,height);this.renderer.setScissorTest(true);this.renderer.render(this.scene,this.sensorCamera);this.renderer.setScissorTest(false);this.renderer.setViewport(0,0,size.x,size.y);}
|
||||
|
||||
private updateMuJoCoScene():void {const s=this.session!; s.module.mjv_updateScene(s.model,s.data,this.option!,s.perturb,this.mjCamera!,s.module.mjtCatBit.mjCAT_ALL.value,this.mjScene!); const geoms=this.mjScene!.geoms; try {for(let i=0;i<geoms.size();i++){const geom=geoms.get(i);if(!geom)continue;try{let mesh=this.meshes[i];const key=this.geometryKey(geom);if(!mesh||mesh.userData.geometryKey!==key){if(mesh){this.scene.remove(mesh);this.disposeMesh(mesh);} mesh=this.createMesh(geom,key);this.meshes[i]=mesh;this.scene.add(mesh);} mesh.visible=true;this.updateMesh(mesh,geom);}finally{geom.delete();}} for(let i=geoms.size();i<this.meshes.length;i++)this.meshes[i].visible=false;}finally{geoms.delete();}}
|
||||
private geometryKey(g:MjvGeom):string {const dataId=g.type===this.session!.module.mjtGeom.mjGEOM_MESH.value?meshIdFromSceneDataId(g.dataid):g.dataid;return `${g.type}:${dataId}:${Array.from(g.size).join(',')}`;}
|
||||
@@ -77,6 +84,6 @@ export class MuJoCoViewer {
|
||||
private showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.scene.add(this.arrow);}
|
||||
private updateArrow(force:[number,number,number]):void {if(!this.arrow)return;const v=new THREE.Vector3(...force),length=v.length()/25;if(length>1e-6){this.arrow.setDirection(v.normalize());this.arrow.setLength(length,Math.min(.2,length*.2),Math.min(.1,length*.1));}}
|
||||
private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).dispose();}
|
||||
private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.selected=null;this.highlightedJointId=-1;this.highlightedBodyId=-1;if(this.jointMarker){this.scene.remove(this.jointMarker);this.jointMarker.geometry.dispose();(this.jointMarker.material as THREE.Material).dispose();this.jointMarker=null;}}
|
||||
private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.sensorCameraId=-1;this.selected=null;this.highlightedJointId=-1;this.highlightedBodyId=-1;if(this.jointMarker){this.scene.remove(this.jointMarker);this.jointMarker.geometry.dispose();(this.jointMarker.material as THREE.Material).dispose();this.jointMarker=null;}}
|
||||
dispose():void {cancelAnimationFrame(this.frame);this.releaseModel();this.resizeObserver.disconnect();this.renderer.domElement.removeEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.removeEventListener('pointermove',this.onPointerMove);window.removeEventListener('pointerup',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.grid.geometry.dispose();const gridMaterials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of gridMaterials)material.dispose();this.renderer.dispose();this.renderer.domElement.remove();}
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'monaco-editor/esm/vs/editor/edcore.main' {
|
||||
export * from 'monaco-editor';
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {dirname,resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
import type {Plugin} from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const PYODIDE_ASSETS=['pyodide.asm.js','pyodide.asm.wasm','python_stdlib.zip','pyodide-lock.json'] as const;
|
||||
const pyodideDirectory=resolve(dirname(fileURLToPath(import.meta.url)),'../node_modules/pyodide');
|
||||
|
||||
/** 让开发服务器与生产构建都从本地 npm 包提供 Pyodide,保持平台可离线部署。 */
|
||||
function localPyodideAssets():Plugin {
|
||||
let isBuild=false;
|
||||
return {name:'local-pyodide-assets',configResolved(config){isBuild=config.command==='build';},configureServer(server){server.middlewares.use((request,response,next)=>{const name=request.url?.split(/[?#]/,1)[0].replace(/^\/pyodide\//,'');if(!name||!PYODIDE_ASSETS.includes(name as typeof PYODIDE_ASSETS[number])){next();return;}response.setHeader('Content-Type',name.endsWith('.wasm')?'application/wasm':name.endsWith('.zip')?'application/zip':name.endsWith('.json')?'application/json':'text/javascript');response.end(readFileSync(resolve(pyodideDirectory,name)));});},buildStart(){if(isBuild)for(const name of PYODIDE_ASSETS)this.emitFile({type:'asset',fileName:`pyodide/${name}`,source:readFileSync(resolve(pyodideDirectory,name))});}};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: 'web_platform', base: './', plugins: [react()], publicDir: 'public',
|
||||
build: {outDir: '../web-platform-dist', emptyOutDir: true, target: 'es2022'},
|
||||
worker: {format: 'es'},
|
||||
server: {open: true, fs: {allow: ['..']}},
|
||||
preview: {headers: {'Cache-Control': 'no-store'}},
|
||||
test: {globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', include: ['src/**/*.test.ts', 'src/**/*.test.tsx']}
|
||||
root:'web_platform',base:'./',plugins:[react(),localPyodideAssets()],publicDir:'public',
|
||||
build:{outDir:'../web-platform-dist',emptyOutDir:true,target:'es2022'},
|
||||
worker:{format:'es'},
|
||||
server:{open:true,fs:{allow:['..']}},
|
||||
preview:{headers:{'Cache-Control':'no-store'}},
|
||||
test:{globals:true,environment:'jsdom',setupFiles:'./src/test/setup.ts',include:['src/**/*.test.ts','src/**/*.test.tsx']}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user