分享图
A
动画渲染工坊
就绪

这个动画需要展示扑翼机构中"单一动力输入 → 拍打+自适应扭转"的核心 IFR 原理。我将采用深色蓝图 + 生物力学的美学方向,用 3D 透视投影渲染翅膀的拍打与扭转,配合曲柄摇杆机构示意图和翼型截面扭转指示器,让原理一目了然。

<!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=Chakra+Petch:wght@400;600;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
  :root {
    --bg: #050a14;
    --grid: #0c1628;
    --rigid: #c9a24c;
    --flexible: #ff6b35;
    --flexible-glow: #ff8c5a;
    --aero: #00d4ff;
    --mechanism: #7b8fa3;
    --mech-accent: #a0b8d0;
    --text: #c8d6e5;
    --text-dim: #5a6f85;
    --accent: #ff6b35;
    --danger: #ff4757;
    --panel-bg: rgba(8,16,32,0.85);
    --border: rgba(100,140,180,0.15);
  }
  * { margin:0; padding:0; box-sizing:border-box; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: 'Chakra Petch', sans-serif;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    overflow-x: hidden;
  }
  .title-bar {
    width: 100%;
    max-width: 1200px;
    padding: 18px 24px 6px;
    display: flex;
    align-items: baseline;
    gap: 18px;
  }
  .title-bar h1 {
    font-size: 1.3rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    color: #e8eff6;
  }
  .title-bar .subtitle {
    font-size: 0.78rem;
    font-weight: 400;
    color: var(--text-dim);
    font-family: 'IBM Plex Mono', monospace;
  }
  .svg-wrap {
    width: 100%;
    max-width: 1200px;
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 8px;
  }
  #mainSvg {
    width: 100%;
    height: auto;
    max-height: 72vh;
    border-radius: 8px;
  }
  .controls {
    width: 100%;
    max-width: 1200px;
    padding: 10px 24px 20px;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 20px;
    justify-content: center;
  }
  .ctrl-group {
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--panel-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 8px 16px;
  }
  .ctrl-group label {
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--text-dim);
    white-space: nowrap;
    font-family: 'IBM Plex Mono', monospace;
  }
  .ctrl-group input[type="range"] {
    -webkit-appearance: none;
    width: 130px;
    height: 4px;
    background: rgba(100,140,180,0.2);
    border-radius: 2px;
    outline: none;
  }
  .ctrl-group input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    background: var(--accent);
    cursor: pointer;
    box-shadow: 0 0 8px rgba(255,107,53,0.5);
  }
  .ctrl-group .val {
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.82rem;
    font-weight: 500;
    color: var(--accent);
    min-width: 38px;
    text-align: right;
  }
  .btn {
    background: transparent;
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    font-family: 'Chakra Petch', sans-serif;
    font-size: 0.85rem;
    font-weight: 600;
    padding: 7px 20px;
    cursor: pointer;
    transition: all 0.2s;
  }
  .btn:hover {
    background: rgba(255,107,53,0.12);
    border-color: var(--accent);
    color: var(--accent);
  }
  .btn.active {
    background: rgba(255,107,53,0.15);
    border-color: var(--accent);
    color: var(--accent);
  }
  .legend {
    display: flex;
    gap: 16px;
    align-items: center;
    font-size: 0.72rem;
    font-family: 'IBM Plex Mono', monospace;
    color: var(--text-dim);
  }
  .legend span {
    display: flex;
    align-items: center;
    gap: 5px;
  }
  .legend .dot {
    width: 10px;
    height: 10px;
    border-radius: 2px;
  }
  @media (max-width: 768px) {
    .title-bar { flex-direction: column; gap: 4px; padding: 12px 12px 4px; }
    .controls { gap: 10px; padding: 8px 12px 14px; }
    .ctrl-group { padding: 6px 10px; }
    .ctrl-group input[type="range"] { width: 90px; }
  }
</style>
</head>
<body>

<div class="title-bar">
  <h1>IFR 自适应扭转扑翼原理</h1>
  <span class="subtitle">Ideal Final Result — 单一输入 → 拍打 + 自适应扭转</span>
</div>

<div class="svg-wrap">
  <svg id="mainSvg" viewBox="-620 -360 1240 720" xmlns="http://www.w3.org/2000/svg">
    <defs>
      <!-- 网格图案 -->
      <pattern id="gridSmall" width="30" height="30" patternUnits="userSpaceOnUse">
        <path d="M 30 0 L 0 0 0 30" fill="none" stroke="rgba(30,55,95,0.25)" stroke-width="0.5"/>
      </pattern>
      <pattern id="gridLarge" width="150" height="150" patternUnits="userSpaceOnUse">
        <rect width="150" height="150" fill="url(#gridSmall)"/>
        <path d="M 150 0 L 0 0 0 150" fill="none" stroke="rgba(40,70,110,0.3)" stroke-width="1"/>
      </pattern>
      <!-- 柔性段辉光 -->
      <filter id="glowFlex" x="-30%" y="-30%" width="160%" height="160%">
        <feGaussianBlur stdDeviation="6" result="blur"/>
        <feFlood flood-color="#ff6b35" flood-opacity="0.4" result="color"/>
        <feComposite in="color" in2="blur" operator="in" result="glow"/>
        <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
      </filter>
      <!-- 分割点辉光 -->
      <filter id="glowSplit" x="-100%" y="-100%" width="300%" height="300%">
        <feGaussianBlur stdDeviation="4" result="blur"/>
        <feFlood flood-color="#00d4ff" flood-opacity="0.7" result="color"/>
        <feComposite in="color" in2="blur" operator="in" result="glow"/>
        <feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
      </filter>
      <!-- 气动力箭头 -->
      <marker id="arrowAero" viewBox="0 0 10 10" refX="9" refY="5"
        markerWidth="7" markerHeight="7" orient="auto-start-reverse">
        <path d="M 0 1 L 9 5 L 0 9 z" fill="#00d4ff"/>
      </marker>
      <!-- 面板背景 -->
      <filter id="panelShadow" x="-5%" y="-5%" width="110%" height="110%">
        <feDropShadow dx="0" dy="2" stdDeviation="6" flood-color="#000" flood-opacity="0.4"/>
      </filter>
    </defs>

    <!-- 背景 -->
    <rect x="-620" y="-360" width="1240" height="720" fill="#050a14"/>
    <rect x="-620" y="-360" width="1240" height="720" fill="url(#gridLarge)" opacity="0.7"/>
    <!-- 中心辐射光 -->
    <radialGradient id="centerGlow" cx="50%" cy="50%">
      <stop offset="0%" stop-color="rgba(0,80,160,0.08)"/>
      <stop offset="60%" stop-color="rgba(0,40,80,0.03)"/>
      <stop offset="100%" stop-color="rgba(0,0,0,0)"/>
    </radialGradient>
    <rect x="-620" y="-360" width="1240" height="720" fill="url(#centerGlow)"/>

    <!-- 动态内容组 -->
    <g id="wingGroup"></g>
    <g id="aeroGroup"></g>
    <g id="fuselageGroup"></g>
    <g id="annotationGroup"></g>

    <!-- 机构面板 -->
    <g id="mechPanel" transform="translate(-490, -310)">
      <rect x="0" y="0" width="210" height="170" rx="8" fill="rgba(8,16,32,0.88)" stroke="rgba(100,140,180,0.2)" stroke-width="1" filter="url(#panelShadow)"/>
      <text x="105" y="22" text-anchor="middle" fill="#7b9ab8" font-size="11" font-family="IBM Plex Mono" font-weight="500">曲柄摇杆机构</text>
      <g id="mechContent" transform="translate(30, 38)"></g>
    </g>

    <!-- 截面面板 -->
    <g id="sectionPanel" transform="translate(350, 120)">
      <rect x="0" y="0" width="220" height="180" rx="8" fill="rgba(8,16,32,0.88)" stroke="rgba(100,140,180,0.2)" stroke-width="1" filter="url(#panelShadow)"/>
      <text x="110" y="22" text-anchor="middle" fill="#7b9ab8" font-size="11" font-family="IBM Plex Mono" font-weight="500">外翼截面扭转</text>
      <g id="sectionContent" transform="translate(110, 105)"></g>
    </g>

    <!-- 阶段指示 -->
    <g id="phaseIndicator" transform="translate(0, -320)">
      <text id="phaseText" x="0" y="0" text-anchor="middle" fill="#00d4ff" font-size="15" font-family="Chakra Petch" font-weight="700" opacity="0.9"></text>
    </g>

    <!-- 状态读数 -->
    <g id="statusReadout" transform="translate(460, -310)">
      <rect x="0" y="0" width="150" height="100" rx="8" fill="rgba(8,16,32,0.88)" stroke="rgba(100,140,180,0.2)" stroke-width="1" filter="url(#panelShadow)"/>
      <text x="75" y="20" text-anchor="middle" fill="#7b9ab8" font-size="10" font-family="IBM Plex Mono" font-weight="500">实时参数</text>
      <text id="statFlap" x="12" y="42" fill="#c9a24c" font-size="11" font-family="IBM Plex Mono"></text>
      <text id="statTwist" x="12" y="60" fill="#ff6b35" font-size="11" font-family="IBM Plex Mono"></text>
      <text id="statAoA" x="12" y="78" fill="#00d4ff" font-size="11" font-family="IBM Plex Mono"></text>
      <text id="statPhase" x="12" y="94" fill="#7b9ab8" font-size="10" font-family="IBM Plex Mono"></text>
    </g>
  </svg>
</div>

<div class="controls">
  <div class="ctrl-group">
    <label>电机转速</label>
    <input type="range" id="motorSlider" min="0.1" max="3" step="0.05" value="1">
    <span class="val" id="motorVal">1.0x</span>
  </div>
  <div class="ctrl-group">
    <label>飞行速度</label>
    <input type="range" id="flightSlider" min="0" max="2" step="0.05" value="1">
    <span class="val" id="flightVal">1.0x</span>
  </div>
  <button class="btn active" id="playBtn">▶ 运行</button>
  <button class="btn" id="resetBtn">↺ 复位</button>
  <div class="legend">
    <span><span class="dot" style="background:#c9a24c"></span>刚性段</span>
    <span><span class="dot" style="background:#ff6b35"></span>柔性段</span>
    <span><span class="dot" style="background:#00d4ff"></span>气动力</span>
  </div>
</div>

<script>
(function() {
  'use strict';

  // ========== 常量 ==========
  const NS = 'http://www.w3.org/2000/svg';
  const DEG = Math.PI / 180;
  const WING_HALF_SPAN = 230;
  const CHORD = 68;
  const SPLIT_RATIO = 0.3;
  const PRETWIST_DEG = 15;
  const MAX_FLAP_DEG = 30;
  const VIEW_ELEV = 22 * DEG;
  const NUM_SPAN = 18;
  const LE_RATIO = 0.32; // 前缘在主梁前的比例

  // 曲柄摇杆参数
  const CR = {
    O1: {x:0, y:0}, O2: {x:72, y:0},
    r1: 20, r2: 78, r3: 42
  };

  // ========== 状态 ==========
  let crankAngle = 0;
  let motorSpeed = 1.0;
  let flightSpeed = 1.0;
  let playing = true;
  let lastTime = 0;

  // ========== DOM ==========
  const svg = document.getElementById('mainSvg');
  const wingGroup = document.getElementById('wingGroup');
  const aeroGroup = document.getElementById('aeroGroup');
  const fuselageGroup = document.getElementById('fuselageGroup');
  const annotationGroup = document.getElementById('annotationGroup');
  const mechContent = document.getElementById('mechContent');
  const sectionContent = document.getElementById('sectionContent');

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

  function setAttrs(e, attrs) {
    Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k,v));
  }

  // 3D → 2D 投影(微俯视正交+轻微透视)
  function proj(x, y, z) {
    // 绕 X 轴旋转(俯视)
    const ce = Math.cos(VIEW_ELEV), se = Math.sin(VIEW_ELEV);
    const y2 = y * ce - z * se;
    const z2 = y * se + z * ce;
    const x2 = x;
    // 轻微透视
    const d = 1800;
    const s = d / (d + z2);
    return { x: x2 * s, y: -y2 * s, z: z2, s };
  }

  // ========== 机翼几何 ==========
  function computeWingStations(side, flapDeg, twistDeg) {
    const flapRad = flapDeg * DEG;
    const cf = Math.cos(flapRad), sf = Math.sin(flapRad);
    const stations = [];

    for (let i = 0; i <= NUM_SPAN; i++) {
      const t = i / NUM_SPAN;
      const spanX = side * t * WING_HALF_SPAN;

      // 局部扭转角:柔性段从0渐增到twistDeg
      let localTwistRad = 0;
      if (t > SPLIT_RATIO) {
        const outerT = (t - SPLIT_RATIO) / (1 - SPLIT_RATIO);
        // 加上预扭
        localTwistRad = (PRETWIST_DEG + twistDeg * outerT) * DEG;
      } else if (t > SPLIT_RATIO * 0.7) {
        // 接近分割点处轻微预扭渐变
        const blend = (t - SPLIT_RATIO * 0.7) / (SPLIT_RATIO * 0.3);
        localTwistRad = PRETWIST_DEG * blend * 0.3 * DEG;
      }

      const ct = Math.cos(localTwistRad), st = Math.sin(localTwistRad);

      // 截面点(主梁位于弦长32%处)
      const leZ = -CHORD * LE_RATIO;
      const teZ = CHORD * (1 - LE_RATIO);

      // 先扭转变形
      const leYt = -leZ * st, leZt = leZ * ct;
      const teYt = -teZ * st, teZt = teZ * ct;

      // 再拍打旋转
      const leYf = leYt * cf - leZt * sf;
      const leZf = leYt * sf + leZt * cf;
      const teYf = teYt * cf - teZt * sf;
      const teZf = teYt * sf + teZt * cf;
      const bmYf = 0;
      const bmZf = 0;

      stations.push({
        t, spanX, isFlex: t >= SPLIT_RATIO,
        le: proj(spanX, leYf, leZf),
        te: proj(spanX, teYf, teZf),
        bm: proj(spanX, bmYf, bmZf)
      });
    }
    return stations;
  }

  // ========== 曲柄摇杆 ==========
  function computeCrankRocker(theta2) {
    const {O1, O2, r1, r2, r3} = CR;
    // 曲柄端点 A
    const A = { x: O1.x + r1 * Math.cos(theta2), y: O1.y + r1 * Math.sin(theta2) };
    // 求 B 点:圆(A,r2) 与 圆(O2,r3) 交点
    const dx = A.x - O2.x, dy = A.y - O2.y;
    const d = Math.sqrt(dx*dx + dy*dy);
    if (d > r2 + r3 || d < Math.abs(r2 - r3) || d < 0.01) return { A, B: O2, theta3: 0 };
    const cosA = (d*d + r3*r3 - r2*r2) / (2*d*r3);
    const ang = Math.acos(Math.max(-1, Math.min(1, cosA)));
    const base = Math.atan2(dy, dx);
    const theta3 = base + ang; // 取一侧解
    const B = { x: O2.x + r3 * Math.cos(theta3), y: O2.y + r3 * Math.sin(theta3) };
    return { A, B, theta3 };
  }

  // ========== 渲染机翼 ==========
  // 预创建元素池
  const wingPolys = [];
  const beamLines = [];
  const leLines = [];
  const teLines = [];

  function ensureWingElements() {
    // 清除旧内容
    wingGroup.innerHTML = '';
    wingPolys.length = 0;
    beamLines.length = 0;
    leLines.length = 0;
    teLines.length = 0;

    for (let side = -1; side <= 1; side += 2) {
      const sideGroup = el('g');
      const sidePolys = [];
      const sideBeam = el('polyline', {fill:'none', stroke: side < 0 ? '#b89040' : '#b89040', 'stroke-width':'2.5', 'stroke-linecap':'round'});
      const sideLE = el('polyline', {fill:'none', stroke:'rgba(200,180,120,0.5)', 'stroke-width':'1'});
      const sideTE = el('polyline', {fill:'none', stroke:'rgba(200,180,120,0.5)', 'stroke-width':'1'});

      for (let i = 0; i < NUM_SPAN; i++) {
        const p = el('polygon', {'stroke-width':'0.5'});
        sidePolys.push(p);
        sideGroup.appendChild(p);
      }
      sideGroup.appendChild(sideLE);
      sideGroup.appendChild(sideTE);
      sideGroup.appendChild(sideBeam);
      wingGroup.appendChild(sideGroup);

      wingPolys.push(sidePolys);
      beamLines.push(sideBeam);
      leLines.push(sideLE);
      teLines.push(sideTE);
    }
  }

  function updateWingStations(stations, sideIdx) {
    const polys = wingPolys[sideIdx];
    for (let i = 0; i < NUM_SPAN; i++) {
      const s0 = stations[i], s1 = stations[i+1];
      const pts = `${s0.le.x},${s0.le.y} ${s1.le.x},${s1.le.y} ${s1.te.x},${s1.te.y} ${s0.te.x},${s0.te.y}`;
      const isFlex = s0.isFlex || s1.isFlex;
      const flexRatio = s0.isFlex && s1.isFlex ? 1 : (s0.isFlex || s1.isFlex ? 0.5 : 0);

      let fill, stroke;
      if (flexRatio > 0.5) {
        // 柔性段 - 鲜明橙色
        const alpha = 0.55 + flexRatio * 0.2;
        fill = `rgba(255,107,53,${alpha})`;
        stroke = 'rgba(255,140,90,0.6)';
      } else if (flexRatio > 0) {
        // 过渡区
        fill = 'rgba(210,160,70,0.5)';
        stroke = 'rgba(200,170,90,0.4)';
      } else {
        // 刚性段 - 金色
        fill = 'rgba(180,140,55,0.4)';
        stroke = 'rgba(200,170,90,0.3)';
      }
      setAttrs(polys[i], {points: pts, fill, stroke});
    }

    // 主梁线
    const bmPts = stations.map(s => `${s.bm.x},${s.bm.y}`).join(' ');
    const isFlexSide = stations.some(s => s.isFlex);
    setAttrs(beamLines[sideIdx], {points: bmPts, stroke: isFlexSide ? '#c9a24c' : '#b89040'});

    // 前缘 / 后缘
    setAttrs(leLines[sideIdx], {points: stations.map(s => `${s.le.x},${s.le.y}`).join(' ')});
    setAttrs(teLines[sideIdx], {points: stations.map(s => `${s.te.x},${s.te.y}`).join(' ')});
  }

  // ========== 分割点标记 ==========
  let splitMarkers = [];
  function ensureSplitMarkers() {
    annotationGroup.querySelectorAll('.split-mark').forEach(e => e.remove());
    splitMarkers = [];
    for (let side = -1; side <= 1; side += 2) {
      const g = el('g', {'class':'split-mark'});
      const outerRing = el('circle', {r:'8', fill:'none', stroke:'#00d4ff', 'stroke-width':'1.5', opacity:'0.6', filter:'url(#glowSplit)'});
      const innerDot = el('circle', {r:'3', fill:'#00d4ff', opacity:'0.9'});
      const label = el('text', {'text-anchor': side < 0 ? 'end' : 'start', fill:'#00d4ff', 'font-size':'9', 'font-family':'IBM Plex Mono', opacity:'0.8'});
      label.textContent = '30% 分割点';
      g.appendChild(outerRing);
      g.appendChild(innerDot);
      g.appendChild(label);
      annotationGroup.appendChild(g);
      splitMarkers.push({g, outerRing, innerDot, label, side});
    }
  }

  function updateSplitMarkers(stationsL, stationsR) {
    const allStations = [stationsL, stationsR];
    splitMarkers.forEach((m, idx) => {
      const stations = allStations[idx];
      const splitIdx = Math.round(SPLIT_RATIO * NUM_SPAN);
      const s = stations[splitIdx];
      const lx = m.side < 0 ? -14 : 14;
      setAttrs(m.g, {transform: `translate(${s.bm.x},${s.bm.y})`});
      setAttrs(m.label, {x: lx, y: -14});
      // 脉冲效果
      const pulse = 0.6 + 0.4 * Math.sin(Date.now() * 0.004);
      setAttrs(m.outerRing, {opacity: pulse * 0.7, r: 7 + pulse * 3});
    });
  }

  // ========== 机身 ==========
  function drawFuselage() {
    fuselageGroup.innerHTML = '';
    // 机身椭圆
    const body = el('ellipse', {cx:'0', cy:'0', rx:'28', ry:'50', fill:'rgba(60,80,100,0.6)', stroke:'rgba(120,150,180,0.5)', 'stroke-width':'1.5'});
    fuselageGroup.appendChild(body);
    // 电机指示
    const motorCircle = el('circle', {cx:'0', cy:'-8', r:'10', fill:'none', stroke:'rgba(160,185,210,0.6)', 'stroke-width':'1', 'stroke-dasharray':'3,2'});
    fuselageGroup.appendChild(motorCircle);
    const motorDot = el('circle', {cx:'0', cy:'-8', r:'3', fill:'#a0b8d0'});
    fuselageGroup.appendChild(motorDot);
    // 电机标签
    const motorLabel = el('text', {x:'0', y:'22', 'text-anchor':'middle', fill:'#7b9ab8', 'font-size':'9', 'font-family':'IBM Plex Mono'});
    motorLabel.textContent = '直流电机';
    fuselageGroup.appendChild(motorLabel);
  }

  // ========== 曲柄摇杆机构绘制 ==========
  let mechElements = {};
  function ensureMechElements() {
    mechContent.innerHTML = '';
    // 固定铰链
    const o1 = el('circle', {cx:CR.O1.x, cy:CR.O1.y, r:'4', fill:'#5a7a95', stroke:'#8ab', 'stroke-width':'1'});
    const o2 = el('circle', {cx:CR.O2.x, cy:CR.O2.y, r:'4', fill:'#5a7a95', stroke:'#8ab', 'stroke-width':'1'});
    // 曲柄
    const crankLine = el('line', {stroke:'#c9a24c', 'stroke-width':'3', 'stroke-linecap':'round'});
    // 连杆
    const couplerLine = el('line', {stroke:'#7b8fa3', 'stroke-width':'2', 'stroke-linecap':'round', 'stroke-dasharray':'4,2'});
    // 摇臂
    const rockerLine = el('line', {stroke:'#00d4ff', 'stroke-width':'2.5', 'stroke-linecap':'round'});
    // 运动点
    const crankPin = el('circle', {r:'3', fill:'#c9a24c'});
    const rockerPin = el('circle', {r:'3', fill:'#00d4ff'});
    // 电机旋转指示
    const motorArc = el('path', {fill:'none', stroke:'rgba(160,185,210,0.4)', 'stroke-width':'1', 'stroke-dasharray':'2,2'});
    // 标签
    const lCrank = el('text', {'font-size':'8', fill:'#c9a24c', 'font-family':'IBM Plex Mono'});
    lCrank.textContent = '曲柄';
    const lCoupler = el('text', {'font-size':'8', fill:'#7b8fa3', 'font-family':'IBM Plex Mono'});
    lCoupler.textContent = '连杆';
    const lRocker = el('text', {'font-size':'8', fill:'#00d4ff', 'font-family':'IBM Plex Mono'});
    lRocker.textContent = '摇臂';

    mechContent.append(o1, o2, motorArc, crankLine, couplerLine, rockerLine, crankPin, rockerPin, lCrank, lCoupler, lRocker);
    mechElements = {crankLine, couplerLine, rockerLine, crankPin, rockerPin, motorArc, lCrank, lCoupler, lRocker};
  }

  function updateMechanism(theta2) {
    const {O1, O2, r1} = CR;
    const res = computeCrankRocker(theta2);
    const {A, B} = res;

    setAttrs(mechElements.crankLine, {x1:O1.x, y1:O1.y, x2:A.x, y2:A.y});
    setAttrs(mechElements.couplerLine, {x1:A.x, y1:A.y, x2:B.x, y2:B.y});
    setAttrs(mechElements.rockerLine, {x1:O2.x, y1:O2.y, x2:B.x, y2:B.y});
    setAttrs(mechElements.crankPin, {cx:A.x, cy:A.y});
    setAttrs(mechElements.rockerPin, {cx:B.x, cy:B.y});

    // 电机旋转弧线
    const arcR = r1 + 5;
    const a1 = theta2 - 0.5, a2 = theta2;
    const ax1 = O1.x + arcR * Math.cos(a1), ay1 = O1.y + arcR * Math.sin(a1);
    const ax2 = O1.x + arcR * Math.cos(a2), ay2 = O1.y + arcR * Math.sin(a2);
    setAttrs(mechElements.motorArc, {d: `M${ax1},${ay1} A${arcR},${arcR} 0 0 1 ${ax2},${ay2}`});

    // 标签位置
    setAttrs(mechElements.lCrank, {x: (O1.x+A.x)/2 - 2, y: (O1.y+A.y)/2 - 5});
    setAttrs(mechElements.lCoupler, {x: (A.x+B.x)/2 + 3, y: (A.y+B.y)/2 - 5});
    setAttrs(mechElements.lRocker, {x: (O2.x+B.x)/2 + 3, y: (O2.y+B.y)/2 - 5});
  }

  // ========== 翼型截面绘制 ==========
  let sectionEls = {};
  function ensureSectionElements() {
    sectionContent.innerHTML = '';
    // 参考线(拍打平面)
    const refLine = el('line', {x1:'-70', y1:'0', x2:'70', y2:'0', stroke:'rgba(100,140,180,0.3)', 'stroke-width':'1', 'stroke-dasharray':'4,3'});
    sectionContent.appendChild(refLine);
    // 弦线
    const chordLine = el('line', {x1:'-50', y1:'0', x2:'50', y2:'0', stroke:'#ff6b35', 'stroke-width':'2', 'stroke-linecap':'round'});
    sectionContent.appendChild(chordLine);
    // 翼型轮廓
    const airfoil = el('path', {fill:'rgba(255,107,53,0.2)', stroke:'#ff6b35', 'stroke-width':'1.5'});
    sectionContent.appendChild(airfoil);
    // 攻角弧线
    const aoaArc = el('path', {fill:'none', stroke:'#00d4ff', 'stroke-width':'1.5'});
    sectionContent.appendChild(aoaArc);
    // 攻角标签
    const aoaLabel = el('text', {'text-anchor':'middle', fill:'#00d4ff', 'font-size':'11', 'font-family':'IBM Plex Mono', 'font-weight':'500'});
    sectionContent.appendChild(aoaLabel);
    // 预扭标记
    const pretwistLabel = el('text', {'text-anchor':'middle', fill:'rgba(255,107,53,0.6)', 'font-size':'9', 'font-family':'IBM Plex Mono'});
    sectionContent.appendChild(pretwistLabel);
    // 气动力箭头
    const aeroArr = el('line', {stroke:'#00d4ff', 'stroke-width':'2', 'marker-end':'url(#arrowAero)'});
    sectionContent.appendChild(aeroArr);
    // 标签
    const leLabel = el('text', {'text-anchor':'middle', fill:'rgba(200,180,120,0.6)', 'font-size':'8', 'font-family':'IBM Plex Mono'});
    leLabel.textContent = '前缘';
    sectionContent.appendChild(leLabel);
    const teLabel = el('text', {'text-anchor':'middle', fill:'rgba(200,180,120,0.6)', 'font-size':'8', 'font-family':'IBM Plex Mono'});
    teLabel.textContent = '后缘';
    sectionContent.appendChild(teLabel);

    sectionEls = {chordLine, airfoil, aoaArc, aoaLabel, pretwistLabel, aeroArr, leLabel, teLabel};
  }

  function updateSection(twistDeg, isDownstroke, flightSpd) {
    const totalTwistRad = (PRETWIST_DEG + twistDeg) * DEG;
    const ct = Math.cos(totalTwistRad), st = Math.sin(totalTwistRad);

    // 翼型截面形状(简化 NACA 对称翼型)
    const chord = 50;
    const le = {x: -chord * 0.5, y: 0};
    const te = {x: chord * 0.5, y: 0};
    const thickness = 8;

    // 旋转翼型
    function rot(px, py) {
      return {x: px * ct - py * st, y: px * st + py * ct};
    }

    // 翼型轮廓点
    const profilePts = [];
    const nPts = 20;
    for (let i = 0; i <= nPts; i++) {
      const t = i / nPts;
      const x = le.x + (te.x - le.x) * t;
      // NACA 0012 厚度分布(简化)
      const xt = t;
      const yt = thickness * (0.2969 * Math.sqrt(Math.max(0,xt)) - 0.1260*xt - 0.3516*xt*xt + 0.2843*xt*xt*xt - 0.1015*xt*xt*xt*xt);
      const r = rot(x, yt);
      profilePts.push(`${r.x},${r.y}`);
    }
    for (let i = nPts; i >= 0; i--) {
      const t = i / nPts;
      const x = le.x + (te.x - le.x) * t;
      const xt = t;
      const yt = -thickness * (0.2969 * Math.sqrt(Math.max(0,xt)) - 0.1260*xt - 0.3516*xt*xt + 0.2843*xt*xt*xt - 0.1015*xt*xt*xt*xt);
      const r = rot(x, yt);
      profilePts.push(`${r.x},${r.y}`);
    }
    setAttrs(sectionEls.airfoil, {d: 'M' + profilePts.join(' L') + ' Z'});

    // 弦线
    const rLe = rot(le.x, le.y);
    const rTe = rot(te.x, te.y);
    setAttrs(sectionEls.chordLine, {x1: rLe.x, y1: rLe.y, x2: rTe.x, y2: rTe.y});

    // 攻角弧线
    const aoaDeg = (PRETWIST_DEG + twistDeg);
    const arcR = 35;
    const startAngle = 0;
    const endAngle = -totalTwistRad;
    const arcStart = {x: arcR, y: 0};
    const arcEnd = {x: arcR * Math.cos(endAngle), y: -arcR * Math.sin(endAngle)};
    const largeArc = Math.abs(aoaDeg) > 180 ? 1 : 0;
    const sweep = aoaDeg > 0 ? 0 : 1;
    setAttrs(sectionEls.aoaArc, {d: `M${arcStart.x},${arcStart.y} A${arcR},${arcR} 0 ${largeArc},${sweep} ${arcEnd.x},${arcEnd.y}`});

    // 攻角标签
    const labelAngle = -totalTwistRad / 2;
    setAttrs(sectionEls.aoaLabel, {
      x: (arcR + 14) * Math.cos(labelAngle),
      y: -(arcR + 14) * Math.sin(labelAngle),
    });
    sectionEls.aoaLabel.textContent = `${aoaDeg.toFixed(1)}°`;

    // 预扭标签
    sectionEls.pretwistLabel.textContent = `预扭 ${PRETWIST_DEG}°`;
    setAttrs(sectionEls.pretwistLabel, {x: 0, y: 55});

    // 气动力箭头
    const arrowLen = 30 * flightSpd;
    const aeroDir = isDownstroke ? 1 : -1;
    const aeroX = rTe.x;
    const aeroY = rTe.y;
    setAttrs(sectionEls.aeroArr, {
      x1: aeroX, y1: aeroY,
      x2: aeroX, y2: aeroY + aeroDir * arrowLen,
      opacity: Math.min(1, flightSpd * 0.8)
    });

    // 前后缘标签
    setAttrs(sectionEls.leLabel, {x: rLe.x, y: rLe.y - 10});
    setAttrs(sectionEls.teLabel, {x: rTe.x, y: rTe.y - 10});
  }

  // ========== 气动力箭头(主视图) ==========
  let aeroArrows = [];
  function ensureAeroArrows() {
    aeroGroup.innerHTML = '';
    aeroArrows = [];
    for (let side = -1; side <= 1; side += 2) {
      const arrows = [];
      for (let j = 0; j < 3; j++) {
        const line = el('line', {stroke:'#00d4ff', 'stroke-width':'2', 'marker-end':'url(#arrowAero)', opacity:'0'});
        aeroGroup.appendChild(line);
        arrows.push(line);
      }
      aeroArrows.push(arrows);
    }
  }

  function updateAeroArrows(stationsL, stationsR, isDownstroke, flightSpd) {
    const allStations = [stationsL, stationsR];
    const dir = isDownstroke ? 1 : -1;
    aeroArrows.forEach((arrows, sideIdx) => {
      const stations = allStations[sideIdx];
      // 在柔性段取3个位置
      for (let j = 0; j < 3; j++) {
        const spanT = SPLIT_RATIO + (1 - SPLIT_RATIO) * (j + 1) / 4;
        const idx = Math.round(spanT * NUM_SPAN);
        const s = stations[Math.min(idx, NUM_SPAN)];
        const arrowLen = 25 + 20 * flightSpd;
        const opacity = Math.min(0.85, flightSpd * 0.6);
        setAttrs(arrows[j], {
          x1: s.te.x, y1: s.te.y,
          x2: s.te.x + dir * 0, y2: s.te.y + dir * arrowLen,
          opacity
        });
      }
    });
  }

  // ========== 阶段与标注文字 ==========
  function updateAnnotations(flapDeg, twistDeg, isDownstroke, flightSpd) {
    const phaseText = document.getElementById('phaseText');
    if (isDownstroke) {
      phaseText.textContent = '▼ 下扑 — 气动力下压后缘 → 正攻角增大';
      phaseText.setAttribute('fill', '#ff6b35');
    } else {
      phaseText.textContent = '▲ 上扑 — 气动力上抬后缘 → 负攻角产生';
      phaseText.setAttribute('fill', '#00d4ff');
    }
    if (flightSpd < 0.3) {
      phaseText.textContent = '⚠ 飞行速度过低 — 气动力不足,扭转退化';
      phaseText.setAttribute('fill', '#ff4757');
    }

    // 状态读数
    document.getElementById('statFlap').textContent = `拍打角  ${flapDeg >= 0 ? '+' : ''}${flapDeg.toFixed(1)}°`;
    document.getElementById('statTwist').textContent = `附加扭转  ${twistDeg >= 0 ? '+' : ''}${twistDeg.toFixed(1)}°`;
    const aoa = PRETWIST_DEG + twistDeg;
    document.getElementById('statAoA').textContent = `有效攻角  ${aoa >= 0 ? '+' : ''}${aoa.toFixed(1)}°`;
    document.getElementById('statPhase').textContent = isDownstroke ? '阶段  下扑' : '阶段  上扑';
  }

  // ========== 初始化 ==========
  ensureWingElements();
  ensureSplitMarkers();
  drawFuselage();
  ensureMechElements();
  ensureSectionElements();
  ensureAeroArrows();

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

    if (playing) {
      crankAngle += motorSpeed * 2.8 * dt;
    }

    // 拍打角(由曲柄摇杆产生)
    const flapDeg = MAX_FLAP_DEG * Math.sin(crankAngle);
    const flapVelDeg = MAX_FLAP_DEG * Math.cos(crankAngle); // 角速度

    // 自适应扭转角(核心 IFR:气动力 + 惯性力 → 被动扭转)
    // 下扑时 flapVelDeg < 0 → 扭转为正(增大攻角)
    // 上扑时 flapVelDeg > 0 → 扭转为负(减小/反转攻角)
    // 幅度与飞行速度正相关
    const twistDeg = -flapVelDeg * flightSpeed * 0.45;
    const isDownstroke = flapVelDeg < 0;

    // 计算两侧机翼几何
    const stationsR = computeWingStations(1, flapDeg, twistDeg);
    const stationsL = computeWingStations(-1, flapDeg, twistDeg);

    // 更新机翼渲染
    updateWingStations(stationsL, 0);
    updateWingStations(stationsR, 1);

    // 分割点标记
    updateSplitMarkers(stationsL, stationsR);

    // 机构
    updateMechanism(crankAngle);

    // 截面
    updateSection(twistDeg, isDownstroke, flightSpeed);

    // 气动力箭头
    updateAeroArrows(stationsL, stationsR, isDownstroke, flightSpeed);

    // 标注
    updateAnnotations(flapDeg, twistDeg, isDownstroke, flightSpeed);

    requestAnimationFrame(animate);
  }

  requestAnimationFrame(animate);

  // ========== 控件交互 ==========
  const motorSlider = document.getElementById('motorSlider');
  const flightSlider = document.getElementById('flightSlider');
  const motorVal = document.getElementById('motorVal');
  const flightVal = document.getElementById('flightVal');
  const playBtn = document.getElementById('playBtn');
  const resetBtn = document.getElementById('resetBtn');

  motorSlider.addEventListener('input', () => {
    motorSpeed = parseFloat(motorSlider.value);
    motorVal.textContent = motorSpeed.toFixed(1) + 'x';
  });

  flightSlider.addEventListener('input', () => {
    flightSpeed = parseFloat(flightSlider.value);
    flightVal.textContent = flightSpeed.toFixed(1) + 'x';
  });

  playBtn.addEventListener('click', () => {
    playing = !playing;
    playBtn.textContent = playing ? '▶ 运行' : '❚❚ 暂停';
    playBtn.classList.toggle('active', playing);
    if (playing) lastTime = 0;
  });

  resetBtn.addEventListener('click', () => {
    crankAngle = 0;
    motorSpeed = 1.0;
    flightSpeed = 1.0;
    motorSlider.value = 1;
    flightSlider.value = 1;
    motorVal.textContent = '1.0x';
    flightVal.textContent = '1.0x';
    playing = true;
    playBtn.textContent = '▶ 运行';
    playBtn.classList.add('active');
    lastTime = 0;
  });

})();
</script>
</body>
</html>

实现说明

核心设计思路

动画聚焦于 IFR 的核心思想——单一动力输入如何无额外机构地实现拍打+自适应扭转,通过三层可视化呈现:

  1. 3D 透视机翼渲染:以微俯视正交投影渲染两侧机翼,每侧机翼沿展向分为 18 个站点,逐站计算拍打旋转和局部扭转,再投影到 2D。刚性段(金色)与柔性段(橙色)色彩鲜明区分,30% 分割点以脉冲辉光标记。

  2. 曲柄摇杆机构面板(左上):实时展示电机旋转 → 曲柄 → 连杆 → 摇臂的运动链,直观说明"连续旋转如何转化为周期拍打"。

  3. 外翼截面扭转面板(右下):以旋转的 NACA 翼型截面直观显示有效攻角变化,标注预扭角 15°、实时攻角数值和气动力方向箭头。

关键机理的视觉引导

  • 气动力箭头:在柔性段后缘绘制青色箭头,下扑时指向下方(压后缘 → 正攻角),上扑时指向上方(抬后缘 → 负攻角),箭头长度随飞行速度缩放
  • 阶段指示:顶部实时显示"下扑—正攻角增大"/"上扑—负攻角产生"
  • 失效提示:当飞行速度滑块拉至极低时,箭头消失,扭转退化,显示红色警告"气动力不足,扭转退化"

交互控制

  • 电机转速滑块:改变拍打频率,同时影响扭转幅度(惯性力变化)
  • 飞行速度滑块:直接改变气动力大小,扭转幅度随之自适应调整——这是理解 IFR 的关键交互
  • 播放/暂停复位按钮
积分规则:第一轮对话扣减6分,后续每轮扣4分