添加单车底盘仿真平台并完善运动控制与夹臂功能

This commit is contained in:
2026-07-27 17:47:50 +08:00
parent 580a936a83
commit e6b99c45b3
47 changed files with 4238 additions and 122 deletions
@@ -0,0 +1,121 @@
using MyParking.Simulation.Core;
namespace MyParking.Simulation.Commands;
/// <summary>
/// 内置离线测试动作;新增带特性的方法后网页会自动生成按钮。
/// </summary>
public static class BuiltInSimulationActions
{
[SimulationAction(
"mode-normal",
"正常",
"舵轮模式",
10)]
public static bool NormalMode(
SimulationVehicle vehicle)
{
return vehicle.SetMode("Normal");
}
[SimulationAction(
"mode-crab-left",
"左蟹行",
"舵轮模式",
20)]
public static bool CrabLeftMode(
SimulationVehicle vehicle)
{
return vehicle.SetMode("CrabLeft");
}
[SimulationAction(
"mode-crab-right",
"右蟹行",
"舵轮模式",
30)]
public static bool CrabRightMode(
SimulationVehicle vehicle)
{
return vehicle.SetMode("CrabRight");
}
[SimulationAction(
"mode-spin",
"自转",
"舵轮模式",
40)]
public static bool SpinMode(
SimulationVehicle vehicle)
{
return vehicle.SetMode("Spin");
}
[SimulationAction(
"forward",
"前进",
"运动测试",
10)]
public static bool Forward(
SimulationVehicle vehicle)
{
return vehicle.Move(1.0);
}
[SimulationAction(
"turn-left",
"左转",
"运动测试",
20)]
public static bool TurnLeft(
SimulationVehicle vehicle)
{
return vehicle.Turn(1.0);
}
[SimulationAction(
"stop",
"停止",
"运动测试",
30)]
public static bool Stop(
SimulationVehicle vehicle)
{
vehicle.Stop();
return true;
}
[SimulationAction(
"turn-right",
"右转",
"运动测试",
40)]
public static bool TurnRight(
SimulationVehicle vehicle)
{
return vehicle.Turn(-1.0);
}
[SimulationAction(
"backward",
"后退",
"运动测试",
50)]
public static bool Backward(
SimulationVehicle vehicle)
{
return vehicle.Move(-1.0);
}
[SimulationAction(
"reset",
"单车复位",
"维护",
10)]
public static bool Reset(
SimulationVehicle vehicle)
{
vehicle.Reset();
return true;
}
}
+31
View File
@@ -0,0 +1,31 @@
using MyParking.Shared;
using MyParking.Simulation.Core;
namespace MyParking.Simulation.Commands;
/// <summary>
/// 我自己增加的停车机器人仿真测试动作。
/// </summary>
public static class MySimulationTests
{
/// <summary>
/// 测试车辆以0.2m/s向车体左侧运动。
/// </summary>
// [SimulationAction(
// key: "move-left-020",
// displayName: "向左移动0.2m/s",
// group: "我的测试",
// order: 10)]
// public static bool MoveLeft(
// SimulationVehicle vehicle)
// {
// var command = new ChassisCommand(
// vehicle.VehicleId,
// new Twist2D(
// vxMetersPerSecond: 0.0,
// vyMetersPerSecond: 0.2,
// omegaRadiansPerSecond: 0.0));
// return vehicle.ApplyCommand(command);
// }
}
@@ -0,0 +1,38 @@
namespace MyParking.Simulation.Commands;
/// <summary>
/// 将一个静态仿真测试方法自动注册为网页按钮。
/// 方法签名必须为bool Xxx(SimulationVehicle vehicle)。
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class SimulationActionAttribute : Attribute
{
public SimulationActionAttribute(
string key,
string displayName,
string group,
int order = 0)
{
Key = key;
DisplayName = displayName;
Group = group;
Order = order;
}
public string Key { get; }
public string DisplayName { get; }
public string Group { get; }
public int Order { get; }
}
/// <summary>
/// 网页生成测试按钮所需的命令元数据。
/// </summary>
public sealed record SimulationActionDescriptor(
string Key,
string DisplayName,
string Group,
int Order);
@@ -0,0 +1,150 @@
using System.Reflection;
using MyParking.Simulation.Core;
namespace MyParking.Simulation.Commands;
/// <summary>
/// 自动发现带SimulationAction特性的测试方法并分发网页命令。
/// </summary>
public sealed class SimulationCommandDispatcher
{
private readonly SimulationWorld _world;
private readonly IReadOnlyDictionary<string, RegisteredAction> _actions;
public SimulationCommandDispatcher(
SimulationWorld world)
{
_world = world;
_actions = DiscoverActions();
}
/// <summary>
/// 返回网页动态生成按钮所需的全部命令。
/// </summary>
public IReadOnlyList<SimulationActionDescriptor> GetActions()
{
return _actions.Values
.Select(action => action.Descriptor)
.OrderBy(action => action.Group)
.ThenBy(action => action.Order)
.ToArray();
}
/// <summary>
/// 对指定车辆执行一个已注册的测试方法。
/// </summary>
public CommandResult Execute(
int vehicleId,
string command)
{
if (!_actions.TryGetValue(
command,
out var registeredAction))
{
return new CommandResult(
false,
$"未注册仿真命令:{command}。");
}
try
{
return _world.WithVehicle(vehicleId, vehicle =>
{
var success = registeredAction.Handler(vehicle);
var message = success
? $"车辆{vehicleId}已执行:{registeredAction.Descriptor.DisplayName}。"
: $"车辆{vehicleId}暂时无法执行:{registeredAction.Descriptor.DisplayName}。";
return new CommandResult(success, message);
});
}
catch (KeyNotFoundException exception)
{
return new CommandResult(
false,
exception.Message);
}
}
private static IReadOnlyDictionary<string, RegisteredAction>
DiscoverActions()
{
var actions = new Dictionary<string, RegisteredAction>(
StringComparer.OrdinalIgnoreCase);
var methods = Assembly.GetExecutingAssembly()
.GetTypes()
.SelectMany(type => type.GetMethods(
BindingFlags.Public |
BindingFlags.NonPublic |
BindingFlags.Static));
foreach (var method in methods)
{
var attribute =
method.GetCustomAttribute<SimulationActionAttribute>();
if (attribute == null)
continue;
ValidateMethod(method, attribute);
var handler =
(Func<SimulationVehicle, bool>)method.CreateDelegate(
typeof(Func<SimulationVehicle, bool>));
var descriptor = new SimulationActionDescriptor(
attribute.Key,
attribute.DisplayName,
attribute.Group,
attribute.Order);
if (!actions.TryAdd(
attribute.Key,
new RegisteredAction(descriptor, handler)))
{
throw new InvalidOperationException(
$"仿真命令Key重复:{attribute.Key}。");
}
}
return actions;
}
private static void ValidateMethod(
MethodInfo method,
SimulationActionAttribute attribute)
{
var parameters = method.GetParameters();
if (method.ReturnType != typeof(bool) ||
parameters.Length != 1 ||
parameters[0].ParameterType !=
typeof(SimulationVehicle))
{
throw new InvalidOperationException(
$"[{nameof(SimulationActionAttribute)}]方法" +
$"{method.DeclaringType?.FullName}.{method.Name}" +
"必须是static bool Xxx(SimulationVehicle vehicle)。");
}
if (string.IsNullOrWhiteSpace(attribute.Key) ||
string.IsNullOrWhiteSpace(attribute.DisplayName) ||
string.IsNullOrWhiteSpace(attribute.Group))
{
throw new InvalidOperationException(
$"仿真命令{method.Name}的特性参数不能为空。");
}
}
private sealed record RegisteredAction(
SimulationActionDescriptor Descriptor,
Func<SimulationVehicle, bool> Handler);
}
/// <summary>
/// 网页测试命令的执行结果。
/// </summary>
public sealed record CommandResult(
bool Success,
string Message);
+38
View File
@@ -0,0 +1,38 @@
using System.Diagnostics;
namespace MyParking.Simulation.Core;
/// <summary>
/// 以固定周期推进离线仿真世界。
/// </summary>
public sealed class SimulationClock(
SimulationWorld world,
ILogger<SimulationClock> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval =
TimeSpan.FromMilliseconds(20);
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
logger.LogInformation(
"停车机器人离线仿真时钟已启动,周期{Period}ms。",
TickInterval.TotalMilliseconds);
using var timer = new PeriodicTimer(TickInterval);
var stopwatch = Stopwatch.StartNew();
var previousSeconds = stopwatch.Elapsed.TotalSeconds;
while (await timer.WaitForNextTickAsync(stoppingToken))
{
var currentSeconds = stopwatch.Elapsed.TotalSeconds;
var deltaTimeSeconds = Math.Clamp(
currentSeconds - previousSeconds,
0.001,
0.1);
previousSeconds = currentSeconds;
world.Step(deltaTimeSeconds);
}
}
}
+382
View File
@@ -0,0 +1,382 @@
using MyParking.Shared;
using MyParking.Simulation.Models;
namespace MyParking.Simulation.Core;
/// <summary>
/// 保存单辆四舵轮停车机器人的离线仿真状态。
/// </summary>
public sealed class SimulationVehicle
{
public const double BodyLengthMeters = 1.472;
public const double BodyWidthMeters = 0.948;
// 当前MDCSToolBox.dll的MultiWheelChassisInitializer运行时轮位:
// X=±750mm、Y=±500mm,舵角机械限位为±120°。
private const double WheelX = 0.75;
private const double WheelY = 0.5;
private const double MaximumBodyAcceleration = 0.6;
private const double MaximumAngularAcceleration = 0.8;
private readonly List<VirtualSteerWheel> _wheels;
private readonly double _initialX;
private readonly double _initialY;
private readonly double _initialYaw;
private Twist2D _targetBodyTwist = Twist2D.Zero;
private double _actualVx;
private double _actualVy;
private double _actualOmega;
public SimulationVehicle(
int vehicleId,
double initialX,
double initialY,
double initialYaw)
{
VehicleId = vehicleId;
_initialX = initialX;
_initialY = initialY;
_initialYaw = initialYaw;
_wheels =
[
new VirtualSteerWheel("左前", WheelX, WheelY),
new VirtualSteerWheel("右前", WheelX, -WheelY),
new VirtualSteerWheel("左后", -WheelX, WheelY),
new VirtualSteerWheel("右后", -WheelX, -WheelY)
];
Reset();
}
public int VehicleId { get; }
public string Mode { get; private set; } = "Normal";
public double XMeters { get; private set; }
public double YMeters { get; private set; }
public double YawRadians { get; private set; }
public bool ModeReady => _wheels.All(wheel => wheel.IsAligned);
/// <summary>
/// 停车并切换舵轮准备模式。
/// </summary>
public bool SetMode(string mode)
{
Stop();
var success = mode switch
{
"Normal" => PrepareParallelDirection(0.0),
"CrabLeft" => PrepareParallelDirection(90.0),
"CrabRight" => PrepareParallelDirection(-90.0),
"Spin" => PrepareSpinDirection(),
_ => false
};
if (success)
Mode = mode;
return success;
}
/// <summary>
/// 按当前模式发送前进或后退命令。
/// </summary>
public bool Move(double directionSign)
{
if (!ModeReady)
return false;
const double linearSpeed = 0.35;
const double angularSpeed = 0.45;
var command = Mode switch
{
"Normal" => new Twist2D(
directionSign * linearSpeed, 0.0, 0.0),
"CrabLeft" => new Twist2D(
0.0, directionSign * linearSpeed, 0.0),
"CrabRight" => new Twist2D(
0.0, -directionSign * linearSpeed, 0.0),
"Spin" => new Twist2D(
0.0, 0.0, directionSign * angularSpeed),
_ => Twist2D.Zero
};
return ApplyCommand(
new ChassisCommand(VehicleId, command));
}
/// <summary>
/// 在当前运动模式下增加逆时针或顺时针转动。
/// </summary>
public bool Turn(double directionSign)
{
if (!ModeReady || Mode == "Spin")
return false;
var command = new Twist2D(
_targetBodyTwist.VxMetersPerSecond,
_targetBodyTwist.VyMetersPerSecond,
directionSign * 0.28);
return ApplyCommand(
new ChassisCommand(VehicleId, command));
}
/// <summary>
/// 将网页虚拟遥控器的油门和转向组合为连续车体速度命令。
/// </summary>
public bool ManualDrive(
double throttle,
double steering,
double speedScale,
double steeringScale)
{
if (!AreFinite(
throttle,
steering,
speedScale,
steeringScale))
{
return false;
}
throttle = Math.Clamp(throttle, -1.0, 1.0);
steering = Math.Clamp(steering, -1.0, 1.0);
speedScale = Math.Clamp(speedScale, 0.0, 1.0);
steeringScale = Math.Clamp(steeringScale, 0.0, 1.0);
if (Math.Abs(throttle) < 0.001 &&
Math.Abs(steering) < 0.001)
{
Stop();
return true;
}
if (!ModeReady)
return false;
const double maximumLinearSpeed = 0.6;
const double maximumAngularSpeed = 0.7;
var linearSpeed =
throttle * maximumLinearSpeed * speedScale;
var angularSpeed =
steering * maximumAngularSpeed * steeringScale;
var twist = Mode switch
{
"Normal" => new Twist2D(
linearSpeed,
0.0,
angularSpeed),
"CrabLeft" => new Twist2D(
0.0,
linearSpeed,
angularSpeed),
"CrabRight" => new Twist2D(
0.0,
-linearSpeed,
angularSpeed),
"Spin" => new Twist2D(
0.0,
0.0,
angularSpeed),
_ => Twist2D.Zero
};
return ApplyCommand(
new ChassisCommand(VehicleId, twist));
}
/// <summary>
/// 应用统一车体速度命令并分解为四个舵轮速度向量。
/// </summary>
public bool ApplyCommand(ChassisCommand command)
{
if (command.VehicleId != VehicleId)
return false;
var twist = command.BodyTwist;
var wheelCommands = _wheels.Select(wheel =>
{
var wheelVx =
twist.VxMetersPerSecond -
twist.OmegaRadiansPerSecond * wheel.YMeters;
var wheelVy =
twist.VyMetersPerSecond +
twist.OmegaRadiansPerSecond * wheel.XMeters;
return (Wheel: wheel, Vx: wheelVx, Vy: wheelVy);
}).ToArray();
foreach (var item in wheelCommands)
{
if (!item.Wheel.SetVelocityVector(
item.Vx,
item.Vy))
{
Stop();
return false;
}
}
_targetBodyTwist = twist;
return true;
}
/// <summary>
/// 将车辆目标速度设置为零并保持当前舵轮角度。
/// </summary>
public void Stop()
{
_targetBodyTwist = Twist2D.Zero;
foreach (var wheel in _wheels)
wheel.Stop();
}
/// <summary>
/// 更新舵轮反馈和车辆世界位姿。
/// </summary>
public void Step(double deltaTimeSeconds)
{
foreach (var wheel in _wheels)
wheel.Step(deltaTimeSeconds);
var canMove = _wheels.All(wheel => wheel.IsAligned);
var targetVx = canMove
? _targetBodyTwist.VxMetersPerSecond
: 0.0;
var targetVy = canMove
? _targetBodyTwist.VyMetersPerSecond
: 0.0;
var targetOmega = canMove
? _targetBodyTwist.OmegaRadiansPerSecond
: 0.0;
_actualVx = MoveTowards(
_actualVx,
targetVx,
MaximumBodyAcceleration * deltaTimeSeconds);
_actualVy = MoveTowards(
_actualVy,
targetVy,
MaximumBodyAcceleration * deltaTimeSeconds);
_actualOmega = MoveTowards(
_actualOmega,
targetOmega,
MaximumAngularAcceleration * deltaTimeSeconds);
var cos = Math.Cos(YawRadians);
var sin = Math.Sin(YawRadians);
var worldVx = cos * _actualVx - sin * _actualVy;
var worldVy = sin * _actualVx + cos * _actualVy;
XMeters += worldVx * deltaTimeSeconds;
YMeters += worldVy * deltaTimeSeconds;
YawRadians = FrameTransform2D.NormalizeAngle(
YawRadians + _actualOmega * deltaTimeSeconds);
}
/// <summary>
/// 恢复车辆初始位置和舵轮状态。
/// </summary>
public void Reset()
{
XMeters = _initialX;
YMeters = _initialY;
YawRadians = _initialYaw;
Mode = "Normal";
_targetBodyTwist = Twist2D.Zero;
_actualVx = 0.0;
_actualVy = 0.0;
_actualOmega = 0.0;
foreach (var wheel in _wheels)
wheel.Reset();
}
/// <summary>
/// 创建供网页读取的不可变状态快照。
/// </summary>
public VehicleStateDto GetSnapshot()
{
return new VehicleStateDto(
VehicleId,
XMeters,
YMeters,
YawRadians,
BodyLengthMeters,
BodyWidthMeters,
Mode,
ModeReady,
new TwistStateDto(
_targetBodyTwist.VxMetersPerSecond,
_targetBodyTwist.VyMetersPerSecond,
_targetBodyTwist.OmegaRadiansPerSecond),
new TwistStateDto(
_actualVx,
_actualVy,
_actualOmega),
_wheels.Select(wheel =>
new WheelStateDto(
wheel.Name,
wheel.XMeters,
wheel.YMeters,
wheel.TargetAngleDegrees,
wheel.ActualAngleDegrees,
wheel.TargetSpeedMetersPerSecond,
wheel.ActualSpeedMetersPerSecond,
wheel.IsAligned)).ToArray());
}
private bool PrepareParallelDirection(double targetAngleDegrees)
{
return _wheels.All(wheel =>
wheel.PrepareDirection(targetAngleDegrees));
}
private bool PrepareSpinDirection()
{
var success = true;
foreach (var wheel in _wheels)
{
var vx = -wheel.YMeters;
var vy = wheel.XMeters;
success &= wheel.SetVelocityVector(vx, vy);
wheel.Stop();
}
return success;
}
private static double MoveTowards(
double current,
double target,
double maximumChange)
{
var difference = target - current;
if (Math.Abs(difference) <= maximumChange)
return target;
return current + Math.Sign(difference) * maximumChange;
}
private static bool AreFinite(params double[] values)
{
return values.All(value =>
!double.IsNaN(value) &&
!double.IsInfinity(value));
}
}
+205
View File
@@ -0,0 +1,205 @@
using MyParking.Shared;
using MyParking.Simulation.Models;
namespace MyParking.Simulation.Core;
/// <summary>
/// 管理离线仿真车辆、车队中心和成员布局。
/// </summary>
public sealed class SimulationWorld
{
private readonly object _syncRoot = new();
private Dictionary<int, SimulationVehicle> _vehicles = new();
private SimulationConfigurationDto _configuration;
public SimulationWorld()
{
_configuration = CreateDefaultConfiguration();
ApplyConfigurationCore(_configuration);
}
public T WithVehicle<T>(
int vehicleId,
Func<SimulationVehicle, T> action)
{
lock (_syncRoot)
{
if (!_vehicles.TryGetValue(
vehicleId,
out var vehicle))
{
throw new KeyNotFoundException(
$"不存在车辆{vehicleId}。");
}
return action(vehicle);
}
}
public void Step(double deltaTimeSeconds)
{
lock (_syncRoot)
{
foreach (var vehicle in _vehicles.Values)
vehicle.Step(deltaTimeSeconds);
}
}
public IReadOnlyList<VehicleStateDto> GetSnapshot()
{
lock (_syncRoot)
{
return _vehicles.Values
.OrderBy(vehicle => vehicle.VehicleId)
.Select(vehicle => vehicle.GetSnapshot())
.ToArray();
}
}
public SimulationConfigurationDto GetConfiguration()
{
lock (_syncRoot)
{
return CloneConfiguration(_configuration);
}
}
public void ApplyConfiguration(
SimulationConfigurationDto configuration)
{
ValidateConfiguration(configuration);
lock (_syncRoot)
{
_configuration = CloneConfiguration(configuration);
ApplyConfigurationCore(_configuration);
}
}
public void Reset()
{
lock (_syncRoot)
{
ApplyConfigurationCore(_configuration);
}
}
private void ApplyConfigurationCore(
SimulationConfigurationDto configuration)
{
var fleetPoseInWorld = new Pose2D(
configuration.FleetCenter.XMeters,
configuration.FleetCenter.YMeters,
configuration.FleetCenter.YawRadians);
_vehicles = configuration.Vehicles
.Take(configuration.VehicleCount)
.Select(layout =>
{
var bodyPoseInFleet = new Pose2D(
layout.XMeters,
layout.YMeters,
layout.YawRadians);
var bodyPoseInWorld =
FrameTransform2D.Compose(
fleetPoseInWorld,
bodyPoseInFleet);
return new SimulationVehicle(
layout.VehicleId,
bodyPoseInWorld.XMeters,
bodyPoseInWorld.YMeters,
bodyPoseInWorld.YawRadians);
})
.ToDictionary(vehicle => vehicle.VehicleId);
}
private static void ValidateConfiguration(
SimulationConfigurationDto configuration)
{
if (configuration.VehicleCount is < 1 or > 8)
{
throw new ArgumentOutOfRangeException(
nameof(configuration.VehicleCount),
"仿真车辆数量必须在1到8之间。");
}
if (configuration.FleetCenter == null)
{
throw new ArgumentException(
"必须提供车队中心位姿。",
nameof(configuration));
}
if (configuration.Vehicles == null ||
configuration.Vehicles.Count <
configuration.VehicleCount)
{
throw new ArgumentException(
"成员布局数量不能少于车辆数量。",
nameof(configuration));
}
var selectedLayouts = configuration.Vehicles
.Take(configuration.VehicleCount)
.ToArray();
if (selectedLayouts.Any(layout =>
layout.VehicleId <= 0) ||
selectedLayouts
.Select(layout => layout.VehicleId)
.Distinct()
.Count() != selectedLayouts.Length)
{
throw new ArgumentException(
"车辆编号必须大于零且不能重复。",
nameof(configuration));
}
var values = new[]
{
configuration.FleetCenter.XMeters,
configuration.FleetCenter.YMeters,
configuration.FleetCenter.YawRadians
}.Concat(selectedLayouts.SelectMany(layout => new[]
{
layout.XMeters,
layout.YMeters,
layout.YawRadians
}));
if (values.Any(value =>
double.IsNaN(value) ||
double.IsInfinity(value)))
{
throw new ArgumentException(
"车队和车辆布局不能包含NaN或无穷大。",
nameof(configuration));
}
}
private static SimulationConfigurationDto
CreateDefaultConfiguration()
{
return new SimulationConfigurationDto(
1,
new FleetCenterDto(0.0, 0.0, 0.0),
new[]
{
new VehicleLayoutDto(1, 0.0, 0.0, 0.0)
});
}
private static SimulationConfigurationDto
CloneConfiguration(
SimulationConfigurationDto configuration)
{
return new SimulationConfigurationDto(
configuration.VehicleCount,
configuration.FleetCenter with { },
configuration.Vehicles
.Select(layout => layout with { })
.ToArray());
}
}
+174
View File
@@ -0,0 +1,174 @@
namespace MyParking.Simulation.Core;
/// <summary>
/// 模拟单个舵轮的转向和驱动响应,不包含真实电机物理模型。
/// </summary>
public sealed class VirtualSteerWheel
{
private const double AngleLowerLimitDegrees = -120.0;
private const double AngleUpperLimitDegrees = 120.0;
private const double MaximumSteeringRateDegreesPerSecond = 90.0;
private const double MaximumDriveAccelerationMetersPerSecondSquared = 0.8;
private const double AlignmentToleranceDegrees = 1.5;
public VirtualSteerWheel(
string name,
double xMeters,
double yMeters)
{
Name = name;
XMeters = xMeters;
YMeters = yMeters;
}
public string Name { get; }
public double XMeters { get; }
public double YMeters { get; }
public double TargetAngleDegrees { get; private set; }
public double ActualAngleDegrees { get; private set; }
public double TargetSpeedMetersPerSecond { get; private set; }
public double ActualSpeedMetersPerSecond { get; private set; }
public bool IsAligned =>
Math.Abs(TargetAngleDegrees - ActualAngleDegrees) <=
AlignmentToleranceDegrees;
/// <summary>
/// 设置期望轮胎速度向量,并在机械限位内选择等价舵角。
/// </summary>
public bool SetVelocityVector(
double vxMetersPerSecond,
double vyMetersPerSecond)
{
var speed = Math.Sqrt(
vxMetersPerSecond * vxMetersPerSecond +
vyMetersPerSecond * vyMetersPerSecond);
if (speed < 1e-6)
{
TargetSpeedMetersPerSecond = 0.0;
return true;
}
var desiredAngleDegrees =
Math.Atan2(vyMetersPerSecond, vxMetersPerSecond) *
180.0 / Math.PI;
return SetDirectionAndSpeed(
desiredAngleDegrees,
speed);
}
/// <summary>
/// 停车时设置舵轮预对齐方向。
/// </summary>
public bool PrepareDirection(double targetAngleDegrees)
{
TargetSpeedMetersPerSecond = 0.0;
return SetDirectionAndSpeed(
targetAngleDegrees,
0.0);
}
/// <summary>
/// 将驱动目标设置为零,并保持当前舵轮方向。
/// </summary>
public void Stop()
{
TargetSpeedMetersPerSecond = 0.0;
}
/// <summary>
/// 按固定转向速度和驱动加速度更新虚拟反馈。
/// </summary>
public void Step(double deltaTimeSeconds)
{
ActualAngleDegrees = MoveTowards(
ActualAngleDegrees,
TargetAngleDegrees,
MaximumSteeringRateDegreesPerSecond *
deltaTimeSeconds);
var allowedTargetSpeed =
IsAligned ? TargetSpeedMetersPerSecond : 0.0;
ActualSpeedMetersPerSecond = MoveTowards(
ActualSpeedMetersPerSecond,
allowedTargetSpeed,
MaximumDriveAccelerationMetersPerSecondSquared *
deltaTimeSeconds);
}
/// <summary>
/// 恢复舵轮初始状态。
/// </summary>
public void Reset()
{
TargetAngleDegrees = 0.0;
ActualAngleDegrees = 0.0;
TargetSpeedMetersPerSecond = 0.0;
ActualSpeedMetersPerSecond = 0.0;
}
private bool SetDirectionAndSpeed(
double desiredAngleDegrees,
double desiredSpeedMetersPerSecond)
{
var candidates = new[]
{
(Angle: NormalizeDegrees(desiredAngleDegrees),
Speed: desiredSpeedMetersPerSecond),
(Angle: NormalizeDegrees(desiredAngleDegrees + 180.0),
Speed: -desiredSpeedMetersPerSecond),
(Angle: NormalizeDegrees(desiredAngleDegrees - 180.0),
Speed: -desiredSpeedMetersPerSecond)
};
var validCandidates = candidates
.Where(candidate =>
candidate.Angle >= AngleLowerLimitDegrees &&
candidate.Angle <= AngleUpperLimitDegrees)
.OrderBy(candidate =>
Math.Abs(candidate.Angle - ActualAngleDegrees))
.ToArray();
if (validCandidates.Length == 0)
{
TargetSpeedMetersPerSecond = 0.0;
return false;
}
var selected = validCandidates[0];
TargetAngleDegrees = selected.Angle;
TargetSpeedMetersPerSecond = selected.Speed;
return true;
}
private static double MoveTowards(
double current,
double target,
double maximumChange)
{
var difference = target - current;
if (Math.Abs(difference) <= maximumChange)
return target;
return current + Math.Sign(difference) * maximumChange;
}
private static double NormalizeDegrees(double angleDegrees)
{
angleDegrees %= 360.0;
if (angleDegrees >= 180.0)
angleDegrees -= 360.0;
if (angleDegrees < -180.0)
angleDegrees += 360.0;
return angleDegrees;
}
}
@@ -0,0 +1,10 @@
namespace MyParking.Simulation.Models;
/// <summary>
/// 网页虚拟遥控器输入,所有输入范围均为-1到1。
/// </summary>
public sealed record ManualControlInputDto(
double Throttle,
double Steering,
double SpeedScale,
double SteeringScale);
@@ -0,0 +1,26 @@
namespace MyParking.Simulation.Models;
/// <summary>
/// 离线仿真的车辆数量、车队中心和成员相对布局。
/// </summary>
public sealed record SimulationConfigurationDto(
int VehicleCount,
FleetCenterDto FleetCenter,
IReadOnlyList<VehicleLayoutDto> Vehicles);
/// <summary>
/// 车队中心在世界坐标系中的位姿。
/// </summary>
public sealed record FleetCenterDto(
double XMeters,
double YMeters,
double YawRadians);
/// <summary>
/// 单车车体坐标系在车队中心坐标系中的位姿。
/// </summary>
public sealed record VehicleLayoutDto(
int VehicleId,
double XMeters,
double YMeters,
double YawRadians);
+25
View File
@@ -0,0 +1,25 @@
namespace MyParking.Simulation.Models;
/// <summary>
/// 网页绘制单辆停车机器人所需的状态快照。
/// </summary>
public sealed record VehicleStateDto(
int VehicleId,
double XMeters,
double YMeters,
double YawRadians,
double BodyLengthMeters,
double BodyWidthMeters,
string Mode,
bool ModeReady,
TwistStateDto TargetBodyTwist,
TwistStateDto ActualBodyTwist,
IReadOnlyList<WheelStateDto> Wheels);
/// <summary>
/// 网页显示的二维车体速度快照。
/// </summary>
public sealed record TwistStateDto(
double VxMetersPerSecond,
double VyMetersPerSecond,
double OmegaRadiansPerSecond);
+14
View File
@@ -0,0 +1,14 @@
namespace MyParking.Simulation.Models;
/// <summary>
/// 网页绘制单个舵轮所需的状态快照。
/// </summary>
public sealed record WheelStateDto(
string Name,
double XMeters,
double YMeters,
double TargetAngleDegrees,
double ActualAngleDegrees,
double TargetSpeedMetersPerSecond,
double ActualSpeedMetersPerSecond,
bool IsAligned);
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MyParking.Simulation</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Shared\ChassisCommand.cs"
Link="Shared\ChassisCommand.cs" />
<Compile Include="..\Shared\FrameTransform2D.cs"
Link="Shared\FrameTransform2D.cs" />
</ItemGroup>
</Project>
+97
View File
@@ -0,0 +1,97 @@
using MyParking.Simulation.Commands;
using MyParking.Simulation.Core;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<SimulationWorld>();
builder.Services.AddSingleton<SimulationCommandDispatcher>();
builder.Services.AddHostedService<SimulationClock>();
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/api/vehicles", (SimulationWorld world) =>
Results.Ok(world.GetSnapshot()));
app.MapGet(
"/api/actions",
(SimulationCommandDispatcher dispatcher) =>
Results.Ok(dispatcher.GetActions()));
app.MapGet("/api/configuration", (SimulationWorld world) =>
Results.Ok(world.GetConfiguration()));
app.MapPost(
"/api/configuration",
(MyParking.Simulation.Models.SimulationConfigurationDto configuration,
SimulationWorld world) =>
{
try
{
world.ApplyConfiguration(configuration);
return Results.Ok(world.GetConfiguration());
}
catch (ArgumentException exception)
{
return Results.BadRequest(new
{
message = exception.Message
});
}
});
app.MapPost(
"/api/vehicles/{vehicleId:int}/commands/{command}",
(int vehicleId, string command, SimulationCommandDispatcher dispatcher) =>
{
var result = dispatcher.Execute(vehicleId, command);
return result.Success
? Results.Ok(result)
: Results.BadRequest(result);
});
app.MapPost(
"/api/vehicles/{vehicleId:int}/manual-control",
(int vehicleId,
MyParking.Simulation.Models.ManualControlInputDto input,
SimulationWorld world) =>
{
try
{
var success = world.WithVehicle(
vehicleId,
vehicle => vehicle.ManualDrive(
input.Throttle,
input.Steering,
input.SpeedScale,
input.SteeringScale));
var result = new CommandResult(
success,
success
? $"车辆{vehicleId}虚拟遥控输入已更新。"
: $"车辆{vehicleId}舵轮尚未到位或输入无效。");
return success
? Results.Ok(result)
: Results.BadRequest(result);
}
catch (KeyNotFoundException exception)
{
return Results.NotFound(new CommandResult(
false,
exception.Message));
}
});
app.MapPost("/api/reset", (SimulationWorld world) =>
{
world.Reset();
return Results.Ok(new { message = "全部仿真车已复位。" });
});
app.MapFallbackToFile("index.html");
app.Run();
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:1095",
"sslPort": 44395
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5203",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7055;http://localhost:5203",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+144
View File
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>停车机器人离线测试台</title>
<link rel="stylesheet" href="viewer.css">
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">MY PARKING · OFFLINE LAB</p>
<h1>停车机器人离线测试台</h1>
</div>
<div class="connection">
<span id="connection-dot" class="dot"></span>
<span id="connection-text">正在连接仿真后端</span>
</div>
</header>
<section class="workspace">
<aside class="panel controls-panel">
<div class="panel-heading">
<span>控制台</span>
<button id="reset-all" class="text-button">全部复位</button>
</div>
<label class="field-label" for="vehicle-select">当前车辆</label>
<select id="vehicle-select">
<option value="1">车辆 1</option>
</select>
<section class="virtual-remote">
<div class="remote-heading">
<p class="group-label">虚拟遥控器</p>
<span id="remote-state">已松开</span>
</div>
<div class="remote-pad" aria-label="虚拟遥控方向面板">
<button data-throttle="1" data-steering="1"
aria-label="前进并左转"></button>
<button data-throttle="1" data-steering="0"
aria-label="前进"></button>
<button data-throttle="1" data-steering="-1"
aria-label="前进并右转"></button>
<button data-throttle="0" data-steering="1"
aria-label="原地向左转"></button>
<button id="remote-stop" class="remote-stop"
aria-label="停止">STOP</button>
<button data-throttle="0" data-steering="-1"
aria-label="原地向右转"></button>
<button data-throttle="-1" data-steering="1"
aria-label="后退并左转"></button>
<button data-throttle="-1" data-steering="0"
aria-label="后退"></button>
<button data-throttle="-1" data-steering="-1"
aria-label="后退并右转"></button>
</div>
<p class="remote-help">
按住操作;键盘 W/S 前后,A/D 转向,可组合按键。
</p>
<label class="remote-slider">
<span>线速度 <output id="speed-scale-value">60%</output></span>
<input id="speed-scale" type="range"
min="0" max="100" value="60">
</label>
<label class="remote-slider">
<span>转向强度 <output id="steering-scale-value">60%</output></span>
<input id="steering-scale" type="range"
min="0" max="100" value="60">
</label>
<p class="remote-mode-note">
遥控方向遵循当前舵轮模式;正常模式下可边走边转。
</p>
</section>
<div id="action-groups"></div>
<div id="command-message" class="message">
请选择车辆和模式。
</div>
<details class="configuration">
<summary>仿真布局配置</summary>
<label class="field-label" for="vehicle-count">车辆数量</label>
<select id="vehicle-count">
<option value="1">1 辆</option>
<option value="2">2 辆</option>
<option value="3">3 辆</option>
<option value="4">4 辆</option>
<option value="5">5 辆</option>
<option value="6">6 辆</option>
<option value="7">7 辆</option>
<option value="8">8 辆</option>
</select>
<p class="group-label">车队中心(世界系)</p>
<div class="numeric-grid">
<label>X / m<input id="fleet-x" type="number" step="0.1"></label>
<label>Y / m<input id="fleet-y" type="number" step="0.1"></label>
<label>Yaw / °<input id="fleet-yaw" type="number" step="1"></label>
</div>
<p class="group-label">单车相对车队中心位姿</p>
<div id="layout-rows" class="layout-rows"></div>
<button id="apply-configuration" class="primary-button">
应用布局并复位
</button>
</details>
<div class="legend">
<div><span class="axis-swatch x-axis"></span>车体 +X(车头)</div>
<div><span class="axis-swatch y-axis"></span>车体 +Y(左侧)</div>
<div><span class="legend-line zero"></span>舵轮机械 0°</div>
<div><span class="legend-line actual"></span>实际舵角</div>
<div><span class="legend-line target"></span>目标舵角</div>
<div><span class="legend-dot ready"></span>舵轮已到位</div>
<div><span class="legend-dot aligning"></span>舵轮转向中</div>
</div>
</aside>
<section class="panel viewport-panel">
<div class="panel-heading">
<span>世界视图</span>
<span class="coordinate-note">X 向右 · Y 向上 · 逆时针为正</span>
</div>
<canvas id="world-canvas"></canvas>
</section>
<aside class="panel status-panel">
<div class="panel-heading">
<span>车辆状态</span>
<span id="update-rate">-- Hz</span>
</div>
<div id="selected-vehicle-detail"></div>
<div id="vehicle-cards" class="vehicle-cards"></div>
</aside>
</section>
</main>
<script src="viewer.js"></script>
</body>
</html>
+603
View File
@@ -0,0 +1,603 @@
:root {
color-scheme: dark;
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
background: #050a0e;
color: #dbe9ef;
--panel: rgba(13, 24, 32, 0.92);
--border: rgba(116, 151, 168, 0.19);
--muted: #78909d;
--accent: #42dfb7;
--warning: #ffb257;
--danger: #ff6577;
}
* {
box-sizing: border-box;
}
body {
min-width: 1100px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 25% 0%,
rgba(32, 117, 106, 0.16), transparent 34%),
linear-gradient(145deg, #071018, #030609 72%);
}
button,
select {
font: inherit;
}
.app-shell {
min-height: 100vh;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
max-width: 1800px;
margin: 0 auto 18px;
}
.eyebrow {
margin: 0 0 7px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
}
h1 {
margin: 0;
font-size: 26px;
font-weight: 650;
}
.connection {
display: flex;
gap: 9px;
align-items: center;
color: var(--muted);
font-size: 13px;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--danger);
box-shadow: 0 0 12px rgba(255, 101, 119, 0.45);
}
.dot.online {
background: var(--accent);
box-shadow: 0 0 12px rgba(66, 223, 183, 0.5);
}
.workspace {
display: grid;
grid-template-columns: 320px minmax(600px, 1fr) 340px;
gap: 14px;
max-width: 1800px;
min-height: calc(100vh - 112px);
margin: 0 auto;
}
.panel {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--panel);
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.25);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
color: #bcd0d9;
font-size: 13px;
font-weight: 650;
}
.controls-panel {
max-height: calc(100vh - 112px);
overflow-y: auto;
padding-bottom: 18px;
}
.controls-panel > :not(.panel-heading) {
margin-right: 16px;
margin-left: 16px;
}
.text-button {
width: auto;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font-size: 12px;
}
.field-label,
.group-label {
display: block;
margin-top: 20px;
margin-bottom: 8px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
select,
.button-grid button,
.drive-pad button {
border: 1px solid rgba(126, 164, 182, 0.23);
border-radius: 8px;
background: #101e27;
color: #dbe9ef;
}
select {
width: calc(100% - 32px);
padding: 10px;
}
.virtual-remote {
margin-top: 18px !important;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(5, 12, 17, 0.58);
}
.remote-heading {
display: flex;
align-items: center;
justify-content: space-between;
}
.remote-heading .group-label {
margin: 0;
}
#remote-state {
color: var(--muted);
font-size: 10px;
}
#remote-state.active {
color: var(--accent);
}
.remote-pad {
display: grid;
grid-template-columns: repeat(3, 54px);
gap: 7px;
justify-content: center;
margin: 12px 0 8px;
touch-action: none;
user-select: none;
}
.remote-pad button {
width: 54px;
height: 45px;
padding: 0;
border: 1px solid rgba(126, 164, 182, 0.3);
border-radius: 9px;
background: #101e27;
color: #dbe9ef;
cursor: pointer;
font-size: 22px;
}
.remote-pad button:hover,
.remote-pad button.pressed {
border-color: var(--accent);
background: rgba(66, 223, 183, 0.18);
color: #78f0d5;
}
.remote-pad .remote-stop {
border-color: rgba(255, 101, 119, 0.45);
color: #ff98a5;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.08em;
}
.remote-help,
.remote-mode-note {
margin: 7px 0 0;
color: #718a96;
font-size: 9px;
line-height: 1.45;
text-align: center;
}
.remote-slider {
display: grid;
gap: 4px;
margin-top: 10px;
color: #8fa8b4;
font-size: 10px;
}
.remote-slider span {
display: flex;
justify-content: space-between;
}
.remote-slider output {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.remote-slider input {
height: 18px;
padding: 0;
accent-color: var(--accent);
}
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.action-grid button {
min-height: 38px;
border: 1px solid rgba(126, 164, 182, 0.23);
border-radius: 8px;
background: #101e27;
color: #dbe9ef;
cursor: pointer;
transition:
border-color 120ms,
background 120ms,
transform 120ms;
}
.action-grid button:hover {
border-color: var(--accent);
background: #142c31;
transform: translateY(-1px);
}
.action-grid .danger-button {
border-color: rgba(255, 101, 119, 0.45);
color: #ff98a5;
}
.configuration {
margin-top: 18px !important;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 13px 0 16px;
}
.configuration summary {
color: #bcd0d9;
cursor: pointer;
font-size: 12px;
font-weight: 650;
}
.configuration select {
width: 100%;
}
.numeric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.numeric-grid label,
.layout-row label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 9px;
}
input {
width: 100%;
min-width: 0;
padding: 7px 5px;
border: 1px solid rgba(126, 164, 182, 0.23);
border-radius: 6px;
outline: 0;
background: #0b171f;
color: #dbe9ef;
font: inherit;
font-variant-numeric: tabular-nums;
}
input:focus {
border-color: var(--accent);
}
.layout-rows {
display: grid;
gap: 7px;
max-height: 275px;
overflow-y: auto;
}
.layout-row {
display: grid;
grid-template-columns: 48px repeat(3, 1fr);
gap: 5px;
align-items: end;
padding: 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: rgba(5, 12, 17, 0.6);
}
.layout-row strong {
align-self: center;
color: #9cb2bd;
font-size: 10px;
}
.primary-button {
width: 100%;
margin-top: 10px;
padding: 9px;
border: 1px solid rgba(66, 223, 183, 0.5);
border-radius: 7px;
background: rgba(66, 223, 183, 0.12);
color: #78f0d5;
cursor: pointer;
}
.message {
min-height: 56px;
margin-top: 18px !important;
padding: 11px;
border-left: 3px solid var(--accent);
border-radius: 4px;
background: rgba(66, 223, 183, 0.07);
color: #a8c4cf;
font-size: 12px;
line-height: 1.5;
}
.message.error {
border-left-color: var(--danger);
background: rgba(255, 101, 119, 0.08);
}
.legend {
display: grid;
gap: 10px;
margin-top: 22px !important;
color: var(--muted);
font-size: 11px;
}
.legend > div {
display: flex;
gap: 9px;
align-items: center;
}
.legend-line {
width: 24px;
height: 0;
border-top: 3px solid var(--accent);
}
.legend-line.target {
border-top-style: dashed;
border-top-color: var(--warning);
}
.legend-line.zero {
border-top-width: 1px;
border-top-style: dashed;
border-top-color: #56a7ff;
}
.axis-swatch {
width: 24px;
height: 3px;
background: #ff6477;
}
.axis-swatch.y-axis {
background: #54d991;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
}
.legend-dot.aligning {
background: var(--warning);
}
.viewport-panel {
display: flex;
min-width: 0;
flex-direction: column;
}
.coordinate-note,
#update-rate {
color: var(--muted);
font-size: 11px;
font-weight: 500;
}
#world-canvas {
display: block;
width: 100%;
height: calc(100% - 48px);
min-height: 620px;
}
.vehicle-cards {
display: grid;
gap: 9px;
padding: 12px;
}
.status-panel {
max-height: calc(100vh - 112px);
overflow-y: auto;
}
.wheel-detail {
padding: 12px;
border-bottom: 1px solid var(--border);
}
.geometry-note {
margin-bottom: 11px;
color: #8fa8b4;
font-size: 10px;
line-height: 1.5;
}
.wheel-row {
margin-top: 10px;
}
.wheel-row-title {
display: flex;
justify-content: space-between;
gap: 8px;
color: #9eb4be;
font-size: 10px;
}
.wheel-row-title span {
font-variant-numeric: tabular-nums;
}
.angle-scale {
position: relative;
height: 8px;
margin: 6px 0 3px;
border-radius: 4px;
background:
linear-gradient(90deg,
rgba(181, 140, 255, 0.42),
rgba(66, 223, 183, 0.15) 50%,
rgba(181, 140, 255, 0.42));
}
.zero-mark,
.target-mark,
.actual-mark {
position: absolute;
top: -3px;
width: 2px;
height: 14px;
transform: translateX(-1px);
}
.zero-mark {
left: 50%;
background: #56a7ff;
}
.target-mark {
background: var(--warning);
}
.actual-mark {
width: 3px;
background: var(--accent);
}
.wheel-speed {
color: #617b88;
font-size: 9px;
text-align: right;
}
.vehicle-card {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 9px;
background: rgba(9, 18, 24, 0.78);
color: inherit;
cursor: pointer;
text-align: left;
}
.vehicle-card.selected {
border-color: rgba(66, 223, 183, 0.7);
background: rgba(36, 120, 105, 0.11);
}
.card-topline {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.status-ready {
color: var(--accent);
}
.status-aligning {
color: var(--warning);
}
.mode-name {
margin: 7px 0 9px;
color: #91a9b5;
font-size: 12px;
}
dl {
display: grid;
gap: 5px;
margin: 0;
font-size: 11px;
}
dl div {
display: flex;
justify-content: space-between;
}
dt {
color: #607985;
}
dd {
margin: 0;
color: #bcd0d9;
font-variant-numeric: tabular-nums;
}
@media (max-width: 1300px) {
body {
min-width: 1100px;
}
.workspace {
grid-template-columns: 285px minmax(520px, 1fr) 305px;
}
}
+842
View File
@@ -0,0 +1,842 @@
const canvas = document.getElementById("world-canvas");
const context = canvas.getContext("2d");
const vehicleSelect = document.getElementById("vehicle-select");
const commandMessage = document.getElementById("command-message");
const connectionDot = document.getElementById("connection-dot");
const connectionText = document.getElementById("connection-text");
const vehicleCards = document.getElementById("vehicle-cards");
const selectedVehicleDetail =
document.getElementById("selected-vehicle-detail");
const actionGroups = document.getElementById("action-groups");
const updateRate = document.getElementById("update-rate");
const vehicleCountInput = document.getElementById("vehicle-count");
const layoutRows = document.getElementById("layout-rows");
const remoteState = document.getElementById("remote-state");
const speedScaleInput = document.getElementById("speed-scale");
const steeringScaleInput =
document.getElementById("steering-scale");
const speedScaleValue =
document.getElementById("speed-scale-value");
const steeringScaleValue =
document.getElementById("steering-scale-value");
let vehicles = [];
let actions = [];
let configuration = {
vehicleCount: 1,
fleetCenter: { xMeters: 0, yMeters: 0, yawRadians: 0 },
vehicles: [
{ vehicleId: 1, xMeters: 0, yMeters: 0, yawRadians: 0 }
]
};
let lastUpdateTime = performance.now();
let successfulUpdates = 0;
let remoteThrottle = 0;
let remoteSteering = 0;
let remotePointerId = null;
let remoteVehicleId = null;
const pressedDriveKeys = new Set();
function resizeCanvas() {
const bounds = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(bounds.width * ratio));
canvas.height = Math.max(1, Math.floor(bounds.height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
}
function getView() {
const centerX = configuration.fleetCenter.xMeters;
const centerY = configuration.fleetCenter.yMeters;
let radiusX = 2.1;
let radiusY = 1.7;
for (const vehicle of vehicles) {
radiusX = Math.max(
radiusX,
Math.abs(vehicle.xMeters - centerX) + 1.2);
radiusY = Math.max(
radiusY,
Math.abs(vehicle.yMeters - centerY) + 1.0);
}
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const scale = Math.min(
width / (radiusX * 2),
height / (radiusY * 2),
155);
return { centerX, centerY, scale, width, height };
}
function worldToScreen(xMeters, yMeters, view = getView()) {
return {
x: view.width / 2 +
(xMeters - view.centerX) * view.scale,
y: view.height / 2 -
(yMeters - view.centerY) * view.scale,
scale: view.scale
};
}
function drawGrid(view) {
const width = view.width;
const height = view.height;
const worldOrigin = worldToScreen(0, 0, view);
const spacing = view.scale;
context.clearRect(0, 0, width, height);
context.fillStyle = "#071018";
context.fillRect(0, 0, width, height);
context.lineWidth = 1;
context.strokeStyle = "rgba(109, 143, 160, 0.12)";
for (let x = worldOrigin.x % spacing; x < width; x += spacing) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, height);
context.stroke();
}
for (let y = worldOrigin.y % spacing; y < height; y += spacing) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(width, y);
context.stroke();
}
context.strokeStyle = "rgba(90, 125, 142, 0.35)";
context.lineWidth = 1.2;
context.beginPath();
context.moveTo(0, worldOrigin.y);
context.lineTo(width, worldOrigin.y);
context.moveTo(worldOrigin.x, 0);
context.lineTo(worldOrigin.x, height);
context.stroke();
}
function drawFleetCenter(view) {
const fleet = configuration.fleetCenter;
const point = worldToScreen(
fleet.xMeters,
fleet.yMeters,
view);
const yaw = fleet.yawRadians;
const axisLength = 55;
context.save();
context.translate(point.x, point.y);
context.rotate(-yaw);
context.fillStyle = "#b58cff";
context.beginPath();
context.moveTo(0, -8);
context.lineTo(8, 0);
context.lineTo(0, 8);
context.lineTo(-8, 0);
context.closePath();
context.fill();
drawArrow(0, 0, axisLength, 0, "#ff7285", 2);
drawArrow(0, 0, 0, -axisLength, "#66d99a", 2);
context.restore();
context.fillStyle = "#c7aaff";
context.font = "12px Inter, sans-serif";
context.fillText("FLEET CENTER", point.x + 12, point.y - 11);
}
function drawVehicle(vehicle, view) {
const point = worldToScreen(
vehicle.xMeters,
vehicle.yMeters,
view);
const selected = Number(vehicleSelect.value) === vehicle.vehicleId;
const bodyLength = vehicle.bodyLengthMeters * point.scale;
const bodyWidth = vehicle.bodyWidthMeters * point.scale;
context.save();
context.translate(point.x, point.y);
context.rotate(-vehicle.yawRadians);
context.fillStyle = selected
? "rgba(26, 180, 155, 0.2)"
: "rgba(50, 73, 88, 0.42)";
context.strokeStyle = selected ? "#39d8b7" : "#668392";
context.lineWidth = selected ? 2.5 : 1.5;
context.beginPath();
context.roundRect(
-bodyLength / 2,
-bodyWidth / 2,
bodyLength,
bodyWidth,
12);
context.fill();
context.stroke();
drawBodyAxes(bodyLength, bodyWidth);
for (const wheel of vehicle.wheels) {
drawWheel(
wheel.xMeters * point.scale,
-wheel.yMeters * point.scale,
wheel);
}
context.fillStyle = "rgba(202, 222, 230, 0.8)";
context.font = "10px Inter, sans-serif";
context.textAlign = "center";
context.fillText(
"1472 × 948 mm",
0,
bodyWidth / 2 - 8);
context.restore();
context.textAlign = "left";
context.fillStyle = "#dbe9ef";
context.font = "600 13px Inter, sans-serif";
context.fillText(
`CAR ${vehicle.vehicleId}`,
point.x - 24,
point.y - bodyWidth / 2 - 18);
context.fillStyle = vehicle.modeReady ? "#42dfb7" : "#ffb257";
context.font = "12px Inter, sans-serif";
context.fillText(
vehicle.modeReady ? "READY" : "ALIGNING",
point.x - 23,
point.y + bodyWidth / 2 + 25);
}
function drawBodyAxes(bodyLength, bodyWidth) {
const xLength = bodyLength / 2 + 30;
const yLength = bodyWidth / 2 + 27;
drawArrow(0, 0, xLength, 0, "#ff6477", 2.4);
drawArrow(0, 0, 0, -yLength, "#54d991", 2.4);
context.font = "700 11px Inter, sans-serif";
context.fillStyle = "#ff8b99";
context.fillText("+X", xLength - 2, -7);
context.fillStyle = "#7debab";
context.fillText("+Y", 6, -yLength + 2);
}
function drawWheel(x, y, wheel) {
const wheelLength = 33;
const limitRadius = 20;
context.save();
context.translate(x, y);
context.strokeStyle = "rgba(86, 167, 255, 0.72)";
context.lineWidth = 1.2;
context.setLineDash([3, 3]);
context.beginPath();
context.moveTo(-22, 0);
context.lineTo(24, 0);
context.stroke();
drawWheelLimitArc(limitRadius);
context.save();
context.rotate(-wheel.targetAngleDegrees * Math.PI / 180);
context.strokeStyle = "rgba(255, 178, 87, 0.9)";
context.lineWidth = 3;
context.setLineDash([5, 4]);
context.beginPath();
context.moveTo(-wheelLength / 2, 0);
context.lineTo(wheelLength / 2, 0);
context.stroke();
context.restore();
context.save();
context.rotate(-wheel.actualAngleDegrees * Math.PI / 180);
context.strokeStyle = wheel.isAligned ? "#42dfb7" : "#f2f6f8";
context.lineWidth = 7;
context.setLineDash([]);
context.lineCap = "round";
context.beginPath();
context.moveTo(-wheelLength / 2, 0);
context.lineTo(wheelLength / 2, 0);
context.stroke();
context.restore();
context.setLineDash([]);
context.fillStyle = wheel.isAligned ? "#7ef4dc" : "#ffca83";
context.font = "700 9px Inter, sans-serif";
context.textAlign = "center";
const sign = wheel.actualAngleDegrees >= 0 ? "+" : "";
context.fillText(
`${sign}${wheel.actualAngleDegrees.toFixed(0)}°`,
0,
-25);
context.restore();
}
function drawWheelLimitArc(radius) {
context.strokeStyle = "rgba(181, 140, 255, 0.5)";
context.lineWidth = 1;
context.setLineDash([]);
context.beginPath();
for (let angle = -120; angle <= 120; angle += 5) {
const radians = -angle * Math.PI / 180;
const x = Math.cos(radians) * radius;
const y = Math.sin(radians) * radius;
if (angle === -120) context.moveTo(x, y);
else context.lineTo(x, y);
}
context.stroke();
for (const angle of [-120, 0, 120]) {
const radians = -angle * Math.PI / 180;
context.beginPath();
context.moveTo(
Math.cos(radians) * (radius - 3),
Math.sin(radians) * (radius - 3));
context.lineTo(
Math.cos(radians) * (radius + 3),
Math.sin(radians) * (radius + 3));
context.stroke();
}
}
function drawArrow(x1, y1, x2, y2, color, width) {
const angle = Math.atan2(y2 - y1, x2 - x1);
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = width;
context.setLineDash([]);
context.beginPath();
context.moveTo(x1, y1);
context.lineTo(x2, y2);
context.stroke();
context.beginPath();
context.moveTo(x2, y2);
context.lineTo(
x2 - 9 * Math.cos(angle - Math.PI / 6),
y2 - 9 * Math.sin(angle - Math.PI / 6));
context.lineTo(
x2 - 9 * Math.cos(angle + Math.PI / 6),
y2 - 9 * Math.sin(angle + Math.PI / 6));
context.closePath();
context.fill();
}
function draw() {
const view = getView();
drawGrid(view);
drawFleetCenter(view);
vehicles.forEach(vehicle => drawVehicle(vehicle, view));
requestAnimationFrame(draw);
}
function renderActions() {
const preferredGroups = ["舵轮模式", "运动测试", "维护"];
const groups = [...new Set(actions.map(action => action.group))]
.sort((left, right) => {
const leftIndex = preferredGroups.indexOf(left);
const rightIndex = preferredGroups.indexOf(right);
if (leftIndex < 0 && rightIndex < 0)
return left.localeCompare(right, "zh-CN");
if (leftIndex < 0) return 1;
if (rightIndex < 0) return -1;
return leftIndex - rightIndex;
});
actionGroups.innerHTML = groups.map(group => {
const buttons = actions
.filter(action => action.group === group)
.sort((a, b) => a.order - b.order)
.map(action => `
<button data-command="${action.key}"
class="${action.key === "stop" ? "danger-button" : ""}">
${action.displayName}
</button>`)
.join("");
return `
<section class="action-group">
<p class="group-label">${group}</p>
<div class="action-grid">${buttons}</div>
</section>`;
}).join("");
actionGroups.querySelectorAll("[data-command]")
.forEach(button => {
button.addEventListener("click", () =>
sendCommand(button.dataset.command));
});
}
function renderVehicleSelector() {
const previous = Number(vehicleSelect.value);
vehicleSelect.innerHTML = vehicles
.map(vehicle =>
`<option value="${vehicle.vehicleId}">车辆 ${vehicle.vehicleId}</option>`)
.join("");
if (vehicles.some(vehicle => vehicle.vehicleId === previous))
vehicleSelect.value = String(previous);
}
function renderCards() {
vehicleCards.innerHTML = vehicles.map(vehicle => {
const actual = vehicle.actualBodyTwist;
const selected =
Number(vehicleSelect.value) === vehicle.vehicleId;
return `
<button class="vehicle-card ${selected ? "selected" : ""}"
data-vehicle="${vehicle.vehicleId}">
<div class="card-topline">
<strong>CAR ${vehicle.vehicleId}</strong>
<span class="${vehicle.modeReady ? "status-ready" : "status-aligning"}">
${vehicle.modeReady ? "READY" : "ALIGNING"}
</span>
</div>
<div class="mode-name">${formatMode(vehicle.mode)}</div>
<dl>
<div><dt>X</dt><dd>${vehicle.xMeters.toFixed(2)} m</dd></div>
<div><dt>Y</dt><dd>${vehicle.yMeters.toFixed(2)} m</dd></div>
<div><dt>Yaw</dt><dd>${radToDeg(vehicle.yawRadians).toFixed(1)}°</dd></div>
<div><dt>Vx / Vy</dt><dd>${actual.vxMetersPerSecond.toFixed(2)} / ${actual.vyMetersPerSecond.toFixed(2)}</dd></div>
</dl>
</button>`;
}).join("");
vehicleCards.querySelectorAll("[data-vehicle]").forEach(card => {
card.addEventListener("click", () => {
vehicleSelect.value = card.dataset.vehicle;
renderCards();
renderSelectedVehicleDetail();
});
});
}
function renderSelectedVehicleDetail() {
const selectedId = Number(vehicleSelect.value);
const vehicle = vehicles.find(item => item.vehicleId === selectedId);
if (!vehicle) {
selectedVehicleDetail.innerHTML = "";
return;
}
selectedVehicleDetail.innerHTML = `
<section class="wheel-detail">
<div class="geometry-note">
车体 1472 × 948 mm · 实际DLL轮位 X±750 / Y±500 mm
</div>
${vehicle.wheels.map(wheel => {
const actualPercent =
(wheel.actualAngleDegrees + 120) / 240 * 100;
const targetPercent =
(wheel.targetAngleDegrees + 120) / 240 * 100;
return `
<div class="wheel-row">
<div class="wheel-row-title">
<strong>${wheel.name}</strong>
<span>实际 ${signed(wheel.actualAngleDegrees)}° · 目标 ${signed(wheel.targetAngleDegrees)}°</span>
</div>
<div class="angle-scale">
<span class="zero-mark"></span>
<span class="target-mark" style="left:${targetPercent}%"></span>
<span class="actual-mark" style="left:${actualPercent}%"></span>
</div>
<div class="wheel-speed">
轮速 ${wheel.actualSpeedMetersPerSecond.toFixed(2)} m/s
</div>
</div>`;
}).join("")}
</section>`;
}
function formatMode(mode) {
return {
Normal: "正常模式",
CrabLeft: "左蟹行",
CrabRight: "右蟹行",
Spin: "自转模式"
}[mode] || mode;
}
async function loadActions() {
const response = await fetch("/api/actions", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
actions = await response.json();
renderActions();
}
async function loadConfiguration() {
const response = await fetch("/api/configuration", {
cache: "no-store"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
configuration = await response.json();
populateConfigurationForm();
}
function populateConfigurationForm() {
vehicleCountInput.value = String(configuration.vehicleCount);
document.getElementById("fleet-x").value =
configuration.fleetCenter.xMeters;
document.getElementById("fleet-y").value =
configuration.fleetCenter.yMeters;
document.getElementById("fleet-yaw").value =
radToDeg(configuration.fleetCenter.yawRadians).toFixed(1);
renderLayoutRows(configuration.vehicleCount, configuration.vehicles);
}
function readExistingLayouts() {
return [...layoutRows.querySelectorAll(".layout-row")].map(row => ({
vehicleId: Number(row.dataset.vehicleId),
xMeters: Number(row.querySelector("[data-field='x']").value),
yMeters: Number(row.querySelector("[data-field='y']").value),
yawRadians: degToRad(
Number(row.querySelector("[data-field='yaw']").value))
}));
}
function renderLayoutRows(count, sourceLayouts = readExistingLayouts()) {
const byId = new Map(
sourceLayouts.map(layout => [layout.vehicleId, layout]));
const fallbackSpacing = 2.0;
layoutRows.innerHTML = Array.from({ length: count }, (_, index) => {
const id = index + 1;
const fallbackX =
(index - (count - 1) / 2) * fallbackSpacing;
const layout = byId.get(id) || {
vehicleId: id,
xMeters: count === 1 ? 0 : fallbackX,
yMeters: 0,
yawRadians: 0
};
return `
<div class="layout-row" data-vehicle-id="${id}">
<strong>CAR ${id}</strong>
<label>X<input data-field="x" type="number" step="0.1"
value="${layout.xMeters}"></label>
<label>Y<input data-field="y" type="number" step="0.1"
value="${layout.yMeters}"></label>
<label>Yaw°<input data-field="yaw" type="number" step="1"
value="${radToDeg(layout.yawRadians).toFixed(1)}"></label>
</div>`;
}).join("");
}
async function applyConfiguration() {
const count = Number(vehicleCountInput.value);
const layouts = readExistingLayouts().slice(0, count);
const payload = {
vehicleCount: count,
fleetCenter: {
xMeters: Number(document.getElementById("fleet-x").value),
yMeters: Number(document.getElementById("fleet-y").value),
yawRadians: degToRad(
Number(document.getElementById("fleet-yaw").value))
},
vehicles: layouts
};
const response = await fetch("/api/configuration", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const result = await response.json();
if (!response.ok) {
commandMessage.textContent =
result.message || "布局配置失败。";
commandMessage.classList.add("error");
return;
}
configuration = result;
commandMessage.textContent =
`已应用 ${configuration.vehicleCount} 辆车的车队布局。`;
commandMessage.classList.remove("error");
await updateVehicles();
}
async function updateVehicles() {
try {
const response = await fetch("/api/vehicles", {
cache: "no-store"
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
vehicles = await response.json();
renderVehicleSelector();
renderCards();
renderSelectedVehicleDetail();
connectionDot.classList.add("online");
connectionText.textContent = "离线仿真运行中";
successfulUpdates += 1;
} catch {
connectionDot.classList.remove("online");
connectionText.textContent = "仿真后端未连接";
}
}
async function sendCommand(command) {
const vehicleId = vehicleSelect.value;
try {
const response = await fetch(
`/api/vehicles/${vehicleId}/commands/${command}`,
{ method: "POST" });
const result = await response.json();
commandMessage.textContent = result.message;
commandMessage.classList.toggle("error", !result.success);
await updateVehicles();
} catch {
commandMessage.textContent =
"命令发送失败,请检查仿真后端。";
commandMessage.classList.add("error");
}
}
function getRemoteScale(input) {
return Number(input.value) / 100;
}
function updateRemoteLabels() {
speedScaleValue.textContent = `${speedScaleInput.value}%`;
steeringScaleValue.textContent =
`${steeringScaleInput.value}%`;
}
async function sendManualControl(
throttle,
steering,
showMessage = false,
vehicleIdOverride = null) {
remoteThrottle = throttle;
remoteSteering = steering;
const active =
Math.abs(throttle) > 0.001 ||
Math.abs(steering) > 0.001;
remoteState.textContent = active
? `油门 ${signed(throttle)} · 转向 ${signed(steering)}`
: "已松开";
remoteState.classList.toggle("active", active);
try {
const vehicleId = vehicleIdOverride ??
(active
? vehicleSelect.value
: remoteVehicleId ?? vehicleSelect.value);
if (active)
remoteVehicleId = String(vehicleId);
const response = await fetch(
`/api/vehicles/${vehicleId}/manual-control`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
throttle,
steering,
speedScale: getRemoteScale(speedScaleInput),
steeringScale: getRemoteScale(steeringScaleInput)
})
});
const result = await response.json();
if (!response.ok || showMessage) {
commandMessage.textContent = result.message;
commandMessage.classList.toggle(
"error",
!result.success);
}
if (!active &&
String(vehicleId) === remoteVehicleId) {
remoteVehicleId = null;
}
} catch {
commandMessage.textContent =
"虚拟遥控命令发送失败,请检查仿真后端。";
commandMessage.classList.add("error");
}
}
function stopRemote(showMessage = false) {
const vehicleId = remoteVehicleId;
pressedDriveKeys.clear();
document.querySelectorAll(".remote-pad .pressed")
.forEach(button => button.classList.remove("pressed"));
return sendManualControl(
0,
0,
showMessage,
vehicleId);
}
function driveFromKeyboard() {
const forward =
pressedDriveKeys.has("KeyW") ||
pressedDriveKeys.has("ArrowUp");
const backward =
pressedDriveKeys.has("KeyS") ||
pressedDriveKeys.has("ArrowDown");
const left =
pressedDriveKeys.has("KeyA") ||
pressedDriveKeys.has("ArrowLeft");
const right =
pressedDriveKeys.has("KeyD") ||
pressedDriveKeys.has("ArrowRight");
const throttle = Number(forward) - Number(backward);
const steering = Number(left) - Number(right);
sendManualControl(throttle, steering);
}
function isTypingTarget(target) {
return target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement;
}
document.querySelectorAll(
".remote-pad [data-throttle][data-steering]")
.forEach(button => {
button.addEventListener("pointerdown", event => {
event.preventDefault();
remotePointerId = event.pointerId;
button.setPointerCapture(event.pointerId);
button.classList.add("pressed");
sendManualControl(
Number(button.dataset.throttle),
Number(button.dataset.steering));
});
const release = event => {
if (remotePointerId !== event.pointerId)
return;
remotePointerId = null;
button.classList.remove("pressed");
stopRemote();
};
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
});
document.getElementById("remote-stop").addEventListener(
"click",
() => stopRemote(true));
window.addEventListener("keydown", event => {
if (isTypingTarget(event.target))
return;
const driveKeys = [
"KeyW", "KeyS", "KeyA", "KeyD",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"
];
if (!driveKeys.includes(event.code))
return;
event.preventDefault();
if (event.repeat)
return;
pressedDriveKeys.add(event.code);
driveFromKeyboard();
});
window.addEventListener("keyup", event => {
if (!pressedDriveKeys.has(event.code))
return;
event.preventDefault();
pressedDriveKeys.delete(event.code);
driveFromKeyboard();
});
window.addEventListener("blur", () => stopRemote());
document.addEventListener("visibilitychange", () => {
if (document.hidden)
stopRemote();
});
[speedScaleInput, steeringScaleInput].forEach(input => {
input.addEventListener("input", () => {
updateRemoteLabels();
if (remoteThrottle !== 0 || remoteSteering !== 0) {
sendManualControl(
remoteThrottle,
remoteSteering);
}
});
});
document.getElementById("reset-all").addEventListener(
"click",
async () => {
await fetch("/api/reset", { method: "POST" });
commandMessage.textContent = "全部仿真车已复位。";
commandMessage.classList.remove("error");
await updateVehicles();
});
document.getElementById("apply-configuration")
.addEventListener("click", applyConfiguration);
vehicleCountInput.addEventListener("change", () =>
renderLayoutRows(Number(vehicleCountInput.value)));
vehicleSelect.addEventListener("change", () => {
stopRemote();
renderCards();
renderSelectedVehicleDetail();
});
window.addEventListener("resize", resizeCanvas);
setInterval(updateVehicles, 100);
setInterval(() => {
const now = performance.now();
const seconds = (now - lastUpdateTime) / 1000;
updateRate.textContent =
`${(successfulUpdates / seconds).toFixed(0)} Hz`;
successfulUpdates = 0;
lastUpdateTime = now;
}, 1000);
function signed(value) {
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
}
function degToRad(value) {
return value * Math.PI / 180;
}
function radToDeg(value) {
return value * 180 / Math.PI;
}
async function initialize() {
try {
await Promise.all([
loadActions(),
loadConfiguration(),
updateVehicles()
]);
} catch {
connectionText.textContent = "仿真初始化失败";
commandMessage.textContent =
"无法读取命令或布局配置,请检查后端。";
commandMessage.classList.add("error");
}
}
resizeCanvas();
updateRemoteLabels();
initialize();
requestAnimationFrame(draw);