Simulation - sim/: the unchanged robot firmware compiled into hexapod_sim.dll against PC stand-ins (Arduino, Servo, Wire + register-level MPU6050, RF24 with matching-config radio), a kinematic body model (support plane + planted-foot odometry) that also drives the simulated IMU, and a headless smoke test (sim_smoke). build.ps1 builds and tests. - Hexapod_Simulation_Unity/ folded in (history kept in a bundle outside the repo). Assets/HexapodSim loads the DLL, drives the existing leg rig, and offers joystick / keyboard / robot-serial input plus a test panel. Robot (all found or confirmed in the simulation) - Config.h geometry taken from the Unity model. - IK femur elevation sign, gait stance direction, body pose rotation direction, yaw lever arm. - Stance feet integrated from the current command; gait restarts at mid-stance; heading integrates the applied turn rate, only while walking. - Leg clearance and reach checks (Limits::feetFeasible); walk command and body pose scaled as a whole, pose scale rate-limited, planted feet guarded so the body never snaps or legs collide. - Robot-gyro self-leveling as an integrator. - Heading mode: steering relative to the body; S walks backward. - Standing height from the remote (STANCE_HEIGHT_RANGE_MM). Protocol v3 (reflash both boards) - CmdPacket gains body_height (22 bytes). Remote - Height slider (Nextion hHeight/nHeight, serial [ ]). - Fixed unspecified I2C read order in RemoteIMU. LEGACY/: owner's old Nextion HMI and remote hardware assets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
14 KiB
C#
327 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text;
|
|
using UnityEngine;
|
|
|
|
namespace HexapodSim
|
|
{
|
|
// Runs the real robot firmware (robot/, compiled into sim/build/hexapod_sim.dll)
|
|
// against the existing Unity model:
|
|
// input (SimRemote) -> CmdPacket over the simulated radio -> firmware loop()
|
|
// -> 18 servo pulses -> joint rotations on the rig.
|
|
// The body's motion (kinematic: it rests on its feet, grounded feet don't
|
|
// slide) and the simulated MPU6050 readings are computed inside the DLL
|
|
// (sim/SimBody.cpp), so the headless smoke test checks the same thing this
|
|
// shows. Nothing here decides how the robot moves.
|
|
[DefaultExecutionOrder(-100)]
|
|
public class HexapodSimulation : MonoBehaviour
|
|
{
|
|
[Header("Firmware")]
|
|
[Tooltip("Empty = <repo>/sim/build/hexapod_sim.dll")]
|
|
public string dllPath = "";
|
|
public bool radioPresent = true;
|
|
public bool imuPresent = true;
|
|
[Tooltip("Remote transmit rate. The real remote sends at 100 Hz.")]
|
|
public float remoteTxHz = 100f;
|
|
|
|
[Header("Rig (auto-detected from the scene if empty)")]
|
|
public Transform chassis;
|
|
public Transform ground;
|
|
public ForwardAxis forwardAxis = ForwardAxis.NegativeX;
|
|
[Tooltip("Unity units per millimetre. The model is built in cm.")]
|
|
public float unitsPerMm = 0.1f;
|
|
[Tooltip("Firmware tibia angle when the rig's Knie rotation is 0 (tibia straight down from a horizontal femur).")]
|
|
public float kneeRestDeg = -90f;
|
|
|
|
[Header("World")]
|
|
[Tooltip("Tilts the ground (deg about robot X = roll, Y = pitch) to exercise the robot gyro.")]
|
|
public Vector2 groundTiltDeg;
|
|
public bool followCamera = true;
|
|
|
|
// ---- Runtime state, read by SimTestPanel ----
|
|
public HexapodSimNative Native { get; private set; }
|
|
public HxGeometry Geometry { get; private set; }
|
|
public HxStatus Status { get; private set; }
|
|
public string LoadError { get; private set; }
|
|
public readonly List<string> SerialLog = new List<string>();
|
|
public readonly List<string> RigWarnings = new List<string>();
|
|
public float[] JointsDeg { get; } = new float[18];
|
|
public float[] FeetMm { get; } = new float[18];
|
|
public bool[] Contact { get; } = new bool[6];
|
|
public int[] ServoUs { get; } = new int[18];
|
|
public int[] ServoAttached { get; } = new int[18];
|
|
public uint[] ServoWrites { get; } = new uint[18];
|
|
public Vector3 BodyPosMm => bodyPos_;
|
|
public float BodyYawDeg => yaw_;
|
|
|
|
SimRemote remote_;
|
|
|
|
class LegRig
|
|
{
|
|
public Transform root, hip1, hip2, knie, footMarker;
|
|
public float coxaOffsetDeg;
|
|
}
|
|
readonly LegRig[] legs_ = new LegRig[6];
|
|
|
|
// Body pose from the DLL, robot-world frame (mm, Z up, ground z = 0 before tilt).
|
|
float yaw_;
|
|
Vector3 bodyPos_;
|
|
Quaternion bodyRot_ = Quaternion.identity; // robot-frame rotation used as a math object
|
|
|
|
// Unity-side anchors captured at start.
|
|
Vector3 originUnity_;
|
|
Quaternion chassisRestRot_;
|
|
Vector3 groundRestPos_;
|
|
Quaternion groundRestRot_;
|
|
Transform camera_;
|
|
Vector3 cameraOffset_;
|
|
StringBuilder serialLine_ = new StringBuilder();
|
|
|
|
static readonly string[] LegacyMovementScripts =
|
|
{ "Walk", "RotationControl", "Leg", "HandleHoystick", "Serial2", "Serial3" };
|
|
|
|
// Robot-frame axes, for readability (Unity's Vector3.up etc. are Unity axes).
|
|
static readonly Vector3 AxisX = new Vector3(1, 0, 0); // forward
|
|
static readonly Vector3 AxisY = new Vector3(0, 1, 0); // left
|
|
|
|
void Awake()
|
|
{
|
|
DisableLegacyScripts();
|
|
remote_ = GetComponent<SimRemote>();
|
|
if (remote_ == null) remote_ = gameObject.AddComponent<SimRemote>();
|
|
if (GetComponent<SimTestPanel>() == null) gameObject.AddComponent<SimTestPanel>();
|
|
|
|
if (chassis == null) chassis = FindChassis();
|
|
if (ground == null) { var g = GameObject.Find("Plane"); if (g) ground = g.transform; }
|
|
if (chassis == null) { LoadError = "No leg rig found (expected 6 objects named 'Leg' with hip1/Hip2/Knie)."; return; }
|
|
|
|
chassisRestRot_ = chassis.rotation;
|
|
float groundY = ground ? ground.position.y : chassis.position.y - 150f * unitsPerMm;
|
|
originUnity_ = new Vector3(chassis.position.x, groundY, chassis.position.z);
|
|
if (ground) { groundRestPos_ = ground.position; groundRestRot_ = ground.rotation; }
|
|
if (Camera.main) { camera_ = Camera.main.transform; cameraOffset_ = camera_.position - chassis.position; }
|
|
|
|
ReloadFirmware();
|
|
}
|
|
|
|
void OnDestroy() => Native?.Dispose();
|
|
|
|
public string ResolvedDllPath =>
|
|
!string.IsNullOrEmpty(dllPath)
|
|
? dllPath
|
|
: Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "sim", "build", "hexapod_sim.dll"));
|
|
|
|
// Fresh power-on: loads the current DLL build and resets the body.
|
|
public void ReloadFirmware()
|
|
{
|
|
Native?.Dispose();
|
|
Native = null;
|
|
LoadError = null;
|
|
try
|
|
{
|
|
Native = HexapodSimNative.Load(ResolvedDllPath);
|
|
Native.SetRadioPresent(radioPresent);
|
|
Native.SetImuPresent(imuPresent);
|
|
Geometry = Native.Geometry;
|
|
BindRig();
|
|
Native.SetGroundTilt(groundTiltDeg.x, groundTiltDeg.y);
|
|
Native.Start();
|
|
remote_.ResetState();
|
|
SerialLog.Add($"--- firmware loaded: {ResolvedDllPath}");
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
LoadError = e.Message;
|
|
Debug.LogError("[HexapodSim] " + e.Message);
|
|
Native?.Dispose();
|
|
Native = null;
|
|
}
|
|
}
|
|
|
|
void FixedUpdate()
|
|
{
|
|
if (Native == null) return;
|
|
float dt = Time.fixedDeltaTime;
|
|
|
|
Native.SetGroundTilt(groundTiltDeg.x, groundTiltDeg.y);
|
|
|
|
// Remote packets at the remote's rate, firmware time advanced in step.
|
|
int sub = Mathf.Max(1, Mathf.RoundToInt(dt * remoteTxHz));
|
|
float subDt = dt / sub;
|
|
for (int n = 0; n < sub; n++)
|
|
{
|
|
remote_.Transmit(Native, subDt);
|
|
Native.Step((uint)Mathf.RoundToInt(subDt * 1e6f));
|
|
}
|
|
|
|
Status = Native.Status;
|
|
Native.GetJointsDeg(JointsDeg);
|
|
Native.GetFeetMm(FeetMm);
|
|
Native.GetServos(ServoUs, ServoAttached, ServoWrites);
|
|
Native.GetBody(out bodyPos_, out bodyRot_, out yaw_, out int contactMask);
|
|
for (int i = 0; i < 6; i++) Contact[i] = (contactMask >> i & 1) != 0;
|
|
DrainSerial();
|
|
|
|
ApplyBodyPose();
|
|
ApplyJoints();
|
|
}
|
|
|
|
void LateUpdate()
|
|
{
|
|
if (followCamera && camera_ && chassis) camera_.position = chassis.position + cameraOffset_;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Rig
|
|
// ------------------------------------------------------------------
|
|
|
|
void DisableLegacyScripts()
|
|
{
|
|
foreach (var mb in FindObjectsByType<MonoBehaviour>(FindObjectsInactive.Include))
|
|
if (System.Array.IndexOf(LegacyMovementScripts, mb.GetType().Name) >= 0)
|
|
mb.enabled = false;
|
|
}
|
|
|
|
static List<Transform> FindLegRoots()
|
|
{
|
|
var result = new List<Transform>();
|
|
foreach (var t in FindObjectsByType<Transform>())
|
|
if (t.name == "Leg" && FindDeep(t, "hip1") != null) result.Add(t);
|
|
return result;
|
|
}
|
|
|
|
static Transform FindChassis()
|
|
{
|
|
var roots = FindLegRoots();
|
|
return roots.Count > 0 ? roots[0].parent : null;
|
|
}
|
|
|
|
static Transform FindDeep(Transform parent, string name)
|
|
{
|
|
foreach (Transform c in parent)
|
|
{
|
|
if (string.Equals(c.name, name, System.StringComparison.OrdinalIgnoreCase)) return c;
|
|
var d = FindDeep(c, name);
|
|
if (d) return d;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Matches each firmware leg (Config.h order) to the rig leg at its mount
|
|
// position, and checks the rig against Config.h so a mismatch is loud.
|
|
void BindRig()
|
|
{
|
|
RigWarnings.Clear();
|
|
var roots = FindLegRoots();
|
|
var g = Geometry;
|
|
for (int i = 0; i < 6; i++)
|
|
{
|
|
Vector3 mountLocal = RobotFrame.ToUnity(new Vector3(g.mountXmm[i], g.mountYmm[i], 0), forwardAxis) * unitsPerMm;
|
|
Transform best = null;
|
|
float bestDist = float.MaxValue;
|
|
foreach (var r in roots)
|
|
{
|
|
float dist = Vector3.Distance(chassis.InverseTransformPoint(r.position), mountLocal);
|
|
if (dist < bestDist) { bestDist = dist; best = r; }
|
|
}
|
|
if (best == null) { legs_[i] = null; continue; }
|
|
roots.Remove(best);
|
|
|
|
var leg = new LegRig
|
|
{
|
|
root = best,
|
|
hip1 = FindDeep(best, "hip1"),
|
|
hip2 = FindDeep(best, "Hip2"),
|
|
knie = FindDeep(best, "Knie"),
|
|
footMarker = FindDeep(best, "Point"),
|
|
};
|
|
if (bestDist / unitsPerMm > 5f)
|
|
RigWarnings.Add($"leg {i}: rig hip is {bestDist / unitsPerMm:F0} mm from Config.h mount ({g.mountXmm[i]}, {g.mountYmm[i]})");
|
|
|
|
// Direction the rig's coxa points at rotation 0, in robot-frame degrees.
|
|
Vector3 outward = RobotFrame.ToRobot(chassis.InverseTransformDirection(best.forward), forwardAxis);
|
|
float rigMountDeg = Mathf.Atan2(outward.y, outward.x) * Mathf.Rad2Deg;
|
|
leg.coxaOffsetDeg = RobotFrame.WrapDeg(g.mountAngleDeg[i] - rigMountDeg);
|
|
if (Mathf.Abs(leg.coxaOffsetDeg) > 1f)
|
|
RigWarnings.Add($"leg {i}: Config mount angle {g.mountAngleDeg[i]:F0} vs rig {rigMountDeg:F0} deg (compensated)");
|
|
|
|
if (leg.hip1 && leg.hip2 && leg.knie)
|
|
{
|
|
float rigCoxa = Vector3.Distance(leg.hip1.position, leg.hip2.position) / unitsPerMm;
|
|
float rigFemur = Vector3.Distance(leg.hip2.position, leg.knie.position) / unitsPerMm;
|
|
if (Mathf.Abs(rigCoxa - g.coxaMm) > 2f || Mathf.Abs(rigFemur - g.femurMm) > 2f)
|
|
RigWarnings.Add($"leg {i}: rig coxa/femur {rigCoxa:F0}/{rigFemur:F0} mm vs Config {g.coxaMm:F0}/{g.femurMm:F0} mm");
|
|
}
|
|
else RigWarnings.Add($"leg {i}: missing hip1/Hip2/Knie under '{best.name}'");
|
|
legs_[i] = leg;
|
|
}
|
|
foreach (var w in RigWarnings) Debug.LogWarning("[HexapodSim] " + w);
|
|
}
|
|
|
|
// Firmware joint convention -> rig rotations (see sim/HexapodSimApi.cpp FK):
|
|
// coxa + = CCW from above; Unity +Y rotation is clockwise from above.
|
|
// femur/tibia + = up; Unity +X rotation tips the leg's +Z down.
|
|
void ApplyJoints()
|
|
{
|
|
for (int i = 0; i < 6; i++)
|
|
{
|
|
var leg = legs_[i];
|
|
if (leg == null || leg.hip1 == null) continue;
|
|
float coxa = JointsDeg[i * 3], femur = JointsDeg[i * 3 + 1], tibia = JointsDeg[i * 3 + 2];
|
|
leg.hip1.localRotation = Quaternion.Euler(0, -(coxa + leg.coxaOffsetDeg), 0);
|
|
if (leg.hip2) leg.hip2.localRotation = Quaternion.Euler(-femur, 0, 0);
|
|
if (leg.knie) leg.knie.localRotation = Quaternion.Euler(-(tibia - kneeRestDeg), 0, 0);
|
|
|
|
// The sphere marks where the firmware-side FK puts the foot. If it
|
|
// doesn't sit on the tibia tip, the rig and Config.h disagree.
|
|
if (leg.footMarker)
|
|
leg.footMarker.position = chassis.TransformPoint(FootLocal(i));
|
|
}
|
|
}
|
|
|
|
Vector3 FootLocal(int i) =>
|
|
RobotFrame.ToUnity(new Vector3(FeetMm[i * 3], FeetMm[i * 3 + 1], FeetMm[i * 3 + 2]), forwardAxis) * unitsPerMm;
|
|
|
|
// ------------------------------------------------------------------
|
|
// World
|
|
// ------------------------------------------------------------------
|
|
|
|
// Robot frame: X forward, Y left (Unity's Vector3.right/up are Unity axes).
|
|
Quaternion GroundRotation() =>
|
|
Quaternion.AngleAxis(groundTiltDeg.x, AxisX) * Quaternion.AngleAxis(groundTiltDeg.y, AxisY);
|
|
|
|
// The robot-world frame is the chassis's starting orientation, so the
|
|
// rig's local axes (which the Config.h mapping is defined against) and
|
|
// the world stay consistent even if the chassis starts rotated.
|
|
void ApplyBodyPose()
|
|
{
|
|
if (!chassis) return;
|
|
chassis.position = originUnity_ + chassisRestRot_ * RobotFrame.ToUnity(bodyPos_, forwardAxis) * unitsPerMm;
|
|
chassis.rotation = chassisRestRot_ * RobotFrame.ToUnity(bodyRot_, forwardAxis);
|
|
if (ground)
|
|
{
|
|
Quaternion g = chassisRestRot_ * RobotFrame.ToUnity(GroundRotation(), forwardAxis) * Quaternion.Inverse(chassisRestRot_);
|
|
ground.rotation = g * groundRestRot_;
|
|
ground.position = originUnity_ + g * (groundRestPos_ - originUnity_);
|
|
}
|
|
}
|
|
|
|
void DrainSerial()
|
|
{
|
|
string s = Native.SerialOutput();
|
|
foreach (char ch in s)
|
|
{
|
|
if (ch == '\n')
|
|
{
|
|
string line = serialLine_.ToString().TrimEnd('\r');
|
|
serialLine_.Clear();
|
|
SerialLog.Add(line);
|
|
Debug.Log("[robot] " + line);
|
|
}
|
|
else serialLine_.Append(ch);
|
|
}
|
|
if (SerialLog.Count > 200) SerialLog.RemoveRange(0, SerialLog.Count - 200);
|
|
}
|
|
}
|
|
}
|