feat(web-platform): release V0.4.1 完成基础控制
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
This commit is contained in:
+12
-1
@@ -22,4 +22,15 @@
|
||||
- actuator:官方 `FL_hip`/`FL_wheel` 风格,或平台生成的 `<joint-name>_motor`
|
||||
- 6轴 IMU:`imu_gyro`(三轴角速度)和 `imu_acc`(三轴加速度)
|
||||
|
||||
脚本默认只做原地站立、基于 IMU 的 roll/pitch 调平和轮毂姿态反馈,不包含行走、转向或轨迹规划。不同接触参数、质量或初始姿态下,可在脚本顶部调整 `NOMINAL`、`KP_FINAL`、`KD`、`RAMP_SECONDS` 和 `MAX_WHEEL_TORQUE`。
|
||||
脚本在原地站立和 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`。速度指令采用斜坡限制,避免轮毂力矩阶跃导致机身后仰;起跳会等待轮速和重心稳定,随后以小腿满力矩爆发伸展,并在腾空阶段主动收腿和持续控制俯仰。
|
||||
|
||||
+306
-11
@@ -33,12 +33,35 @@ 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
|
||||
@@ -99,6 +122,20 @@ def init(api):
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +153,18 @@ def step(ctx, state):
|
||||
_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"])
|
||||
@@ -129,11 +178,139 @@ def step(ctx, state):
|
||||
) + 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]
|
||||
@@ -141,38 +318,142 @@ def step(ctx, state):
|
||||
desired = down + ramp * (up - down)
|
||||
if part == "calf" and ramp > 0.8:
|
||||
# 通过左右/前后轮腿长度差调平车身:高侧缩短,低侧伸长。
|
||||
roll_term = 0.34 * roll + 0.025 * state["filtered_droll"]
|
||||
pitch_term = 0.30 * pitch + 0.022 * state["filtered_dpitch"]
|
||||
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])
|
||||
torque = kp * (target - position) - KD * velocity
|
||||
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))
|
||||
|
||||
# Go2-W 四轮保持自由滚动,只施加温和阻尼以抑制无指令漂移。
|
||||
wheel_velocities = [ctx.qvel(leg["wheel_joint"]) for leg in state["legs"]]
|
||||
average_velocity = sum(wheel_velocities) / len(wheel_velocities)
|
||||
wheel_torque = 1.4 * pitch + 0.10 * state["filtered_dpitch"]
|
||||
wheel_torque -= WHEEL_DAMPING * average_velocity
|
||||
wheel_torque = _clamp(wheel_torque, -MAX_WHEEL_TORQUE, MAX_WHEEL_TORQUE)
|
||||
# 四轮速度闭环:斜坡限速避免瞬时驱动力矩使机身后仰。
|
||||
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"]:
|
||||
ctx.set_control(leg["wheel_actuator"], wheel_torque)
|
||||
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.35:
|
||||
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
|
||||
@@ -180,6 +461,20 @@ def reset(state):
|
||||
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):
|
||||
|
||||
@@ -58,7 +58,7 @@ python3 -m http.server 8080 --directory wasm/web-platform-dist
|
||||
|
||||
## Python 控制器
|
||||
|
||||
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`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 限幅的控制量。异常会自动停止控制器、暂停仿真并清零 `ctrl`。
|
||||
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。
|
||||
|
||||
当前 Python 与 MuJoCo 都运行在主线程,以保证闭环调用严格位于 `mj_step` 前。仅运行可信脚本;死循环仍可能阻塞页面。Pyodide 及 Python 标准库由 npm 包随生产构建离线发布,不从 CDN 下载;暂不支持第三方 Python 包、`pip` 或多文件 import。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {ProjectManifest} from '../project/types';
|
||||
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 {ControllerStatus} from '../controller/types';
|
||||
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';
|
||||
@@ -73,6 +73,7 @@ export function App(){
|
||||
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);};
|
||||
@@ -100,7 +101,7 @@ export function App(){
|
||||
];
|
||||
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} 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} onRemoveController={removeController}/></div>
|
||||
<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,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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useRef,type ChangeEvent} from 'react';
|
||||
import {FileUp,Power,RotateCw,Trash2} from 'lucide-react';
|
||||
import type {ControllerStatus} from '../../controller/types';
|
||||
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 {
|
||||
@@ -12,10 +12,11 @@ export interface PythonControllerPanelProps {
|
||||
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,onRemove}:PythonControllerPanelProps){
|
||||
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>
|
||||
@@ -29,6 +30,7 @@ export function PythonControllerPanel({paths,selectedPath,status,loading,onSelec
|
||||
<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>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {countModelStructureSearchResults,ModelStructureTree} from '../../project
|
||||
import type {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
|
||||
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
|
||||
import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
|
||||
import type {ControllerStatus} from '../../controller/types';
|
||||
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';
|
||||
@@ -23,13 +23,13 @@ interface ModelControlsProps{
|
||||
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;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;onRemoveController:()=>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.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="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} 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>
|
||||
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>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {PyodideInterface} from 'pyodide';
|
||||
import type {PyCallable,PyDict} from 'pyodide/ffi';
|
||||
import type {ControllerBindings,ControllerStatus} from './types';
|
||||
import type {ControllerBindings,ControllerCommand,ControllerStatus} from './types';
|
||||
|
||||
const DEFAULT_CONTROL_HZ=100;
|
||||
const MIN_CONTROL_HZ=1;
|
||||
@@ -33,13 +33,14 @@ export class PythonControllerRuntime {
|
||||
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,lastStepMs:0};
|
||||
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>{
|
||||
@@ -57,6 +58,8 @@ export class PythonControllerRuntime {
|
||||
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('控制器函数必须同步执行');
|
||||
@@ -74,6 +77,22 @@ export class PythonControllerRuntime {
|
||||
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 {
|
||||
@@ -96,6 +115,7 @@ export class PythonControllerRuntime {
|
||||
|
||||
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;}
|
||||
@@ -108,8 +128,8 @@ export class PythonControllerRuntime {
|
||||
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.disposeFunction?.destroy();this.globals?.destroy();
|
||||
this.initFunction=undefined;this.stepFunction=undefined;this.resetFunction=undefined;this.disposeFunction=undefined;this.globals=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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type ControllerCommand='stop'|'forward'|'backward'|'turn_left'|'turn_right'|'jump';
|
||||
|
||||
export interface ControllerStatus {
|
||||
language:'python';
|
||||
path:string;
|
||||
@@ -5,6 +7,8 @@ export interface ControllerStatus {
|
||||
controlHz:number;
|
||||
loaded:boolean;
|
||||
enabled:boolean;
|
||||
acceptsCommands:boolean;
|
||||
activeCommand?:ControllerCommand;
|
||||
lastStepMs:number;
|
||||
error?:string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {prepareProjectForMujoco} from '../project/importer';
|
||||
import {enhanceConvertedMjcf,groundConvertedMjcf,type UrdfBaseMode,type UrdfEnhancementOptions} from '../project/urdfToMjcf';
|
||||
import {MemfsWorkspace} from '../project/workspace';
|
||||
import {SimulationSession,type ActuatorParameters,type FrameResult,type SimulationSnapshot} from './SimulationSession';
|
||||
import type {ControllerStatus} from '../controller/types';
|
||||
import type {ControllerCommand,ControllerStatus} from '../controller/types';
|
||||
|
||||
export type UrdfLoadMode='mjcf'|'native';
|
||||
export type {UrdfBaseMode,UrdfEnhancementOptions};
|
||||
@@ -26,6 +26,7 @@ export interface PhysicsAdapter {
|
||||
clearExternalForce(): void;
|
||||
loadPythonController(source:string,path:string):Promise<ControllerStatus>;
|
||||
setControllerEnabled(enabled:boolean):void;
|
||||
sendControllerCommand(command:ControllerCommand):void;
|
||||
removeController():void;
|
||||
cachedSupportFiles():ProjectFile[];
|
||||
exportMjcf(): Uint8Array;
|
||||
@@ -91,6 +92,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
clearExternalForce():void{this.session?.clearExternalForce();}
|
||||
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{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco';
|
||||
import {meshIdFromSceneDataId} from './geometry';
|
||||
import {PythonControllerRuntime} from '../controller/PythonControllerRuntime';
|
||||
import type {ControllerBindings,ControllerStatus} from '../controller/types';
|
||||
import type {ControllerBindings,ControllerCommand,ControllerStatus} from '../controller/types';
|
||||
|
||||
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;}
|
||||
@@ -73,6 +73,8 @@ export class SimulationSession {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user