分享图
A
动画渲染工坊
就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>仿生五连杆膝关节 — IFR 原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;800&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<style>
  :root {
    --bg: #060b18;
    --fg: #cfd8dc;
    --muted: #455a64;
    --accent: #ff9100;
    --card: rgba(8,16,38,0.92);
    --border: rgba(0,229,255,0.12);
    --cyan: #00e5ff;
    --teal: #00897b;
    --red: #ff1744;
    --green: #76ff03;
    --yellow: #ffd600;
    --amber: #ffab00;
  }
  *{margin:0;padding:0;box-sizing:border-box;}
  body{
    background:var(--bg);
    color:var(--fg);
    font-family:'IBM Plex Mono',monospace;
    overflow:hidden;
    width:100vw;height:100vh;
    cursor:crosshair;
  }
  #main{display:block;position:absolute;top:0;left:0;}
  .panel{
    position:absolute;
    background:var(--card);
    backdrop-filter:blur(16px);
    border:1px solid var(--border);
    border-radius:10px;
    padding:14px 18px;
    z-index:10;
    transition:opacity .3s;
  }
  .panel h2{
    font-family:'Syne',sans-serif;
    font-weight:800;
    color:var(--cyan);
    font-size:11px;
    text-transform:uppercase;
    letter-spacing:2.5px;
    margin-bottom:10px;
    border-bottom:1px solid var(--border);
    padding-bottom:6px;
  }
  .panel p{font-size:11px;line-height:1.65;color:#78909c;}
  .data-row{
    display:flex;justify-content:space-between;
    font-size:11px;padding:3px 0;
    border-bottom:1px solid rgba(255,255,255,0.03);
  }
  .data-row .label{color:#607d8b;}
  .data-row .val{font-weight:500;}
  .val-cyan{color:var(--cyan);}
  .val-amber{color:var(--amber);}
  .val-red{color:var(--red);}
  .val-green{color:var(--green);}
  .val-yellow{color:var(--yellow);}

  #titlePanel{top:16px;left:16px;max-width:320px;}
  #titlePanel h1{
    font-family:'Syne',sans-serif;
    font-weight:800;font-size:18px;
    color:#fff;letter-spacing:1px;
    margin-bottom:4px;
  }
  #titlePanel .subtitle{color:var(--accent);font-size:12px;font-weight:500;margin-bottom:8px;}

  #dataPanel{top:16px;right:16px;min-width:230px;}

  #phasePanel{top:140px;right:16px;min-width:230px;}
  .phase-bar{
    height:6px;border-radius:3px;
    background:rgba(255,255,255,0.06);
    overflow:hidden;margin-top:6px;
  }
  .phase-fill{
    height:100%;border-radius:3px;
    transition:width .1s;
  }

  #anklePanel{bottom:80px;left:16px;}
  #ankleCanvas{display:block;border-radius:6px;}

  #legendPanel{bottom:80px;right:16px;}
  .legend-item{
    display:flex;align-items:center;gap:8px;
    font-size:10px;padding:2px 0;color:#78909c;
  }
  .legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;}

  #controlPanel{
    bottom:16px;left:50%;transform:translateX(-50%);
    display:flex;gap:18px;align-items:center;
    padding:12px 24px;
  }
  .ctrl-btn{
    background:rgba(0,229,255,0.08);
    border:1px solid rgba(0,229,255,0.25);
    color:var(--cyan);border-radius:6px;
    padding:6px 14px;font-size:12px;
    font-family:'IBM Plex Mono',monospace;
    cursor:pointer;transition:all .2s;
  }
  .ctrl-btn:hover{background:rgba(0,229,255,0.18);border-color:var(--cyan);}
  .ctrl-btn.active{background:rgba(0,229,255,0.22);color:#fff;border-color:var(--cyan);}
  .ctrl-group{display:flex;flex-direction:column;gap:2px;}
  .ctrl-group label{font-size:9px;color:#546e7a;text-transform:uppercase;letter-spacing:1px;}
  .ctrl-group input[type=range]{
    -webkit-appearance:none;appearance:none;
    width:110px;height:4px;border-radius:2px;
    background:rgba(255,255,255,0.08);outline:none;
  }
  .ctrl-group input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none;appearance:none;
    width:14px;height:14px;border-radius:50%;
    background:var(--cyan);cursor:pointer;
    border:2px solid var(--bg);
  }
  .ctrl-group .ctrl-val{font-size:10px;color:var(--accent);font-weight:500;}

  @keyframes pulse{0%,100%{opacity:1;}50%{opacity:0.4;}}
  .pulse{animation:pulse 1.2s ease-in-out infinite;}

  @media(max-width:900px){
    #titlePanel{max-width:220px;}
    #dataPanel,#phasePanel{min-width:180px;}
    #controlPanel{gap:10px;padding:10px 16px;}
    .ctrl-group input[type=range]{width:80px;}
  }
</style>
</head>
<body>

<canvas id="main"></canvas>

<!-- 标题面板 -->
<div class="panel" id="titlePanel">
  <h1>五连杆仿生膝关节</h1>
  <div class="subtitle">IFR 最终理想解原理演示</div>
  <p>摒弃单轴铰链,以平面五连杆为中介,双电机差动驱动产生瞬时滚动-滑动复合运动;被动十字铰链脚踝自适应地面起伏并回收冲击能量。</p>
</div>

<!-- 实时参数面板 -->
<div class="panel" id="dataPanel">
  <h2>实时参数</h2>
  <div class="data-row"><span class="label">前大腿角 θ₁</span><span class="val val-cyan" id="dTheta1">0.0°</span></div>
  <div class="data-row"><span class="label">后大腿角 θ₂</span><span class="val val-cyan" id="dTheta2">0.0°</span></div>
  <div class="data-row"><span class="label">差动角 Δθ</span><span class="val val-amber" id="dDelta">0.0°</span></div>
  <div class="data-row"><span class="label">虚拟膝中心 X</span><span class="val val-red" id="dICx">--</span></div>
  <div class="data-row"><span class="label">虚拟膝中心 Y</span><span class="val val-red" id="dICy">--</span></div>
  <div class="data-row"><span class="label">足端坐标</span><span class="val val-green" id="dFoot">--</span></div>
  <div class="data-row"><span class="label">膝弯曲角</span><span class="val val-yellow" id="dBend">--</span></div>
  <div class="data-row"><span class="label">扭簧力矩</span><span class="val val-yellow" id="dSpring">0.0 Nm</span></div>
</div>

<!-- 步态相位面板 -->
<div class="panel" id="phasePanel">
  <h2>步态相位</h2>
  <div class="data-row"><span class="label">当前阶段</span><span class="val val-amber" id="dPhase">--</span></div>
  <div class="phase-bar"><div class="phase-fill" id="phaseFill" style="width:0%;background:var(--accent);"></div></div>
  <div style="display:flex;justify-content:space-between;margin-top:4px;">
    <span style="font-size:9px;color:#546e7a;">起摆</span>
    <span style="font-size:9px;color:#546e7a;">触地</span>
    <span style="font-size:9px;color:#546e7a;">离地</span>
  </div>
</div>

<!-- 脚踝细节面板 -->
<div class="panel" id="anklePanel">
  <h2>被动十字铰链脚踝</h2>
  <canvas id="ankleCanvas" width="210" height="170"></canvas>
</div>

<!-- 图例 -->
<div class="panel" id="legendPanel">
  <h2>图例</h2>
  <div class="legend-item"><div class="legend-dot" style="background:#00e5ff;"></div>前大腿连杆</div>
  <div class="legend-item"><div class="legend-dot" style="background:#00897b;"></div>后大腿连杆</div>
  <div class="legend-item"><div class="legend-dot" style="background:#4dd0e1;"></div>小腿连杆</div>
  <div class="legend-item"><div class="legend-dot" style="background:#76ff03;"></div>足端轨迹</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ff9100;"></div>虚拟膝关节中心</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ff1744;"></div>瞬时旋转中心</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ffd600;"></div>扭簧能量</div>
</div>

<!-- 控制面板 -->
<div class="panel" id="controlPanel">
  <button class="ctrl-btn active" id="btnPlay"><i class="fa-solid fa-pause"></i></button>
  <button class="ctrl-btn" id="btnReset"><i class="fa-solid fa-rotate-left"></i></button>
  <div class="ctrl-group">
    <label>速度</label>
    <input type="range" id="sliderSpeed" min="0.1" max="3" step="0.1" value="0.8">
    <span class="ctrl-val" id="valSpeed">0.8x</span>
  </div>
  <div class="ctrl-group">
    <label>连杆比</label>
    <input type="range" id="sliderRatio" min="1.0" max="1.8" step="0.05" value="1.2">
    <span class="ctrl-val" id="valRatio">1.20</span>
  </div>
  <div class="ctrl-group">
    <label>扭簧刚度</label>
    <input type="range" id="sliderStiff" min="0.5" max="5" step="0.1" value="2.0">
    <span class="ctrl-val" id="valStiff">2.0</span>
  </div>
  <button class="ctrl-btn" id="btnTrail">轨迹</button>
  <button class="ctrl-btn" id="btnIC">瞬心</button>
</div>

<script>
// ===== 主画布设置 =====
const canvas = document.getElementById('main');
const ctx = canvas.getContext('2d');
const ankleCanvas = document.getElementById('ankleCanvas');
const actx = ankleCanvas.getContext('2d');

function resize() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();

// ===== 配置参数 =====
const CFG = {
  L1: 140,          // 前大腿连杆长度 (px)
  L2_base: 117,     // 后大腿连杆基准长度
  L3: 185,          // 前小腿连杆
  L4: 185,          // 后小腿连杆
  ratio: 1.2,       // 前后连杆比
  springK: 2.0,     // 扭簧刚度 Nm/deg
  offset_mm: 15,    // 虚拟膝偏置 mm
  maxBend: 130,     // 最大弯曲角度
  ankleLen: 35,     // 脚踝到足端距离
};

// 动态计算后大腿长度
function getL2() { return CFG.L1 / CFG.ratio; }

// ===== 状态变量 =====
let playing = true;
let speed = 0.8;
let gaitPhase = 0;         // 0~1 步态周期
let showTrail = true;
let showIC = true;
let footTrail = [];         // 足端轨迹
let icTrail = [];           // 瞬心轨迹
const TRAIL_MAX = 600;
let prevAngles = null;
let prevPoints = null;
let lastTime = 0;

// 地面参数
let groundOffset = 0;       // 地面滚动偏移

// ===== 步态角度生成 =====
function getGaitAngles(t) {
  const p = t * 2 * Math.PI;
  // 前大腿:主要摆动分量 + 二次谐波
  const t1 = 30 * Math.sin(p) + 12 * Math.sin(2*p - 0.5) + 3 * Math.sin(3*p + 0.2);
  // 后大腿:相位偏移产生差动
  const t2 = 24 * Math.sin(p + 0.65) + 9 * Math.sin(2*p + 0.3) + 2 * Math.sin(3*p - 0.4);
  return { theta1: t1 * Math.PI / 180, theta2: t2 * Math.PI / 180 };
}

// ===== 五连杆正运动学 =====
function solveFiveBar(hx, hy, th1, th2) {
  const L2 = getL2();
  // 膝关节点
  const K1 = { x: hx + CFG.L1 * Math.sin(th1), y: hy + CFG.L1 * Math.cos(th1) };
  const K2 = { x: hx + L2 * Math.sin(th2), y: hy + L2 * Math.cos(th2) };

  const dx = K2.x - K1.x;
  const dy = K2.y - K1.y;
  const d = Math.sqrt(dx*dx + dy*dy);

  if (d > CFG.L3 + CFG.L4 - 1 || d < Math.abs(CFG.L3 - CFG.L4) + 1 || d < 0.01) {
    return null;
  }

  const a = (CFG.L3*CFG.L3 - CFG.L4*CFG.L4 + d*d) / (2*d);
  const hSq = CFG.L3*CFG.L3 - a*a;
  if (hSq < 0) return null;
  const h = Math.sqrt(Math.max(0, hSq));

  const mx = K1.x + a * dx / d;
  const my = K1.y + a * dy / d;

  // 两个解:选取足端在下方且更靠近髋部正下方的
  const F1 = { x: mx + h * dy / d, y: my - h * dx / d };
  const F2 = { x: mx - h * dy / d, y: my + h * dx / d };

  // 选择y更大(更下方)且x更接近hx的解
  const F = F1.y > F2.y ? F1 : F2;

  return { K1, K2, F };
}

// ===== 瞬时中心计算 =====
function computeIC(hx, hy, th1, th2, K1, K2, dt) {
  if (!prevAngles || dt < 0.0001) return null;
  const L2 = getL2();
  const omega1 = (th1 - prevAngles.th1) / dt;
  const omega2 = (th2 - prevAngles.th2) / dt;

  // K1, K2 速度
  const vK1x = CFG.L1 * Math.cos(th1) * omega1;
  const vK1y = -CFG.L1 * Math.sin(th1) * omega1;
  const vK2x = L2 * Math.cos(th2) * omega2;
  const vK2y = -L2 * Math.sin(th2) * omega2;

  const dx = K2.x - K1.x;
  const dy = K2.y - K1.y;
  const dvx = vK2x - vK1x;
  const dvy = vK2y - vK1y;

  const denom = dx*dx + dy*dy;
  if (denom < 1) return null;

  // 耦合件角速度
  const omegaC = (dx * dvy - dy * dvx) / denom;
  if (Math.abs(omegaC) < 0.01) return null;

  // 瞬时中心 = K1 + (1/ωc) × vK1 (2D叉积)
  const ICx = K1.x + vK1y / omegaC;
  const ICy = K1.y - vK1x / omegaC;

  return { x: ICx, y: ICy, omegaC };
}

// ===== 膝弯曲角计算 =====
function kneeBendAngle(hx, hy, K1, K2, F) {
  // 大腿方向向量(平均)
  const thDir = { x: (K1.x + K2.x)/2 - hx, y: (K1.y + K2.y)/2 - hy };
  // 小腿方向向量
  const shDir = { x: F.x - (K1.x + K2.x)/2, y: F.y - (K1.y + K2.y)/2 };
  const dot = thDir.x*shDir.x + thDir.y*shDir.y;
  const magT = Math.sqrt(thDir.x*thDir.x + thDir.y*thDir.y);
  const magS = Math.sqrt(shDir.x*shDir.x + shDir.y*shDir.y);
  if (magT < 0.01 || magS < 0.01) return 0;
  const cosA = Math.max(-1, Math.min(1, dot / (magT * magS)));
  return Math.acos(cosA) * 180 / Math.PI;
}

// ===== 步态相位判断 =====
function getPhaseInfo(t) {
  if (t < 0.35) return { name: '起摆', color: '#00e5ff', progress: t / 0.35 };
  if (t < 0.50) return { name: '触地', color: '#ff9100', progress: (t-0.35)/0.15 };
  if (t < 0.85) return { name: '支撑', color: '#76ff03', progress: (t-0.50)/0.35 };
  return { name: '离地', color: '#ffd600', progress: (t-0.85)/0.15 };
}

// ===== 绘制函数 =====

// 背景网格
function drawGrid() {
  const w = canvas.width, h = canvas.height;
  ctx.strokeStyle = 'rgba(0,229,255,0.03)';
  ctx.lineWidth = 1;
  const step = 50;
  for (let x = 0; x < w; x += step) {
    ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke();
  }
  for (let y = 0; y < h; y += step) {
    ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke();
  }
}

// 地面
function drawGround(groundY) {
  const w = canvas.width;
  // 地面渐变
  const grd = ctx.createLinearGradient(0, groundY, 0, groundY + 120);
  grd.addColorStop(0, 'rgba(27,94,32,0.4)');
  grd.addColorStop(1, 'rgba(27,94,32,0)');
  ctx.fillStyle = grd;
  ctx.fillRect(0, groundY, w, 120);

  // 地面线
  ctx.strokeStyle = 'rgba(76,175,80,0.5)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, groundY);
  // 微小起伏
  for (let x = 0; x < w; x += 2) {
    const bump = Math.sin((x + groundOffset) * 0.015) * 3 + Math.sin((x + groundOffset) * 0.04) * 1.5;
    ctx.lineTo(x, groundY + bump);
  }
  ctx.stroke();

  // 地面纹理点
  ctx.fillStyle = 'rgba(76,175,80,0.15)';
  for (let i = 0; i < 40; i++) {
    const px = ((i * 47 + groundOffset * 0.3) % w);
    const py = groundY + 8 + Math.sin(i * 1.7) * 5;
    ctx.beginPath();
    ctx.arc(px, py, 1.5, 0, Math.PI * 2);
    ctx.fill();
  }
}

// 连杆绘制
function drawLink(x1, y1, x2, y2, color, width, label) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.stroke();

  // 连杆高光
  ctx.strokeStyle = color.replace(')', ',0.3)').replace('rgb', 'rgba');
  ctx.lineWidth = width + 6;
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.stroke();

  if (label) {
    const mx = (x1+x2)/2, my = (y1+y2)/2;
    const dx = x2-x1, dy = y2-y1;
    const len = Math.sqrt(dx*dx+dy*dy);
    if (len > 0) {
      const nx = -dy/len * 14, ny = dx/len * 14;
      ctx.font = '9px "IBM Plex Mono"';
      ctx.fillStyle = 'rgba(255,255,255,0.35)';
      ctx.textAlign = 'center';
      ctx.fillText(label, mx + nx, my + ny);
    }
  }
}

// 关节绘制
function drawJoint(x, y, r, color, glow) {
  if (glow) {
    const g = ctx.createRadialGradient(x, y, 0, x, y, r * 4);
    g.addColorStop(0, color.replace(')', ',0.4)').replace('rgb', 'rgba'));
    g.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(x, y, r * 4, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.2)';
  ctx.lineWidth = 1;
  ctx.stroke();
}

// 足端轨迹
function drawFootTrail() {
  if (!showTrail || footTrail.length < 2) return;
  ctx.lineWidth = 2.5;
  ctx.lineCap = 'round';
  for (let i = 1; i < footTrail.length; i++) {
    const alpha = i / footTrail.length;
    ctx.strokeStyle = `rgba(118,255,3,${alpha * 0.7})`;
    ctx.beginPath();
    ctx.moveTo(footTrail[i-1].x, footTrail[i-1].y);
    ctx.lineTo(footTrail[i].x, footTrail[i].y);
    ctx.stroke();
  }
}

// 瞬心轨迹
function drawICTrail() {
  if (!showIC || icTrail.length < 2) return;
  ctx.lineWidth = 2;
  ctx.lineCap = 'round';
  for (let i = 1; i < icTrail.length; i++) {
    const alpha = i / icTrail.length;
    ctx.strokeStyle = `rgba(255,23,68,${alpha * 0.5})`;
    ctx.beginPath();
    ctx.moveTo(icTrail[i-1].x, icTrail[i-1].y);
    ctx.lineTo(icTrail[i].x, icTrail[i].y);
    ctx.stroke();
  }
}

// 参考单轴铰链圆弧(虚线)
function drawRefArc(hx, hy, groundY) {
  const radius = CFG.L1 + CFG.L3 * 0.7;
  ctx.setLineDash([6, 8]);
  ctx.strokeStyle = 'rgba(255,255,255,0.06)';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.arc(hx, hy, radius, Math.PI * 0.15, Math.PI * 0.85);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.font = '9px "IBM Plex Mono"';
  ctx.fillStyle = 'rgba(255,255,255,0.12)';
  ctx.textAlign = 'left';
  ctx.fillText('单轴铰链轨迹', hx + radius * 0.3, hy + radius * 0.6);
}

// 瞬时中心到足端的连线
function drawICLine(ic, foot) {
  if (!ic || !showIC) return;
  ctx.setLineDash([4, 6]);
  ctx.strokeStyle = 'rgba(255,23,68,0.25)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(ic.x, ic.y);
  ctx.lineTo(foot.x, foot.y);
  ctx.stroke();
  ctx.setLineDash([]);

  // 旋转半径标注
  const dx = foot.x - ic.x, dy = foot.y - ic.y;
  const r = Math.sqrt(dx*dx + dy*dy);
  const mx = (ic.x + foot.x)/2, my = (ic.y + foot.y)/2;
  ctx.font = '9px "IBM Plex Mono"';
  ctx.fillStyle = 'rgba(255,23,68,0.5)';
  ctx.textAlign = 'center';
  ctx.fillText(`r=${r.toFixed(0)}`, mx + 12, my);
}

// 脚踝绘制
function drawAnkle(F, K1, K2, groundY, phaseInfo) {
  // 小腿方向
  const midK = { x: (K1.x + K2.x)/2, y: (K1.y + K2.y)/2 };
  const dir = { x: F.x - midK.x, y: F.y - midK.y };
  const len = Math.sqrt(dir.x*dir.x + dir.y*dir.y);
  if (len < 1) return;
  const nx = dir.x/len, ny = dir.y/len;

  // 脚踝到足端
  const footEnd = { x: F.x + nx * CFG.ankleLen, y: F.y + ny * CFG.ankleLen };

  // 地面接触时脚踝偏转
  const onGround = F.y > groundY - 15;
  let ankleDeflect = 0;
  if (onGround) {
    // 简化的地面坡度影响
    const bump = Math.sin((F.x + groundOffset) * 0.015) * 3;
    ankleDeflect = bump * 0.03; // 弧度
  }

  // 绘制脚踝连杆
  const deflectX = Math.cos(ankleDeflect) * nx - Math.sin(ankleDeflect) * ny;
  const deflectY = Math.sin(ankleDeflect) * nx + Math.cos(ankleDeflect) * ny;
  const footDeflected = { x: F.x + deflectX * CFG.ankleLen, y: F.y + deflectY * CFG.ankleLen };

  ctx.strokeStyle = 'rgba(77,208,225,0.7)';
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.moveTo(F.x, F.y);
  ctx.lineTo(footDeflected.x, footDeflected.y);
  ctx.stroke();

  // 十字铰链标记
  ctx.save();
  ctx.translate(F.x, F.y);
  const angle = Math.atan2(nx, ny);
  ctx.rotate(-angle);

  // 纵轴
  ctx.strokeStyle = 'rgba(255,214,0,0.6)';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  ctx.moveTo(-8, 0); ctx.lineTo(8, 0);
  ctx.stroke();
  // 横轴
  ctx.beginPath();
  ctx.moveTo(0, -8); ctx.lineTo(0, 8);
  ctx.stroke();

  // 扭簧示意(小螺旋)
  if (onGround && Math.abs(ankleDeflect) > 0.005) {
    const springEnergy = Math.abs(ankleDeflect) * CFG.springK;
    const intensity = Math.min(1, springEnergy * 2);
    ctx.strokeStyle = `rgba(255,214,0,${intensity * 0.8})`;
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    for (let a = 0; a < Math.PI * 2.5; a += 0.2) {
      const sr = 4 + a * 1.2;
      const sx = Math.cos(a + ankleDeflect * 5) * sr * 0.5;
      const sy = Math.sin(a + ankleDeflect * 5) * sr * 0.3;
      if (a === 0) ctx.moveTo(sx, sy);
      else ctx.lineTo(sx, sy);
    }
    ctx.stroke();
  }
  ctx.restore();

  // 足端触地高亮
  if (onGround) {
    const glow = ctx.createRadialGradient(footDeflected.x, groundY, 0, footDeflected.x, groundY, 30);
    glow.addColorStop(0, 'rgba(255,214,0,0.25)');
    glow.addColorStop(1, 'rgba(255,214,0,0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(footDeflected.x, groundY, 30, 0, Math.PI * 2);
    ctx.fill();
  }

  return { ankleDeflect, onGround };
}

// 髋部基座
function drawHipBase(hx, hy) {
  // 基座外形
  ctx.fillStyle = 'rgba(0,229,255,0.08)';
  ctx.strokeStyle = 'rgba(0,229,255,0.4)';
  ctx.lineWidth = 2;
  const bw = 40, bh = 24;
  ctx.beginPath();
  ctx.roundRect(hx - bw/2, hy - bh/2, bw, bh, 6);
  ctx.fill();
  ctx.stroke();

  // 电机标识
  ctx.fillStyle = 'rgba(0,229,255,0.6)';
  ctx.font = '8px "IBM Plex Mono"';
  ctx.textAlign = 'center';
  ctx.fillText('M1', hx - 12, hy - bh/2 - 4);
  ctx.fillText('M2', hx + 12, hy - bh/2 - 4);

  // 电机符号(小圆 + M)
  ctx.strokeStyle = 'rgba(0,229,255,0.5)';
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(hx - 12, hy, 5, 0, Math.PI*2); ctx.stroke();
  ctx.beginPath(); ctx.arc(hx + 12, hy, 5, 0, Math.PI*2); ctx.stroke();
}

// 差动高亮
function drawDifferentialHighlight(hx, hy, th1, th2) {
  const delta = Math.abs(th1 - th2) * 180 / Math.PI;
  if (delta < 2) return;
  const intensity = Math.min(1, delta / 20);

  // 差动角弧线
  const r = 30;
  ctx.strokeStyle = `rgba(255,145,0,${intensity * 0.6})`;
  ctx.lineWidth = 2;
  ctx.beginPath();
  const startAngle = Math.PI/2 - Math.max(th1, th2);
  const endAngle = Math.PI/2 - Math.min(th1, th2);
  ctx.arc(hx, hy, r, startAngle, endAngle);
  ctx.stroke();

  // Δθ 标注
  const midAngle = (startAngle + endAngle) / 2;
  const lx = hx + (r + 14) * Math.cos(midAngle);
  const ly = hy + (r + 14) * Math.sin(midAngle);
  ctx.font = '10px "IBM Plex Mono"';
  ctx.fillStyle = `rgba(255,145,0,${intensity * 0.8})`;
  ctx.textAlign = 'center';
  ctx.fillText(`Δθ=${delta.toFixed(1)}°`, lx, ly);
}

// 虚拟膝关节中心标注
function drawVirtualKnee(K1, K2, ic) {
  // 虚拟膝区域:连接 K1, K2 的三角形区域
  const midX = (K1.x + K2.x) / 2;
  const midY = (K1.y + K2.y) / 2;

  // 半透明三角区域
  ctx.fillStyle = 'rgba(255,145,0,0.04)';
  ctx.strokeStyle = 'rgba(255,145,0,0.15)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(K1.x, K1.y);
  ctx.lineTo(K2.x, K2.y);
  ctx.lineTo(midX + (K2.y - K1.y) * 0.3, midY + (K1.x - K2.x) * 0.3);
  ctx.closePath();
  ctx.fill();
  ctx.stroke();

  // "虚拟膝" 标注
  ctx.font = '10px "IBM Plex Mono"';
  ctx.fillStyle = 'rgba(255,145,0,0.6)';
  ctx.textAlign = 'center';
  ctx.fillText('虚拟膝关节', midX + 30, midY);
}

// ===== 脚踝细节面板绘制 =====
function drawAnkleDetail(ankleDeflect, onGround, springEnergy) {
  const w = ankleCanvas.width, h = ankleCanvas.height;
  actx.clearRect(0, 0, w, h);

  const cx = w / 2, cy = h / 2 - 10;

  // 小腿段
  actx.strokeStyle = '#4dd0e1';
  actx.lineWidth = 4;
  actx.beginPath();
  actx.moveTo(cx, cy - 50);
  actx.lineTo(cx, cy);
  actx.stroke();

  // 十字铰链 - 纵轴
  actx.save();
  actx.translate(cx, cy);

  // 纵轴旋转
  actx.save();
  actx.rotate(ankleDeflect * 3); // 放大显示

  // 纵轴杆
  actx.strokeStyle = '#ffd600';
  actx.lineWidth = 2;
  actx.beginPath();
  actx.moveTo(-18, 0);
  actx.lineTo(18, 0);
  actx.stroke();

  // 纵轴扭簧
  actx.strokeStyle = 'rgba(255,214,0,0.7)';
  actx.lineWidth = 1.5;
  actx.beginPath();
  for (let a = 0; a < Math.PI * 3; a += 0.15) {
    const r = 6 + a * 1.5;
    const sx = Math.cos(a) * r;
    const sy = Math.sin(a) * r * 0.4;
    if (a === 0) actx.moveTo(sx, sy);
    else actx.lineTo(sx, sy);
  }
  actx.stroke();

  // 足端
  actx.strokeStyle = '#76ff03';
  actx.lineWidth = 3;
  actx.beginPath();
  actx.moveTo(0, 0);
  actx.lineTo(0, 45);
  ctx.stroke;
  actx.stroke();

  // 足掌
  actx.beginPath();
  actx.moveTo(-15, 45);
  actx.lineTo(15, 45);
  actx.stroke();

  actx.restore(); // 纵轴旋转

  // 横轴
  actx.strokeStyle = '#ffd600';
  actx.lineWidth = 2;
  actx.beginPath();
  actx.moveTo(0, -12);
  actx.lineTo(0, 12);
  actx.stroke();

  // 横轴扭簧
  actx.strokeStyle = 'rgba(255,214,0,0.5)';
  actx.lineWidth = 1.5;
  actx.beginPath();
  for (let a = 0; a < Math.PI * 2.5; a += 0.15) {
    const r = 5 + a * 1.2;
    const sx = Math.cos(a) * r * 0.4;
    const sy = Math.sin(a) * r;
    if (a === 0) actx.moveTo(sx, sy);
    else actx.lineTo(sx, sy);
  }
  actx.stroke();

  // 铰链中心
  actx.fillStyle = '#ffd600';
  actx.beginPath();
  actx.arc(0, 0, 4, 0, Math.PI * 2);
  actx.fill();

  actx.restore(); // translate

  // 能量指示
  if (onGround && springEnergy > 0.01) {
    const barW = 80, barH = 8;
    const bx = w/2 - barW/2, by = h - 22;
    actx.fillStyle = 'rgba(255,255,255,0.06)';
    actx.fillRect(bx, by, barW, barH);
    const fill = Math.min(1, springEnergy * 2);
    const grd = actx.createLinearGradient(bx, by, bx + barW * fill, by);
    grd.addColorStop(0, '#ffd600');
    grd.addColorStop(1, '#ff9100');
    actx.fillStyle = grd;
    actx.fillRect(bx, by, barW * fill, barH);

    actx.font = '9px "IBM Plex Mono"';
    actx.fillStyle = 'rgba(255,214,0,0.8)';
    actx.textAlign = 'center';
    actx.fillText(`蓄能: ${(springEnergy * 100).toFixed(0)}%`, w/2, by - 4);
  }

  // 标注
  actx.font = '8px "IBM Plex Mono"';
  actx.fillStyle = 'rgba(255,214,0,0.5)';
  actx.textAlign = 'left';
  actx.fillText('纵轴扭簧', 8, 14);
  actx.fillText('横轴扭簧', 8, 26);
  if (ankleDeflect !== 0) {
    actx.fillStyle = 'rgba(255,145,0,0.7)';
    actx.fillText(`偏转: ${(ankleDeflect * 180 / Math.PI).toFixed(1)}°`, 8, 38);
  }
}

// ===== 主绘制循环 =====
function draw(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const rawDt = (timestamp - lastTime) / 1000;
  lastTime = timestamp;
  const dt = playing ? Math.min(rawDt, 0.05) : 0;

  // 更新步态相位
  if (playing) {
    gaitPhase += dt * speed * 0.4;
    if (gaitPhase > 1) {
      gaitPhase -= 1;
      // 清除轨迹(新周期)
      // 保留部分轨迹以展示连续性
    }
    groundOffset += dt * speed * 60;
  }

  const w = canvas.width, h = canvas.height;

  // 清除画布
  ctx.fillStyle = '#060b18';
  ctx.fillRect(0, 0, w, h);

  // 绘制背景网格
  drawGrid();

  // 位置参数
  const hx = w * 0.42;
  const hy = h * 0.20;
  const groundY = h * 0.80;

  // 获取当前步态角度
  const angles = getGaitAngles(gaitPhase);
  const result = solveFiveBar(hx, hy, angles.theta1, angles.theta2);

  // 绘制地面
  drawGround(groundY);

  // 参考圆弧
  drawRefArc(hx, hy, groundY);

  if (result) {
    const { K1, K2, F } = result;

    // 计算瞬时中心
    const ic = computeIC(hx, hy, angles.theta1, angles.theta2, K1, K2, dt);

    // 记录轨迹
    if (playing) {
      footTrail.push({ x: F.x, y: F.y });
      if (footTrail.length > TRAIL_MAX) footTrail.shift();
      if (ic) {
        icTrail.push({ x: ic.x, y: ic.y });
        if (icTrail.length > TRAIL_MAX) icTrail.shift();
      }
    }

    // 膝弯曲角
    const bendAngle = kneeBendAngle(hx, hy, K1, K2, F);

    // 步态相位信息
    const phaseInfo = getPhaseInfo(gaitPhase);

    // 绘制足端轨迹
    drawFootTrail();

    // 绘制瞬心轨迹
    drawICTrail();

    // 绘制瞬心到足端连线
    drawICLine(ic, F);

    // 绘制虚拟膝关节区域
    drawVirtualKnee(K1, K2, ic);

    // 绘制差动高亮
    drawDifferentialHighlight(hx, hy, angles.theta1, angles.theta2);

    // 绘制连杆
    // 前大腿
    drawLink(hx, hy, K1.x, K1.y, 'rgb(0,229,255)', 5, '前大腿');
    // 后大腿
    drawLink(hx, hy, K2.x, K2.y, 'rgb(0,137,123)', 5, '后大腿');
    // 前小腿
    drawLink(K1.x, K1.y, F.x, F.y, 'rgb(77,208,225)', 4, '前小腿');
    // 后小腿
    drawLink(K2.x, K2.y, F.x, F.y, 'rgb(38,198,218)', 4, '后小腿');

    // 绘制脚踝
    const ankleInfo = drawAnkle(F, K1, K2, groundY, phaseInfo);

    // 绘制关节
    drawJoint(hx, hy, 8, '#00e5ff', true);     // 髋部
    drawJoint(K1.x, K1.y, 5, '#00e5ff', false); // 前膝
    drawJoint(K2.x, K2.y, 5, '#00897b', false); // 后膝
    drawJoint(F.x, F.y, 4, '#4dd0e1', false);   // 足端连接

    // 瞬时中心点
    if (ic && showIC) {
      drawJoint(ic.x, ic.y, 5, '#ff1744', true);
      // 瞬心标记
      ctx.font = '9px "IBM Plex Mono"';
      ctx.fillStyle = 'rgba(255,23,68,0.7)';
      ctx.textAlign = 'left';
      ctx.fillText('瞬时中心', ic.x + 10, ic.y - 8);
    }

    // 绘制髋部基座
    drawHipBase(hx, hy);

    // 足端发光
    const footGlow = ctx.createRadialGradient(F.x, F.y, 0, F.x, F.y, 20);
    footGlow.addColorStop(0, 'rgba(118,255,3,0.3)');
    footGlow.addColorStop(1, 'rgba(118,255,3,0)');
    ctx.fillStyle = footGlow;
    ctx.beginPath();
    ctx.arc(F.x, F.y, 20, 0, Math.PI * 2);
    ctx.fill();

    // 膝弯曲限制指示
    if (bendAngle > 110) {
      ctx.font = '10px "IBM Plex Mono"';
      ctx.fillStyle = 'rgba(255,23,68,0.7)';
      ctx.textAlign = 'center';
      const midK = { x: (K1.x+K2.x)/2, y: (K1.y+K2.y)/2 };
      ctx.fillText(`⚠ ${bendAngle.toFixed(0)}°/130°`, midK.x + 40, midK.y);
    }

    // 更新数据面板
    const deltaDeg = (angles.theta1 - angles.theta2) * 180 / Math.PI;
    document.getElementById('dTheta1').textContent = (angles.theta1 * 180 / Math.PI).toFixed(1) + '°';
    document.getElementById('dTheta2').textContent = (angles.theta2 * 180 / Math.PI).toFixed(1) + '°';
    document.getElementById('dDelta').textContent = deltaDeg.toFixed(1) + '°';
    document.getElementById('dICx').textContent = ic ? (ic.x - hx).toFixed(1) + 'px' : '--';
    document.getElementById('dICy').textContent = ic ? (ic.y - hy).toFixed(1) + 'px' : '--';
    document.getElementById('dFoot').textContent = `(${(F.x-hx).toFixed(0)}, ${(F.y-hy).toFixed(0)})`;
    document.getElementById('dBend').textContent = bendAngle.toFixed(1) + '°';

    const springMoment = ankleInfo ? Math.abs(ankleInfo.ankleDeflect * 180 / Math.PI) * CFG.springK : 0;
    document.getElementById('dSpring').textContent = springMoment.toFixed(2) + ' Nm';

    // 更新相位面板
    document.getElementById('dPhase').textContent = phaseInfo.name;
    document.getElementById('dPhase').style.color = phaseInfo.color;
    document.getElementById('phaseFill').style.width = (phaseInfo.progress * 100) + '%';
    document.getElementById('phaseFill').style.background = phaseInfo.color;

    // 绘制脚踝细节
    const springEnergy = ankleInfo ? Math.abs(ankleInfo.ankleDeflect) * CFG.springK * 5 : 0;
    drawAnkleDetail(ankleInfo ? ankleInfo.ankleDeflect : 0, ankleInfo ? ankleInfo.onGround : false, springEnergy);

    // 保存上帧数据
    prevAngles = { th1: angles.theta1, th2: angles.theta2 };
    prevPoints = { K1, K2, F };
  } else {
    // 求解失败时仍绘制髋部
    drawHipBase(hx, hy);
    ctx.font = '12px "IBM Plex Mono"';
    ctx.fillStyle = 'rgba(255,23,68,0.6)';
    ctx.textAlign = 'center';
    ctx.fillText('连杆配置无解 - 调整参数', hx, hy + 200);
  }

  // 左上角装饰线条
  ctx.strokeStyle = 'rgba(0,229,255,0.1)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(w, 0);
  ctx.lineTo(w, h);
  ctx.lineTo(0, h);
  ctx.closePath();
  ctx.stroke();

  // 角标
  const cm = 20;
  ctx.strokeStyle = 'rgba(0,229,255,0.2)';
  ctx.lineWidth = 2;
  // 左上
  ctx.beginPath(); ctx.moveTo(6, cm); ctx.lineTo(6, 6); ctx.lineTo(cm, 6); ctx.stroke();
  // 右上
  ctx.beginPath(); ctx.moveTo(w-cm, 6); ctx.lineTo(w-6, 6); ctx.lineTo(w-6, cm); ctx.stroke();
  // 左下
  ctx.beginPath(); ctx.moveTo(6, h-cm); ctx.lineTo(6, h-6); ctx.lineTo(cm, h-6); ctx.stroke();
  // 右下
  ctx.beginPath(); ctx.moveTo(w-cm, h-6); ctx.lineTo(w-6, h-6); ctx.lineTo(w-6, h-cm); ctx.stroke();

  requestAnimationFrame(draw);
}

// ===== 控件交互 =====
document.getElementById('btnPlay').addEventListener('click', function() {
  playing = !playing;
  this.innerHTML = playing ? '<i class="fa-solid fa-pause"></i>' : '<i class="fa-solid fa-play"></i>';
  this.classList.toggle('active', playing);
});

document.getElementById('btnReset').addEventListener('click', function() {
  gaitPhase = 0;
  footTrail = [];
  icTrail = [];
  groundOffset = 0;
  prevAngles = null;
});

document.getElementById('sliderSpeed').addEventListener('input', function() {
  speed = parseFloat(this.value);
  document.getElementById('valSpeed').textContent = speed.toFixed(1) + 'x';
});

document.getElementById('sliderRatio').addEventListener('input', function() {
  CFG.ratio = parseFloat(this.value);
  document.getElementById('valRatio').textContent = CFG.ratio.toFixed(2);
  footTrail = [];
  icTrail = [];
});

document.getElementById('sliderStiff').addEventListener('input', function() {
  CFG.springK = parseFloat(this.value);
  document.getElementById('valStiff').textContent = CFG.springK.toFixed(1);
});

document.getElementById('btnTrail').addEventListener('click', function() {
  showTrail = !showTrail;
  this.classList.toggle('active', showTrail);
  if (!showTrail) footTrail = [];
});
document.getElementById('btnTrail').classList.add('active');

document.getElementById('btnIC').addEventListener('click', function() {
  showIC = !showIC;
  this.classList.toggle('active', showIC);
  if (!showIC) icTrail = [];
});
document.getElementById('btnIC').classList.add('active');

// 启动动画
requestAnimationFrame(draw);
</script>
</body>
</html>

这个动画完整实现了仿生五连杆膝关节的 IFR 原理演示,以下是核心实现要点:

机构运动学

  • 基于平面五连杆闭环方程,通过两个髋部电机的差动角度(θ₁、θ₂)实时求解膝关节点 K1、K2 和足端 F 的位置,采用双圆交点算法处理闭式解。
  • 虚拟膝关节瞬时旋转中心通过速度分析法计算——由 K1、K2 的线速度反推耦合件角速度 ωc,再求零速度点位置,其轨迹清晰展示了"滑动-滚动复合运动"的非固定中心特征。

IFR 思想体现

  • 动画直接聚焦理想解运行状态,足端轨迹(绿色)与背景虚线单轴圆弧的对比,直观展示五连杆如何突破单铰链的纯圆弧约束。
  • 被动十字铰链脚踝在触地时自动偏转适应地面起伏,扭簧蓄能在离地时释放——零主动控制、零额外能耗的资源自利用。
  • 差动角 Δθ 用橙色弧线高亮标注,视觉引导用户关注"双输入组合产生复合膝运动"这一破矛盾关键。

交互控制

  • 速度、连杆比(1.0~1.8)、扭簧刚度三个滑块实时改变机构参数,用户可亲手体验参数对轨迹形态和虚拟膝中心位置的影响。
  • 轨迹/瞬心按钮可独立开关,便于专注观察某一特征。
  • 右侧数据面板实时显示角度、瞬时中心坐标、膝弯曲角、扭簧力矩等关键参数。
积分规则:第一轮对话扣减6分,后续每轮扣4分