分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<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=Rajdhani:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #050a12;
  --fg: #b8c8d8;
  --muted: #2a3a50;
  --accent: #00e5a0;
  --accent2: #ff5e3a;
  --accent3: #1a6aff;
  --card: #0a1420;
  --border: #142038;
  --skin: #14332a;
  --inflate: #ff5e3a;
  --deflate: #162840;
}
*{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;
  background-image:
    radial-gradient(ellipse 80% 60% at 30% 40%, rgba(0,229,160,0.04) 0%, transparent 70%),
    radial-gradient(ellipse 60% 50% at 75% 60%, rgba(255,94,58,0.03) 0%, transparent 70%);
}
header{
  text-align:center;padding:28px 20px 10px;width:100%;max-width:1200px;
}
header h1{
  font-family:'Rajdhani',sans-serif;font-weight:700;font-size:clamp(22px,3.2vw,38px);
  letter-spacing:2px;color:#e0eaf4;
  background:linear-gradient(90deg,#00e5a0 0%,#e0eaf4 40%,#ff5e3a 100%);
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;
}
header p{
  font-size:clamp(12px,1.5vw,15px);color:var(--muted);margin-top:4px;
  font-weight:300;letter-spacing:1px;
}
.main-wrap{
  display:flex;gap:18px;padding:10px 20px 16px;width:100%;max-width:1200px;
  flex-wrap:wrap;justify-content:center;
}
.snake-panel{
  flex:1 1 680px;min-width:0;
  background:var(--card);border:1px solid var(--border);border-radius:14px;
  overflow:hidden;position:relative;
}
.snake-panel svg{display:block;width:100%;height:auto;}
.side-panel{
  flex:0 0 320px;display:flex;flex-direction:column;gap:14px;
}
.cross-panel{
  background:var(--card);border:1px solid var(--border);border-radius:14px;
  padding:14px;position:relative;overflow:hidden;
}
.cross-panel h3,.info-panel h3{
  font-family:'Rajdhani',sans-serif;font-weight:600;font-size:14px;
  color:var(--accent);letter-spacing:1px;margin-bottom:8px;text-transform:uppercase;
}
.cross-panel svg{display:block;width:100%;height:auto;}
.info-panel{
  background:var(--card);border:1px solid var(--border);border-radius:14px;
  padding:14px;flex:1;
}
.info-row{
  display:flex;justify-content:space-between;align-items:center;
  padding:5px 0;border-bottom:1px solid var(--border);font-size:13px;
}
.info-row:last-child{border-bottom:none}
.info-label{color:var(--muted);font-weight:400}
.info-value{
  font-family:'JetBrains Mono',monospace;font-weight:600;font-size:13px;
  color:#e0eaf4;
}
.info-value.hot{color:var(--accent2)}
.info-value.cool{color:var(--accent)}
.controls{
  display:flex;gap:20px;padding:8px 20px 24px;width:100%;max-width:1200px;
  flex-wrap:wrap;justify-content:center;
}
.ctrl-group{
  background:var(--card);border:1px solid var(--border);border-radius:10px;
  padding:10px 18px;display:flex;align-items:center;gap:12px;min-width:240px;
}
.ctrl-group label{
  font-size:12px;color:var(--muted);white-space:nowrap;min-width:72px;
  font-weight:500;letter-spacing:0.5px;
}
.ctrl-group input[type=range]{
  flex:1;-webkit-appearance:none;appearance:none;height:4px;
  background:var(--border);border-radius:2px;outline:none;cursor:pointer;
}
.ctrl-group input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;width:16px;height:16px;border-radius:50%;
  background:var(--accent);border:2px solid var(--bg);cursor:pointer;
  box-shadow:0 0 8px rgba(0,229,160,0.4);
}
.ctrl-group .ctrl-val{
  font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--accent);
  min-width:52px;text-align:right;
}
.legend{
  display:flex;gap:16px;padding:2px 20px 10px;flex-wrap:wrap;justify-content:center;
}
.legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted)}
.legend-dot{width:10px;height:10px;border-radius:50%}
@media(max-width:768px){
  .side-panel{flex:1 1 100%}
  .ctrl-group{min-width:200px}
}
</style>
</head>
<body>

<header>
  <h1>PNEUMATIC SOFT SNAKE &mdash; IFR</h1>
  <p>消除刚性关节 &middot; 柔性形变驱动连续体蜿蜒 &middot; 气动人工肌肉行波蠕动</p>
</header>

<div class="main-wrap">
  <!-- 主蛇体动画 -->
  <div class="snake-panel">
    <svg id="main-svg" viewBox="0 0 900 360" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <!-- 发光滤镜 -->
        <filter id="glow-o" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="glow-g" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="glow-c" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <!-- 皮肤纹理 -->
        <pattern id="scales" width="10" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(-5)">
          <path d="M0 8 Q5 2 10 8" stroke="#1a4a3a" stroke-width="0.4" fill="none" opacity="0.5"/>
        </pattern>
        <!-- 身体填充渐变 (沿 X 轴) -->
        <linearGradient id="bodyGrad" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#0d1f18"/>
          <stop offset="100%" stop-color="#0d1f18"/>
        </linearGradient>
      </defs>
      <!-- 网格背景 -->
      <g opacity="0.08" stroke="#3a6a5a" stroke-width="0.5">
        <line x1="0" y1="180" x2="900" y2="180"/>
        <line x1="0" y1="90" x2="900" y2="90" stroke-dasharray="4,8"/>
        <line x1="0" y1="270" x2="900" y2="270" stroke-dasharray="4,8"/>
      </g>
      <!-- 蛇体元素层 -->
      <g id="snake-group">
        <path id="body-path" fill="#0f2a20" stroke="#1a4a3a" stroke-width="1"/>
        <path id="body-texture" fill="url(#scales)" opacity="0.35"/>
        <!-- 左侧肌肉激活指示 -->
        <g id="left-indicators"></g>
        <!-- 右侧肌肉激活指示 -->
        <g id="right-indicators"></g>
        <!-- 中心气道 -->
        <path id="center-path" fill="none" stroke="#00e5a0" stroke-width="2.5" stroke-linecap="round" opacity="0.7" filter="url(#glow-g)"/>
        <!-- 气体粒子 -->
        <g id="particles"></g>
        <!-- 行波相位标记 -->
        <g id="phase-markers"></g>
      </g>
      <!-- 标注 -->
      <g id="annotations" font-family="'Noto Sans SC',sans-serif" font-size="11" fill="#5a7a8a"></g>
    </svg>
  </div>

  <!-- 侧面板 -->
  <div class="side-panel">
    <div class="cross-panel">
      <h3>Cross Section &middot; 截面构型</h3>
      <svg id="cross-svg" viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <filter id="cross-glow" x="-50%" y="-50%" width="200%" height="200%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>
            <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
          </filter>
        </defs>
        <!-- 外层弹性皮肤 -->
        <circle cx="140" cy="140" r="105" fill="none" stroke="#1a4a3a" stroke-width="2" stroke-dasharray="6,3" opacity="0.6"/>
        <circle cx="140" cy="140" r="95" fill="#0a1a14" stroke="#1a3a2a" stroke-width="1.5"/>
        <!-- 肌肉组 A (顶部) -->
        <path id="muscle-a" fill="#162840" stroke="#2a4a6a" stroke-width="1"/>
        <!-- 肌肉组 B (左下) -->
        <path id="muscle-b" fill="#162840" stroke="#2a4a6a" stroke-width="1"/>
        <!-- 肌肉组 C (右下) -->
        <path id="muscle-c" fill="#162840" stroke="#2a4a6a" stroke-width="1"/>
        <!-- 中央硅胶管 -->
        <circle cx="140" cy="140" r="28" fill="#0a1a14" stroke="#00e5a0" stroke-width="1.5" opacity="0.8"/>
        <circle cx="140" cy="140" r="18" fill="none" stroke="#00e5a0" stroke-width="0.8" opacity="0.4" stroke-dasharray="3,3"/>
        <!-- 气道标记 -->
        <circle cx="140" cy="140" r="5" fill="#00e5a0" opacity="0.6" filter="url(#cross-glow)"/>
        <!-- 弯曲方向箭头 -->
        <g id="bend-arrow" opacity="0.8">
          <line id="bend-line" x1="140" y1="140" x2="140" y2="50" stroke="#ff5e3a" stroke-width="2" stroke-dasharray="4,3"/>
          <polygon id="bend-head" points="140,44 135,54 145,54" fill="#ff5e3a"/>
        </g>
        <!-- 标签 -->
        <text x="140" y="22" text-anchor="middle" fill="#5a8a7a" font-size="10" font-family="'Noto Sans SC'">弹性皮肤</text>
        <text x="60" y="60" text-anchor="middle" fill="#5a7a9a" font-size="9" font-family="'Noto Sans SC'" id="lbl-a">肌肉 A</text>
        <text x="220" y="210" text-anchor="middle" fill="#5a7a9a" font-size="9" font-family="'Noto Sans SC'" id="lbl-b">肌肉 B</text>
        <text x="60" y="210" text-anchor="middle" fill="#5a7a9a" font-size="9" font-family="'Noto Sans SC'" id="lbl-c">肌肉 C</text>
        <text x="140" y="144" text-anchor="middle" fill="#00e5a0" font-size="8" font-family="'Noto Sans SC'" opacity="0.7">硅胶管</text>
        <!-- 120° 标注 -->
        <path d="M 140 95 A 45 45 0 0 1 101 117" fill="none" stroke="#3a5a6a" stroke-width="0.8" stroke-dasharray="2,2"/>
        <text x="108" y="100" fill="#3a5a6a" font-size="8" font-family="'JetBrains Mono'">120°</text>
      </svg>
    </div>
    <div class="info-panel">
      <h3>Parameters &middot; 实时参数</h3>
      <div class="info-row"><span class="info-label">工作气压</span><span class="info-value hot" id="val-pressure">0.30 MPa</span></div>
      <div class="info-row"><span class="info-label">编织角</span><span class="info-value cool" id="val-angle">30°</span></div>
      <div class="info-row"><span class="info-label">行波速度</span><span class="info-value" id="val-speed">1.20 rad/s</span></div>
      <div class="info-row"><span class="info-label">最大曲率</span><span class="info-value" id="val-kappa">0.035</span></div>
      <div class="info-row"><span class="info-label">活跃肌肉组</span><span class="info-value hot" id="val-active">B + C</span></div>
      <div class="info-row"><span class="info-label">弯曲方向</span><span class="info-value cool" id="val-bend">→ 右</span></div>
    </div>
  </div>
</div>

<div class="legend">
  <div class="legend-item"><div class="legend-dot" style="background:#ff5e3a;box-shadow:0 0 6px #ff5e3a"></div>充气收缩 (轴向缩短)</div>
  <div class="legend-item"><div class="legend-dot" style="background:#162840;border:1px solid #2a4a6a"></div>放气松弛 (轴向伸长)</div>
  <div class="legend-item"><div class="legend-dot" style="background:#00e5a0;box-shadow:0 0 6px #00e5a0"></div>中央气道 / 硅胶管</div>
  <div class="legend-item"><div class="legend-dot" style="background:#ffd700;box-shadow:0 0 6px #ffd700"></div>行波相位前沿</div>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>行波速度</label>
    <input type="range" id="ctrl-speed" min="0.3" max="3" step="0.1" value="1.2">
    <span class="ctrl-val" id="cv-speed">1.2</span>
  </div>
  <div class="ctrl-group">
    <label>工作气压</label>
    <input type="range" id="ctrl-pressure" min="0.2" max="0.4" step="0.01" value="0.30">
    <span class="ctrl-val" id="cv-pressure">0.30</span>
  </div>
  <div class="ctrl-group">
    <label>螺旋编织角</label>
    <input type="range" id="ctrl-angle" min="25" max="35" step="1" value="30">
    <span class="ctrl-val" id="cv-angle">30°</span>
  </div>
</div>

<script>
/* ===== 配置 ===== */
const CFG = {
  segments: 80,       // 蛇体分段数
  segLen: 3.2,        // 每段长度
  waveK: 0.09,        // 波数
  baseRadius: 17,      // 基础截面半径
  inflateMax: 9,       // 最大膨胀增量
  headSegs: 5,        // 头部渐变段数
  tailSegs: 12,       // 尾部渐变段数
  numParticles: 18,    // 气体粒子数
  numIndicators: 24,   // 每侧激活指示器数
  numPhaseMarkers: 5,  // 行波相位标记数
};

/* ===== 状态 ===== */
const S = {
  time: 0,
  speed: 1.2,
  pressure: 0.30,
  spiralAngle: 30,
  maxKappa: 0.035,
};

/* ===== DOM 引用 ===== */
const NS = 'http://www.w3.org/2000/svg';
let mainSvg, bodyPath, bodyTexture, centerPath;
let leftIndicators = [], rightIndicators = [];
let particles = [], phaseMarkers = [];
let annotations;

/* ===== 初始化 ===== */
document.addEventListener('DOMContentLoaded', init);

function init() {
  mainSvg = document.getElementById('main-svg');
  bodyPath = document.getElementById('body-path');
  bodyTexture = document.getElementById('body-texture');
  centerPath = document.getElementById('center-path');
  annotations = document.getElementById('annotations');

  // 创建激活指示器(每侧)
  const lGrp = document.getElementById('left-indicators');
  const rGrp = document.getElementById('right-indicators');
  for (let i = 0; i < CFG.numIndicators; i++) {
    const lc = document.createElementNS(NS, 'ellipse');
    lc.setAttribute('rx', '4'); lc.setAttribute('ry', '3');
    lc.setAttribute('opacity', '0');
    lGrp.appendChild(lc);
    leftIndicators.push(lc);

    const rc = document.createElementNS(NS, 'ellipse');
    rc.setAttribute('rx', '4'); rc.setAttribute('ry', '3');
    rc.setAttribute('opacity', '0');
    rGrp.appendChild(rc);
    rightIndicators.push(rc);
  }

  // 创建气体粒子
  const pGrp = document.getElementById('particles');
  for (let i = 0; i < CFG.numParticles; i++) {
    const c = document.createElementNS(NS, 'circle');
    c.setAttribute('r', String(1.5 + Math.random() * 1.5));
    c.setAttribute('fill', '#00ffd5');
    c.setAttribute('opacity', '0');
    pGrp.appendChild(c);
    particles.push({ el: c, phase: Math.random(), speed: 0.3 + Math.random() * 0.5 });
  }

  // 创建行波相位标记
  const mGrp = document.getElementById('phase-markers');
  for (let i = 0; i < CFG.numPhaseMarkers; i++) {
    const d = document.createElementNS(NS, 'polygon');
    d.setAttribute('fill', '#ffd700');
    d.setAttribute('opacity', '0');
    mGrp.appendChild(d);
    phaseMarkers.push({ el: d, offset: i / CFG.numPhaseMarkers });
  }

  // 绑定控件
  setupControls();

  // 启动动画
  requestAnimationFrame(animate);
}

function setupControls() {
  const cs = document.getElementById('ctrl-speed');
  const cp = document.getElementById('ctrl-pressure');
  const ca = document.getElementById('ctrl-angle');

  cs.addEventListener('input', () => {
    S.speed = parseFloat(cs.value);
    document.getElementById('cv-speed').textContent = S.speed.toFixed(1);
  });
  cp.addEventListener('input', () => {
    S.pressure = parseFloat(cp.value);
    document.getElementById('cv-pressure').textContent = S.pressure.toFixed(2);
  });
  ca.addEventListener('input', () => {
    S.spiralAngle = parseInt(ca.value);
    document.getElementById('cv-angle').textContent = S.spiralAngle + '°';
  });
}

/* ===== 动画循环 ===== */
let lastTs = 0;

function animate(ts) {
  const dt = lastTs ? Math.min((ts - lastTs) / 1000, 0.05) : 0.016;
  lastTs = ts;

  S.time += dt * S.speed;
  // 气压影响最大曲率
  S.maxKappa = 0.015 + (S.pressure - 0.2) / 0.2 * 0.04;

  const pts = computeSnake();

  updateBodyPath(pts);
  updateCenterPath(pts);
  updateIndicators(pts);
  updateParticles(pts, dt);
  updatePhaseMarkers(pts);
  updateAnnotations(pts);
  updateCrossSection(pts);
  updateInfoPanel(pts);

  requestAnimationFrame(animate);
}

/* ===== 蛇体中心线计算 (曲率积分法) ===== */
function computeSnake() {
  const N = CFG.segments;
  const L = CFG.segLen;
  const K = CFG.waveK;
  const w = S.speed * 2.8;
  const k0 = S.maxKappa;

  let theta = 0, x = 0, y = 0;
  const pts = [{ x, y, theta, kappa: 0 }];

  for (let i = 1; i <= N; i++) {
    const s = i * L;
    // 2.5 个波长的行波
    const kappa = k0 * Math.sin(K * s - w * S.time);
    theta += kappa * L;
    x += Math.cos(theta) * L;
    y += Math.sin(theta) * L;
    pts.push({ x, y, theta, kappa });
  }

  // 计算边界并居中
  let mnX = Infinity, mxX = -Infinity, mnY = Infinity, mxY = -Infinity;
  for (const p of pts) {
    mnX = Math.min(mnX, p.x); mxX = Math.max(mxX, p.x);
    mnY = Math.min(mnY, p.y); mxY = Math.max(mxY, p.y);
  }
  const cx = (mnX + mxX) / 2, cy = (mnY + mxY) / 2;
  const offX = 450 - cx, offY = 180 - cy;
  for (const p of pts) { p.x += offX; p.y += offY; }

  return pts;
}

/* ===== 半径计算 (含头尾渐变 & 肌肉膨胀) ===== */
function segRadius(i, side, pts) {
  const N = pts.length - 1;
  const k0 = Math.max(S.maxKappa, 0.001);
  const kappa = pts[i].kappa;

  // 左侧肌肉在 kappa>0 时激活 (蛇体右弯), 右侧在 kappa<0 时激活
  let activation;
  if (side === 'left') {
    activation = Math.max(0, kappa / k0);
  } else {
    activation = Math.max(0, -kappa / k0);
  }

  // 膨胀量与气压成正比
  const inflate = CFG.inflateMax * activation * (S.pressure / 0.3);

  // 头尾渐变
  let taper = 1;
  if (i < CFG.headSegs) taper = 0.4 + 0.6 * (i / CFG.headSegs);
  if (i > N - CFG.tailSegs) taper = Math.max(0.05, (N - i) / CFG.tailSegs);

  return Math.max(1, (CFG.baseRadius + inflate) * taper);
}

/* ===== 更新蛇体轮廓 ===== */
function updateBodyPath(pts) {
  const N = pts.length - 1;
  const leftPts = [], rightPts = [];

  for (let i = 0; i <= N; i++) {
    const p = pts[i];
    const nx = -Math.sin(p.theta);
    const ny = Math.cos(p.theta);
    const lr = segRadius(i, 'left', pts);
    const rr = segRadius(i, 'right', pts);

    leftPts.push({ x: p.x + nx * lr, y: p.y + ny * lr });
    rightPts.push({ x: p.x - nx * rr, y: p.y - ny * rr });
  }

  // 构建闭合路径
  let d = `M ${leftPts[0].x.toFixed(1)} ${leftPts[0].y.toFixed(1)}`;
  for (let i = 1; i <= N; i++) {
    d += ` L ${leftPts[i].x.toFixed(1)} ${leftPts[i].y.toFixed(1)}`;
  }
  // 尾端弧线
  const tl = leftPts[N], tr = rightPts[N];
  d += ` Q ${pts[N].x.toFixed(1)} ${pts[N].y.toFixed(1)} ${tr.x.toFixed(1)} ${tr.y.toFixed(1)}`;
  for (let i = N - 1; i >= 0; i--) {
    d += ` L ${rightPts[i].x.toFixed(1)} ${rightPts[i].y.toFixed(1)}`;
  }
  // 头端弧线
  const hl = leftPts[0], hr = rightPts[0];
  d += ` Q ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} ${hl.x.toFixed(1)} ${hl.y.toFixed(1)}`;
  d += ' Z';

  bodyPath.setAttribute('d', d);
  bodyTexture.setAttribute('d', d);
}

/* ===== 更新中心气道 ===== */
function updateCenterPath(pts) {
  let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
  for (let i = 1; i < pts.length; i++) {
    d += ` L ${pts[i].x.toFixed(1)} ${pts[i].y.toFixed(1)}`;
  }
  centerPath.setAttribute('d', d);
}

/* ===== 更新肌肉激活指示器 ===== */
function updateIndicators(pts) {
  const N = pts.length - 1;
  const k0 = Math.max(S.maxKappa, 0.001);
  const step = Math.floor(N / CFG.numIndicators);

  for (let j = 0; j < CFG.numIndicators; j++) {
    const i = Math.min(j * step, N);
    const p = pts[i];
    const nx = -Math.sin(p.theta);
    const ny = Math.cos(p.theta);

    const leftAct = Math.max(0, p.kappa / k0);
    const rightAct = Math.max(0, -p.kappa / k0);

    const lr = segRadius(i, 'left', pts);
    const rr = segRadius(i, 'right', pts);

    // 左侧指示器 - 偏移到体表内侧
    const loff = lr * 0.55;
    leftIndicators[j].setAttribute('cx', (p.x + nx * loff).toFixed(1));
    leftIndicators[j].setAttribute('cy', (p.y + ny * loff).toFixed(1));
    leftIndicators[j].setAttribute('rx', String(3 + leftAct * 5));
    leftIndicators[j].setAttribute('ry', String(2 + leftAct * 4));
    if (leftAct > 0.15) {
      const r = Math.round(255 * leftAct + 22 * (1 - leftAct));
      const g = Math.round(94 * leftAct + 40 * (1 - leftAct));
      const b = Math.round(58 * leftAct + 64 * (1 - leftAct));
      leftIndicators[j].setAttribute('fill', `rgb(${r},${g},${b})`);
      leftIndicators[j].setAttribute('opacity', String(Math.min(1, leftAct * 1.2)));
      leftIndicators[j].setAttribute('filter', leftAct > 0.5 ? 'url(#glow-o)' : '');
    } else {
      leftIndicators[j].setAttribute('fill', '#162840');
      leftIndicators[j].setAttribute('opacity', '0.3');
      leftIndicators[j].setAttribute('filter', '');
    }

    // 右侧指示器
    const roff = rr * 0.55;
    rightIndicators[j].setAttribute('cx', (p.x - nx * roff).toFixed(1));
    rightIndicators[j].setAttribute('cy', (p.y - ny * roff).toFixed(1));
    rightIndicators[j].setAttribute('rx', String(3 + rightAct * 5));
    rightIndicators[j].setAttribute('ry', String(2 + rightAct * 4));
    if (rightAct > 0.15) {
      const r = Math.round(255 * rightAct + 22 * (1 - rightAct));
      const g = Math.round(94 * rightAct + 40 * (1 - rightAct));
      const b = Math.round(58 * rightAct + 64 * (1 - rightAct));
      rightIndicators[j].setAttribute('fill', `rgb(${r},${g},${b})`);
      rightIndicators[j].setAttribute('opacity', String(Math.min(1, rightAct * 1.2)));
      rightIndicators[j].setAttribute('filter', rightAct > 0.5 ? 'url(#glow-o)' : '');
    } else {
      rightIndicators[j].setAttribute('fill', '#162840');
      rightIndicators[j].setAttribute('opacity', '0.3');
      rightIndicators[j].setAttribute('filter', '');
    }
  }
}

/* ===== 更新气体粒子 ===== */
function updateParticles(pts, dt) {
  const N = pts.length - 1;
  for (const p of particles) {
    p.phase += dt * p.speed * S.speed;
    if (p.phase > 1) p.phase -= 1;
    // 沿中心线运动
    const idx = Math.min(Math.floor(p.phase * N), N - 1);
    const frac = p.phase * N - idx;
    const px = pts[idx].x + (pts[idx + 1].x - pts[idx].x) * frac;
    const py = pts[idx].y + (pts[idx + 1].y - pts[idx].y) * frac;
    p.el.setAttribute('cx', px.toFixed(1));
    p.el.setAttribute('cy', py.toFixed(1));
    // 粒子透明度脉动
    const pulse = 0.4 + 0.4 * Math.sin(S.time * 4 + p.phase * 12);
    p.el.setAttribute('opacity', String(pulse.toFixed(2)));
  }
}

/* ===== 更新行波相位标记 ===== */
function updatePhaseMarkers(pts) {
  const N = pts.length - 1;
  const k0 = Math.max(S.maxKappa, 0.001);
  const nx0 = -Math.sin(pts[0].theta);
  const ny0 = Math.cos(pts[0].theta);

  for (let m = 0; m < phaseMarkers.length; m++) {
    const pm = phaseMarkers[m];
    // 相位标记位于行波曲率最大处(波峰位置)
    // 波峰位置: K*s - w*t = π/2 + 2πn → s = (π/2 + 2πn + w*t) / K
    const w = S.speed * 2.8;
    const n = m;
    let sPos = (Math.PI / 2 + 2 * Math.PI * n + w * S.time) / CFG.waveK;
    const totalLen = N * CFG.segLen;
    // 归一化
    sPos = ((sPos % totalLen) + totalLen) % totalLen;
    const idx = Math.min(Math.floor(sPos / CFG.segLen), N - 1);
    if (idx < 1 || idx > N - 2) {
      pm.el.setAttribute('opacity', '0');
      continue;
    }
    const p = pts[idx];
    const nx = -Math.sin(p.theta);
    const ny = Math.cos(p.theta);
    const size = 5;
    // 三角形标记,指向行波传播方向
    const dx = Math.cos(p.theta);
    const dy = Math.sin(p.theta);
    const tipX = p.x + dx * size;
    const tipY = p.y + dy * size;
    const b1x = p.x - dx * size * 0.4 + nx * size * 0.5;
    const b1y = p.y - dy * size * 0.4 + ny * size * 0.5;
    const b2x = p.x - dx * size * 0.4 - nx * size * 0.5;
    const b2y = p.y - dy * size * 0.4 - ny * size * 0.5;
    pm.el.setAttribute('points',
      `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`);
    const pulse = 0.5 + 0.4 * Math.sin(S.time * 5 + m * 1.2);
    pm.el.setAttribute('opacity', String(pulse.toFixed(2)));
    pm.el.setAttribute('filter', 'url(#glow-c)');
  }
}

/* ===== 更新标注 ===== */
function updateAnnotations(pts) {
  // 清空旧标注
  while (annotations.firstChild) annotations.removeChild(annotations.firstChild);

  const N = pts.length - 1;
  // 头部标注
  const head = pts[2];
  addLabel(head.x, head.y - 30, '充气→径向膨胀+轴向缩短', '#ff5e3a', 10);
  // 中段标注
  const mid = pts[Math.floor(N * 0.5)];
  addLabel(mid.x, mid.y + 38, '行波蠕动方向 →', '#ffd700', 10);
  // 尾部标注
  const tail = pts[N - 3];
  addLabel(tail.x, tail.y - 28, '放气→恢复伸长', '#4a8a7a', 9);
}

function addLabel(x, y, text, color, size) {
  const t = document.createElementNS(NS, 'text');
  t.setAttribute('x', String(x));
  t.setAttribute('y', String(y));
  t.setAttribute('text-anchor', 'middle');
  t.setAttribute('fill', color);
  t.setAttribute('font-size', String(size));
  t.setAttribute('font-family', "'Noto Sans SC', sans-serif");
  t.setAttribute('opacity', '0.7');
  t.textContent = text;
  annotations.appendChild(t);
}

/* ===== 更新截面图 ===== */
function updateCrossSection(pts) {
  const N = pts.length - 1;
  const midIdx = Math.floor(N * 0.4);
  const kappa = pts[midIdx].kappa;
  const k0 = Math.max(S.maxKappa, 0.001);

  // 3组肌肉在截面上的激活状态
  // 组A (顶部 90°): 用于垂直弯曲,水平蜿蜒时激活较少
  // 组B (左下 210°): kappa>0 时激活(蛇体右弯,左侧肌肉收缩)
  // 组C (右下 330°): kappa<0 时激活(蛇体左弯,右侧肌肉收缩)
  const actA = 0.15 + 0.1 * Math.sin(S.time * 1.5); // 轻微脉动
  const actB = Math.max(0, kappa / k0);
  const actC = Math.max(0, -kappa / k0);

  drawMuscleArc('muscle-a', 140, 140, 65, 30, 90, actA);
  drawMuscleArc('muscle-b', 140, 140, 65, 30, 210, actB);
  drawMuscleArc('muscle-c', 140, 140, 65, 30, 330, actC);

  // 更新标签颜色
  updateMuscleLabel('lbl-a', actA);
  updateMuscleLabel('lbl-b', actB);
  updateMuscleLabel('lbl-c', actC);

  // 更新弯曲方向箭头
  const bendAngle = kappa > 0 ? -90 : 90; // kappa>0 → 右弯 → 箭头朝右
  const bendMag = Math.min(Math.abs(kappa / k0), 1);
  const arrowLen = 40 + bendMag * 50;
  const bx = 140 + arrowLen * (kappa > 0 ? 1 : -1) * 0; // 保持中心
  const arrowAngle = kappa > 0 ? 0 : 180; // 朝右或朝左
  const rad = arrowAngle * Math.PI / 180;
  const endX = 140 + Math.cos(rad) * arrowLen;
  const endY = 140 + Math.sin(rad) * arrowLen * 0; // 水平

  // 实际用角度旋转箭头组
  const bendArrow = document.getElementById('bend-arrow');
  const rotAngle = kappa > 0 ? 0 : 180;
  bendArrow.setAttribute('transform', `rotate(${rotAngle}, 140, 140)`);
  bendArrow.setAttribute('opacity', String(0.3 + bendMag * 0.7));

  const bendLine = document.getElementById('bend-line');
  bendLine.setAttribute('x2', String(140 + arrowLen));
  bendLine.setAttribute('y2', '140');

  const bendHead = document.getElementById('bend-head');
  const hx = 140 + arrowLen;
  bendHead.setAttribute('points',
    `${hx+6},140 ${hx-4},135 ${hx-4},145`);
}

function drawMuscleArc(id, cx, cy, r, thickness, angleDeg, activation) {
  const el = document.getElementById(id);
  const startAngle = (angleDeg - 30) * Math.PI / 180;
  const endAngle = (angleDeg + 30) * Math.PI / 180;
  const rInner = r - thickness / 2;
  const rOuter = r + thickness / 2 + activation * 12; // 激活时径向膨胀

  const x1 = cx + rInner * Math.cos(startAngle);
  const y1 = cy + rInner * Math.sin(startAngle);
  const x2 = cx + rInner * Math.cos(endAngle);
  const y2 = cy + rInner * Math.sin(endAngle);
  const x3 = cx + rOuter * Math.cos(endAngle);
  const y3 = cy + rOuter * Math.sin(endAngle);
  const x4 = cx + rOuter * Math.cos(startAngle);
  const y4 = cy + rOuter * Math.sin(startAngle);

  const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${rInner} ${rInner} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)} L ${x3.toFixed(1)} ${y3.toFixed(1)} A ${rOuter} ${rOuter} 0 0 0 ${x4.toFixed(1)} ${y4.toFixed(1)} Z`;
  el.setAttribute('d', d);

  // 颜色插值
  if (activation > 0.15) {
    const r = Math.round(255 * activation + 22 * (1 - activation));
    const g = Math.round(94 * activation + 40 * (1 - activation));
    const b = Math.round(58 * activation + 64 * (1 - activation));
    el.setAttribute('fill', `rgb(${r},${g},${b})`);
    el.setAttribute('stroke', `rgba(255,94,58,${(activation * 0.8).toFixed(2)})`);
    if (activation > 0.5) {
      el.setAttribute('filter', 'url(#cross-glow)');
    } else {
      el.removeAttribute('filter');
    }
  } else {
    el.setAttribute('fill', '#162840');
    el.setAttribute('stroke', '#2a4a6a');
    el.removeAttribute('filter');
  }
}

function updateMuscleLabel(id, activation) {
  const el = document.getElementById(id);
  if (activation > 0.4) {
    el.setAttribute('fill', '#ff5e3a');
    el.setAttribute('font-weight', '700');
  } else {
    el.setAttribute('fill', '#5a7a9a');
    el.setAttribute('font-weight', '400');
  }
}

/* ===== 更新信息面板 ===== */
function updateInfoPanel(pts) {
  const N = pts.length - 1;
  const midIdx = Math.floor(N * 0.4);
  const kappa = pts[midIdx].kappa;
  const k0 = Math.max(S.maxKappa, 0.001);

  document.getElementById('val-pressure').textContent = S.pressure.toFixed(2) + ' MPa';
  document.getElementById('val-angle').textContent = S.spiralAngle + '°';
  document.getElementById('val-speed').textContent = S.speed.toFixed(2) + ' rad/s';
  document.getElementById('val-kappa').textContent = S.maxKappa.toFixed(3);

  // 活跃肌肉组
  const actB = Math.max(0, kappa / k0);
  const actC = Math.max(0, -kappa / k0);
  let activeStr = '—';
  if (actB > 0.3 && actC < 0.3) activeStr = 'B (左下)';
  else if (actC > 0.3 && actB < 0.3) activeStr = 'C (右下)';
  else if (actB > 0.3 && actC > 0.3) activeStr = 'B + C';
  document.getElementById('val-active').textContent = activeStr;

  // 弯曲方向
  const bendDir = kappa > 0.005 ? '→ 右' : kappa < -0.005 ? '← 左' : '— 直线';
  document.getElementById('val-bend').textContent = bendDir;
}
</script>
</body>
</html>

实现说明

本动画以 TRIZ "最终理想解" 思想为核心,直接展示消除刚性关节后的柔性气动蛇理想工作状态:

  1. 蛇体形变原理:采用曲率积分法(而非简单正弦函数)计算蛇体中心线,从曲率逐段积分出姿态角,再积分出坐标,物理上精确还原了"气动肌肉收缩→轴向缩短→截面弯曲"的连续体运动。蛇体轮廓随肌肉膨胀动态变化——充气侧径向膨胀、体壁增厚,对侧保持基础半径,视觉上直观呈现弯曲机理。

  2. 肌肉激活可视化:左右两侧各 24 个椭圆指示器实时映射该位置的曲率/激活程度,从冷蓝色(松弛)平滑过渡到亮橙色(充气收缩),高激活区域附带 SVG 辉光滤镜,引导视线聚焦核心创新点。

  3. 截面构型联动:右侧截面图展示 120° 分布的三组 McKibben 肌肉,随蛇体中段曲率实时联动——激活组径向膨胀并发出暖色辉光,弯曲方向箭头同步偏转,清晰呈现"3 组肌肉即可实现任意方向弯曲"的精简构型。

  4. 行波相位标记:金色三角沿蛇体标示行波波峰传播位置,配合 18 个沿气道流动的青色粒子,直观表达"按行波相位依次充放气 → 连续侧向蠕动波"的协同过程。

  5. 交互控制:三个滑块分别调节行波速度、工作气压(0.2–0.4 MPa)和螺旋编织角(25°–35°),参数变化实时反映到蛇体运动幅度、肌肉膨胀程度和截面构型上,让用户亲手验证理想解的动态边界。

积分规则:第一轮对话扣减8分,后续每轮扣6分