Firmware simulation, Unity project, and sim-verified robot fixes

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>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-09-29 00:37:12 +02:00
1 parent 91dac16fca
commit 935526eab5
145 files changed
+431258 -114

No files matched your search

+54 -7
View File
@@ -1,4 +1,5 @@
#include "BodyPose.h"
#include "Limits.h"
void BodyPose::begin() {
s_roll_.reset(0); s_pitch_.reset(0); s_yaw_.reset(0);
@@ -25,10 +26,17 @@ void BodyPose::update(float dt) {
// whether offsetting +X moves the feet the way you expect) depends on your
// leg numbering and mount angles - bench-check it the same way as the IK
// elbow direction, and flip signs if it goes the wrong way.
FootTarget BodyPose::apply(const FootTarget& in) const {
float roll = radians(s_roll_.current + correction_roll_);
float pitch = radians(s_pitch_.current + correction_pitch_);
float yaw = radians(s_yaw_.current);
FootTarget BodyPose::apply(const FootTarget& in) const { return applyScaled(in, reach_scale_); }
FootTarget BodyPose::applyScaled(const FootTarget& in, float s) const {
// The feet stay put in the world, so to rotate the body by (roll, pitch,
// yaw) the body-frame foot targets rotate by the inverse: negated angles,
// applied yaw -> pitch -> roll. Result: +roll = right side down,
// +pitch = nose down, +yaw = CCW (HexapodProtocol.h), checked in sim_smoke.
// `s` scales the whole pose (see fitToReach).
float roll = -radians(s * (s_roll_.current + correction_roll_));
float pitch = -radians(s * (s_pitch_.current + correction_pitch_));
float yaw = -radians(s * s_yaw_.current);
float x = in.x, y = in.y, z = in.z;
@@ -43,8 +51,47 @@ FootTarget BodyPose::apply(const FootTarget& in) const {
float z3 = y1 * sin(roll) + z2 * cos(roll);
FootTarget out;
out.x = x2 - s_x_.current;
out.y = y3 - s_y_.current;
out.z = z3 - s_z_.current;
out.x = x2 - s * s_x_.current;
out.y = y3 - s * s_y_.current;
out.z = z3 - s * s_z_.current;
return out;
}
bool BodyPose::reachable(const FootTarget sets[][6], int count, float s) const {
for (int k = 0; k < count; k++) {
FootTarget posed[6];
for (int i = 0; i < 6; i++) posed[i] = applyScaled(sets[k][i], s);
if (!Limits::feetFeasible(posed)) return false;
}
return true;
}
bool BodyPose::feasible(const FootTarget feet[6]) const {
FootTarget posed[6];
for (int i = 0; i < 6; i++) posed[i] = apply(feet[i]);
return Limits::feetFeasible(posed);
}
// Clamping each foot separately would bend the six feet out of their rigid
// shape on the ground (feet dragging, legs fighting each other) as soon as
// a combined pose asks for more than a leg can reach, and nothing would stop
// neighbouring legs from swinging into each other. Instead the whole pose
// is scaled down by one factor until every foot set is physically possible,
// so the body does the most of what was asked that it really can.
//
// The scale itself moves at a bounded rate: a sudden drop would snap the
// whole body, faster than the servos can follow together. While walking,
// the gait slows down against the current pose in the meantime (see
// GaitEngine::limitTwist), so it stays physical during the handover.
void BodyPose::fitToReach(const FootTarget sets[][6], int count, float dt) {
float fit = 1.0f;
if (!reachable(sets, count, 1.0f)) {
float lo = 0.0f, hi = 1.0f; // lo stays feasible (or 0 if even no pose isn't - leave that to Limits)
for (int n = 0; n < 8; n++) {
float mid = 0.5f * (lo + hi);
if (reachable(sets, count, mid)) lo = mid; else hi = mid;
}
fit = lo;
}
reach_scale_ = constrain(fit, reach_scale_ - REACH_DROP_PER_SEC * dt, reach_scale_ + REACH_RECOVER_PER_SEC * dt);
}
+24 -1
View File
@@ -20,12 +20,35 @@ public:
void update(float dt);
// Applies the current (smoothed) pose transform to a body-frame foot target.
// Scales the whole pose down (one factor for all six legs) until every set
// of six feet in `sets` is still physically possible with the pose applied
// (reachable, no colliding legs). Pass the stride envelope while walking,
// so the pose fits the whole stride rather than this tick's feet, or the
// stand targets otherwise. Call after update() and before apply().
void fitToReach(const FootTarget sets[][6], int count, float dt);
float reachScale() const { return reach_scale_; } // 1 = full commanded pose
// The operator's tilt as actually applied (smoothed and reach-scaled),
// without the gyro correction - what self-leveling should keep.
float commandRollDeg() const { return s_roll_.current * reach_scale_; }
float commandPitchDeg() const { return s_pitch_.current * reach_scale_; }
// Applies the current (smoothed, reach-scaled) pose transform to a body-frame foot target.
FootTarget apply(const FootTarget& in) const;
// True if these (un-posed) feet are physically possible with the current
// pose applied - see Limits::feetFeasible.
bool feasible(const FootTarget feet[6]) const;
private:
PoseCommand command_;
float correction_roll_ = 0, correction_pitch_ = 0;
float reach_scale_ = 1.0f;
static constexpr float REACH_RECOVER_PER_SEC = 1.0f;
static constexpr float REACH_DROP_PER_SEC = 3.0f;
FootTarget applyScaled(const FootTarget& in, float scale) const;
bool reachable(const FootTarget sets[][6], int count, float scale) const;
SmoothedValue s_roll_{POSE_TILT_MAX_RATE_DEG};
SmoothedValue s_pitch_{POSE_TILT_MAX_RATE_DEG};
+31 -8
View File
@@ -7,16 +7,20 @@
// =====================================================================
// ---- Leg geometry ----
// Taken from the Unity model (Hexapod_Simulation_Unity/Assets/Main.unity,
// 1 Unity unit = 10 mm): hip pivots at x = +-10 / 0, z = +-5 / +-6.5;
// coxa 5.5, femur 10, tibia 15. BENCH: confirm against the real chassis.
constexpr int NUM_LEGS = 6;
// Leg order: 0=front-right, 1=mid-right, 2=rear-right, 3=rear-left, 4=mid-left, 5=front-left
// Angles measured counterclockwise from the body's +X (forward) axis, viewed from above.
constexpr float LEG_MOUNT_ANGLE_DEG[NUM_LEGS] = { -45, -90, -135, 135, 90, 45 };
constexpr float LEG_MOUNT_X_MM[NUM_LEGS] = { 80, 0, -80, -80, 0, 80 };
constexpr float LEG_MOUNT_Y_MM[NUM_LEGS] = { -60, -90, -60, 60, 90, 60 };
// This is the direction the coxa points at servo center: straight sideways on this chassis.
constexpr float LEG_MOUNT_ANGLE_DEG[NUM_LEGS] = { -90, -90, -90, 90, 90, 90 };
constexpr float LEG_MOUNT_X_MM[NUM_LEGS] = { 100, 0, -100, -100, 0, 100 };
constexpr float LEG_MOUNT_Y_MM[NUM_LEGS] = { -50, -65, -50, 50, 65, 50 };
constexpr float COXA_LEN_MM = 30.0f;
constexpr float FEMUR_LEN_MM = 60.0f;
constexpr float TIBIA_LEN_MM = 100.0f;
constexpr float COXA_LEN_MM = 55.0f;
constexpr float FEMUR_LEN_MM = 100.0f;
constexpr float TIBIA_LEN_MM = 150.0f;
// ---- Joint limits, degrees ----
constexpr float COXA_MIN_DEG = -60, COXA_MAX_DEG = 60;
@@ -24,8 +28,21 @@ constexpr float FEMUR_MIN_DEG = -80, FEMUR_MAX_DEG = 80;
constexpr float TIBIA_MIN_DEG = -150, TIBIA_MAX_DEG = -20;
// ---- Neutral stance, relative to each leg's own coxa pivot ----
constexpr float STANCE_RADIUS_MM = 140.0f; // how far out the foot rests
constexpr float STANCE_HEIGHT_MM = 90.0f; // how far below the coxa pivot the foot rests
// From the Unity model's rest foot point: 130 mm out from the coxa pivot
// (= COXA_LEN_MM + 75), 150 mm below it.
constexpr float STANCE_RADIUS_MM = 75.0f; // how far out the foot rests, beyond the coxa
constexpr float STANCE_HEIGHT_MM = 150.0f; // how far below the coxa pivot the foot rests (default height)
// The remote's height slider (CmdPacket.body_height) moves the standing
// height by up to this much either way (100..200 mm here - both ends checked
// reachable in the simulation), at no more than STANCE_HEIGHT_RATE_MM_S.
constexpr float STANCE_HEIGHT_RANGE_MM = 50.0f;
constexpr float STANCE_HEIGHT_RATE_MM_S = 60.0f;
// ---- Leg collision ----
// Minimum distance between the center lines of neighbouring legs' segments
// (coxa, femur, tibia). Gait strides and body poses are scaled down to keep
// it. BENCH: set to roughly the width of a leg (servo body + bracket).
constexpr float LEG_CLEARANCE_MM = 30.0f;
// ---- Servo pins (Teensy 4.0). Verify these are PWM-capable on your board. ----
// Order per leg: coxa, femur, tibia.
@@ -55,6 +72,12 @@ constexpr uint8_t RADIO_CE_PIN = 33;
constexpr uint8_t RADIO_CSN_PIN = 34;
constexpr float LINK_TIMEOUT_SEC = 0.3f;
// ---- Robot-gyro self-leveling ----
// Integral gain (1/s): a leftover tilt of 1 deg changes the correction by
// this many deg/s. Higher levels faster but can wobble. BENCH: tune on the robot.
constexpr float ROBOT_GYRO_KI = 3.0f;
constexpr float ROBOT_GYRO_MAX_CORR_DEG = 15.0f; // steepest slope it will level out
// ---- Setpoint smoothing rates ----
constexpr float POSE_TILT_MAX_RATE_DEG = 120.0f; // roll/pitch/yaw offset, deg/s
constexpr float POSE_OFFSET_MAX_RATE_MM = 150.0f; // X/Y/Z offset, mm/s
+119 -20
View File
@@ -1,4 +1,5 @@
#include "GaitEngine.h"
#include "Limits.h"
const GaitPattern GAIT_TRIPOD = {
0.5f,
@@ -11,16 +12,79 @@ const GaitPattern GAIT_WAVE = {
};
void GaitEngine::begin() {
resetFeet();
}
// Every foot starts at neutral, which is the *middle* of a stride. So the
// cycle restarts with the pattern shifted to put leg 0 at mid-stance (for
// the tripod that puts every grounded leg at mid-stance); a grounded leg
// that started at the beginning of its stance would instead run a full
// stride past neutral into the workspace clamp and stop pushing. Legs that
// land in their swing phase lift off from neutral over the remaining swing.
void GaitEngine::resetFeet() {
for (int i = 0; i < 6; i++) {
legs_pattern_[i] = requested_pattern_;
leg_phase_[i] = legs_pattern_[i]->leg_phase_offset[i];
const GaitPattern* p = legs_pattern_[i];
float phase = p->leg_phase_offset[i] + p->duty_factor * 0.5f;
leg_phase_[i] = phase - floorf(phase);
foot_[i] = Kinematics::neutralStance(i);
liftoff_[i] = foot_[i];
swing_start_[i] = leg_phase_[i] < p->duty_factor ? p->duty_factor : leg_phase_[i];
}
}
static float smoothstep(float t) { return t * t * (3.0f - 2.0f * t); }
void GaitEngine::update(float dt, const Twist& twist, FootTarget out[6]) {
void GaitEngine::strideEnvelope(const Twist& twist, FootTarget sets[ENVELOPE_SETS][6]) const {
float half = cycle_sec_ * requested_pattern_->duty_factor * 0.5f;
for (int i = 0; i < 6; i++) {
FootTarget n = Kinematics::neutralStance(i);
float dx = (twist.vx - twist.omega * n.y) * half; // half-stride: body velocity at the foot
float dy = (twist.vy + twist.omega * n.x) * half;
float sign = (i % 2 == 0) ? 1.0f : -1.0f;
sets[0][i] = { n.x + sign * dx, n.y + sign * dy, n.z };
sets[1][i] = { n.x - sign * dx, n.y - sign * dy, n.z };
sets[2][i] = { n.x, n.y, n.z + SWING_LIFT_MM };
}
}
const BodyPose GaitEngine::NO_POSE{};
bool GaitEngine::envelopeFeasible(const Twist& twist, const BodyPose& pose) const {
FootTarget sets[ENVELOPE_SETS][6];
strideEnvelope(twist, sets);
for (int k = 0; k < ENVELOPE_SETS; k++)
if (!pose.feasible(sets[k])) return false;
return true;
}
// Neighbouring legs step in opposite phases, so each one's stride eats into
// the gap between them from both sides; turning on the spot at full rate
// asks for ~60 mm half-strides at the outer feet, enough to make the legs
// meet. Rather than letting single feet clamp (or collide), the whole
// command is slowed until the stride fits.
Twist GaitEngine::limitTwist(const Twist& twist, const BodyPose& pose, bool chained) {
float s = 1.0f;
if (!envelopeFeasible(twist, pose)) {
float lo = 0.0f, hi = 1.0f;
for (int n = 0; n < 8; n++) {
float mid = 0.5f * (lo + hi);
Twist t = { twist.vx * mid, twist.vy * mid, twist.omega * mid };
if (envelopeFeasible(t, pose)) lo = mid; else hi = mid;
}
s = lo;
}
twist_scale_ = chained ? twist_scale_ * s : s;
return { twist.vx * s, twist.vy * s, twist.omega * s };
}
void GaitEngine::update(float dt, const Twist& twist, const BodyPose& pose, FootTarget out[6]) {
// Pass 1: advance phases, place swing feet, and work out how far each
// planted foot would move this tick.
bool stance[6];
FootTarget step[6] = {};
for (int i = 0; i < 6; i++) {
bool was_stance = leg_phase_[i] < legs_pattern_[i]->duty_factor;
leg_phase_[i] += dt / cycle_sec_;
// Touchdown: this leg's cycle wraps back to 0 - the one moment it's safe
@@ -33,33 +97,68 @@ void GaitEngine::update(float dt, const Twist& twist, FootTarget out[6]) {
const GaitPattern* p = legs_pattern_[i];
bool in_stance = leg_phase_[i] < p->duty_factor;
stance[i] = in_stance;
FootTarget neutral = Kinematics::neutralStance(i);
float stride_time = cycle_sec_ * p->duty_factor;
FootTarget& foot = foot_[i];
// Half-stride displacement from the commanded twist.
float half_len_x = twist.vx * stride_time * 0.5f;
float half_len_y = twist.vy * stride_time * 0.5f;
// Rough yaw contribution using this leg's mount position as the moment arm.
float yaw_dx = -twist.omega * LEG_MOUNT_Y_MM[i] * stride_time * 0.5f;
float yaw_dy = twist.omega * LEG_MOUNT_X_MM[i] * stride_time * 0.5f;
FootTarget start = { neutral.x - half_len_x - yaw_dx, neutral.y - half_len_y - yaw_dy, neutral.z };
FootTarget end = { neutral.x + half_len_x + yaw_dx, neutral.y + half_len_y + yaw_dy, neutral.z };
FootTarget foot;
if (in_stance) {
float t = leg_phase_[i] / p->duty_factor;
foot.x = start.x + (end.x - start.x) * t;
foot.y = start.y + (end.y - start.y) * t;
// Planted foot: stays put in the world, so in the body frame it moves
// opposite to the body's velocity at that point (v + omega x r).
// Integrated every tick, so the body moves exactly as commanded even
// when the command changes mid-stride.
step[i].x = -(twist.vx - twist.omega * foot.y) * dt;
step[i].y = -(twist.vy + twist.omega * foot.x) * dt;
foot.z = neutral.z;
} else {
float t = (leg_phase_[i] - p->duty_factor) / (1.0f - p->duty_factor);
if (was_stance) { // just lifted off
liftoff_[i] = foot;
swing_start_[i] = p->duty_factor;
}
// Swing toward the leading end of the next stride: the point that a
// full stance at the current command carries back through neutral.
float half = stride_time * 0.5f;
FootTarget touchdown = {
neutral.x + (twist.vx - twist.omega * neutral.y) * half,
neutral.y + (twist.vy + twist.omega * neutral.x) * half,
neutral.z
};
float t = (leg_phase_[i] - swing_start_[i]) / (1.0f - swing_start_[i]);
float st = smoothstep(t);
foot.x = end.x + (start.x - end.x) * st;
foot.y = end.y + (start.y - end.y) * st;
foot.x = liftoff_[i].x + (touchdown.x - liftoff_[i].x) * st;
foot.y = liftoff_[i].y + (touchdown.y - liftoff_[i].y) * st;
foot.z = neutral.z + SWING_LIFT_MM * sin(PI * t);
}
out[i] = foot;
}
// Pass 2: the planted feet move together - they're what carries the body.
// limitTwist() sizes the command for a steady stride, but when the command
// changes mid-step (a key pressed while a foot has just landed) a planted
// foot can be asked to travel further than its leg reaches, or into its
// neighbour. Then all planted feet move less, by one common factor: the
// body slows down rather than one foot being clamped out of formation.
float s = 1.0f;
FootTarget proposed[6];
auto propose = [&](float k) {
for (int i = 0; i < 6; i++)
proposed[i] = stance[i] ? FootTarget{ foot_[i].x + step[i].x * k, foot_[i].y + step[i].y * k, foot_[i].z }
: foot_[i];
return pose.feasible(proposed);
};
if (!propose(1.0f)) {
float lo = 0.0f, hi = 1.0f;
for (int n = 0; n < 6; n++) {
float mid = 0.5f * (lo + hi);
if (propose(mid)) lo = mid; else hi = mid;
}
s = lo;
propose(s);
}
applied_twist_ = { twist.vx * s, twist.vy * s, twist.omega * s };
for (int i = 0; i < 6; i++) {
foot_[i] = proposed[i];
out[i] = foot_[i];
}
}
+42 -2
View File
@@ -1,5 +1,6 @@
#pragma once
#include "Kinematics.h"
#include "BodyPose.h"
#include "Config.h"
struct GaitPattern {
@@ -22,14 +23,53 @@ public:
void setCycleDuration(float sec) { cycle_sec_ = sec; }
// Advances every leg by dt under the current twist and pattern, writing each
// leg's target foot position (body frame) into out[6].
void update(float dt, const Twist& twist, FootTarget out[6]);
// leg's target foot position (body frame, before the body pose) into out[6].
// `pose` is the body pose that will be applied on top: planted feet only
// move as far as stays physically possible with it.
void update(float dt, const Twist& twist, const BodyPose& pose, FootTarget out[6]);
// Puts every foot back at its neutral stance position and restarts the
// cycle to match. Call when walking starts, since the stand pose holds the
// feet at neutral in between.
void resetFeet();
bool legInStance(int i) const { return leg_phase_[i] < legs_pattern_[i]->duty_factor; }
// The extreme foot positions a stride at this twist reaches:
// [0] even legs at the leading end of their stride, odd legs at the trailing end,
// [1] the other way round (tripod legs alternate even/odd, so neighbours are
// always in opposite phases - this is when they come closest),
// [2] every foot at neutral, lifted by SWING_LIFT_MM (mid-swing).
static constexpr int ENVELOPE_SETS = 3;
void strideEnvelope(const Twist& twist, FootTarget sets[ENVELOPE_SETS][6]) const;
// Scales the twist down (all components together) until that envelope,
// with `pose` applied, is physically possible: every foot reachable and no
// legs colliding. Pass GaitEngine::NO_POSE for the stride alone.
// twistScale() reports the factor applied (1 = as commanded); with
// `chained`, this call's factor multiplies onto the previous call's, for a
// second limit applied to an already-limited twist.
Twist limitTwist(const Twist& twist, const BodyPose& pose, bool chained = false);
float twistScale() const { return twist_scale_; }
static const BodyPose NO_POSE;
// The twist the planted feet actually carried out in the last update()
// (it can be less than asked when a stride change runs a foot out of reach).
const Twist& appliedTwist() const { return applied_twist_; }
// Current foot targets (body frame, before the body pose).
const FootTarget* feet() const { return foot_; }
private:
Twist applied_twist_;
bool envelopeFeasible(const Twist& twist, const BodyPose& pose) const;
float twist_scale_ = 1.0f;
float leg_phase_[6];
const GaitPattern* legs_pattern_[6];
FootTarget foot_[6]; // current foot target, body frame (stance feet are integrated)
FootTarget liftoff_[6]; // where each swing started
float swing_start_[6]; // phase at which each swing started (duty factor, except right after resetFeet)
const GaitPattern* requested_pattern_ = &GAIT_TRIPOD;
float cycle_sec_ = DEFAULT_CYCLE_SEC;
};
+22 -4
View File
@@ -30,17 +30,33 @@ MapperOutput InputMapper::map(const CmdPacket& p, float heading_deg, float dt) {
out.twist.vx = raw_vx * MAX_LINEAR_MM_S;
out.twist.vy = raw_vy * MAX_LINEAR_MM_S;
out.twist.omega = radians(raw_yaw_in * MAX_TURN_RATE_DEG_S);
// Hold the current heading, so switching to mode B doesn't turn the
// robot toward some old target.
heading_target_deg_ = heading_deg;
stick_active_ = false;
} else {
// Mode B: heading-hold. Stick angle becomes a target heading; forward
// speed comes from stick magnitude. yaw_rate is unused here.
// Mode B: steering, relative to where the body faces right now. The
// stick's forward/back component sets the walking direction, its sideways
// component steers: forward walks straight on, back walks straight back,
// forward+left or back+left walk while turning left, left alone turns on
// the spot (for as long as it's held). Let go and the robot holds the
// heading it has at that moment. yaw_rate is unused here.
float mag = sqrt(raw_vx * raw_vx + raw_vy * raw_vy);
float direction = raw_vx < 0 ? -1.0f : 1.0f;
if (mag > 0.05f) {
heading_target_deg_ = degrees(atan2(raw_vy, raw_vx));
stick_active_ = true;
heading_target_deg_ = wrapDeg(heading_deg + degrees(atan2(raw_vy, fabs(raw_vx))));
} else if (stick_active_) {
stick_active_ = false;
heading_target_deg_ = heading_deg; // released: hold this heading
}
float heading_error = wrapDeg(heading_target_deg_ - heading_deg);
float omega_deg_s = constrain(HEADING_KP * heading_error, -MAX_TURN_RATE_DEG_S, MAX_TURN_RATE_DEG_S);
out.twist.omega = radians(omega_deg_s);
out.twist.vx = mag * MAX_LINEAR_MM_S; // forward-only in the robot's current facing
// Along the robot's current facing (forward or back), scaled by how well
// it already faces the target: a hard turn happens mostly on the spot.
float facing = max(0.0f, cos(radians(heading_error)));
out.twist.vx = direction * mag * facing * MAX_LINEAR_MM_S;
out.twist.vy = 0;
}
@@ -66,5 +82,7 @@ MapperOutput InputMapper::map(const CmdPacket& p, float heading_deg, float dt) {
-MAX_TILT_DEG, MAX_TILT_DEG);
}
out.stance_height_mm = STANCE_HEIGHT_MM + normalize(p.body_height) * STANCE_HEIGHT_RANGE_MM;
return out;
}
+2
View File
@@ -13,6 +13,7 @@ struct MapperOutput {
bool gait_wave = false;
bool remote_gyro_enabled = false;
bool robot_gyro_enabled = false;
float stance_height_mm = STANCE_HEIGHT_MM; // requested standing height (feet below hips)
};
class InputMapper {
@@ -22,6 +23,7 @@ public:
private:
float heading_target_deg_ = 0;
bool stick_active_ = false; // mode B: stick currently pushed
static constexpr float MAX_TURN_RATE_DEG_S = 90.0f;
static constexpr float HEADING_KP = 2.0f;
static constexpr float MAX_LINEAR_MM_S = 150.0f; // tune to your leg workspace / servo speed
+25 -3
View File
@@ -39,8 +39,9 @@ JointAngles Kinematics::solve(int i, const FootTarget& target) {
cos_tibia = constrain(cos_tibia, -1.0f, 1.0f);
float tibia_rad = -(PI - acos(cos_tibia)); // knee-down convention; flip sign if yours mounts the other way
// Femur angle: elevation to the foot, plus the triangle's femur-side angle.
float elevation = atan2(-local_z, horiz);
// Femur angle: elevation to the foot (Z up, so a foot below the hip gives a
// negative elevation), plus the triangle's femur-side angle.
float elevation = atan2(local_z, horiz);
float cos_femur_tri = (FEMUR_LEN_MM * FEMUR_LEN_MM + dist * dist - TIBIA_LEN_MM * TIBIA_LEN_MM)
/ (2.0f * FEMUR_LEN_MM * dist);
cos_femur_tri = constrain(cos_femur_tri, -1.0f, 1.0f);
@@ -57,11 +58,32 @@ JointAngles Kinematics::solve(int i, const FootTarget& target) {
return out;
}
void Kinematics::jointPositions(int i, const JointAngles& a, FootTarget out[4]) {
float yaw = radians(LEG_MOUNT_ANGLE_DEG[i] + a.coxa_deg);
float femur = radians(a.femur_deg);
float tibia = radians(a.femur_deg + a.tibia_deg); // tibia angle is relative to the femur
float cx = cos(yaw), cy = sin(yaw);
float r_knee = COXA_LEN_MM + FEMUR_LEN_MM * cos(femur);
float z_knee = FEMUR_LEN_MM * sin(femur);
float r_foot = r_knee + TIBIA_LEN_MM * cos(tibia);
float z_foot = z_knee + TIBIA_LEN_MM * sin(tibia);
float mx = LEG_MOUNT_X_MM[i], my = LEG_MOUNT_Y_MM[i];
out[0] = { mx, my, 0 };
out[1] = { mx + cx * COXA_LEN_MM, my + cy * COXA_LEN_MM, 0 };
out[2] = { mx + cx * r_knee, my + cy * r_knee, z_knee };
out[3] = { mx + cx * r_foot, my + cy * r_foot, z_foot };
}
static float g_stance_height_mm = STANCE_HEIGHT_MM;
void Kinematics::setStanceHeight(float mm) { g_stance_height_mm = mm; }
float Kinematics::stanceHeight() { return g_stance_height_mm; }
FootTarget Kinematics::neutralStance(int i) {
float a = radians(LEG_MOUNT_ANGLE_DEG[i]);
FootTarget t;
t.x = LEG_MOUNT_X_MM[i] + cos(a) * (COXA_LEN_MM + STANCE_RADIUS_MM);
t.y = LEG_MOUNT_Y_MM[i] + sin(a) * (COXA_LEN_MM + STANCE_RADIUS_MM);
t.z = -STANCE_HEIGHT_MM;
t.z = -g_stance_height_mm;
return t;
}
+12 -1
View File
@@ -17,6 +17,17 @@ public:
// Solves leg i's joint angles for a body-frame foot target.
static JointAngles solve(int leg_index, const FootTarget& target);
// The leg's resting/neutral foot target, in body frame.
// The leg's resting/neutral foot target, in body frame, at the current
// stance height.
static FootTarget neutralStance(int leg_index);
// Standing height: how far below the hips the neutral feet sit (starts at
// STANCE_HEIGHT_MM). Everything built on neutralStance() - gait, sit/stand,
// workspace limits - follows it.
static void setStanceHeight(float mm);
static float stanceHeight();
// Forward kinematics: the leg's joint positions for the given angles, body
// frame - [0] coxa pivot (hip), [1] femur pivot, [2] knee, [3] foot.
static void jointPositions(int leg_index, const JointAngles& a, FootTarget out[4]);
};
+80 -3
View File
@@ -1,16 +1,25 @@
#include "Limits.h"
#include "Config.h"
#include <Arduino.h>
#include <math.h>
static constexpr float MAX_HORIZ_MM = 70.0f; // tune to your real leg reach, with margin
static constexpr float MAX_VERT_MM = 60.0f;
bool Limits::inWorkspace(int i, const FootTarget& target) {
FootTarget neutral = Kinematics::neutralStance(i);
float dx = target.x - neutral.x;
float dy = target.y - neutral.y;
float dz = target.z - neutral.z;
return dx * dx + dy * dy <= MAX_HORIZ_MM * MAX_HORIZ_MM && fabs(dz) <= MAX_VERT_MM;
}
FootTarget Limits::clampWorkspace(int i, const FootTarget& target) {
FootTarget neutral = Kinematics::neutralStance(i);
float dx = target.x - neutral.x;
float dy = target.y - neutral.y;
float dz = target.z - neutral.z;
constexpr float MAX_HORIZ_MM = 70.0f; // tune to your real leg reach, with margin
constexpr float MAX_VERT_MM = 60.0f;
float horiz = sqrt(dx * dx + dy * dy);
if (horiz > MAX_HORIZ_MM) {
float scale = MAX_HORIZ_MM / horiz;
@@ -22,6 +31,74 @@ FootTarget Limits::clampWorkspace(int i, const FootTarget& target) {
return { neutral.x + dx, neutral.y + dy, neutral.z + dz };
}
// Neighbouring legs: along each side, plus the front and rear pairs.
static const uint8_t NEIGHBOURS[][2] = { {0, 1}, {1, 2}, {3, 4}, {4, 5}, {0, 5}, {2, 3} };
static FootTarget sub(const FootTarget& a, const FootTarget& b) { return { a.x - b.x, a.y - b.y, a.z - b.z }; }
static float dot(const FootTarget& a, const FootTarget& b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
// Shortest distance between segments p1-q1 and p2-q2 (Ericson, Real-Time
// Collision Detection, 5.1.9).
static float segmentDistance(const FootTarget& p1, const FootTarget& q1,
const FootTarget& p2, const FootTarget& q2) {
FootTarget d1 = sub(q1, p1), d2 = sub(q2, p2), r = sub(p1, p2);
float a = dot(d1, d1), e = dot(d2, d2), f = dot(d2, r);
float s = 0, t = 0;
constexpr float EPS = 1e-6f;
if (a > EPS || e > EPS) {
if (a <= EPS) {
t = constrain(f / e, 0.0f, 1.0f);
} else {
float c = dot(d1, r);
if (e <= EPS) {
s = constrain(-c / a, 0.0f, 1.0f);
} else {
float b = dot(d1, d2);
float denom = a * e - b * b;
s = denom > EPS ? constrain((b * f - c * e) / denom, 0.0f, 1.0f) : 0.0f;
t = (b * s + f) / e;
if (t < 0) { t = 0; s = constrain(-c / a, 0.0f, 1.0f); }
else if (t > 1) { t = 1; s = constrain((b - c) / a, 0.0f, 1.0f); }
}
}
}
FootTarget c1 = { p1.x + d1.x * s, p1.y + d1.y * s, p1.z + d1.z * s };
FootTarget c2 = { p2.x + d2.x * t, p2.y + d2.y * t, p2.z + d2.z * t };
FootTarget d = sub(c1, c2);
return sqrt(dot(d, d));
}
// Joint positions of all legs for these feet; false if any leg can't reach.
static bool solveAll(const FootTarget feet[6], FootTarget joints[6][4], bool check_workspace) {
for (int i = 0; i < 6; i++) {
if (check_workspace && !Limits::inWorkspace(i, feet[i])) return false;
JointAngles a = Kinematics::solve(i, feet[i]);
if (!a.valid) return false;
Kinematics::jointPositions(i, a, joints[i]);
}
return true;
}
static float minClearance(const FootTarget joints[6][4]) {
float best = 1e9f;
for (const auto& n : NEIGHBOURS)
for (int s1 = 0; s1 < 3; s1++) // segments: coxa, femur, tibia
for (int s2 = 0; s2 < 3; s2++)
best = min(best, segmentDistance(joints[n[0]][s1], joints[n[0]][s1 + 1],
joints[n[1]][s2], joints[n[1]][s2 + 1]));
return best;
}
bool Limits::feetFeasible(const FootTarget feet[6]) {
FootTarget joints[6][4];
return solveAll(feet, joints, true) && minClearance(joints) >= LEG_CLEARANCE_MM;
}
float Limits::legClearance(const FootTarget feet[6]) {
FootTarget joints[6][4];
return solveAll(feet, joints, false) ? minClearance(joints) : -1e9f;
}
float Limits::clampJointRate(float current_deg, float target_deg, float max_deg_per_sec, float dt) {
float max_step = max_deg_per_sec * dt;
float delta = constrain(target_deg - current_deg, -max_step, max_step);
+11
View File
@@ -5,6 +5,17 @@ class Limits {
public:
// Clamps a foot target into a conservative safe envelope around the leg's neutral stance.
static FootTarget clampWorkspace(int leg_index, const FootTarget& target);
// True if clampWorkspace() would leave the target unchanged.
static bool inWorkspace(int leg_index, const FootTarget& target);
// True if all six feet can be placed there at once: each target in its
// workspace with a valid IK solution, and no two neighbouring legs'
// segments closer than LEG_CLEARANCE_MM.
static bool feetFeasible(const FootTarget feet[6]);
// Smallest distance between neighbouring legs' segments for these feet
// (diagnostics; a large negative number if some leg has no IK solution).
static float legClearance(const FootTarget feet[6]);
// Rate-limits a joint angle change to the servo's real max speed.
static float clampJointRate(float current_deg, float target_deg, float max_deg_per_sec, float dt);
+33 -18
View File
@@ -18,8 +18,9 @@ sketchbook `libraries/` folder and install **RF24** by TMRh20.
## Before you trust this on the floor
Everything in `Config.h` is a placeholder for a generic small hexapod. Measure
your own robot and edit:
The leg geometry in `Config.h` is taken from the Unity model
(`Hexapod_Simulation_Unity`, 1 unit = 10 mm). Everything else there is
still a placeholder. Check all of it against the real robot:
- `LEG_MOUNT_ANGLE_DEG` / `LEG_MOUNT_X_MM` / `LEG_MOUNT_Y_MM` — where each
coxa pivot actually sits on your chassis, and which way it points
@@ -27,6 +28,9 @@ your own robot and edit:
- `COXA_MIN_DEG..TIBIA_MAX_DEG` — your servos' real safe range of motion
- `STANCE_RADIUS_MM` / `STANCE_HEIGHT_MM` — how far out/down you want the
resting foot position
- `LEG_CLEARANCE_MM`: how close neighbouring legs may get. Walking speed
and body pose are scaled down to keep it, so set it to about one leg's
width.
- `SERVO_PINS` — must be PWM-capable pins on your Teensy 4.0 wiring
- `SERVO_US_AT_0DEG` / `SERVO_US_PER_DEG` — calibrate per servo, they vary
batch to batch
@@ -43,13 +47,16 @@ These are sign conventions that depend on which way your hardware is
physically mounted, and the code has a best-guess default that may be wrong
for your build:
1. **IK elbow/tibia direction** (`Kinematics.cpp`) — command one leg to a
few known foot positions by hand (e.g. via the serial harness) and watch
whether the knee bends the way you expect. Flip the sign noted in the
comment on `tibia_rad` if it bends backwards.
2. **BodyPose roll/pitch sign** (`BodyPose.cpp`) — command a small roll/pitch
offset and verify the body leans the direction you asked for, not the
opposite.
1. **IK knee direction** (`Kinematics.cpp`). The femur elevation sign bug
(the foot was solved *above* the hip) is fixed and verified in the
simulation: the stance pose comes out femur −1.2°, tibia −98.3°. Still
command one leg to a few known foot positions on the bench and check the
knee bends the way the sim shows.
2. **BodyPose roll/pitch sign** (`BodyPose.cpp`). Fixed in the simulation:
the pose was applied as the forward rotation, which inverted roll, pitch
and yaw. On the bench, command a small roll/pitch and verify the body
leans the way the protocol says (+roll = right side down, +pitch = nose
down).
3. **RobotIMU register map** (`RobotIMU.cpp`) — written for an MPU6050-style
chip (I2C addr `0x68`). If your gyro is a different part, its register
addresses and scale factors will differ; swap them in.
@@ -58,14 +65,22 @@ for your build:
If your chassis is meant to rest its belly on the ground instead, this
needs a different, sequenced approach (fold legs up rather than just
lowering).
5. **Gait travel direction** (`GaitEngine.cpp`) — the protocol defines
`+vx` = forward, `+vy` = left, `+yaw_rate` = turn left (CCW). As written,
the stance phase sweeps each foot from `neutral - half_stride` to
`neutral + half_stride`, i.e. the foot moves *with* the commanded
direction, which pushes the body the opposite way. Verify with `w` on the
bench; if the robot backs up, swap `start`/`end` in `GaitEngine::update`
(fixes translation and yaw together). The `z`/`c` harness keys were
written against the current behavior — recheck them after that fix.
5. **Gait travel direction** (`GaitEngine.cpp`). Fixed: stance now sweeps
each foot opposite to the commanded motion. The smoke test checks that
`+vx` moves the body forward and `+yaw_rate` turns it left (CCW). On
the bench, confirm `w` walks forward and `z` turns left.
6. **Per-servo calibration**. `ServoDriver` uses one
`SERVO_US_AT_0DEG` / `SERVO_US_PER_DEG` for all 18 servos, so servo
center = joint angle 0 (tibia *straight*). On this build the knee servo
center is the tibia at 90° to the femur, and the left side is mirrored
(per the old Unity/servo code). That needs a per-joint offset and
direction before the real robot will stand.
## Testing in the simulation
This exact code also compiles for the PC and runs in the Unity model. See
`../sim/README.md`. `sim/build/sim_smoke.exe` is a quick headless check
after every change.
## Testing without a remote yet
@@ -79,7 +94,7 @@ keystrokes) and use:
| `s` | toggle sit / stand |
| `w` / `x` | walk forward / backward (`vx` ±) |
| `q` / `e` | strafe left / right (`vy` ±) |
| `z` / `c` | yaw left / right |
| `z` / `c` | turn left / right (`yaw_rate` ±) |
| `g` | toggle gait: tripod ↔ wave |
| `m` | toggle walk mode: direct (A) ↔ heading-hold (B) |
| space | zero all velocity (stop walking, stay in current state) |
+57 -13
View File
@@ -8,6 +8,7 @@
#include "Limits.h"
#include "Comms.h"
#include "ServoDriver.h"
#include "SmoothedValue.h"
// Uncomment to drive the robot from the Serial Monitor before a remote exists.
// See README.md for the key map.
@@ -22,6 +23,8 @@ Comms comms;
ServoDriver servo_driver;
float current_heading_deg = 0; // open-loop: accumulates commanded omega. See note in loop().
SmoothedValue stance_height(STANCE_HEIGHT_RATE_MM_S); // remote height slider, rate-limited
float gyro_corr_roll_deg = 0, gyro_corr_pitch_deg = 0; // robot-gyro self-leveling (integrated)
float last_joint_deg[NUM_LEGS][3] = {}; // for per-joint rate limiting
uint32_t last_loop_us = 0;
RobotState last_state = RobotState::DISARMED;
@@ -41,8 +44,9 @@ void pollSerialTestInput() {
case 'x': test_packet.vx = max(test_packet.vx - 200, -1000); test_packet.flags |= FLAG_WALK; break;
case 'q': test_packet.vy = min(test_packet.vy + 200, 1000); test_packet.flags |= FLAG_WALK; break;
case 'e': test_packet.vy = max(test_packet.vy - 200, -1000); test_packet.flags |= FLAG_WALK; break;
case 'z': test_packet.yaw_rate = max(test_packet.yaw_rate - 200, -1000); test_packet.flags |= FLAG_WALK; break;
case 'c': test_packet.yaw_rate = min(test_packet.yaw_rate + 200, 1000); test_packet.flags |= FLAG_WALK; break;
// yaw_rate + = turn left (CCW).
case 'z': test_packet.yaw_rate = min(test_packet.yaw_rate + 200, 1000); test_packet.flags |= FLAG_WALK; break;
case 'c': test_packet.yaw_rate = max(test_packet.yaw_rate - 200, -1000); test_packet.flags |= FLAG_WALK; break;
case 'g': test_packet.flags ^= FLAG_GAIT_WAVE; break;
case 'm': test_packet.flags ^= FLAG_MODE_B; break;
case ' ':
@@ -64,6 +68,7 @@ void setup() {
gait.begin();
pose.begin();
state_machine.begin();
stance_height.reset(STANCE_HEIGHT_MM);
if (!imu.begin()) Serial.println("robot IMU not responding - stabilization disabled");
if (!comms.begin()) Serial.println("radio not responding - waiting for injected/test commands only");
@@ -87,15 +92,20 @@ void loop() {
MapperOutput mapped = mapper.map(cmd, current_heading_deg, dt);
// Standing height: every neutral foot position (gait, sit/stand, limits)
// follows this, so it's set before anything below uses them.
stance_height.setTarget(mapped.stance_height_mm);
Kinematics::setStanceHeight(stance_height.update(dt));
// Slow the walk command down to a stride the legs can really take (reach,
// no neighbouring legs colliding) - the stride alone; the body pose then
// makes room for it below.
gait.setRequestedPattern(mapped.gait_wave ? &GAIT_WAVE : &GAIT_TRIPOD);
mapped.twist = gait.limitTwist(mapped.twist, GaitEngine::NO_POSE);
imu.enabled = mapped.robot_gyro_enabled;
imu.update(dt);
// Simple open-loop heading estimate: accumulates the omega we've been
// commanding. Good enough for mode B in a normal session; fusing the
// robot IMU's yaw rate in as well is the more robust upgrade if legs
// ever slip on your surface.
current_heading_deg += degrees(mapped.twist.omega) * dt;
state_machine.update(mapped.armed, mapped.stand_request, mapped.walk_active, dt);
RobotState state = state_machine.state();
@@ -105,23 +115,55 @@ void loop() {
} else if (state != RobotState::DISARMED && last_state == RobotState::DISARMED) {
servo_driver.begin();
}
// The stand pose holds every foot at neutral, so walking starts from there.
if (state == RobotState::WALKING && last_state != RobotState::WALKING) gait.resetFeet();
last_state = state;
gait.setRequestedPattern(mapped.gait_wave ? &GAIT_WAVE : &GAIT_TRIPOD);
// Stick pose + remote-tilt feedforward (gated on FLAG_REMOTE_GYRO) are
// already combined in InputMapper.
pose.setCommand(mapped.pose);
pose.setCorrection(mapped.robot_gyro_enabled ? -imu.rollDeg() : 0,
mapped.robot_gyro_enabled ? -imu.pitchDeg() : 0);
// Robot-gyro self-leveling. The IMU measures the body's tilt in the world,
// which already includes the correction applied so far - so the correction
// integrates (a plain "correction = -measured tilt" only halves a slope).
// The tilt the operator asked for is subtracted first, so leveling doesn't
// cancel the stick. Switched off, the correction bleeds away smoothly.
if (mapped.robot_gyro_enabled) {
gyro_corr_roll_deg -= ROBOT_GYRO_KI * (imu.rollDeg() - pose.commandRollDeg()) * dt;
gyro_corr_pitch_deg -= ROBOT_GYRO_KI * (imu.pitchDeg() - pose.commandPitchDeg()) * dt;
gyro_corr_roll_deg = constrain(gyro_corr_roll_deg, -ROBOT_GYRO_MAX_CORR_DEG, ROBOT_GYRO_MAX_CORR_DEG);
gyro_corr_pitch_deg = constrain(gyro_corr_pitch_deg, -ROBOT_GYRO_MAX_CORR_DEG, ROBOT_GYRO_MAX_CORR_DEG);
} else {
float step = POSE_TILT_MAX_RATE_DEG * dt;
gyro_corr_roll_deg = constrain(0.0f, gyro_corr_roll_deg - step, gyro_corr_roll_deg + step);
gyro_corr_pitch_deg = constrain(0.0f, gyro_corr_pitch_deg - step, gyro_corr_pitch_deg + step);
}
pose.setCorrection(gyro_corr_roll_deg, gyro_corr_pitch_deg);
pose.update(dt);
FootTarget gait_targets[NUM_LEGS];
bool in_stance[NUM_LEGS];
if (state == RobotState::WALKING) {
gait.update(dt, mapped.twist, gait_targets);
// Walking has priority over the body pose: the pose shrinks until the
// stride the walk command wants fits (and where the feet are right now,
// since a stride in transition can be outside that envelope). The pose
// shrinks at a bounded rate - no snapping - so meanwhile the walk slows
// down against the pose as it is, and everything stays physical.
FootTarget sets[GaitEngine::ENVELOPE_SETS + 1][NUM_LEGS];
gait.strideEnvelope(mapped.twist, sets);
for (int i = 0; i < NUM_LEGS; i++) sets[GaitEngine::ENVELOPE_SETS][i] = gait.feet()[i];
pose.fitToReach(sets, GaitEngine::ENVELOPE_SETS + 1, dt);
Twist twist = gait.limitTwist(mapped.twist, pose, true);
gait.update(dt, twist, pose, gait_targets);
for (int i = 0; i < NUM_LEGS; i++) in_stance[i] = gait.legInStance(i);
// Simple open-loop heading estimate: accumulates the omega the planted
// feet actually carried out (only while walking - the body only turns
// then). Good enough for mode B in a normal session; fusing the robot
// IMU's yaw rate in as well is the more robust upgrade if legs ever slip.
current_heading_deg += degrees(gait.appliedTwist().omega) * dt;
} else {
// Sit/stand: blend between a crouched and a neutral stance height, no
// stepping. This assumes "sit" keeps all six feet grounded - if your
@@ -136,6 +178,8 @@ void loop() {
gait_targets[i].z = sit_target.z + (neutral.z - sit_target.z) * blend;
in_stance[i] = true;
}
// Shrink the body pose as a whole until it's physically possible here.
pose.fitToReach(&gait_targets, 1, dt);
}
bool stable = Limits::stancePolygonOk(in_stance);