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

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-24 17:24:21 +08:00
32 changed files with 1704 additions and 55 deletions
+110
View File
@@ -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",
+3
View File
@@ -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"
+36
View File
@@ -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`。速度指令采用斜坡限制,避免轮毂力矩阶跃导致机身后仰;起跳会等待轮速和重心稳定,随后以小腿满力矩爆发伸展,并在腾空阶段主动收腿和持续控制俯仰。
+481
View File
@@ -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
+11 -1
View File
@@ -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`(限制为 1500 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/slideball/free joint 只读。
- MuJoCo WASM 本身不支持 DAE mesh。平台会移除 DAE visual,并以 collision 几何显示;DAE collision 会替换为半径 0.05 m 的占位球体并在界面警告。高精度仿真应先将 DAE 转为 OBJ/STL 或改为 URDF primitive。
- 导入工程只存在当前页面内存,刷新页面后需重新导入。
+166
View File
@@ -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 和 SlideFree、Ball Joint 暂不提供完整的多自由度编辑器。
- 导入工程不会持久化,刷新页面后需要重新导入。
+65 -1
View File
@@ -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(/kpMJCF stiffness/),kv=page.getByLabel(/kvMJCF 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>
+40 -15
View File
@@ -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(/kpMJCF 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={`kpMJCF stiffness${forceUnit}/${actuator.jointType===3?'rad':'m'}`} value={actuator.kp} onCommit={kp=>update({kp})}/><ParameterInput label={`kvMJCF 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;
}
}
}
+37
View File
@@ -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轴 IMUimu_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();}
}
+10 -3
View File
@@ -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();}
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
declare module 'monaco-editor/esm/vs/editor/edcore.main' {
export * from 'monaco-editor';
}
+20 -6
View File
@@ -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']}
});