独立渲染引擎就绪引擎就绪
<!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分
等待动画代码生成...
