<!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分
等待动画代码生成...
