From e6b99c45b352f24ff58092f7855d0eadd8828d42 Mon Sep 17 00:00:00 2001 From: shenyuxiang Date: Mon, 27 Jul 2026 17:47:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=95=E8=BD=A6=E5=BA=95?= =?UTF-8?q?=E7=9B=98=E4=BB=BF=E7=9C=9F=E5=B9=B3=E5=8F=B0=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E8=BF=90=E5=8A=A8=E6=8E=A7=E5=88=B6=E4=B8=8E=E5=A4=B9?= =?UTF-8?q?=E8=87=82=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ClumsyPilot/ClumsyPilot.csproj | 5 + ClumsyPilot/MovementTests.cs | 53 +- ClumsyPilot/Movements.cs | 1 + ClumsyPilot/PilotConfig.cs | 24 +- ClumsyPilot/PilotDefinition.cs | 30 + ClumsyPilot/build/Clumsy/ClumsyPilot.dll | Bin 17408 -> 26624 bytes ClumsyPilot/build/Clumsy/ClumsyPilot.pdb | Bin 10792 -> 14224 bytes .../obj/Debug/ClumsyPilot.AssemblyInfo.cs | 2 +- .../ClumsyPilot.AssemblyInfoInputs.cache | 2 +- ....GeneratedMSBuildEditorConfig.editorconfig | 2 +- .../obj/Debug/ClumsyPilot.assets.cache | Bin 1890 -> 1890 bytes ...ClumsyPilot.csproj.AssemblyReference.cache | Bin 58869 -> 54987 bytes ...ClumsyPilot.csproj.CoreCompileInputs.cache | 2 +- ClumsyPilot/obj/Debug/ClumsyPilot.dll | Bin 17408 -> 26624 bytes ClumsyPilot/obj/Debug/ClumsyPilot.pdb | Bin 10792 -> 14224 bytes MedullaAdapter/AlarmRoutine.cs | 3 +- MedullaAdapter/DiverCartDefinition.cs | 271 ++++-- MedullaAdapter/MedullaAdapter.csproj | 12 +- MedullaAdapter/MotorRoutine.cs | 33 +- MedullaAdapter/Remote.cs | 73 +- .../build/Medulla/plugins/MedullaAdapter.dll | Bin 24576 -> 60416 bytes .../build/Medulla/plugins/MedullaAdapter.pdb | Bin 16436 -> 30144 bytes Shared/ChassisCommand.cs | 183 ++++ Shared/FleetKinematics.cs | 1 + Shared/FrameTransform2D.cs | 177 ++++ Shared/MultiWheelChassisAdapter.cs | 289 ++++++ Shared/SteeringConstraintManager.cs | 225 +++++ .../Commands/BuiltInSimulationActions.cs | 121 +++ Simulation/Commands/MySimulationTests.cs | 31 + .../Commands/SimulationActionAttribute.cs | 38 + .../Commands/SimulationCommandDispatcher.cs | 150 ++++ Simulation/Core/SimulationClock.cs | 38 + Simulation/Core/SimulationVehicle.cs | 382 ++++++++ Simulation/Core/SimulationWorld.cs | 205 +++++ Simulation/Core/VirtualSteerWheel.cs | 174 ++++ Simulation/Models/ManualControlInputDto.cs | 10 + .../Models/SimulationConfigurationDto.cs | 26 + Simulation/Models/VehicleStateDto.cs | 25 + Simulation/Models/WheelStateDto.cs | 14 + Simulation/MyParking.Simulation.csproj | 17 + Simulation/Program.cs | 97 ++ Simulation/Properties/launchSettings.json | 38 + Simulation/appsettings.Development.json | 8 + Simulation/appsettings.json | 9 + Simulation/wwwroot/index.html | 144 +++ Simulation/wwwroot/viewer.css | 603 +++++++++++++ Simulation/wwwroot/viewer.js | 842 ++++++++++++++++++ 47 files changed, 4238 insertions(+), 122 deletions(-) create mode 100644 Shared/ChassisCommand.cs create mode 100644 Shared/FleetKinematics.cs create mode 100644 Shared/FrameTransform2D.cs create mode 100644 Shared/MultiWheelChassisAdapter.cs create mode 100644 Shared/SteeringConstraintManager.cs create mode 100644 Simulation/Commands/BuiltInSimulationActions.cs create mode 100644 Simulation/Commands/MySimulationTests.cs create mode 100644 Simulation/Commands/SimulationActionAttribute.cs create mode 100644 Simulation/Commands/SimulationCommandDispatcher.cs create mode 100644 Simulation/Core/SimulationClock.cs create mode 100644 Simulation/Core/SimulationVehicle.cs create mode 100644 Simulation/Core/SimulationWorld.cs create mode 100644 Simulation/Core/VirtualSteerWheel.cs create mode 100644 Simulation/Models/ManualControlInputDto.cs create mode 100644 Simulation/Models/SimulationConfigurationDto.cs create mode 100644 Simulation/Models/VehicleStateDto.cs create mode 100644 Simulation/Models/WheelStateDto.cs create mode 100644 Simulation/MyParking.Simulation.csproj create mode 100644 Simulation/Program.cs create mode 100644 Simulation/Properties/launchSettings.json create mode 100644 Simulation/appsettings.Development.json create mode 100644 Simulation/appsettings.json create mode 100644 Simulation/wwwroot/index.html create mode 100644 Simulation/wwwroot/viewer.css create mode 100644 Simulation/wwwroot/viewer.js diff --git a/ClumsyPilot/ClumsyPilot.csproj b/ClumsyPilot/ClumsyPilot.csproj index 28ab17a..ceb2716 100644 --- a/ClumsyPilot/ClumsyPilot.csproj +++ b/ClumsyPilot/ClumsyPilot.csproj @@ -15,6 +15,11 @@ + + + + ref\CommonUsage.dll diff --git a/ClumsyPilot/MovementTests.cs b/ClumsyPilot/MovementTests.cs index 123a689..7cd300f 100644 --- a/ClumsyPilot/MovementTests.cs +++ b/ClumsyPilot/MovementTests.cs @@ -72,30 +72,55 @@ namespace MultiWheelC [MovementTest(name = "底盘旋转测试")] public class RotateToAngleTest : MovementTest { - // 底盘旋转测试不支持停止操作。 + private DriveTask _dt; + + // 停止当前正在执行的底盘原地旋转任务。 public override void TestStop() { - throw new NotImplementedException(); + _dt?.Stop(); } // 交互输入目标角度后执行底盘原地旋转测试。 public override void Test() { - var target = UI.GetInput("输入旋转角度:"); - new DriveTask(new MultiWheelRotateInPlace() + var input = UI.GetInput("输入旋转角度:"); + if (!float.TryParse(input, out var angleTarget)) { - AngleTarget = float.Parse(target), - PidparamsRead = () => new PIDParams() + Console.WriteLine( + $"旋转测试输入无效:{input}"); + return; + } + // 防止重复启动测试时,上一项旋转任务仍在运行。 + _dt?.Stop(); + var task = new DriveTask( + new MultiWheelRotateInPlace { - Kp = PilotDefinition.Conf.InPlaceRotateKp, - Ki = PilotDefinition.Conf.InPlaceRotateKi, - Kd = PilotDefinition.Conf.InPlaceRotateKd, - DeadZone = PilotDefinition.Conf.InPlaceRotateArriveDeg, - SpeedAccPerSec = PilotDefinition.Conf.InPlaceRotateAcc, - OutputUpperThreshold = PilotDefinition.Conf.InPlaceRotateMaxSpeed, - MaxI = PilotDefinition.Conf.InPlaceRotateMaxI, + AngleTarget = angleTarget, + + PidparamsRead = () => new PIDParams + { + Kp = PilotDefinition.Conf.InPlaceRotateKp, + Ki = PilotDefinition.Conf.InPlaceRotateKi, + Kd = PilotDefinition.Conf.InPlaceRotateKd, + DeadZone = PilotDefinition.Conf.InPlaceRotateArriveDeg, + SpeedAccPerSec = PilotDefinition.Conf.InPlaceRotateAcc, + OutputUpperThreshold = PilotDefinition.Conf.InPlaceRotateMaxSpeed, + MaxI = PilotDefinition.Conf.InPlaceRotateMaxI, + } + }.Get()); + _dt = task; + try + { + task.Wait(); + } + finally + { + // 防止旧任务结束时,错误清除后来启动的新任务。 + if (ReferenceEquals(_dt, task)) + { + _dt = null; } - }.Get()).Wait(); + } } } } diff --git a/ClumsyPilot/Movements.cs b/ClumsyPilot/Movements.cs index 05fbaac..e42c5f5 100644 --- a/ClumsyPilot/Movements.cs +++ b/ClumsyPilot/Movements.cs @@ -124,4 +124,5 @@ namespace MultiWheelC } } + } diff --git a/ClumsyPilot/PilotConfig.cs b/ClumsyPilot/PilotConfig.cs index 5ec4874..fc10a29 100644 --- a/ClumsyPilot/PilotConfig.cs +++ b/ClumsyPilot/PilotConfig.cs @@ -60,7 +60,7 @@ public class PilotConfig : MultiWheelPilotConfig #endregion -#if false + #region 单车-钻车与夹抱 [FieldMember(desc = "2腿检测:雷达名(逗号分隔可多个)")] @@ -165,10 +165,8 @@ public class PilotConfig : MultiWheelPilotConfig [FieldMember(desc = "抱夹控制pid:Thresh")] public float ClampControlThresh = 0.2f; [FieldMember(desc = "抱夹控制pid:DeadZone")] public float ClampControlDeadZone = 5f; [FieldMember(desc = "抱夹最大速度")] public float MaxClampSpeed = 1.5f; - - #endregion - +#if false #region 多车-编队与遥控 [FieldMember(desc = "联动时转向角爬升加速度")] public float SyncThAccPerSec = 30f; [FieldMember(desc = "两车间距 (mm)")] public float TestCarSyncDistance = 2400f; @@ -330,24 +328,6 @@ public class PilotConfig : MultiWheelPilotConfig #endif - #region 仿真-Playground - [FieldMember(desc = "Playground WebAPI 基地址")] - public string PlaygroundWebApiUrl = "http://localhost:18090"; - - [FieldMember(desc = "Playground 小车名称(场景 robots[].name)")] - public string PlaygroundRobotName = "agv_multi_1"; - - [FieldMember(desc = "Playground 邻车名称(仅主车用于原地旋转位姿诊断)")] - public string PlaygroundNeighborRobotName = "agv_multi_2"; - - [FieldMember(desc = "旋转位姿诊断开关(simulation only)")] - public bool MultiVehicleRotatePoseWebApiDiagEnabled = false; - [FieldMember(desc = "WebAPI 平移测试:平移距离(mm)")] - public float WebApiTranslateMm = 100f; - - [FieldMember(desc = "WebAPI 旋转测试:旋转角度(deg)")] - public float WebApiRotateDeg = 5f; - #endregion } diff --git a/ClumsyPilot/PilotDefinition.cs b/ClumsyPilot/PilotDefinition.cs index 07239b9..188fa94 100644 --- a/ClumsyPilot/PilotDefinition.cs +++ b/ClumsyPilot/PilotDefinition.cs @@ -11,4 +11,34 @@ public class PilotDefinition : MultiWheelPilotDefinition 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 literal 17408 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 v8y-LY(z+48)6utHjA{bC3L>HkJ!)od^CuHaZEc diff --git a/ClumsyPilot/build/Clumsy/ClumsyPilot.pdb b/ClumsyPilot/build/Clumsy/ClumsyPilot.pdb index 6acbfcd4e9509a16e8d8b01b85c6f85c023b9447..fc4ea1fdbd2ba68827f26bc6ae414a0ee8f2b2a6 100644 GIT binary patch delta 4933 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 D`c$l} delta 1531 zcmZ8hUu;ul6hG(Q_V#vN*L7uM!)?9&vo$CV+p&qvnCWo1kOh*u3>Kpq*)oW11v({* z#;#ykSf*io@+Rv|;bG##Vip&P4=(XRd=QNI2NMm%2jop-0*~tNbS=4Xli&B9-}(2Q zp6~QKmsx)GTs(J_h+_N44q>BvfaL;zkH~Zr)ru`Wed5T}L{{vL(7{Zxp)@r^o)vk| z(7K%!CyL>k!0`kgMGK&S=oK%{VUha)-CtFga3REH4P6JeO`@-A@>t?QxL)c zunRDN05A`{42V-TpaWtP)B~Qy-xEz<2oe+q#KB+QH>nox$u($9ZJI`huph_Hbg`A` zKfnkx`6Y-nY7J{NUy^nD$P%4SE0#`cW%(Dh<8Pon;-cq)MW6_*0$)YM-%Yn823?FA zbS)}eKAnDbM|Ij4Q%OCfdUcn;4fWwiG)>U_5QB4UBTNGCfM?>)xExx3I?k|hsUJi> zxN8nBUC|%oE(f<@H&AFL{yf_1aqvdiO=v21xdHD(o+C;?QT0%;E2kBPl4AucsqEo!f`%b0_vcU9>`Y%gdXFY9NYtYy@QW{Z${r-<@n7= z(})r)9P>y`4&DadjDp23=hGhuXeld%_X2GW{xkT~4lYC6;ox#bNeBN6`BDzv3HvS- zTq)>naQWbt!y!s8g$v3g*+Fwv8KPVY^ZKn>+GR>mZiCAK4N42 z39E;0MP_w3?TyXqCOsLmG3MW5HEBI&8EzVjTZTz{@h;G@Bppl9^yrC+@#)pPuST=< zv-M|#i|J=quQlyCvoLr4wFB#GW7~UfT#YQB3Eup2K&|+@)C++=BmZ0ePX4Z%3-p)7 zQ~l~@Aj(noAduzfR^P0>@Pzs_Ibtqd=A{*0`d;}{7PIO|9SlNMcm^siyvT*uwFXuf zQrknJzRcugW@;!qIx*hScX%|LJ)*u&^>~c*Om=#FQvI76Y?sJIE?koM!qrmzcU<_B zZ4cWuY;R?IC)=;6ZZ{Z*7q|z`u2yAMcptjcOE^ iyu)5K)qRqE>TdUk&1I(xsHBD9W!(Y2k*#iEXp1|6c Hu_!tR5$_i| diff --git a/ClumsyPilot/obj/Debug/ClumsyPilot.csproj.AssemblyReference.cache b/ClumsyPilot/obj/Debug/ClumsyPilot.csproj.AssemblyReference.cache index 6f1b425c58133098630756a89899067a49147f9b..787d0262596f00dec0234cd46ef4759a10173abf 100644 GIT binary patch delta 110 zcmex*n)&ovW-d0yN(Kf7Mx7L^$q%`tCk8B->?$L`Yzg5`PGLR{=0l|?|6+m2>w)EW jgXQ$WY?aBE_-29mMqs|&=4_!v#>t`QlQ+AGuD<{P&j2O7 delta 2884 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 literal 17408 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 v8y-LY(z+48)6utHjA{bC3L>HkJ!)od^CuHaZEc diff --git a/ClumsyPilot/obj/Debug/ClumsyPilot.pdb b/ClumsyPilot/obj/Debug/ClumsyPilot.pdb index 6acbfcd4e9509a16e8d8b01b85c6f85c023b9447..fc4ea1fdbd2ba68827f26bc6ae414a0ee8f2b2a6 100644 GIT binary patch delta 4933 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 D`c$l} delta 1531 zcmZ8hUu;ul6hG(Q_V#vN*L7uM!)?9&vo$CV+p&qvnCWo1kOh*u3>Kpq*)oW11v({* z#;#ykSf*io@+Rv|;bG##Vip&P4=(XRd=QNI2NMm%2jop-0*~tNbS=4Xli&B9-}(2Q zp6~QKmsx)GTs(J_h+_N44q>BvfaL;zkH~Zr)ru`Wed5T}L{{vL(7{Zxp)@r^o)vk| z(7K%!CyL>k!0`kgMGK&S=oK%{VUha)-CtFga3REH4P6JeO`@-A@>t?QxL)c zunRDN05A`{42V-TpaWtP)B~Qy-xEz<2oe+q#KB+QH>nox$u($9ZJI`huph_Hbg`A` zKfnkx`6Y-nY7J{NUy^nD$P%4SE0#`cW%(Dh<8Pon;-cq)MW6_*0$)YM-%Yn823?FA zbS)}eKAnDbM|Ij4Q%OCfdUcn;4fWwiG)>U_5QB4UBTNGCfM?>)xExx3I?k|hsUJi> zxN8nBUC|%oE(f<@H&AFL{yf_1aqvdiO=v21xdHD(o+C;?QT0%;E2kBPl4AucsqEo!f`%b0_vcU9>`Y%gdXFY9NYtYy@QW{Z${r-<@n7= z(})r)9P>y`4&DadjDp23=hGhuXeld%_X2GW{xkT~4lYC6;ox#bNeBN6`BDzv3HvS- zTq)>naQWbt!y!s8g$v3g*+Fwv8KPVY^ZKn>+GR>mZiCAK4N42 z39E;0MP_w3?TyXqCOsLmG3MW5HEBI&8EzVjTZTz{@h;G@Bppl9^yrC+@#)pPuST=< zv-M|#i|J=quQlyCvoLr4wFB#GW7~UfT#YQB3Eup2K&|+@)C++=BmZ0ePX4Z%3-p)7 zQ~l~@Aj(noAduzfR^P0>@Pzs_Ibtqd=A{*0`d;}{7PIO|9SlNMcm^siyvT*uwFXuf zQrknJzRcugW@;!qIx*hScX%|LJ)*u&^>~c*Om=#FQvI76Y?sJIE?koM!qrmzcU<_B zZ4cWuY;R?IC)=;6ZZ{Z*7q|z`u2yAMcptjcOE^ iyu)5K)qRqE>TdUk&1I( cart.LeftArmErrorCode != 0); + AddAlarm("右夹臂驱动报警", 2, () => cart.RightArmErrorCode != 0); } } } \ No newline at end of file diff --git a/MedullaAdapter/DiverCartDefinition.cs b/MedullaAdapter/DiverCartDefinition.cs index b427d3d..85a76d2 100644 --- a/MedullaAdapter/DiverCartDefinition.cs +++ b/MedullaAdapter/DiverCartDefinition.cs @@ -6,6 +6,7 @@ using Medulla.Types; using System; using System.Collections.Generic; using System.Threading; +using MyParking.Shared; namespace MedullaAdapter { @@ -26,11 +27,16 @@ namespace MedullaAdapter } internal ManualControlMode TransmitterControlMode = ManualControlMode.Normal; internal DateTime TransmitterLastTime = DateTime.Now; // 物理遥控器计算两次实体遥控器指令之间的时间间隔 + private ManualControlMode? _pendingManualMode; + private ManualControlMode? _activeManualMode; #endregion #region AsUpperIO [AsUpperIO(desc = "从C上复位")] public bool ResetFromC; [AsUpperIO(desc = "从C将驱动轮下使能")] public bool DisableFromC; + [AsUpperIO(desc = "左夹臂下发速度", timeOutReset = true)] public float SpeedLeftArm; + [AsUpperIO(desc = "右夹臂下发速度", timeOutReset = true)] public float SpeedRightArm; + [AsUpperIO(desc = "夹臂不同步报警")] public bool ClampOutOfSync; #endregion #region AsLowerIO @@ -42,6 +48,16 @@ namespace MedullaAdapter [AsLowerIO(desc = "左后右轮实际位置")] public float LRRActualPos; [AsLowerIO(desc = "右后左轮实际位置")] public float RRLActualPos; [AsLowerIO(desc = "右后右轮实际位置")] public float RRRActualPos; + [AsLowerIO(desc = "左夹臂实际速度")] public float ActualSpeedLeftArm; + [AsLowerIO(desc = "右夹臂实际速度")] public float ActualSpeedRightArm; + [AsLowerIO(desc = "左夹臂状态字")] public int LeftArmStateCode; + [AsLowerIO(desc = "右夹臂状态字")] public int RightArmStateCode; + [AsLowerIO(desc = "左夹臂错误字")] public int LeftArmErrorCode; + [AsLowerIO(desc = "右夹臂错误字")] public int RightArmErrorCode; + [AsLowerIO(desc = "左夹臂电流")] public float LeftArmElectric; + [AsLowerIO(desc = "右夹臂电流")] public float RightArmElectric; + [AsLowerIO(desc = "左夹臂实际位置")] public float ActualPosLeftArm; + [AsLowerIO(desc = "右夹臂实际位置")] public float ActualPosRightArm; [AsLowerIO(desc = "驱动轮使能状态")] public bool WheelAbleState = true; [AsLowerIO(desc = "电池健康状态")] public float SOH; [AsInitParam(desc = "车号")][AsLowerIO] public int CarNum = 1; @@ -51,6 +67,12 @@ namespace MedullaAdapter [AsInitParam(desc = "MCU端口号")] public string MCUPort = "COM4"; [AsInitParam(desc = "遥控器速度上限")] public float TransmitterSpeedUpperLimit = 1.0f; [AsInitParam(desc = "遥控器速度下限")] public float TransmitterSpeedLowerLimit = 0.0f; + [AsInitParam(desc = "手动控制夹臂速度系数")] public float ManualArmSpeedFac = 1.0f; + [AsInitParam(desc = "左夹臂低限位")][AsLowerIO] public int LeftArmLowerPos = -10000; + [AsInitParam(desc = "左夹臂高限位")][AsLowerIO] public int LeftArmUpperPos = 5927610; + [AsInitParam(desc = "右夹臂低限位")][AsLowerIO] public int RightArmLowerPos = -17295; + [AsInitParam(desc = "右夹臂高限位")][AsLowerIO] public int RightArmUpperPos = 5927610; + #endregion @@ -75,6 +97,8 @@ namespace MedullaAdapter [IOObjectMonitor(desc = "左后右驱动器远程帧706")] public byte LRRRemoteCode = 0; [IOObjectMonitor(desc = "右后左驱动器远程帧707")] public byte RRLRemoteCode = 0; [IOObjectMonitor(desc = "右后右驱动器远程帧708")] public byte RRRRemoteCode = 0; + [IOObjectMonitor(desc = "左夹臂驱动器远程帧709")] public byte LArmRemoteCode = 0; + [IOObjectMonitor(desc = "右夹臂驱动器远程帧70A")] public byte RArmRemoteCode = 0; #endregion #region 操作按钮 @@ -188,43 +212,193 @@ namespace MedullaAdapter TimeSpan? interval = null) { if (Chassis == null) return; + + var adapter = GetChassisAdapter(); + if (adapter == null) return; + + // 模式变化时先停车并下发舵轮准备角度; + // 在实际舵角到位之前,不开放驱动速度。 + if (!EnsureManualModeReady(mode, interval)) + { + adapter.StopImmediately(); + return; + } + var speed = speedThreshold * y; - var thPow = (float)Math.Pow(Math.Abs(x), ManualThetaPow) * Math.Sign(x); - var frontTh = -thPow * MaxManualTheta; - var rearTh = thPow * MaxManualTheta; + var omega = CalculateManualOmega(speed, x); ManualMode = (int)mode; + switch (mode) { case ManualControlMode.Normal: - SetChassisDirection(frontDirection); - Chassis.SendMotion(speed, frontTh, rearTh, interval); + SendBodyCommand(vx: speed, vy: 0.0, omegaRadiansPerSecond: omega, interval); break; case ManualControlMode.Crab: - SetChassisDirection(90); - Chassis.SendMotion(speed, frontTh, rearTh, interval); + SendBodyCommand(vx: 0.0, vy: speed, omegaRadiansPerSecond: omega, interval); break; case ManualControlMode.Spin: - Chassis.SendRotateMotion(speed * MaxAngularSpeed, interval); + var spinOmega = + speed * MaxAngularSpeed * + Math.PI / 180.0; + + SendBodyCommand( + vx: 0.0, + vy: 0.0, + omegaRadiansPerSecond: spinOmega, + interval); break; default: ManualMode = -1; - Chassis.PredefinedDriveStop(); + adapter.StopImmediately(); break; } } - private void SetChassisDirection(float direction) + // 停车后切换模式:先预转舵轮,实际角度到位后才允许发送运动命令。 + private bool EnsureManualModeReady( + ManualControlMode mode, + TimeSpan? interval) { - var origin = Chassis.GetOriginBias(); - // 方向没有变化时不重复计算轮组几何关系。 - if (Math.Abs(origin.Z - direction) < 0.001f) + var adapter = GetChassisAdapter(); + if (adapter == null) + return false; + + // 当前模式已经完成准备,可以直接接受运动命令。 + if (_activeManualMode == mode && + _pendingManualMode == null) + { + return true; + } + + // 第一次收到新模式时,停车并下发一次舵轮准备姿态。 + if (_pendingManualMode != mode) + { + adapter.StopImmediately(); + + var preparationAccepted = mode switch + { + ManualControlMode.Normal => + adapter.PrepareParallelDirection(0.0), + + ManualControlMode.Crab => + adapter.PrepareParallelDirection( + Math.PI / 2.0), + + ManualControlMode.Spin => + adapter.PrepareSpin(interval), + + _ => false + }; + + if (!preparationAccepted) + { + _pendingManualMode = null; + return false; + } + + _pendingManualMode = mode; + return false; + } + + // 后续控制周期保持停车,并读取实际舵角判断是否到位。 + adapter.StopImmediately(); + + const double toleranceRadians = + 2.0 * Math.PI / 180.0; + + bool aligned; + + if (mode == ManualControlMode.Spin) + { + // 自转的四个舵轮目标角不同,等待期间持续刷新其目标。 + var preparationAccepted = + adapter.PrepareSpin(interval); + + aligned = + preparationAccepted && + adapter.AreSpinWheelsAligned; + } + else + { + var targetDirection = mode == + ManualControlMode.Crab + ? Math.PI / 2.0 + : 0.0; + + aligned = + adapter.AreParallelWheelsAligned( + targetDirection, + toleranceRadians); + } + + if (!aligned) + return false; + + _activeManualMode = mode; + _pendingManualMode = null; + return true; + } + + private double CalculateManualOmega( + float speed, + float steeringInput) + { + var normalizedSteering = + (float)Math.Pow( + Math.Abs(steeringInput), + ManualThetaPow) * + Math.Sign(steeringInput); + + var steeringDegrees = + -normalizedSteering * MaxManualTheta; + + var steeringRadians = + steeringDegrees * Math.PI / 180.0; + + // CommonUsage中的ControlPointRadius单位为毫米。 + var halfWheelBaseMeters = + Math.Max( + Chassis.ControlPointRadius / 1000.0, + 0.01); + + return speed * Math.Tan(steeringRadians) / halfWheelBaseMeters; + } + + internal void SendBodyCommand(double vx, double vy, double omegaRadiansPerSecond, TimeSpan? interval = null) + { + var adapter = GetChassisAdapter(); + + if (adapter == null) return; - Chassis.SetOriginBias( - origin.X, - origin.Y, - direction); - // 坐标系改变后重新等待四个舵轮对齐。 - Chassis.AfterDirectionChanged(); + + var command = new ChassisCommand( + CarNum, + new Twist2D(vx, vy, omegaRadiansPerSecond)); + + if (!adapter.Send(command, interval)) + { + adapter.StopImmediately(); + + Console.WriteLine( + "底盘命令分解失败,车辆已经停车:" + + adapter.LastFailureReason); + } + } + private MultiWheelChassisAdapter _chassisAdapter; + + private MultiWheelChassisAdapter GetChassisAdapter() + { + if (Chassis == null) + return null; + + if (_chassisAdapter == null || + _chassisAdapter.VehicleId != CarNum) + { + _chassisAdapter = + new MultiWheelChassisAdapter(Chassis, CarNum); + } + + return _chassisAdapter; } #region MCURoutine兼容参数(暂保留原硬件协议) @@ -237,65 +411,6 @@ namespace MedullaAdapter [AsUpperIO(desc = "多车灯光同步兼容值,-1使用本车灯光")] public int MultiVehicleLightSync = -1; - // 以下夹臂字段用于兼容原MCU的0x209、0x20A等CAN协议;当前不使用时目标速度保持为0。 - [AsUpperIO(desc = "左夹臂目标速度", timeOutReset = true)] - public float SpeedLeftArm; - - [AsUpperIO(desc = "右夹臂目标速度", timeOutReset = true)] - public float SpeedRightArm; - - [AsLowerIO(desc = "左夹臂实际速度")] - public float ActualSpeedLeftArm; - - [AsLowerIO(desc = "右夹臂实际速度")] - public float ActualSpeedRightArm; - - [AsLowerIO(desc = "左夹臂实际位置")] - public float ActualPosLeftArm; - - [AsLowerIO(desc = "右夹臂实际位置")] - public float ActualPosRightArm; - - [AsLowerIO(desc = "左夹臂状态字")] - public int LeftArmStateCode; - - [AsLowerIO(desc = "右夹臂状态字")] - public int RightArmStateCode; - - [AsLowerIO(desc = "左夹臂错误字")] - public int LeftArmErrorCode; - - [AsLowerIO(desc = "右夹臂错误字")] - public int RightArmErrorCode; - - [AsLowerIO(desc = "左夹臂电流")] - public float LeftArmElectric; - - [AsLowerIO(desc = "右夹臂电流")] - public float RightArmElectric; - - [AsInitParam(desc = "左夹臂低限位")] - [AsLowerIO] - public int LeftArmLowerPos = -10000; - - [AsInitParam(desc = "左夹臂高限位")] - [AsLowerIO] - public int LeftArmUpperPos = 5927610; - - [AsInitParam(desc = "右夹臂低限位")] - [AsLowerIO] - public int RightArmLowerPos = -17295; - - [AsInitParam(desc = "右夹臂高限位")] - [AsLowerIO] - public int RightArmUpperPos = 5927610; - - [AsLowerIO(desc = "左夹臂驱动器远程帧709")] - public byte LArmRemoteCode; - - [AsLowerIO(desc = "右夹臂驱动器远程帧70A")] - public byte RArmRemoteCode; - #endregion } diff --git a/MedullaAdapter/MedullaAdapter.csproj b/MedullaAdapter/MedullaAdapter.csproj index dec728d..fbac02d 100644 --- a/MedullaAdapter/MedullaAdapter.csproj +++ b/MedullaAdapter/MedullaAdapter.csproj @@ -17,7 +17,6 @@ ref\RefCartActivator.dll false - ref\RefMedullaCore.dll false @@ -44,4 +43,15 @@ + + + + + + + + diff --git a/MedullaAdapter/MotorRoutine.cs b/MedullaAdapter/MotorRoutine.cs index 9725a05..6e5b980 100644 --- a/MedullaAdapter/MotorRoutine.cs +++ b/MedullaAdapter/MotorRoutine.cs @@ -56,6 +56,7 @@ namespace MedullaAdapter cart.TransmitterSpeed, DateTime.Now - cart.TransmitterLastTime); + StopClampArms(); cart.TransmitterLastTime = DateTime.Now; } @@ -95,6 +96,7 @@ namespace MedullaAdapter 0, 0, 0, cart.TransmitterSpeed, interval); + StopClampArms(); return; } // SA关闭后立即停车。 @@ -105,6 +107,7 @@ namespace MedullaAdapter 0, 0, 0, cart.TransmitterSpeed, interval); + StopClampArms(); return; } // 限制实体遥控器的最大速度。 @@ -116,6 +119,9 @@ namespace MedullaAdapter // SD的Mode0作为底盘驾驶档。 if (cart.Transmitter_SD == TransmitterState.Mode0) { + // 底盘驾驶档不允许保留上一周期的夹臂速度。 + StopClampArms(); + cart.ManualControl( cart.TransmitterControlMode, cart.TransmitterLeftJoystickValX, @@ -126,12 +132,37 @@ namespace MedullaAdapter return; } + if (cart.Transmitter_SD == TransmitterState.Mode1) + { + // 切换到夹臂档时,先确保底盘停止。 + cart.ManualControl( + cart.TransmitterControlMode, + 0, 0, 0, + cart.TransmitterSpeed, + interval); + + var armSpeed = + cart.TransmitterRightJoystickValX * + cart.ManualArmSpeedFac; + + cart.SpeedLeftArm = armSpeed; + cart.SpeedRightArm = armSpeed; + return; + } // 非驾驶档必须主动停车,防止上一条运动指令残留。 cart.ManualControl( cart.TransmitterControlMode, 0, 0, 0, cart.TransmitterSpeed, interval); + StopClampArms(); + } + + // M层单车夹臂安全:清除物理遥控器留下的左右夹臂速度命令。 + private void StopClampArms() + { + cart.SpeedLeftArm = 0; + cart.SpeedRightArm = 0; } // M层单车底盘:根据四个舵轮的目标角度和实际角度修正8个驱动电机速度。 @@ -328,4 +359,4 @@ namespace MedullaAdapter } } -} \ No newline at end of file +} diff --git a/MedullaAdapter/Remote.cs b/MedullaAdapter/Remote.cs index 842d426..85ceb6e 100644 --- a/MedullaAdapter/Remote.cs +++ b/MedullaAdapter/Remote.cs @@ -1,15 +1,86 @@ // Medulla虚拟遥控器和夹臂控制 using MDCSToolBox.Medulla.Chassis.MultiWheel; +using CartActivator; namespace MedullaAdapter { // M层单车虚拟遥控器:使用父类提供的底盘控制界面。 public class Remote : MultiWheelRemote { + // 将父类虚拟遥控器界面输入统一转发到本车的ManualControl。 + public override void ChassisOperation() + { + if (MultiVehicleMode.on) + { + MultiVehicleModeChassisLogic(); + statusText = "单车版本不支持多车联动遥控"; + return; + } + + // 当前单车适配层只定义Normal、Crab和Spin三种模式。 + // 禁止这些旧按钮绕过适配层直接修改底盘坐标偏置。 + if (AckermannMode.on || + SwayMode.on || + XYThMode.on) + { + cart.Chassis?.PredefinedDriveStop(); + statusText = "当前单车版本暂不支持阿克曼、斜行或全向模式"; + return; + } + + var mode = SpinMode.on + ? DiverCartDefinition.ManualControlMode.Spin + : CrabMode.on + ? DiverCartDefinition.ManualControlMode.Crab + : DiverCartDefinition.ManualControlMode.Normal; + + cart.ManualControl( + mode, + SpeedPad.x, + SpeedPad.y, + FrontDirection.dval * 180, + SpeedThreshold.val); + + statusText = + $"{mode}, x={SpeedPad.x:0.00}, " + + $"y={SpeedPad.y:0.00}, " + + $"speed={SpeedThreshold.val:0.00}"; + } + [AsControlItem(name = "夹抱速度", LayoutRow = 0, LayoutCol = 4)] + public Throttle ArmSpeed; + + [AsControlItem(name = "夹抱打开", LayoutRow = 1, LayoutCol = 0)] + public Button Open; + + [AsControlItem(name = "夹抱关闭", LayoutRow = 1, LayoutCol = 2)] + public Button Close; + // M层虚拟遥控器:控制左右夹臂同步打开或关闭。 + public override void CustomOperation() + { + if (Open.pressed) + { + cart.SpeedLeftArm = + -ArmSpeed.val * cart.ManualArmSpeedFac; + cart.SpeedRightArm = + -ArmSpeed.val * cart.ManualArmSpeedFac; + } + else if (Close.pressed) + { + cart.SpeedLeftArm = + ArmSpeed.val * cart.ManualArmSpeedFac; + cart.SpeedRightArm = + ArmSpeed.val * cart.ManualArmSpeedFac; + } + else + { + cart.SpeedLeftArm = 0; + cart.SpeedRightArm = 0; + } + } // 单车不支持多车联动,误打开多车开关时主动停车。 public override void MultiVehicleModeChassisLogic() { cart.Chassis?.PredefinedDriveStop(); } } -} \ No newline at end of file +} diff --git a/MedullaAdapter/build/Medulla/plugins/MedullaAdapter.dll b/MedullaAdapter/build/Medulla/plugins/MedullaAdapter.dll index 8f7e6442c3757a7a0b6b6d15299443953bec8aa0..ef41c12bb39e626c8f29b0e8a30ad5b1ed121e9e 100644 GIT binary patch literal 60416 zcmdqK34B%6wLiYjx%16<6EZ*`xd{X=xdE98PLKgG${0`xsNs@a;F{saa}y9ll!$d` zooLn8v7#NcT8C<%wzd^)9a`&a)sE`3*y^ixwzI8;|97pu&z%yW&-Zzs-{

-gWj` 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 literal 24576 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 HJrDeE>ccx2 diff --git a/MedullaAdapter/build/Medulla/plugins/MedullaAdapter.pdb b/MedullaAdapter/build/Medulla/plugins/MedullaAdapter.pdb index 367927cf46691ed18dbc540e7d896b1733501f0d..28ef8cca8df5951ae8c556a9c4b718750fdc8d1a 100644 GIT binary patch literal 30144 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 delta 3066 zcmZvedvH|M9mjv??#({(+z{A=BqY0RLY}*uO#(uUkOa~gXv||tK;D~hgV7|L?t>VM zYyv(q)=`{OtsSXFz&}{q(MG1=2oe!n`>;~2(~8bCjvcE)+p$g=J9gTBf1BNSneoni zzxVg~o#)+i?zuaARh*s_M@pg(6OprPZ$EBi7DOdx@Ij(#3(*{x`NF}W(S0EVR&0yfM1?}W1>b#0jmi<3^wT43dRF2 zlryL3`0ro~*pz6`E1RlgUN5X}bfE$-UOJR)fGtZII_7fTBKq%`1p@Tvl zbA@F(<__?ZE!5}oVoWGGfj@)o;Pk|Vegk%ZGZTCb>;`8gI2#=G=o0)Y)aaO>-m7D7 zP^)7PxK778;Cdb31YQZYCI+kmuhMZHum4RqCTO*e-$DcC#pqxw;*JC_0q;c21FS*0 zEinM^Oo%6W~@IzkvqK%hBNjcry#JjhRcpkAQi}m`lND zb-V)nH*giindsLk1M%u}wXdB^1#N*V*xCH* zy$nyKSK(>#0W#vA6?ZFLKz>pDF>LfU;`s%dcR?E|n_lzS>CPH15j0(?@N*05s^u@R zpf_vzqi4sb@0rRn9IYvaGHjlvlqO`(RUU(vRh_`|84Py8r-4&&JOCGlIbo#xsz~Ad ztith}0bWh2k^Wb0hgZ1ls+l~lCZmzgyY|4}c10&~`>*R5ywp7lzs)@dA9s_YX#0DaT7@j>&AM9&Zz3Od9@d{hY-}yH?s$ zjdYaX(e=KP#%vWLNd1w)LEN-pFc2hv5RsfKbTrSGwoHVz5cS7> z)E^$&uXP22;Si0*J*b5#sD&cKVHyZTM#H4t9WQAP2K^JVq3Bcl<-w=ZVs{;IJokj_ z{-6wQzdoHC+w|EZSDt?SQM+l)R}ahe#m;Rnq<0o>|AS?9;I$u5mz=%y!py<_kA8gS ziPKrt?vG4I{@R_uh*6)~@fHH%n}hz*&=VzEJt+C;V@O=^c)rz`FT&3EfsIVAq59jyJE(dLzz%)qC8D{d^I9n?(sR{_xZ}l3Cy81@+b-$j+CYOid?tTU4c)pWoplc k0{m?fhx;-Ft>FIZ6`Va;3jPpf!J1s@jI0y%e;qE#n*aa+ diff --git a/Shared/ChassisCommand.cs b/Shared/ChassisCommand.cs new file mode 100644 index 0000000..5f073fd --- /dev/null +++ b/Shared/ChassisCommand.cs @@ -0,0 +1,183 @@ +// 纯数据层:只描述坐标、速度和命令 +// 定义二维坐标、位姿、速度、车队布局和单车底盘命令。 +// Shared层统一使用SI单位:位置m、线速度m/s、角度rad、角速度rad/s。 +// 车体坐标系采用右手系:X向前、Y向左、逆时针角度和角速度为正。 +// 命名约定:XxxInYyy表示Xxx在Yyy坐标系中的表达。 + +namespace MyParking.Shared +{ + ///

+ /// 二维坐标点,X、Y单位均为米。 + /// + public readonly struct Point2D + { + public Point2D(double xMeters, double yMeters) + { + XMeters = xMeters; + YMeters = yMeters; + } + + public double XMeters { get; } + + public double YMeters { get; } + + public static Point2D Zero => new Point2D(0.0, 0.0); + } + + /// + /// 二维局部坐标系在父坐标系中的位姿。 + /// 位置单位为米,朝向单位为弧度,逆时针为正。 + /// 具体父子关系由变量名称说明,例如RadarPoseInBody。 + /// + public readonly struct Pose2D + { + public Pose2D( + double xMeters, + double yMeters, + double yawRadians) + { + XMeters = xMeters; + YMeters = yMeters; + YawRadians = yawRadians; + } + + public double XMeters { get; } + + public double YMeters { get; } + + public double YawRadians { get; } + + public Point2D Position => + new Point2D(XMeters, YMeters); + + public static Pose2D Identity => + new Pose2D(0.0, 0.0, 0.0); + } + + /// + /// 二维刚体速度。 + /// 线速度单位为m/s,角速度单位为rad/s。 + /// 速度所属坐标系由持有该Twist2D的外层类型或变量名称确定。 + /// + public readonly struct Twist2D + { + public Twist2D( + double vxMetersPerSecond, + double vyMetersPerSecond, + double omegaRadiansPerSecond) + { + VxMetersPerSecond = vxMetersPerSecond; + VyMetersPerSecond = vyMetersPerSecond; + OmegaRadiansPerSecond = omegaRadiansPerSecond; + } + + public double VxMetersPerSecond { get; } + + public double VyMetersPerSecond { get; } + + public double OmegaRadiansPerSecond { get; } + + public static Twist2D Zero => + new Twist2D(0.0, 0.0, 0.0); + } + + /// + /// 发送给单辆车的车体坐标系速度命令。 + /// + public readonly struct ChassisCommand + { + public ChassisCommand( + int vehicleId, + Twist2D bodyTwist) + { + VehicleId = vehicleId; + BodyTwist = bodyTwist; + } + + public int VehicleId { get; } + + /// + /// 单车车体坐标系速度:X向前、Y向左、逆时针旋转为正。 + /// + public Twist2D BodyTwist { get; } + + /// + /// 创建指定车辆的停止命令。 + /// + public static ChassisCommand Stop(int vehicleId) + { + return new ChassisCommand( + vehicleId, + Twist2D.Zero); + } + } + + /// + /// 单辆车的车体坐标系在车队坐标系中的位姿。 + /// + public readonly struct VehicleLayout + { + public VehicleLayout( + int vehicleId, + Pose2D poseInFleet) + { + VehicleId = vehicleId; + PoseInFleet = poseInFleet; + } + + public int VehicleId { get; } + + public Pose2D PoseInFleet { get; } + } + + /// + /// 车队整体运动命令,速度分量均在车队坐标系中表达。 + /// + public readonly struct FleetMotionCommand + { + public FleetMotionCommand( + Point2D referencePointInFleet, + Twist2D twistAtReferencePoint) + { + ReferencePointInFleet = referencePointInFleet; + TwistAtReferencePoint = twistAtReferencePoint; + } + + /// + /// 速度命令对应的参考点,也可作为自定义旋转中心。 + /// + public Point2D ReferencePointInFleet { get; } + + /// + /// 参考点处的车队速度。 + /// + public Twist2D TwistAtReferencePoint { get; } + + /// + /// 创建绕指定中心原地旋转的车队命令。 + /// + public static FleetMotionCommand RotateAround( + Point2D rotationCenterInFleet, + double omegaRadiansPerSecond) + { + return new FleetMotionCommand( + rotationCenterInFleet, + new Twist2D( + 0.0, + 0.0, + omegaRadiansPerSecond)); + } + + /// + /// 创建车队停止命令。 + /// + public static FleetMotionCommand Stop() + { + return new FleetMotionCommand( + Point2D.Zero, + Twist2D.Zero); + } + } + + +} \ No newline at end of file diff --git a/Shared/FleetKinematics.cs b/Shared/FleetKinematics.cs new file mode 100644 index 0000000..46b6899 --- /dev/null +++ b/Shared/FleetKinematics.cs @@ -0,0 +1 @@ +// 把车队整体速度分解为每辆车的局部速度 diff --git a/Shared/FrameTransform2D.cs b/Shared/FrameTransform2D.cs new file mode 100644 index 0000000..367c7bf --- /dev/null +++ b/Shared/FrameTransform2D.cs @@ -0,0 +1,177 @@ +// 车体、运动、车队坐标系之间的转换 +using System; + +namespace MyParking.Shared +{ + /// + /// 提供二维刚体坐标系之间的点、向量、位姿和速度变换。 + /// 坐标系采用X向前、Y向左、逆时针为正的右手系。 + /// + public static class FrameTransform2D + { + private const double TwoPi = 2.0 * Math.PI; + /// + /// 将角度归一化到[-π, π)范围。 + /// + public static double NormalizeAngle(double angleRadians) + { + + if (double.IsNaN(angleRadians) || double.IsInfinity(angleRadians)) + { + throw new ArgumentOutOfRangeException( + nameof(angleRadians), + "角度必须是有限数值。"); + } + angleRadians %= TwoPi; + if (angleRadians >= Math.PI) + angleRadians -= TwoPi; + if (angleRadians < -Math.PI) + angleRadians += TwoPi; + return angleRadians; + + } + + /// + /// 计算从current到target的最短角度差。 + /// 返回正值表示逆时针旋转。 + /// + public static double ShortestAngleDifference( + double targetRadians, + double currentRadians) + { + return NormalizeAngle(targetRadians - currentRadians); + } + + /// + /// 将源坐标系中的点变换到目标坐标系。 + /// sourcePoseInTarget表示源坐标系在目标坐标系中的位姿。 + /// + public static Point2D TransformPoint( + Pose2D sourcePoseInTarget, + Point2D pointInSource) + { + var cos = Math.Cos(sourcePoseInTarget.YawRadians); + var sin = Math.Sin(sourcePoseInTarget.YawRadians); + + return new Point2D( + sourcePoseInTarget.XMeters + + cos * pointInSource.XMeters - + sin * pointInSource.YMeters, + + sourcePoseInTarget.YMeters + + sin * pointInSource.XMeters + + cos * pointInSource.YMeters); + } + + /// + /// 将目标坐标系中的点反向变换到源坐标系。 + /// + public static Point2D InverseTransformPoint( + Pose2D sourcePoseInTarget, + Point2D pointInTarget) + { + var dx = pointInTarget.XMeters - sourcePoseInTarget.XMeters; + + var dy = pointInTarget.YMeters - sourcePoseInTarget.YMeters; + + var cos = Math.Cos(sourcePoseInTarget.YawRadians); + var sin = Math.Sin(sourcePoseInTarget.YawRadians); + + return new Point2D( + cos * dx + sin * dy, + -sin * dx + cos * dy); + } + + /// + /// 将源坐标系中的向量旋转到目标坐标系。 + /// 向量没有位置,因此不叠加平移量。 + /// + public static Point2D TransformVector( + Pose2D sourcePoseInTarget, + Point2D vectorInSource) + { + var cos = Math.Cos(sourcePoseInTarget.YawRadians); + var sin = Math.Sin(sourcePoseInTarget.YawRadians); + + return new Point2D( + cos * vectorInSource.XMeters - + sin * vectorInSource.YMeters, + + sin * vectorInSource.XMeters + + cos * vectorInSource.YMeters); + } + + /// + /// 组合两级坐标变换。 + /// parentFromMiddle表示middle在parent中的位姿; + /// middleFromChild表示child在middle中的位姿; + /// 返回child在parent中的位姿。 + /// + public static Pose2D Compose( + Pose2D parentFromMiddle, + Pose2D middleFromChild) + { + var childPositionInParent = TransformPoint( + parentFromMiddle, + middleFromChild.Position); + + return new Pose2D( + childPositionInParent.XMeters, + childPositionInParent.YMeters, + NormalizeAngle( + parentFromMiddle.YawRadians + + middleFromChild.YawRadians)); + } + + /// + /// 对坐标变换求逆。 + /// 输入child在parent中的位姿,返回parent在child中的位姿。 + /// + public static Pose2D Inverse(Pose2D childPoseInParent) + { + var cos = Math.Cos(childPoseInParent.YawRadians); + var sin = Math.Sin(childPoseInParent.YawRadians); + + return new Pose2D( + -cos * childPoseInParent.XMeters - + sin * childPoseInParent.YMeters, + + sin * childPoseInParent.XMeters - + cos * childPoseInParent.YMeters, + + NormalizeAngle( + -childPoseInParent.YawRadians)); + } + + + /// + /// 将源坐标系中的位姿变换到目标坐标系。 + /// + public static Pose2D TransformPose( + Pose2D sourcePoseInTarget, + Pose2D poseInSource) + { + return Compose(sourcePoseInTarget, poseInSource); + } + + /// + /// 转换同一物理参考点处的速度表达坐标系。 + /// 只旋转线速度,角速度保持不变。 + /// + public static Twist2D TransformTwistAtSamePoint( + Pose2D sourcePoseInTarget, + Twist2D twistInSource) + { + var linearVelocityInTarget = TransformVector( + sourcePoseInTarget, + new Point2D( + twistInSource.VxMetersPerSecond, + twistInSource.VyMetersPerSecond)); + + return new Twist2D( + linearVelocityInTarget.XMeters, + linearVelocityInTarget.YMeters, + twistInSource.OmegaRadiansPerSecond); + } + } +} \ No newline at end of file diff --git a/Shared/MultiWheelChassisAdapter.cs b/Shared/MultiWheelChassisAdapter.cs new file mode 100644 index 0000000..0af8468 --- /dev/null +++ b/Shared/MultiWheelChassisAdapter.cs @@ -0,0 +1,289 @@ +// 将统一命令转换为原 Chassis API 调用 +using System; +using CommonUsage.Chassis; + +namespace MyParking.Shared +{ + /// + /// 将统一的单车车体速度命令转换为旧版MultiWheelChassis调用。 + /// 车体坐标系固定为X向前、Y向左、逆时针为正。 + /// + public sealed class MultiWheelChassisAdapter + { + #region 辅助内容 + private const double RadiansToDegrees = 180.0 / Math.PI; + private const float BiasTolerance = 0.001f; + private readonly MultiWheelChassis _chassis; + /// + /// 当前适配器对应的车辆编号。 + /// + public int VehicleId { get; } + + /// + /// 检查旧底盘是否仍处于无偏置的真实车体坐标系。 + /// + private void EnsureBodyFrameIsActive() + { + var bias = _chassis.GetOriginBias(); + + if (Math.Abs(bias.X) <= BiasTolerance && + Math.Abs(bias.Y) <= BiasTolerance && + Math.Abs(bias.Z) <= BiasTolerance) + { + return; + } + + throw new InvalidOperationException( + "MultiWheelChassis的坐标偏置在适配器创建后被修改。" + + $"当前偏置为X={bias.X}, Y={bias.Y}, Th={bias.Z}°。" + + "请不要再调用DirectionAngle或SetOriginBias控制蟹行。"); + } + /// + /// 检查底盘命令是否包含无效数值。 + /// + private static void ValidateTwist(Twist2D twist) + { + ValidateFinite( + twist.VxMetersPerSecond, + nameof(twist.VxMetersPerSecond)); + + ValidateFinite( + twist.VyMetersPerSecond, + nameof(twist.VyMetersPerSecond)); + + ValidateFinite( + twist.OmegaRadiansPerSecond, + nameof(twist.OmegaRadiansPerSecond)); + } + /// + /// 检查数值是否为有限值。 + /// + private static void ValidateFinite( + double value, + string parameterName) + { + if (double.IsNaN(value) || + double.IsInfinity(value)) + { + throw new ArgumentOutOfRangeException( + parameterName, + "底盘速度命令不能是NaN或无穷大。"); + } + + if (value > float.MaxValue || + value < -float.MaxValue) + { + throw new ArgumentOutOfRangeException( + parameterName, + "底盘速度命令超过float可表示范围。"); + } + } + + /// + /// 获取最近一次底盘运动分解失败原因。 + /// + public string LastFailureReason => + _chassis.LastMotionDecomposeFailureReason; + #endregion + + /// + /// 将旧底盘的原点偏置恢复为真实单车车体坐标系。 + /// + public void ResetToBodyFrame() + { + _chassis.SetOriginBias( + x: 0.0f, + y: 0.0f, + th: 0.0f); + } + public MultiWheelChassisAdapter(MultiWheelChassis chassis, int vehicleId) + { + _chassis = chassis ?? throw new ArgumentNullException(nameof(chassis)); + if (vehicleId <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(vehicleId), + "车辆编号必须大于零。"); + } + VehicleId = vehicleId; +#pragma warning disable CS0612, CS0618 + var wheels = _chassis.GetSteerWheels(); +#pragma warning restore CS0612, CS0618 + + if (wheels.Count == 0) + { + throw new InvalidOperationException( + "MultiWheelChassis尚未完成舵轮初始化," + + "不能创建底盘适配器。"); + } + // 禁用旧版DirectionAngle/ZeroDirection坐标偏置, + // 保证SendXYThSpeed直接使用真实车体坐标系。 + ResetToBodyFrame(); + } + + + /// + /// 将车体坐标系速度命令发送给多舵轮底盘。 + /// + public bool Send(ChassisCommand command, TimeSpan? interval = null) + { + if (command.VehicleId != VehicleId) + { + throw new InvalidOperationException( + $"命令车辆编号{command.VehicleId}与适配器车辆编号" + + $"{VehicleId}不一致。"); + } + ValidateTwist(command.BodyTwist); + // 防止其他旧逻辑再次调用DirectionAngle或 + // SetOriginBias改变底盘坐标语义。 + EnsureBodyFrameIsActive(); + var vxMetersPerSecond = + (float)command.BodyTwist.VxMetersPerSecond; + var vyMetersPerSecond = + (float)command.BodyTwist.VyMetersPerSecond; + var omegaDegreesPerSecond = + (float)( + command.BodyTwist.OmegaRadiansPerSecond * + RadiansToDegrees); + var success = _chassis.SendXYThSpeed( + vxMetersPerSecond, + vyMetersPerSecond, + omegaDegreesPerSecond, + interval); + if (!success) + { + // 防止分解失败后继续执行上一条运动命令。 + _chassis.PredefinedDriveStop(); + } + return success; + } + /// + /// 按底盘减速度配置平滑停车,需要在控制周期中持续调用。 + /// + public void RampStop(TimeSpan? interval = null) + { + _chassis.RampStop(interval); + } + /// + /// 立即将所有驱动轮速度下发为零。 + /// + public void StopImmediately() + { + _chassis.PredefinedDriveStop(); + } + + /// + /// 停车并将所有舵轮转到指定的车体角度。 + /// 只调整舵轮角度,不产生车辆线速度。 + /// + public bool PrepareParallelDirection( + double directionRadians) + { + EnsureBodyFrameIsActive(); + var targetDegrees = (float)(FrameTransform2D.NormalizeAngle(directionRadians) * + RadiansToDegrees); + +#pragma warning disable CS0612, CS0618 + var wheels = _chassis.GetSteerWheels(); +#pragma warning restore CS0612, CS0618 + + // 没有舵轮时不能认为预对齐成功。 + if (wheels.Count == 0) + { + return false; + } + + // 先检查所有舵轮能否到达目标机械角度。 + foreach (var wheel in wheels) + { + if (targetDegrees < wheel.AngleLowerLimit || + targetDegrees > wheel.AngleUpperLimit) + { + return false; + } + } + + // 模式切换前立即停止驱动轮。 + _chassis.PredefinedDriveStop(); + // 检查完成后再统一下发,避免只转动一部分舵轮。 + foreach (var wheel in wheels) + { + wheel.WriteAngle(targetDegrees); + } + + return true; + } + + /// + /// 检查所有舵轮是否已经对准给定方向。 + /// + public bool AreParallelWheelsAligned( + double directionRadians, + double toleranceRadians) + { + if (double.IsNaN(toleranceRadians) || + double.IsInfinity(toleranceRadians) || + toleranceRadians < 0.0) + { + throw new ArgumentOutOfRangeException( + nameof(toleranceRadians), + "舵轮到位容差必须是非负有限值。"); + } + + EnsureBodyFrameIsActive(); + var targetDegrees = (float)( + FrameTransform2D.NormalizeAngle(directionRadians) * + 180.0 / Math.PI); + + var toleranceDegrees = (float)( + Math.Abs(toleranceRadians) * + 180.0 / Math.PI); + +#pragma warning disable CS0612, CS0618 + var wheels = _chassis.GetSteerWheels(); +#pragma warning restore CS0612, CS0618 + + foreach (var wheel in wheels) + { + var angleErrorDegrees = targetDegrees - wheel.ReadAngle(); + + if (Math.Abs(angleErrorDegrees) > + toleranceDegrees) + { + return false; + } + } + + return true; + } + + /// + /// 停车并将舵轮预对齐到原地自转方向。 + /// 返回是否成功生成舵轮目标。 + /// + public bool PrepareSpin( + TimeSpan? interval = null) + { + EnsureBodyFrameIsActive(); + _chassis.PredefinedDriveStop(); + + var success = + _chassis.SendRotateMotion( + 0.0f, + interval); + + if (!success) + { + _chassis.PredefinedDriveStop(); + } + return success; + } + /// + /// 所有舵轮是否已对齐到原地自转方向。 + /// + public bool AreSpinWheelsAligned => _chassis.LastRotateAligned; + + + + } +} diff --git a/Shared/SteeringConstraintManager.cs b/Shared/SteeringConstraintManager.cs new file mode 100644 index 0000000..33a2890 --- /dev/null +++ b/Shared/SteeringConstraintManager.cs @@ -0,0 +1,225 @@ +// 然后因为我这个车的舵轮是有正负120°限制的,所以有这种情况,比如我给了+90°让它进入蟹行模式,然后我在蟹行模式我去追一个相对于车左侧方向+40°的轨迹,那这时候就超过限制了,那我这是要做一个什么措施吗,比如我能想到的就是达到这个限位要么就停车打印说明到达限位,安全措施;要么就是我在追这个轨迹的过程中知道我大概可能超过限位,然后我提前停下,自转,调整为车头朝向为轨迹的方向,也就是自转调整到我车当前模式的初始姿态方向这种;你有什么好的建议吗,其他的建议吗,或者有什么可行的方法,因为实际的过程还是需要考虑这个限位的问题的 + + + + + + +// 9:49 + + + + + + +// 这个问题必须考虑,但“目标轮角超过 +120°”不一定意味着命令无法执行。对于舵轮,首先要利用“轮角反向、轮速反向”的等价关系,然后才考虑停车和车身重定向。 +// 1. +130° 不一定超过限位后就无法运动 +// 你的例子是: +// 蟹行基准方向:+90° +// 轨迹又向该方向增加:+40° +// 期望运动方向:+130° +// 舵轮限制:[-120°, +120°] +// 直接使用: +// 轮角:+130° +// 轮速:+0.3 m/s +// 确实超过了 +120°。 +// 但它有一个完全等价的表达: +// 轮角:-50° +// 轮速:-0.3 m/s +// 因为: +// (θ, v) ≡ (θ - 180°, -v) +// 即: +// (+130°, +0.3) ≡ (-50°, -0.3) +// 虽然轮子的机械朝向和转速符号变了,但轮胎与地面的速度向量完全相同。 +// 所以第一层措施应当是: +// 先尝试期望角度 +// 如果超过限位 +// 尝试角度±180° +// 同时将轮速取反 +// 如果等价角度满足限位 +// 使用等价解 +// 否则才判定无法执行 +// 你当前的 CommonUsage.dll 中已经有类似的舵角反解逻辑,SendXYThSpeed() 返回 bool 也是为了告诉上层运动分解是否成功。 +// 2. 你的±120°结构在静态上覆盖了所有运动方向 +// 因为: +// 可转范围总宽度 = 240° +// 大于 180°。 +// 只要驱动轮允许正反转,那么对于任意二维速度方向,通常都能在下面两种表达中找到一个合法值: +// (θ, +v) +// (θ ± 180°, -v) +// 因此单纯从“某一时刻目标速度方向”看,+130° 可以用 -50° 表达,不一定需要旋转车身。 +// 真正困难的是动态过程: +// 当前轮角:+90° +// 新目标等价轮角:-50° +// 需要转动:-140° +// 虽然最终角度合法,但舵轮需要转过很大的角度。在舵轮没有到位前,不能继续以正常速度驱动车辆,否则实际运动方向会与轨迹命令不一致。 +// 所以核心问题不是“有没有合法终态”,而是: +// 从当前轮角切换到合法目标轮角的过程是否安全、连续。 + +// 3. 建议采用四层处理策略 +// 第一层:等价舵角选择 +// 为每一个舵轮计算速度向量: +// Vix = Vx - ω·yi +// Viy = Vy + ω·xi +// 然后: +// θi = atan2(Viy, Vix) +// si = sqrt(Vix² + Viy²) +// 尝试候选解: +// 候选1:(θi, si) +// 候选2:(θi + 180°, -si) +// 候选3:(θi - 180°, -si) +// 过滤掉超过 [-120°, +120°] 的候选,再选择相对当前机械轮角转动最小的候选。 +// 这里计算机械转角距离时,不要简单把差值归一化成最短圆周角,因为舵轮不能无限旋转。对于有限机械区间,应当直接比较: +// Math.Abs(candidateAngle - currentMechanicalAngle) +// 第二层:软限位 +// 不要把 ±120° 当作正常工作的边界,建议设置软限位,例如: +// 物理硬限位:[-120°, +120°] +// 软件工作限位:[-105°, +105°] +// 预警区域:[105°, 120°] +// 具体余量需要实车标定,初期可以保留 10°~20°。 +// 状态可以分成: +// |θ| < 100° 正常 +// 100°~105° 接近限位,开始规划 +// 105°~120° 限速或停止切换 +// >120° 禁止下发并报警 +// 软限位的意义是给控制器留出制动、舵轮响应和误差空间。 +// 第三层:舵轮转向期间限制驱动速度 +// 如果新的合法舵角与当前舵角相差很大,例如: +// 当前:+90° +// 目标:-50° +// 差值:140° +// 不应当马上正常行驶,应进入: +// 减速 +// ↓ +// 驱动速度降为0 +// ↓ +// 舵轮转到-50° +// ↓ +// 确认所有舵轮到位 +// ↓ +// 轮速使用负值重新起步 +// 可以设置阈值: +// 舵角误差 < 2°:允许正常起步 +// 舵角误差 2°~10°:低速或保持 +// 舵角误差 > 10°:驱动速度为0 +// 实际阈值根据舵向响应速度标定。 +// 需要注意:把所有 Vx、Vy、Omega 同比例减小,只能降低运动速度,通常不会改变目标舵轮角度。因此“减速”能让切换更安全,但不一定能让超限角度重新变得合法。 +// 4. 车身重定向是更平滑的高级方案 +// 你提出的: +// 提前停车,然后自转,调整车头朝向,再继续跟踪。 + +// 这是正确而且很实用的方案,尤其适合连续轨迹跟踪。 +// 但不一定要把车头完全转到轨迹方向,应当根据当前驾驶模式选择车身朝向。 +// 假设轨迹方向相对当前车体为: +// +130° +// 如果希望继续保持蟹行模式,舵轮的优选方向是 +90°,那么只需要让车体逆时针自转: +// 130° - 90° = 40° +// 车体旋转 +40° 后: +// 世界轨迹方向不变 +// 轨迹在新车体坐标系中的方向:+90° +// 舵轮回到标准蟹行角度:+90° +// 不需要让车头旋转完整的 130°。 +// 如果准备切换成正常前进模式,优选舵角是 0°,才需要让车头基本对准轨迹: +// 130° - 0° = 130° +// 因此可以使用下面的通用公式: +// 期望车身朝向 = +// 世界轨迹切线方向 - 当前模式的优选舵轮方向 +// 其中: +// 正常模式优选角:0° +// 左蟹行优选角:+90° +// 右蟹行优选角:-90° +// 对于蟹行轨迹,车身朝向参考值为: +// yawReference = +// pathTangentWorldRadians - +// Math.PI / 2.0; +// 这样车体会逐渐调整自身朝向,使轨迹方向始终落在舵轮舒适区,而不是一直顶着 ±120° 工作。 +// 5. 不建议到达硬限位才突然自转 +// 更好的方案是向前预测。 +// 轨迹跟踪本身通常有前视点,可以提前计算未来一段轨迹对应的目标舵角: +// 当前时刻目标轮角 +// 未来0.2秒目标轮角 +// 未来0.5秒目标轮角 +// 未来1.0秒目标轮角 +// 一旦预测到未来会进入软限位,就提前触发车身重定向: +// 正常跟踪 +// ↓ +// 预测舵角接近软限位 +// ↓ +// 逐渐降低线速度 +// ↓ +// 停车 +// ↓ +// 车身原地旋转 +// ↓ +// 舵轮调整到模式初始方向 +// ↓ +// 重新捕获轨迹 +// ↓ +// 继续跟踪 +// 可以定义状态机: +// internal enum TrackingConstraintState +// { +// Tracking, +// ApproachingSteeringLimit, +// Stopping, +// ReorientingBody, +// AligningWheels, +// Resuming, +// Fault +// } +// 不要在一个控制周期内同时完成停车、车身旋转和恢复跟踪。 +// 6. LQR和限位处理应该分层 +// 以后写 LQR 时,不建议让基础 LQR 直接承担全部舵轮限位逻辑。 +// 推荐结构: +// 轨迹参考 +// ↓ +// LQR轨迹控制器 +// 输出期望 Vx、Vy、Omega +// ↓ +// 舵轮约束管理器 +// 等价角选择、软限位、预测、车身重定向 +// ↓ +// ChassisCommand +// ↓ +// MultiWheelChassisAdapter +// ↓ +// SendXYThSpeed +// 可以以后增加一个文件: +// Shared/SteeringConstraintManager.cs +// 它负责: +// 根据 Vx、Vy、Omega 预测每个舵轮目标角。 +// 选择“舵角反向、轮速反向”的等价解。 +// 检查硬限位和软限位。 +// 判断是否需要停车对齐。 +// 给出是否需要重新规划车身朝向。 +// 记录哪个舵轮导致不可执行。 +// 而 FrameTransform2D、ChassisCommand 不应该包含这些车辆机械约束。 +// 7. 当前阶段建议先做到什么 +// 现在先实现最小安全版本: +// 使用 SendXYThSpeed(),让底层优先选择等价舵角。 +// 检查 adapter.Send(command) 的返回值。 +// 返回 false 时立即停车,不能继续沿用上一条速度。 +// 打印每个舵轮的当前角度、目标角度和上下限。 +// 模式切换时先停车、预转舵轮、确认到位,再开放摇杆。 +// 先用较低速度测试 +90° → +110° → +120° → +130° 的过程。 +// 最低限度的发送逻辑应当是: +// var success = adapter.Send(command, interval); + +// if (!success) +// { +// adapter.Stop(interval); + +// Console.WriteLine( +// "底盘命令分解失败,已停车:" + +// "可能存在舵角限制或舵轮无法完成方向切换。"); +// } +// 然后再实现预测重定向。 +// 推荐的最终策略 +// 对于你的停车机器人,我建议采用: +// 优先级1:使用θ±180°、轮速反向的等价解 +// 优先级2:选择机械转角变化较小的等价解 +// 优先级3:接近软限位时降低速度 +// 优先级4:提前规划车身朝向,保持舵轮在舒适角度 +// 优先级5:无法连续切换时停车、自转、重新对齐 +// 优先级6:仍不可行时保持停车并报警 +// 所以你的 +130° 示例不应该一开始就判故障。它可以先使用 -50° + 反向轮速。如果从当前 +90° 切换到 -50° 的动态过程不安全,再停车调整。若轨迹允许改变车身姿态,更理想的办法是让车身提前旋转 +40°,从而继续以标准 +90° 蟹行姿态跟踪,而不是一直逼近机械硬限位。 \ No newline at end of file diff --git a/Simulation/Commands/BuiltInSimulationActions.cs b/Simulation/Commands/BuiltInSimulationActions.cs new file mode 100644 index 0000000..c5009a2 --- /dev/null +++ b/Simulation/Commands/BuiltInSimulationActions.cs @@ -0,0 +1,121 @@ +using MyParking.Simulation.Core; + +namespace MyParking.Simulation.Commands; + +/// +/// 内置离线测试动作;新增带特性的方法后网页会自动生成按钮。 +/// +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; + } +} diff --git a/Simulation/Commands/MySimulationTests.cs b/Simulation/Commands/MySimulationTests.cs new file mode 100644 index 0000000..1e127e5 --- /dev/null +++ b/Simulation/Commands/MySimulationTests.cs @@ -0,0 +1,31 @@ +using MyParking.Shared; +using MyParking.Simulation.Core; + +namespace MyParking.Simulation.Commands; + +/// +/// 我自己增加的停车机器人仿真测试动作。 +/// +public static class MySimulationTests +{ + /// + /// 测试车辆以0.2m/s向车体左侧运动。 + /// + // [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); + // } +} \ No newline at end of file diff --git a/Simulation/Commands/SimulationActionAttribute.cs b/Simulation/Commands/SimulationActionAttribute.cs new file mode 100644 index 0000000..a583439 --- /dev/null +++ b/Simulation/Commands/SimulationActionAttribute.cs @@ -0,0 +1,38 @@ +namespace MyParking.Simulation.Commands; + +/// +/// 将一个静态仿真测试方法自动注册为网页按钮。 +/// 方法签名必须为bool Xxx(SimulationVehicle vehicle)。 +/// +[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; } +} + +/// +/// 网页生成测试按钮所需的命令元数据。 +/// +public sealed record SimulationActionDescriptor( + string Key, + string DisplayName, + string Group, + int Order); diff --git a/Simulation/Commands/SimulationCommandDispatcher.cs b/Simulation/Commands/SimulationCommandDispatcher.cs new file mode 100644 index 0000000..57c6406 --- /dev/null +++ b/Simulation/Commands/SimulationCommandDispatcher.cs @@ -0,0 +1,150 @@ +using System.Reflection; +using MyParking.Simulation.Core; + +namespace MyParking.Simulation.Commands; + +/// +/// 自动发现带SimulationAction特性的测试方法并分发网页命令。 +/// +public sealed class SimulationCommandDispatcher +{ + private readonly SimulationWorld _world; + private readonly IReadOnlyDictionary _actions; + + public SimulationCommandDispatcher( + SimulationWorld world) + { + _world = world; + _actions = DiscoverActions(); + } + + /// + /// 返回网页动态生成按钮所需的全部命令。 + /// + public IReadOnlyList GetActions() + { + return _actions.Values + .Select(action => action.Descriptor) + .OrderBy(action => action.Group) + .ThenBy(action => action.Order) + .ToArray(); + } + + /// + /// 对指定车辆执行一个已注册的测试方法。 + /// + 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 + DiscoverActions() + { + var actions = new Dictionary( + 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(); + + if (attribute == null) + continue; + + ValidateMethod(method, attribute); + + var handler = + (Func)method.CreateDelegate( + typeof(Func)); + + 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 Handler); +} + +/// +/// 网页测试命令的执行结果。 +/// +public sealed record CommandResult( + bool Success, + string Message); diff --git a/Simulation/Core/SimulationClock.cs b/Simulation/Core/SimulationClock.cs new file mode 100644 index 0000000..cf64a77 --- /dev/null +++ b/Simulation/Core/SimulationClock.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +namespace MyParking.Simulation.Core; + +/// +/// 以固定周期推进离线仿真世界。 +/// +public sealed class SimulationClock( + SimulationWorld world, + ILogger 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); + } + } +} diff --git a/Simulation/Core/SimulationVehicle.cs b/Simulation/Core/SimulationVehicle.cs new file mode 100644 index 0000000..af04c3e --- /dev/null +++ b/Simulation/Core/SimulationVehicle.cs @@ -0,0 +1,382 @@ +using MyParking.Shared; +using MyParking.Simulation.Models; + +namespace MyParking.Simulation.Core; + +/// +/// 保存单辆四舵轮停车机器人的离线仿真状态。 +/// +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 _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); + + /// + /// 停车并切换舵轮准备模式。 + /// + 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; + } + + /// + /// 按当前模式发送前进或后退命令。 + /// + 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)); + } + + /// + /// 在当前运动模式下增加逆时针或顺时针转动。 + /// + 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)); + } + + /// + /// 将网页虚拟遥控器的油门和转向组合为连续车体速度命令。 + /// + 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)); + } + + /// + /// 应用统一车体速度命令并分解为四个舵轮速度向量。 + /// + 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; + } + + /// + /// 将车辆目标速度设置为零并保持当前舵轮角度。 + /// + public void Stop() + { + _targetBodyTwist = Twist2D.Zero; + foreach (var wheel in _wheels) + wheel.Stop(); + } + + /// + /// 更新舵轮反馈和车辆世界位姿。 + /// + 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); + } + + /// + /// 恢复车辆初始位置和舵轮状态。 + /// + 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(); + } + + /// + /// 创建供网页读取的不可变状态快照。 + /// + 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)); + } +} diff --git a/Simulation/Core/SimulationWorld.cs b/Simulation/Core/SimulationWorld.cs new file mode 100644 index 0000000..c7197aa --- /dev/null +++ b/Simulation/Core/SimulationWorld.cs @@ -0,0 +1,205 @@ +using MyParking.Shared; +using MyParking.Simulation.Models; + +namespace MyParking.Simulation.Core; + +/// +/// 管理离线仿真车辆、车队中心和成员布局。 +/// +public sealed class SimulationWorld +{ + private readonly object _syncRoot = new(); + private Dictionary _vehicles = new(); + private SimulationConfigurationDto _configuration; + + public SimulationWorld() + { + _configuration = CreateDefaultConfiguration(); + ApplyConfigurationCore(_configuration); + } + + public T WithVehicle( + int vehicleId, + Func 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 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()); + } +} diff --git a/Simulation/Core/VirtualSteerWheel.cs b/Simulation/Core/VirtualSteerWheel.cs new file mode 100644 index 0000000..3852696 --- /dev/null +++ b/Simulation/Core/VirtualSteerWheel.cs @@ -0,0 +1,174 @@ +namespace MyParking.Simulation.Core; + +/// +/// 模拟单个舵轮的转向和驱动响应,不包含真实电机物理模型。 +/// +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; + + /// + /// 设置期望轮胎速度向量,并在机械限位内选择等价舵角。 + /// + 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); + } + + /// + /// 停车时设置舵轮预对齐方向。 + /// + public bool PrepareDirection(double targetAngleDegrees) + { + TargetSpeedMetersPerSecond = 0.0; + return SetDirectionAndSpeed( + targetAngleDegrees, + 0.0); + } + + /// + /// 将驱动目标设置为零,并保持当前舵轮方向。 + /// + public void Stop() + { + TargetSpeedMetersPerSecond = 0.0; + } + + /// + /// 按固定转向速度和驱动加速度更新虚拟反馈。 + /// + public void Step(double deltaTimeSeconds) + { + ActualAngleDegrees = MoveTowards( + ActualAngleDegrees, + TargetAngleDegrees, + MaximumSteeringRateDegreesPerSecond * + deltaTimeSeconds); + + var allowedTargetSpeed = + IsAligned ? TargetSpeedMetersPerSecond : 0.0; + + ActualSpeedMetersPerSecond = MoveTowards( + ActualSpeedMetersPerSecond, + allowedTargetSpeed, + MaximumDriveAccelerationMetersPerSecondSquared * + deltaTimeSeconds); + } + + /// + /// 恢复舵轮初始状态。 + /// + 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; + } +} diff --git a/Simulation/Models/ManualControlInputDto.cs b/Simulation/Models/ManualControlInputDto.cs new file mode 100644 index 0000000..2e527c9 --- /dev/null +++ b/Simulation/Models/ManualControlInputDto.cs @@ -0,0 +1,10 @@ +namespace MyParking.Simulation.Models; + +/// +/// 网页虚拟遥控器输入,所有输入范围均为-1到1。 +/// +public sealed record ManualControlInputDto( + double Throttle, + double Steering, + double SpeedScale, + double SteeringScale); diff --git a/Simulation/Models/SimulationConfigurationDto.cs b/Simulation/Models/SimulationConfigurationDto.cs new file mode 100644 index 0000000..a60e1b4 --- /dev/null +++ b/Simulation/Models/SimulationConfigurationDto.cs @@ -0,0 +1,26 @@ +namespace MyParking.Simulation.Models; + +/// +/// 离线仿真的车辆数量、车队中心和成员相对布局。 +/// +public sealed record SimulationConfigurationDto( + int VehicleCount, + FleetCenterDto FleetCenter, + IReadOnlyList Vehicles); + +/// +/// 车队中心在世界坐标系中的位姿。 +/// +public sealed record FleetCenterDto( + double XMeters, + double YMeters, + double YawRadians); + +/// +/// 单车车体坐标系在车队中心坐标系中的位姿。 +/// +public sealed record VehicleLayoutDto( + int VehicleId, + double XMeters, + double YMeters, + double YawRadians); diff --git a/Simulation/Models/VehicleStateDto.cs b/Simulation/Models/VehicleStateDto.cs new file mode 100644 index 0000000..660afd2 --- /dev/null +++ b/Simulation/Models/VehicleStateDto.cs @@ -0,0 +1,25 @@ +namespace MyParking.Simulation.Models; + +/// +/// 网页绘制单辆停车机器人所需的状态快照。 +/// +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 Wheels); + +/// +/// 网页显示的二维车体速度快照。 +/// +public sealed record TwistStateDto( + double VxMetersPerSecond, + double VyMetersPerSecond, + double OmegaRadiansPerSecond); diff --git a/Simulation/Models/WheelStateDto.cs b/Simulation/Models/WheelStateDto.cs new file mode 100644 index 0000000..fda10cf --- /dev/null +++ b/Simulation/Models/WheelStateDto.cs @@ -0,0 +1,14 @@ +namespace MyParking.Simulation.Models; + +/// +/// 网页绘制单个舵轮所需的状态快照。 +/// +public sealed record WheelStateDto( + string Name, + double XMeters, + double YMeters, + double TargetAngleDegrees, + double ActualAngleDegrees, + double TargetSpeedMetersPerSecond, + double ActualSpeedMetersPerSecond, + bool IsAligned); diff --git a/Simulation/MyParking.Simulation.csproj b/Simulation/MyParking.Simulation.csproj new file mode 100644 index 0000000..221b2fd --- /dev/null +++ b/Simulation/MyParking.Simulation.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + MyParking.Simulation + + + + + + + + diff --git a/Simulation/Program.cs b/Simulation/Program.cs new file mode 100644 index 0000000..c45638f --- /dev/null +++ b/Simulation/Program.cs @@ -0,0 +1,97 @@ +using MyParking.Simulation.Commands; +using MyParking.Simulation.Core; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +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(); diff --git a/Simulation/Properties/launchSettings.json b/Simulation/Properties/launchSettings.json new file mode 100644 index 0000000..1ba4702 --- /dev/null +++ b/Simulation/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Simulation/appsettings.Development.json b/Simulation/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Simulation/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Simulation/appsettings.json b/Simulation/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/Simulation/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Simulation/wwwroot/index.html b/Simulation/wwwroot/index.html new file mode 100644 index 0000000..6fd3506 --- /dev/null +++ b/Simulation/wwwroot/index.html @@ -0,0 +1,144 @@ + + + + + + 停车机器人离线测试台 + + + +
+
+
+

MY PARKING · OFFLINE LAB

+

停车机器人离线测试台

+
+
+ + 正在连接仿真后端 +
+
+ +
+ + +
+
+ 世界视图 + X 向右 · Y 向上 · 逆时针为正 +
+ +
+ + +
+
+ + + + diff --git a/Simulation/wwwroot/viewer.css b/Simulation/wwwroot/viewer.css new file mode 100644 index 0000000..b36b8ce --- /dev/null +++ b/Simulation/wwwroot/viewer.css @@ -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; + } +} diff --git a/Simulation/wwwroot/viewer.js b/Simulation/wwwroot/viewer.js new file mode 100644 index 0000000..aeee9ac --- /dev/null +++ b/Simulation/wwwroot/viewer.js @@ -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 => ` + `) + .join(""); + + return ` +
+

${group}

+
${buttons}
+
`; + }).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 => + ``) + .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 ` + `; + }).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 = ` +
+
+ 车体 1472 × 948 mm · 实际DLL轮位 X±750 / Y±500 mm +
+ ${vehicle.wheels.map(wheel => { + const actualPercent = + (wheel.actualAngleDegrees + 120) / 240 * 100; + const targetPercent = + (wheel.targetAngleDegrees + 120) / 240 * 100; + return ` +
+
+ ${wheel.name} + 实际 ${signed(wheel.actualAngleDegrees)}° · 目标 ${signed(wheel.targetAngleDegrees)}° +
+
+ + + +
+
+ 轮速 ${wheel.actualSpeedMetersPerSecond.toFixed(2)} m/s +
+
`; + }).join("")} +
`; +} + +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 ` +
+ CAR ${id} + + + +
`; + }).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);