独立渲染引擎就绪引擎就绪
<!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 形状记忆合金丝 — 压缩比 > 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分
等待动画代码生成...
