这个动画需要展示扑翼机构中"单一动力输入 → 拍打+自适应扭转"的核心 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 的核心思想——单一动力输入如何无额外机构地实现拍打+自适应扭转,通过三层可视化呈现:
3D 透视机翼渲染:以微俯视正交投影渲染两侧机翼,每侧机翼沿展向分为 18 个站点,逐站计算拍打旋转和局部扭转,再投影到 2D。刚性段(金色)与柔性段(橙色)色彩鲜明区分,30% 分割点以脉冲辉光标记。
曲柄摇杆机构面板(左上):实时展示电机旋转 → 曲柄 → 连杆 → 摇臂的运动链,直观说明"连续旋转如何转化为周期拍打"。
外翼截面扭转面板(右下):以旋转的 NACA 翼型截面直观显示有效攻角变化,标注预扭角 15°、实时攻角数值和气动力方向箭头。
关键机理的视觉引导
- 气动力箭头:在柔性段后缘绘制青色箭头,下扑时指向下方(压后缘 → 正攻角),上扑时指向上方(抬后缘 → 负攻角),箭头长度随飞行速度缩放
- 阶段指示:顶部实时显示"下扑—正攻角增大"/"上扑—负攻角产生"
- 失效提示:当飞行速度滑块拉至极低时,箭头消失,扭转退化,显示红色警告"气动力不足,扭转退化"
交互控制
- 电机转速滑块:改变拍打频率,同时影响扭转幅度(惯性力变化)
- 飞行速度滑块:直接改变气动力大小,扭转幅度随之自适应调整——这是理解 IFR 的关键交互
- 播放/暂停与复位按钮
积分规则:第一轮对话扣减6分,后续每轮扣4分
等待动画代码生成...
