分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多段铰接式柔性底盘 · 不规则台阶攀爬原理</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&family=Noto+Sans+SC:wght@300;400;700;900&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #060a12;
  --fg: #d8e2ec;
  --muted: #3e4c5e;
  --accent: #00e5ff;
  --accent2: #ff6b35;
  --card: #0c1220;
  --border: #162030;
  --stair: #111b2a;
  --stairEdge: #1a3050;
}
*{margin:0;padding:0;box-sizing:border-box;}
body{
  background:var(--bg);
  color:var(--fg);
  font-family:'Noto Sans SC',sans-serif;
  min-height:100vh;
  display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;
}
.page-header{
  width:100%;max-width:1260px;
  padding:28px 30px 10px;
  display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;
}
.page-header h1{
  font-size:26px;font-weight:900;letter-spacing:1px;
  background:linear-gradient(135deg,#00e5ff 0%,#00ffa3 100%);
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
.page-header .sub{
  font-size:14px;color:#6b8199;font-family:'JetBrains Mono',monospace;font-weight:300;
}
.main-wrap{
  width:100%;max-width:1260px;
  padding:0 20px 30px;
  display:flex;gap:20px;flex-wrap:wrap;
}
.svg-container{
  flex:1 1 800px;min-width:0;
  background:var(--card);
  border:1px solid var(--border);
  border-radius:14px;overflow:hidden;
  position:relative;
}
.svg-container svg{display:block;width:100%;height:auto;}
.side-panel{
  flex:0 0 320px;
  display:flex;flex-direction:column;gap:14px;
}
.card{
  background:var(--card);border:1px solid var(--border);
  border-radius:12px;padding:18px 20px;
}
.card h3{
  font-size:13px;font-weight:700;color:#5a7a96;
  text-transform:uppercase;letter-spacing:2px;margin-bottom:14px;
  font-family:'JetBrains Mono',monospace;
}
.param-row{
  display:flex;justify-content:space-between;align-items:center;
  padding:7px 0;border-bottom:1px solid var(--border);
}
.param-row:last-child{border-bottom:none;}
.param-label{font-size:13px;color:#8aa0b8;}
.param-value{
  font-size:14px;font-weight:700;color:var(--accent);
  font-family:'JetBrains Mono',monospace;
}
.ctrl-group{margin-bottom:14px;}
.ctrl-group:last-child{margin-bottom:0;}
.ctrl-label{
  display:flex;justify-content:space-between;align-items:center;
  margin-bottom:6px;
}
.ctrl-label span{font-size:12px;color:#6b8199;}
.ctrl-label .val{
  font-family:'JetBrains Mono',monospace;font-weight:700;
  color:var(--accent);font-size:13px;
}
input[type=range]{
  -webkit-appearance:none;width:100%;height:6px;
  background:var(--border);border-radius:3px;outline:none;
}
input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;width:16px;height:16px;
  background:var(--accent);border-radius:50%;cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,255,0.4);
}
.btn-reset{
  width:100%;padding:10px;border:1px solid var(--accent2);
  background:transparent;color:var(--accent2);border-radius:8px;
  font-size:13px;font-weight:700;cursor:pointer;
  font-family:'Noto Sans SC',sans-serif;
  transition:all .2s;
}
.btn-reset:hover{background:var(--accent2);color:#fff;}
.ifr-box{
  border-color:#1a3040;
  background:linear-gradient(135deg,rgba(0,229,255,0.03),rgba(255,107,53,0.03));
}
.ifr-box h3{color:#4a9ab5;}
.ifr-item{
  font-size:12.5px;line-height:1.7;color:#8ab0c8;
  margin-bottom:8px;padding-left:14px;position:relative;
}
.ifr-item::before{
  content:'';position:absolute;left:0;top:8px;
  width:6px;height:6px;border-radius:50%;
}
.ifr-item.cyan::before{background:var(--accent);}
.ifr-item.orange::before{background:var(--accent2);}
.ifr-item.green::before{background:#00ffa3;}
.legend{display:flex;gap:16px;flex-wrap:wrap;margin-top:4px;}
.legend-item{display:flex;align-items:center;gap:6px;font-size:11px;color:#6b8199;}
.legend-dot{width:10px;height:10px;border-radius:50%;}
@media(max-width:900px){
  .side-panel{flex:1 1 100%;}
  .page-header{padding:18px 16px 6px;}
  .main-wrap{padding:0 10px 20px;}
}
</style>
</head>
<body>

<div class="page-header">
  <h1>多段铰接式柔性底盘</h1>
  <span class="sub">Caterpillar Climbing Mechanism · IFR Principle</span>
</div>

<div class="main-wrap">
  <div class="svg-container">
    <svg id="scene" viewBox="0 0 1200 620" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <filter id="glow">
          <feGaussianBlur stdDeviation="3" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="jointGlow">
          <feGaussianBlur stdDeviation="7" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="shadow">
          <feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity="0.5"/>
        </filter>
        <linearGradient id="stairGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#152030"/>
          <stop offset="100%" stop-color="#0c1520"/>
        </linearGradient>
        <linearGradient id="segGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#1a3a50"/>
          <stop offset="100%" stop-color="#0e2535"/>
        </linearGradient>
        <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
          <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#0c1520" stroke-width="0.5"/>
        </pattern>
        <clipPath id="sceneClip"><rect x="0" y="0" width="1200" height="620"/></clipPath>
      </defs>
      <g clip-path="url(#sceneClip)">
        <rect width="1200" height="620" fill="#060a12"/>
        <rect width="1200" height="620" fill="url(#grid)" opacity="0.5"/>
      </g>
    </svg>
  </div>

  <div class="side-panel">
    <div class="card">
      <h3>关键参数</h3>
      <div class="param-row"><span class="param-label">舱段数量</span><span class="param-value" id="pSegments">5</span></div>
      <div class="param-row"><span class="param-label">万向节最大弯折角</span><span class="param-value">45°</span></div>
      <div class="param-row"><span class="param-label">履带宽度</span><span class="param-value">80mm</span></div>
      <div class="param-row"><span class="param-label">舱段间距</span><span class="param-value">60mm</span></div>
      <div class="param-row"><span class="param-label">当前最大弯折</span><span class="param-value" id="pMaxBend">0°</span></div>
      <div class="param-row"><span class="param-label">攀爬进度</span><span class="param-value" id="pProgress">0%</span></div>
    </div>

    <div class="card">
      <h3>动画控制</h3>
      <div class="ctrl-group">
        <div class="ctrl-label"><span>播放速度</span><span class="val" id="vSpeed">1.0x</span></div>
        <input type="range" id="sSpeed" min="0.3" max="3" step="0.1" value="1">
      </div>
      <div class="ctrl-group">
        <div class="ctrl-label"><span>台阶不规则度</span><span class="val" id="vIrreg">70%</span></div>
        <input type="range" id="sIrreg" min="0" max="1" step="0.05" value="0.7">
      </div>
      <button class="btn-reset" id="btnReset">重置动画</button>
    </div>

    <div class="card ifr-box">
      <h3>IFR 理想解分析</h3>
      <div class="ifr-item cyan">底盘形态随地形被动重塑,无需额外传感器与主动姿态控制</div>
      <div class="ifr-item orange">台阶对立面的反力即折叠驱动力——问题本身成为资源</div>
      <div class="ifr-item green">跨越障碍后自重恢复平直,系统自动回归理想状态</div>
    </div>

    <div class="card">
      <h3>图例</h3>
      <div class="legend">
        <div class="legend-item"><div class="legend-dot" style="background:#00e5ff"></div>舱段/履带</div>
        <div class="legend-item"><div class="legend-dot" style="background:#ff6b35"></div>弯折关节</div>
        <div class="legend-item"><div class="legend-dot" style="background:#00ffa3"></div>驱动力</div>
        <div class="legend-item"><div class="legend-dot" style="background:#ff4466"></div>台阶反力</div>
      </div>
    </div>
  </div>
</div>

<script>
(function(){
  /* ====== 常量 ====== */
  const NS='http://www.w3.org/2000/svg';
  const NUM_SEG=5;
  const SEG_ARC=60;
  const WHEEL_R=10;
  const TRACK_H=18;
  const WHEEL_OFFSET=TRACK_H/2;
  const MAX_BEND=45;
  const SEG_BODY_H=20;
  const BASE_SPEED=1.2;

  /* ====== 状态 ====== */
  let terrain,arcTable,totalArcLen;
  let robotDist=0;
  let speed=1;
  let irregularity=0.7;
  let cumulDist=0;
  let animId=null;
  let lastTime=0;

  /* ====== DOM ====== */
  const svg=document.getElementById('scene');
  const gClip=svg.querySelector('g[clip-path]');

  /* ====== 工具函数 ====== */
  function el(tag,attrs){
    const e=document.createElementNS(NS,tag);
    for(const[k,v]of Object.entries(attrs||{}))e.setAttribute(k,v);
    return e;
  }

  /* ====== 地形生成 ====== */
  function genTerrain(irreg){
    const baseR=[76,76,76,76];
    const irrR=[50,115,40,95];
    const treads=[130,140,130];
    const rises=baseR.map((b,i)=>b+(irrR[i]-b)*irreg);
    const pts=[[-500,520],[330,520]];
    let x=330,y=520;
    for(let i=0;i<rises.length;i++){
      y-=rises[i];
      pts.push([x,y]);
      if(i<rises.length-1){x+=treads[i];pts.push([x,y]);}
      else{x+=400;pts.push([x,y]);}
    }
    pts.push([1700,y]);
    return pts;
  }

  function buildArc(poly){
    const t=[{d:0,x:poly[0][0],y:poly[0][1]}];
    let total=0;
    for(let i=1;i<poly.length;i++){
      const dx=poly[i][0]-poly[i-1][0],dy=poly[i][1]-poly[i-1][1];
      total+=Math.sqrt(dx*dx+dy*dy);
      t.push({d:total,x:poly[i][0],y:poly[i][1]});
    }
    return t;
  }

  function ptAtDist(dist){
    const t=arcTable;
    if(dist<=0)return{x:t[0].x,y:t[0].y,nx:0,ny:-1};
    const last=t[t.length-1];
    if(dist>=last.d)return{x:last.x,y:last.y,nx:0,ny:-1};
    for(let i=1;i<t.length;i++){
      if(dist<=t[i].d){
        const p=t[i-1],c=t[i];
        const f=(dist-p.d)/(c.d-p.d);
        const x=p.x+f*(c.x-p.x),y=p.y+f*(c.y-p.y);
        const dx=c.x-p.x,dy=c.y-p.y;
        const len=Math.sqrt(dx*dx+dy*dy)||1;
        return{x,y,nx:dy/len,ny:-dx/len};
      }
    }
    return{x:last.x,y:last.y,nx:0,ny:-1};
  }

  /* ====== 机器人状态计算 ====== */
  function computeState(frontD){
    const wheels=[];
    for(let i=0;i<=NUM_SEG;i++){
      const d=frontD-i*SEG_ARC;
      const p=ptAtDist(d);
      wheels.push({
        cx:p.x+p.nx*WHEEL_OFFSET,
        cy:p.y+p.ny*WHEEL_OFFSET,
        gx:p.x,gy:p.y,
        nx:p.nx,ny:p.ny,
        dist:d
      });
    }
    const segs=[];
    for(let i=0;i<NUM_SEG;i++){
      const w1=wheels[i],w2=wheels[i+1];
      const angle=Math.atan2(w2.cy-w1.cy,w2.cx-w1.cx);
      segs.push({angle});
    }
    let maxBend=0;
    const bends=[0];
    for(let i=1;i<NUM_SEG;i++){
      let b=(segs[i].angle-segs[i-1].angle)*180/Math.PI;
      while(b>180)b-=360;while(b<-180)b+=360;
      bends.push(b);
      if(Math.abs(b)>maxBend)maxBend=Math.abs(b);
    }
    return{wheels,segs,bends,maxBend};
  }

  /* ====== SVG 元素创建 ====== */
  // 层结构
  const gStair=el('g');gClip.appendChild(gStair);
  const gShadow=el('g');gClip.appendChild(gShadow);
  const gTrackBody=el('g');gClip.appendChild(gTrackBody);
  const gTrackTread=el('g');gClip.appendChild(gTrackTread);
  const gSegs=el('g');gClip.appendChild(gSegs);
  const gWheels=el('g');gClip.appendChild(gWheels);
  const gJoints=el('g');gClip.appendChild(gJoints);
  const gArrows=el('g');gClip.appendChild(gArrows);
  const gAnnot=el('g');gClip.appendChild(gAnnot);

  // 楼梯多边形
  const stairPoly=el('polygon',{fill:'url(#stairGrad)',stroke:'#1a3555','stroke-width':1.5});
  gStair.appendChild(stairPoly);
  // 楼梯边缘高亮线
  const stairEdge=el('polyline',{fill:'none',stroke:'#1a4060','stroke-width':2,opacity:0.7});
  gStair.appendChild(stairEdge);
  // 台阶面标注
  const stairLabelsG=el('g');gStair.appendChild(stairLabelsG);

  // 轨道本体
  const trackBody=el('path',{fill:'#1a1a22',stroke:'#0e0e14','stroke-width':1.5});
  gTrackBody.appendChild(trackBody);
  // 轨道纹理 - 底部
  const trackTreadBottom=el('path',{fill:'none',stroke:'#00e5ff','stroke-width':2,
    'stroke-dasharray':'4 10','stroke-linecap':'round',opacity:0.35});
  gTrackTread.appendChild(trackTreadBottom);
  // 轨道纹理 - 顶部
  const trackTreadTop=el('path',{fill:'none',stroke:'#00e5ff','stroke-width':1.5,
    'stroke-dasharray':'3 9','stroke-linecap':'round',opacity:0.2});
  gTrackTread.appendChild(trackTreadTop);

  // 阴影
  const robotShadow=el('path',{fill:'rgba(0,0,0,0.25)',filter:'url(#shadow)'});
  gShadow.appendChild(robotShadow);

  // 舱段
  const segEls=[];
  for(let i=0;i<NUM_SEG;i++){
    const g=el('g');
    const rect=el('rect',{rx:4,ry:4,fill:'url(#segGrad)',stroke:'#00b8d4','stroke-width':1.2});
    g.appendChild(rect);
    // 驱动标识
    const drv=el('circle',{r:3.5,fill:'#00e5ff',opacity:0.6});
    g.appendChild(drv);
    segEls.push({g,rect,drv});
    gSegs.appendChild(g);
  }

  // 轮子
  const wheelEls=[];
  for(let i=0;i<=NUM_SEG;i++){
    const c=el('circle',{r:WHEEL_R,fill:'#0a1520',stroke:'#00c8e8','stroke-width':1.8});
    const inner=el('circle',{r:3,fill:'#00e5ff',opacity:0.5});
    gWheels.appendChild(c);
    gWheels.appendChild(inner);
    wheelEls.push({outer:c,inner});
  }

  // 关节
  const jointEls=[];
  for(let i=0;i<=NUM_SEG;i++){
    const ring=el('circle',{r:6,fill:'none',stroke:'#00e5ff','stroke-width':1.5,opacity:0.4});
    const glow=el('circle',{r:10,fill:'#ff6b35',opacity:0});
    gJoints.appendChild(glow);
    gJoints.appendChild(ring);
    jointEls.push({ring,glow});
  }

  // 力箭头
  const arrowEls=[];
  for(let i=0;i<3;i++){
    const line=el('line',{stroke:'#ff4466','stroke-width':2.5,'marker-end':'url(#arrowHead)',opacity:0});
    const head=el('polygon',{fill:'#ff4466',opacity:0});
    gArrows.appendChild(line);
    gArrows.appendChild(head);
    arrowEls.push({line,head});
  }
  // 箭头标记
  const marker=el('marker',{id:'arrowHead',markerWidth:8,markerHeight:6,refX:8,refY:3,orient:'auto'});
  marker.appendChild(el('polygon',{points:'0 0, 8 3, 0 6',fill:'#ff4466'}));
  svg.querySelector('defs').appendChild(marker);

  // 驱动力箭头(绿色)
  const driveArrowEls=[];
  for(let i=0;i<NUM_SEG;i++){
    const arr=el('path',{fill:'none',stroke:'#00ffa3','stroke-width':1.5,
      'stroke-dasharray':'4 3',opacity:0,'marker-end':'url(#driveHead)'});
    gArrows.appendChild(arr);
    driveArrowEls.push(arr);
  }
  const dMarker=el('marker',{id:'driveHead',markerWidth:6,markerHeight:5,refX:6,refY:2.5,orient:'auto'});
  dMarker.appendChild(el('polygon',{points:'0 0, 6 2.5, 0 5',fill:'#00ffa3'}));
  svg.querySelector('defs').appendChild(dMarker);

  // 标注文字
  const annotTexts=[];
  const annotDefs=[
    {id:'fold',text:'被动折叠',color:'#ff6b35'},
    {id:'straight',text:'自重恢复',color:'#00ffa3'},
    {id:'drive',text:'履带持续驱动',color:'#00e5ff'},
    {id:'ifr',text:'问题即资源',color:'#ffcc00'},
  ];
  for(const a of annotDefs){
    const bg=el('rect',{rx:4,fill:'rgba(6,10,18,0.85)',stroke:a.color,'stroke-width':1,opacity:0});
    const txt=el('text',{fill:a.color,'font-size':'12','font-family':'Noto Sans SC, sans-serif',
      'font-weight':'700','text-anchor':'middle',opacity:0});
    txt.textContent=a.text;
    gAnnot.appendChild(bg);
    gAnnot.appendChild(txt);
    annotTexts.push({bg,txt,def:a,opacity:0});
  }

  // 角度指示弧线
  const angleArcs=[];
  for(let i=1;i<NUM_SEG;i++){
    const p=el('path',{fill:'none',stroke:'#ff6b35','stroke-width':1.5,'stroke-dasharray':'3 2',opacity:0});
    gAnnot.appendChild(p);
    angleArcs.push(p);
  }
  // 角度文字
  const angleTexts=[];
  for(let i=1;i<NUM_SEG;i++){
    const t=el('text',{fill:'#ff6b35','font-size':'10','font-family':'JetBrains Mono, monospace',
      'font-weight':'700','text-anchor':'middle',opacity:0});
    gAnnot.appendChild(t);
    angleTexts.push(t);
  }

  /* ====== 绘制楼梯 ====== */
  function drawStairs(){
    const t=terrain;
    // 多边形:从第一个地面点开始,沿楼梯轮廓,再沿底部回来
    let polyPts=t.slice(1,-1).map(p=>p[0]+','+p[1]).join(' ');
    // 底部封闭
    const firstGround=t[1];
    const lastStep=t[t.length-2];
    polyPts+=' '+lastStep[0]+',520 '+firstGround[0]+',520';
    stairPoly.setAttribute('points',polyPts);

    // 边缘线
    let edgePts=t.slice(1,-1).map(p=>p[0]+','+p[1]).join(' ');
    stairEdge.setAttribute('points',edgePts);

    // 台阶高度标注
    while(stairLabelsG.firstChild)stairLabelsG.removeChild(stairLabelsG.firstChild);
    let y=520;
    for(let i=1;i<t.length-1;i++){
      const prev=t[i-1],curr=t[i];
      if(curr[1]<y-10){
        const rise=Math.round(y-curr[1]);
        const midY=(y+curr[1])/2;
        // 竖直标注线
        const ln=el('line',{x1:curr[0]-18,y1:y,x2:curr[0]-18,y2:curr[1],
          stroke:'#2a5070','stroke-width':1,'stroke-dasharray':'2 3'});
        stairLabelsG.appendChild(ln);
        const tx=el('text',{x:curr[0]-24,y:midY+4,fill:'#3a7090','font-size':'10',
          'font-family':'JetBrains Mono, monospace','text-anchor':'end'});
        tx.textContent=rise+'mm';
        stairLabelsG.appendChild(tx);
        y=curr[1];
      }else if(curr[1]>y+10){
        y=curr[1];
      }
    }
  }

  /* ====== 构建轨道路径 ====== */
  function buildTrackPath(state){
    const ws=state.wheels;
    const frontD=ws[0].dist;
    const backD=ws[ws.length-1].dist;

    // 底部:沿地形从后到前
    const bottomPts=[];
    const N=60;
    for(let i=0;i<=N;i++){
      const d=backD+(frontD-backD)*i/N;
      const p=ptAtDist(d);
      bottomPts.push(p);
    }

    // 前轮弧
    const fw=ws[0];
    const fwBottom={x:fw.gx,y:fw.gy};
    const fwTop={x:fw.cx+fw.nx*WHEEL_R,y:fw.cy+fw.ny*WHEEL_R};
    const frontArcPts=[];
    const fNormAngle=Math.atan2(fw.ny,fw.nx);
    for(let a=0;a<=12;a++){
      const ang=fNormAngle+Math.PI-a*(Math.PI/12);
      frontArcPts.push({
        x:fw.cx+Math.cos(ang)*WHEEL_R,
        y:fw.cy+Math.sin(ang)*WHEEL_R
      });
    }

    // 顶部:轮顶从前到后
    const topPts=[];
    for(let i=0;i<ws.length;i++){
      topPts.push({x:ws[i].cx+ws[i].nx*WHEEL_R,y:ws[i].cy+ws[i].ny*WHEEL_R});
    }

    // 后轮弧
    const bw=ws[ws.length-1];
    const bwTop={x:bw.cx+bw.nx*WHEEL_R,y:bw.cy+bw.ny*WHEEL_R};
    const bwBottom={x:bw.gx,y:bw.gy};
    const backArcPts=[];
    const bNormAngle=Math.atan2(bw.ny,bw.nx);
    for(let a=0;a<=12;a++){
      const ang=bNormAngle+a*(Math.PI/12);
      backArcPts.push({
        x:bw.cx+Math.cos(ang)*WHEEL_R,
        y:bw.cy+Math.sin(ang)*WHEEL_R
      });
    }

    // 组合路径
    let d=`M ${bottomPts[0].x} ${bottomPts[0].y}`;
    for(let i=1;i<bottomPts.length;i++)d+=` L ${bottomPts[i].x} ${bottomPts[i].y}`;
    for(const p of frontArcPts)d+=` L ${p.x} ${p.y}`;
    for(const p of topPts)d+=` L ${p.x} ${p.y}`;
    for(const p of backArcPts)d+=` L ${p.x} ${p.y}`;
    d+=' Z';

    // 底部纹理路径
    let bd=`M ${bottomPts[0].x} ${bottomPts[0].y}`;
    for(let i=1;i<bottomPts.length;i++)bd+=` L ${bottomPts[i].x} ${bottomPts[i].y}`;

    // 顶部纹理路径
    let td=`M ${topPts[0].x} ${topPts[0].y}`;
    for(let i=1;i<topPts.length;i++)td+=` L ${topPts[i].x} ${topPts[i].y}`;

    // 阴影路径(底部略微偏移)
    let sd=`M ${bottomPts[0].x} ${bottomPts[0].y+8}`;
    for(let i=1;i<bottomPts.length;i++)sd+=` L ${bottomPts[i].x} ${bottomPts[i].y+8}`;
    for(let i=topPts.length-1;i>=0;i--)sd+=` L ${topPts[i].x} ${topPts[i].y+8}`;
    sd+=' Z';

    return{mainPath:d,bottomPath:bd,topPath:td,shadowPath:sd};
  }

  /* ====== 渲染帧 ====== */
  function render(state){
    const ws=state.wheels;
    const segs=state.segs;
    const bends=state.bends;

    // 轨道
    const tp=buildTrackPath(state);
    trackBody.setAttribute('d',tp.mainPath);
    robotShadow.setAttribute('d',tp.shadowPath);

    // 纹理偏移
    const treadOff=-cumulDist*0.8;
    trackTreadBottom.setAttribute('d',tp.bottomPath);
    trackTreadBottom.setAttribute('stroke-dashoffset',treadOff.toFixed(1));
    trackTreadTop.setAttribute('d',tp.topPath);
    trackTreadTop.setAttribute('stroke-dashoffset',(-treadOff*1.2).toFixed(1));

    // 舱段
    for(let i=0;i<NUM_SEG;i++){
      const w1=ws[i],w2=ws[i+1];
      const cx=(w1.cx+w2.cx)/2,cy=(w1.cy+w2.cy)/2;
      const dx=w2.cx-w1.cx,dy=w2.cy-w1.cy;
      const len=Math.sqrt(dx*dx+dy*dy);
      const ang=segs[i].angle*180/Math.PI;
      const s=segEls[i];
      s.rect.setAttribute('x',-len/2+4);
      s.rect.setAttribute('y',-SEG_BODY_H/2);
      s.rect.setAttribute('width',len-8);
      s.rect.setAttribute('height',SEG_BODY_H);
      s.drv.setAttribute('cx',0);
      s.drv.setAttribute('cy',0);
      s.g.setAttribute('transform',`translate(${cx},${cy}) rotate(${ang})`);
      // 驱动标识闪烁
      const pulse=0.3+0.3*Math.sin(Date.now()/200+i);
      s.drv.setAttribute('opacity',pulse.toFixed(2));
    }

    // 轮子
    for(let i=0;i<=NUM_SEG;i++){
      const w=ws[i];
      wheelEls[i].outer.setAttribute('cx',w.cx);
      wheelEls[i].outer.setAttribute('cy',w.cy);
      wheelEls[i].inner.setAttribute('cx',w.cx);
      wheelEls[i].inner.setAttribute('cy',w.cy);
    }

    // 关节
    for(let i=0;i<=NUM_SEG;i++){
      const w=ws[i];
      const bend=i>0&&i<NUM_SEG?Math.abs(bends[i]):0;
      const isBending=bend>8;
      const j=jointEls[i];
      j.ring.setAttribute('cx',w.cx);
      j.ring.setAttribute('cy',w.cy);
      j.glow.setAttribute('cx',w.cx);
      j.glow.setAttribute('cy',w.cy);
      if(isBending){
        const intensity=Math.min(1,(bend-8)/30);
        j.ring.setAttribute('stroke','#ff6b35');
        j.ring.setAttribute('stroke-width','2.5');
        j.ring.setAttribute('opacity',(0.5+intensity*0.5).toFixed(2));
        j.glow.setAttribute('opacity',(intensity*0.4).toFixed(2));
        j.glow.setAttribute('r',(8+intensity*6).toFixed(1));
        j.ring.setAttribute('filter','url(#jointGlow)');
      }else{
        j.ring.setAttribute('stroke','#00e5ff');
        j.ring.setAttribute('stroke-width','1.5');
        j.ring.setAttribute('opacity','0.4');
        j.glow.setAttribute('opacity','0');
        j.ring.removeAttribute('filter');
      }
    }

    // 力箭头 - 找到与台阶接触的轮子
    let arrowIdx=0;
    for(let i=0;i<ws.length&&arrowIdx<arrowEls.length;i++){
      const w=ws[i];
      const p=ptAtDist(w.dist);
      // 检查是否在竖直段附近
      const eps=3;
      let onRiser=false;
      for(let j=2;j<terrain.length-1;j+=2){
        if(Math.abs(p.x-terrain[j][0])<eps&&p.y<terrain[j-1][1]+5&&p.y>terrain[j][1]-5){
          onRiser=true;break;
        }
      }
      const ae=arrowEls[arrowIdx];
      if(onRiser&&i>0){
        const prevW=ws[i-1];
        // 反力箭头:从台阶指向舱段
        const ax=p.x-25,ay=w.cy;
        const bx=p.x-5,by=w.cy;
        ae.line.setAttribute('x1',ax);ae.line.setAttribute('y1',ay);
        ae.line.setAttribute('x2',bx);ae.line.setAttribute('y2',by);
        ae.line.setAttribute('opacity','0.8');
        ae.head.setAttribute('opacity','0');
        arrowIdx++;
      }else{
        ae.line.setAttribute('opacity','0');
        ae.head.setAttribute('opacity','0');
      }
    }
    for(let i=arrowIdx;i<arrowEls.length;i++){
      arrowEls[i].line.setAttribute('opacity','0');
      arrowEls[i].head.setAttribute('opacity','0');
    }

    // 驱动力箭头
    for(let i=0;i<NUM_SEG;i++){
      const w1=ws[i],w2=ws[i+1];
      const cx=(w1.cx+w2.cx)/2,cy=(w1.cy+w2.cy)/2;
      const ang=segs[i].angle;
      const dirX=Math.cos(ang),dirY=Math.sin(ang);
      // 在舱段中心下方画一个向右的小箭头
      const bx=cx-dirX*10,by=cy-dirY*10+WHEEL_R+6;
      const ex=cx+dirX*10,ey=cy+dirY*10+WHEEL_R+6;
      driveArrowEls[i].setAttribute('d',`M ${bx} ${by} L ${ex} ${ey}`);
      const isActive=Math.abs(bends[i>0?i:0])>3||Math.abs(bends[Math.min(i+1,NUM_SEG-1)])>3;
      driveArrowEls[i].setAttribute('opacity',isActive?'0.6':'0.25');
    }

    // 角度弧线
    for(let i=1;i<NUM_SEG;i++){
      const bend=bends[i];
      const absBend=Math.abs(bend);
      if(absBend>5){
        const w=ws[i];
        const arcR=20;
        const a1=segs[i-1].angle;
        const a2=segs[i].angle;
        const startA=Math.min(a1,a2);
        const endA=Math.max(a1,a2);
        let d='';
        for(let a=0;a<=8;a++){
          const ang=startA+(endA-startA)*a/8;
          const px=w.cx+Math.cos(ang)*arcR;
          const py=w.cy+Math.sin(ang)*arcR;
          d+=(a===0?'M':'L')+` ${px.toFixed(1)} ${py.toFixed(1)} `;
        }
        angleArcs[i-1].setAttribute('d',d);
        angleArcs[i-1].setAttribute('opacity',Math.min(1,absBend/25).toFixed(2));
        // 角度文字
        const midA=(startA+endA)/2;
        const tx=w.cx+Math.cos(midA)*(arcR+12);
        const ty=w.cy+Math.sin(midA)*(arcR+12);
        angleTexts[i-1].setAttribute('x',tx.toFixed(1));
        angleTexts[i-1].setAttribute('y',(ty+4).toFixed(1));
        angleTexts[i-1].textContent=Math.round(absBend)+'°';
        angleTexts[i-1].setAttribute('opacity',Math.min(1,absBend/25).toFixed(2));
      }else{
        angleArcs[i-1].setAttribute('opacity','0');
        angleTexts[i-1].setAttribute('opacity','0');
      }
    }

    // 标注文字
    updateAnnotations(state);

    // 侧边栏数据
    document.getElementById('pMaxBend').textContent=Math.round(state.maxBend)+'°';
    const prog=Math.max(0,Math.min(100,(robotDist/totalArcLen)*100));
    document.getElementById('pProgress').textContent=Math.round(prog)+'%';
  }

  /* ====== 标注管理 ====== */
  function updateAnnotations(state){
    const ws=state.wheels;
    const bends=state.bends;
    let maxBendIdx=1,maxBendVal=0;
    for(let i=1;i<NUM_SEG;i++){
      if(Math.abs(bends[i])>maxBendVal){maxBendVal=Math.abs(bends[i]);maxBendIdx=i;}
    }

    // 被动折叠标注
    const foldA=annotTexts.find(a=>a.def.id==='fold');
    if(maxBendVal>15){
      const w=ws[maxBendIdx];
      const px=w.cx-50,py=w.cy-35;
      foldA.bg.setAttribute('x',px-4);foldA.bg.setAttribute('y',py-12);
      foldA.bg.setAttribute('width',108);foldA.bg.setAttribute('height',20);
      foldA.txt.setAttribute('x',px+50);foldA.txt.setAttribute('y',py+2);
      const op=Math.min(1,(maxBendVal-15)/20);
      foldA.bg.setAttribute('opacity',op.toFixed(2));
      foldA.txt.setAttribute('opacity',op.toFixed(2));
    }else{
      foldA.bg.setAttribute('opacity','0');foldA.txt.setAttribute('opacity','0');
    }

    // 自重恢复标注
    const straightA=annotTexts.find(a=>a.def.id==='straight');
    const onTop=ws[0].gy<250&&ws[ws.length-1].gy<300;
    const allStraight=maxBendVal<8;
    if(onTop&&allStraight){
      const cx=(ws[0].cx+ws[ws.length-1].cx)/2;
      const cy=ws[0].cy-50;
      straightA.bg.setAttribute('x',cx-54);straightA.bg.setAttribute('y',cy-12);
      straightA.bg.setAttribute('width',108);straightA.bg.setAttribute('height',20);
      straightA.txt.setAttribute('x',cx);straightA.txt.setAttribute('y',cy+2);
      straightA.bg.setAttribute('opacity','0.9');straightA.txt.setAttribute('opacity','0.9');
    }else{
      straightA.bg.setAttribute('opacity','0');straightA.txt.setAttribute('opacity','0');
    }

    // 履带持续驱动标注
    const driveA=annotTexts.find(a=>a.def.id==='drive');
    const midSeg=Math.floor(NUM_SEG/2);
    const dw=ws[midSeg];
    const dx=dw.cx+30,dy=dw.cy+30;
    driveA.bg.setAttribute('x',dx-4);driveA.bg.setAttribute('y',dy-12);
    driveA.bg.setAttribute('width',120);driveA.bg.setAttribute('height',20);
    driveA.txt.setAttribute('x',dx+56);driveA.txt.setAttribute('y',dy+2);
    const drOp=0.4+0.3*Math.sin(Date.now()/800);
    driveA.bg.setAttribute('opacity',drOp.toFixed(2));
    driveA.txt.setAttribute('opacity',drOp.toFixed(2));

    // IFR 标注
    const ifrA=annotTexts.find(a=>a.def.id==='ifr');
    if(maxBendVal>20){
      const w=ws[maxBendIdx];
      const px=w.cx+20,py=w.cy-55;
      ifrA.bg.setAttribute('x',px-4);ifrA.bg.setAttribute('y',py-12);
      ifrA.bg.setAttribute('width',100);ifrA.bg.setAttribute('height',20);
      ifrA.txt.setAttribute('x',px+46);ifrA.txt.setAttribute('y',py+2);
      const op=Math.min(0.9,(maxBendVal-20)/15);
      ifrA.bg.setAttribute('opacity',op.toFixed(2));
      ifrA.txt.setAttribute('opacity',op.toFixed(2));
    }else{
      ifrA.bg.setAttribute('opacity','0');ifrA.txt.setAttribute('opacity','0');
    }
  }

  /* ====== 动画循环 ====== */
  function animate(timestamp){
    if(!lastTime)lastTime=timestamp;
    const dt=Math.min(32,timestamp-lastTime);
    lastTime=timestamp;

    const pxPerFrame=BASE_SPEED*speed*(dt/16.67);
    robotDist+=pxPerFrame;
    cumulDist+=pxPerFrame;

    // 循环:到达终点后重置
    if(robotDist>totalArcLen+NUM_SEG*SEG_ARC+100){
      robotDist=0;
      cumulDist=0;
    }

    const state=computeState(robotDist);
    render(state);
    animId=requestAnimationFrame(animate);
  }

  /* ====== 初始化 ====== */
  function init(){
    terrain=genTerrain(irregularity);
    arcTable=buildArc(terrain);
    totalArcLen=arcTable[arcTable.length-1].d;
    robotDist=0;
    cumulDist=0;
    drawStairs();
  }

  /* ====== 控件绑定 ====== */
  document.getElementById('sSpeed').addEventListener('input',function(){
    speed=parseFloat(this.value);
    document.getElementById('vSpeed').textContent=speed.toFixed(1)+'x';
  });

  document.getElementById('sIrreg').addEventListener('input',function(){
    irregularity=parseFloat(this.value);
    document.getElementById('vIrreg').textContent=Math.round(irregularity*100)+'%';
    terrain=genTerrain(irregularity);
    arcTable=buildArc(terrain);
    totalArcLen=arcTable[arcTable.length-1].d;
    drawStairs();
  });

  document.getElementById('btnReset').addEventListener('click',function(){
    robotDist=0;cumulDist=0;lastTime=0;
  });

  /* ====== 启动 ====== */
  init();
  animId=requestAnimationFrame(animate);

  // 页面重新可见时重置计时
  document.addEventListener('visibilitychange',function(){
    if(!document.hidden)lastTime=0;
  });
})();
</script>
</body>
</html>

这个动画实现了以下核心要素:

原理展示(IFR 聚焦):

  • 动画直接展示柔性底盘在攀爬不规则台阶时的最终理想工作状态——舱段被动折叠适应地形、履带持续卷动驱动、跨越后自重恢复平直,无需前后对比
  • 当关节弯折超过阈值时,橙色辉光高亮显示被动折叠关键动作,并弹出「被动折叠」「问题即资源」标注,突出台阶反力本身即驱动折叠的资源利用
  • 红色力箭头标示台阶对立面的反力方向,绿色虚线箭头标示履带驱动力方向

动画实现:

  • 基于 arc-length 参数化的地形轮廓跟踪算法,5 段 6 轮机器人沿不规则楼梯轮廓蠕动攀爬
  • 轨道本体以闭合多边形绘制(底部沿地形→前轮弧→顶部沿轮顶→后轮弧),履带纹理通过动态 stroke-dashoffset 实现持续卷动效果
  • 关节弯折角实时计算并以弧线+角度数值可视化,超过 45° 会触发视觉警示

交互控制:

  • 速度滑块(0.3x ~ 3x)控制攀爬速率
  • 台阶不规则度滑块(0% ~ 100%)实时重建地形,从均匀台阶到极不规则台阶
  • 重置按钮可随时重新开始动画
积分规则:第一轮对话扣减8分,后续每轮扣6分