分享图
动画工坊
引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>折纸伞骨 · SMA驱动折叠原理</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
:root {
  --bg: #060a12;
  --fg: #d4dce8;
  --muted: #4a5e78;
  --accent-hot: #ff5722;
  --accent-cold: #00e5a0;
  --card: #0b1221;
  --border: #162040;
  --rib: #8fa8c8;
  --fabric: rgba(55, 85, 170, 0.14);
}
*{margin:0;padding:0;box-sizing:border-box;}
body{
  background:var(--bg);
  color:var(--fg);
  font-family:'DM Mono',monospace;
  min-height:100vh;
  display:flex;flex-direction:column;align-items:center;
  overflow-x:hidden;
  background-image:
    radial-gradient(ellipse 80% 60% at 50% 30%, rgba(0,229,160,0.03) 0%, transparent 70%),
    radial-gradient(ellipse 60% 40% at 70% 70%, rgba(255,87,34,0.02) 0%, transparent 60%);
}
header{
  width:100%;max-width:1100px;
  padding:28px 32px 8px;
  display:flex;flex-direction:column;gap:4px;
}
header h1{
  font-family:'Syne',sans-serif;
  font-weight:800;font-size:clamp(22px,3vw,32px);
  letter-spacing:-0.5px;
  background:linear-gradient(135deg,#e0e8f4 40%,var(--accent-cold));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
header p{
  font-size:clamp(11px,1.3vw,13px);color:var(--muted);
  letter-spacing:0.5px;
}
main{
  width:100%;max-width:1100px;
  padding:12px 24px 32px;
  display:flex;flex-direction:column;gap:16px;
}
.svg-wrap{
  width:100%;aspect-ratio:16/8.5;
  background:var(--card);
  border:1px solid var(--border);
  border-radius:14px;
  overflow:hidden;position:relative;
}
.svg-wrap svg{width:100%;height:100%;display:block;}
.panels{
  display:grid;
  grid-template-columns:1fr 1fr;
  gap:14px;
}
@media(max-width:700px){.panels{grid-template-columns:1fr;}}
.panel{
  background:var(--card);
  border:1px solid var(--border);
  border-radius:12px;
  padding:16px 18px;
  position:relative;overflow:hidden;
}
.panel-title{
  font-family:'Syne',sans-serif;
  font-weight:700;font-size:13px;
  color:var(--muted);
  text-transform:uppercase;letter-spacing:1.2px;
  margin-bottom:12px;
}
.panel svg{width:100%;height:auto;display:block;}
.status-grid{
  display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;
}
.status-item{display:flex;flex-direction:column;gap:4px;}
.status-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;}
.status-value{font-size:18px;font-weight:500;font-family:'Syne',sans-serif;}
.status-value.hot{color:var(--accent-hot);}
.status-value.cold{color:var(--accent-cold);}
.status-value.neutral{color:var(--fg);}
.phase-bar{
  margin-top:14px;
  display:flex;gap:4px;height:4px;border-radius:2px;overflow:hidden;
}
.phase-seg{flex:1;border-radius:2px;opacity:0.25;transition:opacity .3s;}
.phase-seg.active{opacity:1;}
.ctrl-row{
  display:flex;align-items:center;gap:14px;
  padding:0 4px;
}
.ctrl-label{
  font-size:11px;color:var(--muted);white-space:nowrap;
  min-width:90px;
}
input[type=range]{
  flex:1;-webkit-appearance:none;appearance:none;
  height:6px;border-radius:3px;
  background:var(--border);outline:none;
  cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;appearance:none;
  width:18px;height:18px;border-radius:50%;
  background:var(--accent-cold);border:2px solid var(--bg);
  box-shadow:0 0 10px rgba(0,229,160,0.4);
  transition:background .2s;
}
input[type=range].heated::-webkit-slider-thumb{
  background:var(--accent-hot);
  box-shadow:0 0 10px rgba(255,87,34,0.4);
}
.ctrl-btn{
  width:36px;height:36px;border-radius:8px;
  border:1px solid var(--border);background:var(--card);
  color:var(--fg);cursor:pointer;
  display:flex;align-items:center;justify-content:center;
  font-size:14px;transition:all .2s;
}
.ctrl-btn:hover{border-color:var(--accent-cold);color:var(--accent-cold);}
.legend{
  display:flex;gap:18px;flex-wrap:wrap;
  padding:4px 4px 0;
}
.legend-item{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--muted);}
.legend-dot{width:8px;height:8px;border-radius:50%;}
</style>
</head>
<body>

<header>
  <h1>折纸伞骨 · SMA 驱动折叠原理</h1>
  <p>三浦折叠拓扑折痕 + NiTi 形状记忆合金丝 — 压缩比 &gt; 15 : 1</p>
</header>

<main>
  <!-- 主动画 -->
  <div class="svg-wrap">
    <svg id="mainSvg" viewBox="0 0 1000 530" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <!-- SMA 冷态发光 -->
        <filter id="glowCold" x="-80%" y="-80%" width="260%" height="260%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="b"/>
          <feFlood flood-color="#00e5a0" flood-opacity="0.6" result="c"/>
          <feComposite in="c" in2="b" operator="in" result="d"/>
          <feMerge><feMergeNode in="d"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <!-- SMA 热态发光 -->
        <filter id="glowHot" x="-80%" y="-80%" width="260%" height="260%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"/>
          <feFlood flood-color="#ff5722" flood-opacity="0.7" result="c"/>
          <feComposite in="c" in2="b" operator="in" result="d"/>
          <feMerge><feMergeNode in="d"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <!-- 折痕发光 -->
        <filter id="creaseGlow" x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur in="SourceGraphic" stdDeviation="2" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <!-- 伞面渐变 -->
        <radialGradient id="fabricGrad" cx="50%" cy="40%" r="55%">
          <stop offset="0%" stop-color="rgba(70,110,200,0.18)"/>
          <stop offset="100%" stop-color="rgba(40,65,140,0.06)"/>
        </radialGradient>
        <!-- 背景网格 -->
        <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
          <path d="M40 0L0 0 0 40" fill="none" stroke="rgba(30,50,90,0.15)" stroke-width="0.5"/>
        </pattern>
      </defs>
      <!-- 背景网格 -->
      <rect width="1000" height="530" fill="url(#grid)"/>
    </svg>
  </div>

  <!-- 下方面板 -->
  <div class="panels">
    <!-- 折痕细节面板 -->
    <div class="panel">
      <div class="panel-title">折痕拓扑细节</div>
      <svg id="detailSvg" viewBox="0 0 480 170" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <filter id="detailGlow" x="-60%" y="-60%" width="220%" height="220%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b"/>
            <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
          </filter>
        </defs>
      </svg>
    </div>
    <!-- 状态面板 -->
    <div class="panel">
      <div class="panel-title">系统状态</div>
      <div class="status-grid">
        <div class="status-item">
          <span class="status-label">运行阶段</span>
          <span id="phaseText" class="status-value neutral">展开</span>
        </div>
        <div class="status-item">
          <span class="status-label">SMA 温度</span>
          <span id="tempText" class="status-value cold">25°C</span>
        </div>
        <div class="status-item">
          <span class="status-label">压缩比</span>
          <span id="ratioText" class="status-value neutral">1 : 1</span>
        </div>
      </div>
      <div class="phase-bar" id="phaseBar">
        <div class="phase-seg" style="background:var(--accent-cold)"></div>
        <div class="phase-seg" style="background:var(--accent-hot)"></div>
        <div class="phase-seg" style="background:var(--accent-hot)"></div>
        <div class="phase-seg" style="background:var(--accent-cold)"></div>
      </div>
      <div class="legend" style="margin-top:16px;">
        <div class="legend-item"><div class="legend-dot" style="background:var(--accent-cold)"></div>SMA 冷态 (25°C)</div>
        <div class="legend-item"><div class="legend-dot" style="background:var(--accent-hot)"></div>SMA 热态 (45°C)</div>
        <div class="legend-item"><div class="legend-dot" style="background:var(--rib)"></div>伞骨复合基材</div>
        <div class="legend-item"><div class="legend-dot" style="background:rgba(70,110,200,0.5)"></div>伞面织物</div>
      </div>
    </div>
  </div>

  <!-- 控制滑块 -->
  <div class="ctrl-row">
    <span class="ctrl-label"><i class="fa-solid fa-sliders" style="margin-right:6px"></i>折叠控制</span>
    <input type="range" id="foldSlider" min="0" max="1000" value="0">
    <button class="ctrl-btn" id="playBtn" title="播放/暂停"><i class="fa-solid fa-pause"></i></button>
    <button class="ctrl-btn" id="resetBtn" title="重置"><i class="fa-solid fa-rotate-left"></i></button>
  </div>
</main>

<script>
/* ============================
   折纸伞骨 SMA 驱动折叠原理动画
   ============================ */

// ---- 常量 ----
const CX = 500, CY = 185;          // 伞顶中心
const SHAFT_LEN = 260;              // 伞柄长度
const RIB_OPEN_LEN = 210;           // 伞骨展开长度
const COMPRESSION = 15;             // 压缩比
const NUM_FOLDS = 10;               // 每根伞骨折痕段数
const PERSP_Y = 0.42;              // 透视 Y 压缩

// 伞骨配置:侧视图可见的6根伞骨(左右各3根)
const RIBS = [
  { side: 1, baseAngle: 68, lenFactor: 1.0 },
  { side: 1, baseAngle: 52, lenFactor: 0.95 },
  { side: 1, baseAngle: 36, lenFactor: 0.88 },
  { side:-1, baseAngle: 68, lenFactor: 1.0 },
  { side:-1, baseAngle: 52, lenFactor: 0.95 },
  { side:-1, baseAngle: 36, lenFactor: 0.88 },
];

// 动画周期(秒)
const T_OPEN = 2.0;
const T_FOLD = 3.5;
const T_CLOSED = 1.8;
const T_UNFOLD = 3.5;
const CYCLE = T_OPEN + T_FOLD + T_CLOSED + T_UNFOLD;

// ---- 状态 ----
let animTime = 0;
let playing = true;
let manualMode = false;
let manualFold = 0;
let lastFrame = 0;
let resumeTimer = null;

// ---- DOM 引用 ----
const mainSvg = document.getElementById('mainSvg');
const detailSvg = document.getElementById('detailSvg');
const foldSlider = document.getElementById('foldSlider');
const playBtn = document.getElementById('playBtn');
const resetBtn = document.getElementById('resetBtn');
const phaseText = document.getElementById('phaseText');
const tempText = document.getElementById('tempText');
const ratioText = document.getElementById('ratioText');
const phaseBar = document.getElementById('phaseBar');

// ---- 工具函数 ----
function lerp(a, b, t) { return a + (b - a) * t; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeInOut(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
function easeOut(t) { return 1 - (1 - t) * (1 - t); }

// 颜色插值(hex)
function lerpColor(c1, c2, t) {
  const r1 = parseInt(c1.slice(1,3),16), g1 = parseInt(c1.slice(3,5),16), b1 = parseInt(c1.slice(5,7),16);
  const r2 = parseInt(c2.slice(1,3),16), g2 = parseInt(c2.slice(3,5),16), b2 = parseInt(c2.slice(5,7),16);
  const r = Math.round(lerp(r1,r2,t)), g = Math.round(lerp(g1,g2,t)), b = Math.round(lerp(b1,b2,t));
  return `rgb(${r},${g},${b})`;
}

// 创建 SVG 元素
function svgEl(tag, attrs) {
  const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
  for (const [k,v] of Object.entries(attrs)) el.setAttribute(k, v);
  return el;
}

// ---- 动画状态计算 ----
function getState(t) {
  const phase = t % CYCLE;
  let foldAmt = 0, smaHeat = 0, phaseIdx = 0;

  if (phase < T_OPEN) {
    // 展开保持
    foldAmt = 0; smaHeat = 0; phaseIdx = 0;
  } else if (phase < T_OPEN + T_FOLD) {
    // 折叠中
    const p = easeInOut((phase - T_OPEN) / T_FOLD);
    foldAmt = p;
    smaHeat = Math.min(1, p * 1.8); // SMA 先热
    phaseIdx = 1;
  } else if (phase < T_OPEN + T_FOLD + T_CLOSED) {
    // 收纳保持
    foldAmt = 1; smaHeat = Math.max(0, 1 - (phase - T_OPEN - T_FOLD) / T_CLOSED * 1.2);
    phaseIdx = 2;
  } else {
    // 展开中
    const p = easeInOut((phase - T_OPEN - T_FOLD - T_CLOSED) / T_UNFOLD);
    foldAmt = 1 - p;
    smaHeat = 0;
    phaseIdx = 3;
  }
  return { foldAmt, smaHeat, phaseIdx };
}

// ---- 伞骨路径计算 ----
function calcRibPoints(rib, foldAmt) {
  const side = rib.side;
  const openAngle = rib.baseAngle * Math.PI / 180;
  const closedAngle = 6 * Math.PI / 180;
  const angle = lerp(openAngle, closedAngle, foldAmt);
  const openLen = RIB_OPEN_LEN * rib.lenFactor;
  const closedLen = openLen / COMPRESSION;
  const ribLen = lerp(openLen, closedLen, foldAmt);

  // 方向(侧视:x 水平,y 向下)
  const dx = side * Math.sin(angle);
  const dy = Math.cos(angle);

  const pts = [[CX, CY]];
  const baseAmp = 1.5;
  const foldAmp = 13;
  const amp = lerp(baseAmp, foldAmp, foldAmt);
  // 折痕方向垂直于伞骨
  const px = -dy * side;
  const py = dx * side;

  for (let i = 1; i <= NUM_FOLDS; i++) {
    const frac = i / NUM_FOLDS;
    const cx2 = CX + ribLen * frac * dx;
    const cy2 = CY + ribLen * frac * dy;
    // 首尾不偏移,中间交替偏移
    const offset = (i > 0 && i < NUM_FOLDS) ? (i % 2 === 0 ? 1 : -1) * amp : 0;
    pts.push([cx2 + offset * px, cy2 + offset * py]);
  }
  return pts;
}

// ---- 主 SVG 绘制 ----
function drawMain(state) {
  // 移除之前动态内容(保留 defs 和背景)
  const keep = mainSvg.querySelectorAll('defs, rect');
  while (mainSvg.childNodes.length > keep.length + 1) {
    // 从后往前删,跳过 defs 和背景 rect
  }
  // 简单方式:标记动态组
  let dynGroup = mainSvg.querySelector('#dynMain');
  if (!dynGroup) {
    dynGroup = svgEl('g', { id: 'dynMain' });
    mainSvg.appendChild(dynGroup);
  }
  dynGroup.innerHTML = '';

  const { foldAmt, smaHeat } = state;
  const smaColor = lerpColor('#00e5a0', '#ff5722', smaHeat);
  const ribStroke = lerpColor('#8fa8c8', '#ffb89a', smaHeat * 0.5);

  // 1) 伞面织物(先画,在底层)
  const allRibTips = RIBS.map(r => {
    const pts = calcRibPoints(r, foldAmt);
    return pts[pts.length - 1];
  });
  // 伞面:连接所有肋尖端
  if (allRibTips.length >= 2) {
    // 按角度排序
    const sorted = [...allRibTips].sort((a, b) => a[0] - b[0]);
    // 左侧伞面
    const leftTips = sorted.filter(p => p[0] <= CX);
    const rightTips = sorted.filter(p => p[0] > CX);
    
    // 左侧面料
    if (leftTips.length >= 1) {
      const farthest = leftTips.reduce((a,b) => a[0] < b[0] ? a : b);
      let d = `M ${CX} ${CY}`;
      // 贝塞尔曲线到最远端
      const cpx = lerp(CX, farthest[0], 0.5);
      const cpy = lerp(CY, farthest[1], 0.3);
      d += ` Q ${cpx} ${cpy} ${farthest[0]} ${farthest[1]}`;
      // 下方弧线回来
      const bottomY = CY + (farthest[1] - CY) * 1.08;
      d += ` Q ${lerp(farthest[0], CX, 0.5)} ${bottomY} ${CX} ${CY + 15}`;
      d += ' Z';
      dynGroup.appendChild(svgEl('path', {
        d, fill: 'url(#fabricGrad)', stroke: 'rgba(70,110,200,0.12)', 'stroke-width': '0.8'
      }));
    }
    // 右侧面料
    if (rightTips.length >= 1) {
      const farthest = rightTips.reduce((a,b) => a[0] > b[0] ? a : b);
      let d = `M ${CX} ${CY}`;
      const cpx = lerp(CX, farthest[0], 0.5);
      const cpy = lerp(CY, farthest[1], 0.3);
      d += ` Q ${cpx} ${cpy} ${farthest[0]} ${farthest[1]}`;
      const bottomY = CY + (farthest[1] - CY) * 1.08;
      d += ` Q ${lerp(farthest[0], CX, 0.5)} ${bottomY} ${CX} ${CY + 15}`;
      d += ' Z';
      dynGroup.appendChild(svgEl('path', {
        d, fill: 'url(#fabricGrad)', stroke: 'rgba(70,110,200,0.12)', 'stroke-width': '0.8'
      }));
    }
  }

  // 2) 伞骨(Miura-ori 折痕 + SMA 指示点)
  RIBS.forEach((rib) => {
    const pts = calcRibPoints(rib, foldAmt);
    // 伞骨线
    const pl = pts.map(p => p.join(',')).join(' ');
    dynGroup.appendChild(svgEl('polyline', {
      points: pl, fill: 'none', stroke: ribStroke,
      'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
    }));
    // 折痕节点 SMA 指示点
    for (let i = 1; i < pts.length - 1; i++) {
      const r = lerp(2.2, 4, smaHeat);
      const dot = svgEl('circle', {
        cx: pts[i][0], cy: pts[i][1], r: r,
        fill: smaColor, opacity: lerp(0.5, 1, smaHeat)
      });
      if (smaHeat > 0.15) {
        dot.setAttribute('filter', smaHeat > 0.5 ? 'url(#glowHot)' : 'url(#glowCold)');
      }
      dynGroup.appendChild(dot);
    }
    // 折痕辅助线(半透明短线,表示折痕方向)
    if (foldAmt < 0.85) {
      for (let i = 1; i < pts.length - 1; i++) {
        const angle = rib.baseAngle * Math.PI / 180;
        const side = rib.side;
        const perpX = -Math.cos(angle) * side;
        const perpY = Math.sin(angle) * side;
        const markLen = lerp(3, 8, foldAmt);
        const x1 = pts[i][0] - perpX * markLen;
        const y1 = pts[i][1] - perpY * markLen;
        const x2 = pts[i][0] + perpX * markLen;
        const y2 = pts[i][1] + perpY * markLen;
        dynGroup.appendChild(svgEl('line', {
          x1, y1, x2, y2,
          stroke: smaColor, 'stroke-width': '0.8', opacity: lerp(0.25, 0.6, foldAmt),
          'stroke-dasharray': '2 2'
        }));
      }
    }
  });

  // 3) 伞柄
  const shaftBot = CY + SHAFT_LEN;
  dynGroup.appendChild(svgEl('line', {
    x1: CX, y1: CY, x2: CX, y2: shaftBot,
    stroke: '#3e506a', 'stroke-width': '3.5', 'stroke-linecap': 'round'
  }));
  // 手柄弯钩
  dynGroup.appendChild(svgEl('path', {
    d: `M ${CX} ${shaftBot} C ${CX-12} ${shaftBot+18}, ${CX-12} ${shaftBot+30}, ${CX} ${shaftBot+30}`,
    stroke: '#3e506a', 'stroke-width': '3.5', fill: 'none', 'stroke-linecap': 'round'
  }));
  // 顶端伞帽
  dynGroup.appendChild(svgEl('circle', {
    cx: CX, cy: CY, r: 5, fill: '#4a6080'
  }));

  // 4) 按钮指示(伞柄上的触发按钮)
  const btnY = shaftBot - 40;
  const btnGlow = smaHeat > 0.3 ? lerp(0, 0.8, smaHeat) : 0;
  dynGroup.appendChild(svgEl('rect', {
    x: CX - 6, y: btnY - 4, width: 12, height: 8, rx: 3,
    fill: smaHeat > 0.3 ? lerpColor('#2a3a50', '#ff5722', smaHeat) : '#2a3a50',
    stroke: smaHeat > 0.3 ? smaColor : '#4a6080', 'stroke-width': '1'
  }));
  if (btnGlow > 0) {
    dynGroup.appendChild(svgEl('rect', {
      x: CX - 8, y: btnY - 6, width: 16, height: 12, rx: 4,
      fill: 'none', stroke: smaColor, 'stroke-width': '0.8', opacity: btnGlow,
      filter: 'url(#glowHot)'
    }));
  }

  // 5) 标注文字
  const labelColor = `rgba(160,185,220,${lerp(0.35, 0.7, Math.max(foldAmt, 0.3))})`;
  // 压缩比标注
  if (foldAmt > 0.1) {
    const ratio = (1 + foldAmt * (COMPRESSION - 1)).toFixed(0);
    const tipRight = calcRibPoints(RIBS[0], foldAmt);
    const tip = tipRight[tipRight.length - 1];
    dynGroup.appendChild(svgEl('text', {
      x: tip[0] + 16, y: tip[1] - 8,
      fill: smaColor, 'font-size': '13', 'font-family': 'Syne, sans-serif', 'font-weight': '700'
    })).textContent = `${ratio}:1`;
  }

  // 6) 收纳状态长度标注线
  if (foldAmt > 0.5) {
    const openLenPx = RIB_OPEN_LEN;
    const closedLenPx = RIB_OPEN_LEN / COMPRESSION;
    const annotY = CY + 70;
    // 展开长度(虚线)
    dynGroup.appendChild(svgEl('line', {
      x1: CX, y1: annotY, x2: CX + openLenPx, y2: annotY,
      stroke: 'rgba(100,130,170,0.2)', 'stroke-width': '1', 'stroke-dasharray': '4 3'
    }));
    // 折叠长度(实线)
    const curLen = lerp(openLenPx, closedLenPx, foldAmt);
    dynGroup.appendChild(svgEl('line', {
      x1: CX, y1: annotY + 12, x2: CX + curLen, y2: annotY + 12,
      stroke: smaColor, 'stroke-width': '2'
    }));
    dynGroup.appendChild(svgEl('text', {
      x: CX + openLenPx + 8, y: annotY + 4,
      fill: 'rgba(100,130,170,0.3)', 'font-size': '9', 'font-family': 'DM Mono, monospace'
    })).textContent = '展开长度';
    dynGroup.appendChild(svgEl('text', {
      x: CX + curLen + 8, y: annotY + 16,
      fill: smaColor, 'font-size': '9', 'font-family': 'DM Mono, monospace'
    })).textContent = '折叠厚度';
  }
}

// ---- 细节面板绘制 ----
function drawDetail(state) {
  const { foldAmt, smaHeat } = state;
  let g = detailSvg.querySelector('#dynDetail');
  if (!g) {
    g = svgEl('g', { id: 'dynDetail' });
    detailSvg.appendChild(g);
  }
  g.innerHTML = '';

  const smaColor = lerpColor('#00e5a0', '#ff5722', smaHeat);
  const ribColor = lerpColor('#8fa8c8', '#ffb89a', smaHeat * 0.4);

  // 绘制一段 Miura-ori 折痕的侧视图
  const startX = 30, startY = 85;
  const numSeg = 8;
  const openSegW = 50;
  const closedSegW = openSegW / COMPRESSION * 2.5; // 视觉放大以便看清
  const segW = lerp(openSegW, closedSegW, foldAmt);
  const baseAmp = 3;
  const foldAmpH = 28;
  const amp = lerp(baseAmp, foldAmpH, foldAmt);

  const pts = [[startX, startY]];
  for (let i = 1; i <= numSeg; i++) {
    const x = startX + segW * i;
    const offset = (i % 2 === 0 ? 1 : -1) * amp;
    pts.push([x, startY + offset]);
  }

  // 伞骨基材(宽带)
  const bandW = lerp(14, 6, foldAmt);
  for (let i = 0; i < pts.length - 1; i++) {
    const p1 = pts[i], p2 = pts[i + 1];
    const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
    const len = Math.max(0.01, Math.sqrt(dx*dx + dy*dy));
    const nx = -dy / len * bandW / 2, ny = dx / len * bandW / 2;
    const path = `M ${p1[0]+nx} ${p1[1]+ny} L ${p2[0]+nx} ${p2[1]+ny} L ${p2[0]-nx} ${p2[1]-ny} L ${p1[0]-nx} ${p1[1]-ny} Z`;
    g.appendChild(svgEl('path', {
      d: path, fill: lerpColor('#1a2a44', '#2a1a14', smaHeat * 0.3),
      stroke: ribColor, 'stroke-width': '1', opacity: '0.85'
    }));
  }

  // 折痕线(中心线)
  const pl = pts.map(p => p.join(',')).join(' ');
  g.appendChild(svgEl('polyline', {
    points: pl, fill: 'none', stroke: ribColor,
    'stroke-width': '2', 'stroke-linejoin': 'round', 'stroke-linecap': 'round'
  }));

  // SMA 丝(沿折痕的发光线)
  g.appendChild(svgEl('polyline', {
    points: pl, fill: 'none', stroke: smaColor,
    'stroke-width': lerp(1.5, 3, smaHeat), 'stroke-linejoin': 'round',
    opacity: lerp(0.4, 1, Math.max(smaHeat, 0.3)),
    filter: smaHeat > 0.2 ? 'url(#detailGlow)' : ''
  }));

  // 折痕节点标记
  for (let i = 1; i < pts.length - 1; i++) {
    const r = lerp(2.5, 4.5, smaHeat);
    g.appendChild(svgEl('circle', {
      cx: pts[i][0], cy: pts[i][1], r,
      fill: smaColor, opacity: lerp(0.5, 1, smaHeat)
    }));
    // 山折/谷折标记
    const markType = i % 2 === 0 ? 'M' : 'V';
    g.appendChild(svgEl('text', {
      x: pts[i][0], y: pts[i][1] + (i % 2 === 0 ? -14 : 18),
      fill: 'rgba(140,170,210,0.4)', 'font-size': '8', 'text-anchor': 'middle',
      'font-family': 'DM Mono, monospace'
    })).textContent = markType;
  }

  // 标注
  const labelX = startX + segW * numSeg + 20;
  g.appendChild(svgEl('text', {
    x: labelX, y: startY - 20, fill: smaColor, 'font-size': '10',
    'font-family': 'DM Mono, monospace', 'font-weight': '500'
  })).textContent = `SMA 丝 ${smaHeat > 0.5 ? '● 收缩中' : '○ 松弛'}`;
  g.appendChild(svgEl('text', {
    x: labelX, y: startY, fill: 'rgba(140,170,210,0.5)', 'font-size': '9',
    'font-family': 'DM Mono, monospace'
  })).textContent = '聚酰亚胺 + 碳纤维';
  g.appendChild(svgEl('text', {
    x: labelX, y: startY + 16, fill: 'rgba(140,170,210,0.5)', 'font-size': '9',
    'font-family': 'DM Mono, monospace'
  })).textContent = '三浦折叠拓扑折痕';

  // 温度色条
  const barX = labelX, barY = startY + 32, barW = 80, barH = 6;
  g.appendChild(svgEl('rect', {
    x: barX, y: barY, width: barW, height: barH, rx: 3,
    fill: '#1a2a44', stroke: 'rgba(100,130,170,0.2)', 'stroke-width': '0.5'
  }));
  const fillW = Math.max(1, barW * smaHeat);
  g.appendChild(svgEl('rect', {
    x: barX, y: barY, width: fillW, height: barH, rx: 3,
    fill: smaColor
  }));
  g.appendChild(svgEl('text', {
    x: barX + barW + 6, y: barY + 5.5, fill: smaColor, 'font-size': '9',
    'font-family': 'DM Mono, monospace'
  })).textContent = `${lerp(25, 45, smaHeat).toFixed(0)}°C`;
}

// ---- 状态面板更新 ----
function updateStatus(state) {
  const { foldAmt, smaHeat, phaseIdx } = state;
  const phaseNames = ['展开', '折叠中', '收纳', '展开中'];
  const phaseClasses = ['neutral', 'hot', 'hot', 'cold'];
  phaseText.textContent = phaseNames[phaseIdx];
  phaseText.className = 'status-value ' + phaseClasses[phaseIdx];

  const temp = lerp(25, 45, smaHeat);
  tempText.textContent = `${temp.toFixed(1)}°C`;
  tempText.className = 'status-value ' + (smaHeat > 0.5 ? 'hot' : 'cold');

  const ratio = (1 + foldAmt * (COMPRESSION - 1));
  ratioText.textContent = `${ratio.toFixed(1)} : 1`;
  ratioText.className = 'status-value ' + (foldAmt > 0.5 ? 'hot' : 'neutral');

  // 相位条
  const segs = phaseBar.children;
  for (let i = 0; i < segs.length; i++) {
    segs[i].classList.toggle('active', i === phaseIdx);
  }

  // 滑块样式
  foldSlider.classList.toggle('heated', smaHeat > 0.4);
}

// ---- 动画循环 ----
function tick(now) {
  if (!lastFrame) lastFrame = now;
  const dt = (now - lastFrame) / 1000;
  lastFrame = now;

  if (playing && !manualMode) {
    animTime += dt;
  }

  const state = manualMode
    ? { foldAmt: manualFold, smaHeat: manualFold > 0.3 ? Math.min(1, manualFold * 1.5) : 0, phaseIdx: manualFold < 0.05 ? 0 : manualFold > 0.95 ? 2 : 1 }
    : getState(animTime);

  drawMain(state);
  drawDetail(state);
  updateStatus(state);

  if (!manualMode) {
    foldSlider.value = Math.round(state.foldAmt * 1000);
  }

  requestAnimationFrame(tick);
}

// ---- 交互 ----
foldSlider.addEventListener('input', () => {
  manualMode = true;
  manualFold = parseInt(foldSlider.value) / 1000;
  if (resumeTimer) clearTimeout(resumeTimer);
});
foldSlider.addEventListener('change', () => {
  resumeTimer = setTimeout(() => {
    manualMode = false;
    // 同步 animTime
    animTime = T_OPEN + manualFold * T_FOLD;
  }, 2500);
});

playBtn.addEventListener('click', () => {
  playing = !playing;
  playBtn.innerHTML = playing
    ? '<i class="fa-solid fa-pause"></i>'
    : '<i class="fa-solid fa-play"></i>';
});

resetBtn.addEventListener('click', () => {
  animTime = 0;
  manualMode = false;
  playing = true;
  foldSlider.value = 0;
  playBtn.innerHTML = '<i class="fa-solid fa-pause"></i>';
});

// ---- 启动 ----
requestAnimationFrame(tick);
</script>
</body>
</html>

这个动画实现了以下设计与功能要点:

IFR 理想解视觉呈现

  • 直接展示伞骨在 SMA 驱动下沿三浦折叠折痕自动"塌陷"与弹开的完整过程,无需与传统伞骨对比即可理解创新原理
  • 折痕线始终可见(即使是展开状态也有微弱锯齿),强调折痕拓扑是"内建于材料中的资源",SMA 仅提供触发收缩力——这正体现了 IFR"系统自行完成所需功能"的思想

视觉引导

  • SMA 丝在加热时从冷态青绿(#00e5a0)渐变为热态橙红(#ff5722),配合 SVG 滤镜发光效果,直观标识驱动源
  • 折痕节点处有发光指示点,折叠过程中振幅逐渐增大,视觉上"折痕被激活"
  • 压缩比数值跟随伞骨端点实时显示,底部还有展开长度/折叠厚度的对比标注线

交互控制

  • 滑块可手动控制折叠程度,松手 2.5 秒后自动恢复循环播放
  • 播放/暂停、重置按钮提供完整控制
  • 状态面板实时显示运行阶段、SMA 温度、压缩比

细节面板

  • 右下角展示一段伞骨的 Miura-ori 折痕侧视细节,标注了山折(M)/谷折(V)标记、SMA 丝状态、基材组成和实时温度色条
积分规则:第一轮对话扣减8分,后续每轮扣6分