分享图
A
动画渲染工坊
就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>面阵式伯努利吸盘 · 极速装盘原理动画</title>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@300;500;700&family=Noto+Sans+SC:wght@300;400;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#070b14;--surface:#0d1424;--border:#1a2540;--text:#d8e2f0;--muted:#6b7fa0;--cyan:#00d4e8;--amber:#f5a623;--emerald:#00c9a7;--rose:#ff4f6e;--purple:#a87bff;--conv:#141e35;--lane:#1c2d4a}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Noto Sans SC',sans-serif;min-height:100vh;display:flex;flex-direction:column;align-items:center}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse at 25% 30%,rgba(0,212,232,.04),transparent 55%),radial-gradient(ellipse at 75% 70%,rgba(0,201,167,.04),transparent 55%);pointer-events:none}
.wrap{position:relative;z-index:1;width:100%;max-width:1520px;padding:16px 20px}
.hdr{text-align:center;margin-bottom:12px}
.hdr h1{font-family:'Rajdhani',sans-serif;font-size:1.85rem;font-weight:700;letter-spacing:3px;background:linear-gradient(120deg,var(--cyan),var(--emerald));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.hdr .sub{font-size:.82rem;color:var(--muted);margin-top:2px;font-weight:300;letter-spacing:1px}
.svg-box{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:0 0 60px rgba(0,212,232,.04),inset 0 0 80px rgba(0,0,0,.3)}
.svg-box svg{display:block;width:100%;height:auto}
.panel{display:flex;gap:14px;margin-top:14px;flex-wrap:wrap}
.ctrl{display:flex;align-items:center;gap:12px;padding:12px 20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;flex:1;min-width:300px;flex-wrap:wrap}
.ctrl button{font-family:'Rajdhani',sans-serif;font-size:.88rem;font-weight:600;padding:7px 18px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;transition:all .2s;letter-spacing:1px}
.ctrl button:hover{border-color:var(--cyan);color:var(--cyan)}
.ctrl button.on{background:var(--cyan);color:var(--bg);border-color:var(--cyan)}
.ctrl label{font-size:.78rem;color:var(--muted);display:flex;align-items:center;gap:6px;white-space:nowrap}
.ctrl input[type=range]{width:100px;accent-color:var(--cyan);cursor:pointer}
.sval{font-family:'Rajdhani',sans-serif;font-weight:700;color:var(--cyan);min-width:28px;text-align:center}
.stats{display:flex;gap:18px;padding:10px 20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;flex:2;min-width:400px;flex-wrap:wrap;align-items:center}
.si{display:flex;align-items:center;gap:6px}
.sl{font-size:.72rem;color:var(--muted)}
.sv{font-family:'Rajdhani',sans-serif;font-size:1.05rem;font-weight:700}
.sv.c1{color:var(--cyan)}.sv.c2{color:var(--amber)}.sv.c3{color:var(--emerald)}.sv.c4{color:var(--rose)}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.on{background:var(--emerald);box-shadow:0 0 8px var(--emerald)}
.dot.off{background:var(--muted)}
@keyframes scanPulse{0%,100%{opacity:.3}50%{opacity:.9}}
</style>
</head>
<body>
<div class="wrap">
  <div class="hdr">
    <h1>BERNOULLI ARRAY PICKER</h1>
    <div class="sub">IFR 理想解 — 面阵式非接触吸附 · 8列并行 · 零停顿装盘</div>
  </div>
  <div class="svg-box"><svg id="cv" viewBox="0 0 1440 880" xmlns="http://www.w3.org/2000/svg"></svg></div>
  <div class="panel">
    <div class="ctrl">
      <button id="btnPlay" class="on">RUN</button>
      <button id="btnStep">STEP</button>
      <button id="btnReset">RESET</button>
      <label>Speed <input type="range" id="spd" min="0.2" max="3" step="0.1" value="1"><span class="sval" id="spdV">1.0x</span></label>
      <label>Gap <input type="range" id="gapR" min="1" max="3" step="0.5" value="1.5"><span class="sval" id="gapV">1.5mm</span></label>
    </div>
    <div class="stats">
      <div class="si"><div class="dot on" id="runDot"></div><span class="sl">状态</span><span class="sv c1" id="stPhase">IDLE</span></div>
      <div class="si"><span class="sl">行进度</span><span class="sv c2" id="stRow">0/8</span></div>
      <div class="si"><span class="sl">累计</span><span class="sv c3" id="stTotal">0 pcs</span></div>
      <div class="si"><span class="sl">等效节拍</span><span class="sv c4" id="stRate">—</span></div>
      <div class="si"><span class="sl">停顿</span><span class="sv c3" id="stStop">0s</span></div>
    </div>
  </div>
</div>

<script>
/* ============================================
   面阵式伯努利吸盘极速装盘 — 原理动画引擎
   ============================================ */
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.getElementById('cv');

/* ---- 工具函数 ---- */
function el(tag, attrs, parent) {
  const e = document.createElementNS(SVG_NS, tag);
  if (attrs) Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, v));
  if (parent) parent.appendChild(e);
  return e;
}
function lerp(a, b, t) { return a + (b - a) * t; }
function easeInOut(t) { return t < .5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2; }
function easeOut(t) { return 1 - Math.pow(1-t,3); }

/* ---- 布局常量 ---- */
const L = {
  // 输送带
  convX: 70, convY: 100, convW: 560, convH: 224,
  laneH: 24, laneGap: 4, numLanes: 8,
  // 视觉/翻转区
  visX: 240, visW: 28,
  flipX: 380, flipW: 28,
  // 吸盘模组 (8x8)
  modCellSize: 24, modCellGap: 4,
  // 模组在输送带终端的位置
  modConvX: 680, modConvY: 100,
  // 模组在Tray盘上方位置
  modTrayX: 1020, modTrayY: 100,
  // Tray盘
  trayX: 980, trayY: 380, trayW: 240, trayH: 240,
  trayCellSize: 26, trayCellGap: 4,
  // 时间线
  tlY: 700, tlH: 24, tlX: 70, tlW: 1300,
};

const modGridSize = L.numLanes * (L.modCellSize + L.modCellGap) - L.modCellGap;
const trayGridCols = 8, trayGridRows = 8;

/* ---- 颜色 ---- */
const C = {
  bg: '#080c18', grid: '#0d1525', surface: '#111b30',
  convBg: '#0e1728', lane: '#182844', laneLine: '#1e3458',
  mat: '#f5a623', matGlow: 'rgba(245,166,35,.5)',
  visZone: '#00d4e8', visBg: 'rgba(0,212,232,.08)',
  flipZone: '#a87bff', flipBg: 'rgba(168,123,255,.08)',
  modFrame: '#00c9a7', modCell: '#1a2e48', modActive: '#00e6be',
  modHead: '#2a4a6a', modHeadActive: '#00ffd0',
  trayBg: '#0c1520', trayCell: '#152238', trayFilled: '#f5a623',
  text: '#c8d8ec', muted: '#5a7090', accent: '#00d4e8',
  rose: '#ff4f6e', emerald: '#00c9a7',
  arrowFlow: 'rgba(0,212,232,.35)',
};

/* ---- 构建SVG ---- */
// defs
const defs = el('defs', null, svg);
// 网格背景图案
const pat = el('pattern', {id:'grid',width:40,height:40,patternUnits:'userSpaceOnUse'}, defs);
el('rect', {width:40,height:40,fill:C.bg}, pat);
el('line', {x1:0,y1:0,x2:0,y2:40,stroke:'#0e1830',strokeWidth:.5}, pat);
el('line', {x1:0,y1:0,x2:40,y2:0,stroke:'#0e1830',strokeWidth:.5}, pat);
// 发光滤镜
function makeGlow(id, color, std) {
  const f = el('filter', {id, x:'-50%',y:'-50%',width:'200%',height:'200%'}, defs);
  const gb = el('feGaussianBlur', {in:'SourceGraphic',stdDeviation:String(std),result:'blur'}, f);
  const cm = el('feColorMatrix', {in:'blur',type:'matrix',values:'1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 0.7 0',result:'glow'}, f);
  const merge = el('feMerge', null, f);
  el('feMergeNode', {in:'glow'}, merge);
  el('feMergeNode', {in:'SourceGraphic'}, merge);
}
makeGlow('glowCyan', C.visZone, 4);
makeGlow('glowAmber', C.mat, 3);
makeGlow('glowEmerald', C.modActive, 4);
makeGlow('glowRose', C.rose, 3);

// 背景层
const bgLayer = el('g', {id:'bg'}, svg);
el('rect', {width:1440,height:880,fill:'url(#grid)'}, bgLayer);

// 静态标注层
const labelLayer = el('g', {id:'labels'}, svg);

// 输送带
const convGroup = el('g', {id:'conv'}, svg);
el('rect', {x:L.convX, y:L.convY, width:L.convW, height:L.convH, rx:6, fill:C.convBg, stroke:C.laneLine,strokeWidth:1}, convGroup);
// 8条流道
for (let i = 0; i < L.numLanes; i++) {
  const ly = L.convY + 8 + i * (L.laneH + L.laneGap);
  el('rect', {x:L.convX+4, y:ly, width:L.convW-8, height:L.laneH, rx:3, fill:C.lane, opacity:.7}, convGroup);
}
// 视觉区
el('rect', {x:L.convX+L.visX-L.convX, y:L.convY-4, width:L.visW, height:L.convH+8, rx:3, fill:C.visBg, stroke:C.visZone,strokeWidth:1,strokeDasharray:'3,3',opacity:.7}, convGroup);
// 翻转区
el('rect', {x:L.convX+L.flipX-L.convX, y:L.convY-4, width:L.flipW, height:L.convH+8, rx:3, fill:C.flipBg, stroke:C.flipZone,strokeWidth:1,strokeDasharray:'3,3',opacity:.7}, convGroup);

// 流道箭头
for (let i = 0; i < L.numLanes; i++) {
  const ly = L.convY + 8 + i * (L.laneH + L.laneGap) + L.laneH/2;
  for (let ax = 100; ax < L.convW-40; ax += 80) {
    el('polygon', {points:`${L.convX+ax},${ly-3} ${L.convX+ax+8},${ly} ${L.convX+ax},${ly+3}`,fill:C.arrowFlow}, convGroup);
  }
}

// 标签
function addLabel(x, y, text, color, size, anchor) {
  const t = el('text', {x,y,fill:color||C.text,'font-family':"'Rajdhani',sans-serif",'font-size':size||13,'font-weight':600,'text-anchor':anchor||'start'}, labelLayer);
  t.textContent = text;
  return t;
}
function addLabelCN(x, y, text, color, size, anchor) {
  const t = el('text', {x,y,fill:color||C.muted,'font-family':"'Noto Sans SC',sans-serif",'font-size':size||11,'text-anchor':anchor||'start'}, labelLayer);
  t.textContent = text;
  return t;
}

addLabel(L.convX+10, L.convY-14, 'WIDE CONVEYOR', C.visZone, 13);
addLabelCN(L.convX+10, L.convY-2, '8列宽幅输送带 · 线扫视觉+瞬时翻板', C.muted, 10);
addLabel(L.convX+L.visX-L.convX+L.visW/2, L.convY+L.convH+20, 'VISION', C.visZone, 11, 'middle');
addLabelCN(L.convX+L.visX-L.convX+L.visW/2, L.convY+L.convH+34, '线扫识别', C.muted, 9, 'middle');
addLabel(L.convX+L.flipX-L.convX+L.flipW/2, L.convY+L.convH+20, 'FLIP', C.flipZone, 11, 'middle');
addLabelCN(L.convX+L.flipX-L.convX+L.flipW/2, L.convY+L.convH+34, '瞬时翻板', C.muted, 9, 'middle');

// 伯努利吸盘模组框架
const modGroup = el('g', {id:'module'}, svg);
const modFrameRect = el('rect', {x:0,y:0,width:modGridSize+20,height:modGridSize+20,rx:6,fill:'rgba(0,201,167,.06)',stroke:C.modFrame,strokeWidth:1.5,strokeDasharray:'6,3'}, modGroup);
// 模组标题
const modTitle = el('text', {x:modGridSize/2+10,y:-12,fill:C.modFrame,'font-family':"'Rajdhani',sans-serif",'font-size':12,'font-weight':600,'text-anchor':'middle'}, modGroup);
modTitle.textContent = '8×8 BERNOULLI ARRAY';

// 64个吸盘头
const modCells = [];
for (let r = 0; r < 8; r++) {
  modCells[r] = [];
  for (let c = 0; c < 8; c++) {
    const cx = 10 + c * (L.modCellSize + L.modCellGap) + L.modCellSize/2;
    const cy = 10 + r * (L.modCellSize + L.modCellGap) + L.modCellSize/2;
    // 伯努利吸盘外圈
    const outer = el('circle', {cx,cy,r:L.modCellSize/2,fill:C.modCell,stroke:C.modHead,strokeWidth:1}, modGroup);
    // 内圈(吸附孔)
    const inner = el('circle', {cx,cy,r:L.modCellSize/5,fill:C.modHead}, modGroup);
    // 物料占位(初始隐藏)
    const mat = el('rect', {x:cx-L.modCellSize/2+2,y:cy-L.modCellSize/2+2,width:L.modCellSize-4,height:L.modCellSize-4,rx:3,fill:C.mat,opacity:0}, modGroup);
    modCells[r][c] = {outer, inner, mat, cx, cy, filled: false};
  }
}

// Tray盘区域
const trayGroup = el('g', {id:'tray'}, svg);
el('rect', {x:L.trayX-10,y:L.trayY-10,width:L.trayW+20,height:L.trayH+50,rx:8,fill:C.trayBg,stroke:'#1e3458',strokeWidth:1}, trayGroup);
addLabel(L.trayX+L.trayW/2, L.trayY-18, 'TRAY STATION', C.muted, 12, 'middle');
addLabelCN(L.trayX+L.trayW/2, L.trayY+L.trayH+35, '双工位切换 · 满盘/空盘无缝衔接', C.muted, 10, 'middle');

const trayCells = [];
for (let r = 0; r < trayGridRows; r++) {
  trayCells[r] = [];
  for (let c = 0; c < trayGridCols; c++) {
    const tx = L.trayX + c * (L.trayCellSize + L.trayCellGap);
    const ty = L.trayY + r * (L.trayCellSize + L.trayCellGap);
    const cell = el('rect', {x:tx,y:ty,width:L.trayCellSize,height:L.trayCellSize,rx:3,fill:C.trayCell,stroke:'#1a2e48',strokeWidth:.5}, trayGroup);
    trayCells[r][c] = {el: cell, filled: false};
  }
}

// 物料在输送带上的动画元素
const matGroup = el('g', {id:'mats'}, svg);
const matPool = [];
const MAT_POOL_SIZE = 48;
for (let i = 0; i < MAT_POOL_SIZE; i++) {
  const m = el('rect', {width:L.laneH-4,height:L.laneH-4,rx:3,fill:C.mat,opacity:0}, matGroup);
  matPool.push({el:m, active:false, lane:0, x:0, flipped:false});
}

// 视觉扫描线
const scanLine = el('line', {x1:0,y1:0,x2:0,y2:L.convH+8,stroke:C.visZone,strokeWidth:2,opacity:0}, svg);

// 翻转指示器
const flipIndicators = [];
for (let i = 0; i < L.numLanes; i++) {
  const fy = L.convY + 8 + i * (L.laneH + L.laneGap) + L.laneH/2;
  const fx = L.convX + L.flipX - L.convX + L.flipW/2;
  const ind = el('text', {x:fx,y:fy+4,fill:C.flipZone,'font-size':14,'text-anchor':'middle',opacity:0}, svg);
  ind.textContent = '↻';
  flipIndicators.push(ind);
}

// 高亮脉冲(吸取时)
const pickPulse = el('rect', {x:0,y:0,width:20,height:L.convH,rx:4,fill:'none',stroke:C.emerald,strokeWidth:2,opacity:0}, svg);

// 释放脉冲
const releasePulse = el('rect', {x:L.trayX-5,y:L.trayY-5,width:L.trayW+10,height:L.trayH+10,rx:10,fill:'none',stroke:C.amber,strokeWidth:2,opacity:0}, svg);

// 动态标注
const dynLabel = el('text', {x:720,y:660,fill:C.emerald,'font-family':"'Rajdhani',sans-serif",'font-size':16,'font-weight':700,'text-anchor':'middle',opacity:0}, svg);

// 连接箭头(模组移动路径)
const pathLine = el('line', {x1:L.modConvX+modGridSize/2+10,y1:L.modConvY+modGridSize+30,x2:L.modTrayX+modGridSize/2+10,y2:L.modConvY+modGridSize+30,stroke:'#1a2e48',strokeWidth:1.5,strokeDasharray:'8,6'}, svg);
addLabel((L.modConvX+L.modTrayX)/2+modGridSize/2+10, L.modConvY+modGridSize+48, 'TRANSFER PATH', '#1a2e48', 10, 'middle');

// 下方时间线
const tlGroup = el('g', {id:'timeline'}, svg);
el('rect', {x:L.tlX,y:L.tlY,width:L.tlW,height:L.tlH,rx:4,fill:'#0a1020',stroke:C.laneLine,strokeWidth:1}, tlGroup);
const tlProgress = el('rect', {x:L.tlX,y:L.tlY,width:0,height:L.tlH,rx:4,fill:'rgba(0,212,232,.25)'}, tlGroup);
const tlMarker = el('rect', {x:L.tlX,y:L.tlY-2,width:3,height:L.tlH+4,rx:1,fill:C.visZone}, tlGroup);
addLabelCN(L.tlX, L.tlY+L.tlH+18, '时间线 · 一个完整周期', C.muted, 10);

// IFR说明
const ifrGroup = el('g', {id:'ifr'}, svg);
addLabel(L.tlX, L.tlY+60, 'IFR IDEAL FINAL RESULT', C.emerald, 14);
addLabelCN(L.tlX, L.tlY+80, '核心矛盾: 1s/pcs极速节拍 vs 换盘停顿', C.muted, 11);
addLabelCN(L.tlX, L.tlY+98, '理想解: 8列并吸→8次步进→整体放置 → 等效0.03s/pcs, 零停顿', C.visZone, 11);
addLabelCN(L.tlX, L.tlY+116, '关键资源复用: 模组自身8×8框架作为缓存空间, 省去独立缓存机构', C.amber, 11);

// 节拍对比图
const cmpGroup = el('g', {id:'compare'}, svg);
const cmpX = 820, cmpY = L.tlY + 55;
addLabel(cmpX, cmpY, 'BEAT COMPARISON', C.muted, 12);
// 传统方式
addLabelCN(cmpX, cmpY+20, '传统串行: 1s/pcs + 换盘停顿', C.rose, 10);
el('rect', {x:cmpX,y:cmpY+28,width:300,height:14,rx:3,fill:'#1a1020'}, cmpGroup);
el('rect', {x:cmpX,y:cmpY+28,width:250,height:14,rx:3,fill:'rgba(255,79,110,.3)'}, cmpGroup);
el('rect', {x:cmpX+250,y:cmpY+28,width:50,height:14,rx:0,fill:C.rose,opacity:.6}, cmpGroup);
addLabelCN(cmpX+260, cmpY+39, '停顿', '#fff', 8);
// 并行方式
addLabelCN(cmpX, cmpY+56, '8列并行: 等效0.03s/pcs + 零停顿', C.emerald, 10);
el('rect', {x:cmpX,y:cmpY+64,width:300,height:14,rx:3,fill:'rgba(0,201,167,.3)'}, cmpGroup);
el('rect', {x:cmpX,y:cmpY+64,width:300,height:14,rx:3,fill:'rgba(0,201,167,.5)'}, cmpGroup);
addLabelCN(cmpX+200, cmpY+75, '连续无中断', '#fff', 8);

/* ---- 动画状态机 ---- */
const PHASES = [
  {name:'CONVEYOR',dur:.5},    // 输送带送料
  {name:'SCAN',dur:.3},        // 视觉扫描
  {name:'FLIP',dur:.2},        // 翻转
  {name:'DESCEND',dur:.25},    // 模组下降
  {name:'PICK',dur:.15},       // 吸取
  {name:'ASCEND',dur:.25},     // 模组上升
  {name:'SHIFT',dur:.25},      // 模组内部平移
];
const POST_PHASES = [
  {name:'TRANSFER',dur:.6},    // 模组移至Tray
  {name:'RELEASE_DESC',dur:.25}, // 下降释放
  {name:'RELEASE',dur:.2},     // 释放64个
  {name:'RELEASE_ASC',dur:.25}, // 上升
  {name:'RETURN',dur:.5},      // 返回输送带
  {name:'SWITCH',dur:.3},      // 切换Tray
];

let state = {
  playing: true,
  speed: 1.0,
  gap: 1.5,
  rowIdx: 0,           // 当前吸取行 0-7
  phaseIdx: 0,         // 当前阶段
  phaseTime: 0,        // 当前阶段已过时间
  inPost: false,       // 是否在后期阶段(吸取8行后)
  moduleX: L.modConvX,
  moduleY: L.modConvY,
  moduleH: 0,          // 0=上方, 1=下降到位
  moduleGrid: Array.from({length:8}, ()=>Array(8).fill(false)), // 吸取状态
  trayGrid: Array.from({length:8}, ()=>Array(8).fill(false)),
  totalPlaced: 0,
  cyclesDone: 0,
  conveyorOffset: 0,
  scanActive: false,
  flipLanes: [],       // 需要翻转的流道
};

let lastTime = 0;

/* ---- 物料在输送带上的管理 ---- */
function spawnMaterials() {
  // 在输送带终端放置8个物料(每条流道一个)
  for (let i = 0; i < L.numLanes; i++) {
    const m = getFreeMat();
    if (m) {
      m.active = true;
      m.lane = i;
      m.x = L.convX + L.convW - L.laneH - 10;
      m.flipped = Math.random() > 0.7; // 30%需要翻转
      m.el.setAttribute('opacity', '0.85');
    }
  }
}

function getFreeMat() {
  for (const m of matPool) {
    if (!m.active) return m;
  }
  return null;
}

function updateMaterials(dt) {
  state.conveyorOffset += dt * 120; // 输送带速度
  for (const m of matPool) {
    if (m.active) {
      const ly = L.convY + 8 + m.lane * (L.laneH + L.laneGap) + 2;
      m.el.setAttribute('x', m.x);
      m.el.setAttribute('y', ly);
      m.el.setAttribute('opacity', '0.85');
    }
  }
}

function hideMat(lane) {
  for (const m of matPool) {
    if (m.active && m.lane === lane) {
      m.active = false;
      m.el.setAttribute('opacity', '0');
      return;
    }
  }
}

function clearAllMats() {
  for (const m of matPool) {
    m.active = false;
    m.el.setAttribute('opacity', '0');
  }
}

/* ---- 模组渲染 ---- */
function renderModule() {
  modGroup.setAttribute('transform', `translate(${state.moduleX},${state.moduleY})`);
  // 高度指示: 通过缩放和透明度
  const hScale = lerp(1, 0.92, state.moduleH);
  const hOpacity = lerp(1, 0.7, state.moduleH);
  // 模组整体缩放表示高度变化
  const cx = modGridSize/2+10, cy = modGridSize/2+10;
  const subGroup = modGroup;
  // 更简洁: 用translate模拟下降
  const yOff = state.moduleH * 20; // 下降20px
  subGroup.setAttribute('transform', `translate(${state.moduleX},${state.moduleY + yOff})`);
  
  // 更新每个格子
  for (let r = 0; r < 8; r++) {
    for (let c = 0; c < 8; c++) {
      const cell = modCells[r][c];
      if (state.moduleGrid[r][c]) {
        cell.mat.setAttribute('opacity', '0.9');
        cell.inner.setAttribute('fill', C.modHeadActive);
        cell.inner.setAttribute('filter', 'url(#glowEmerald)');
        cell.outer.setAttribute('stroke', C.modActive);
      } else {
        cell.mat.setAttribute('opacity', '0');
        cell.inner.setAttribute('fill', C.modHead);
        cell.inner.removeAttribute('filter');
        cell.outer.setAttribute('stroke', C.modHead);
      }
    }
  }
}

/* ---- Tray渲染 ---- */
function renderTray() {
  for (let r = 0; r < trayGridRows; r++) {
    for (let c = 0; c < trayGridCols; c++) {
      const cell = trayCells[r][c];
      if (state.trayGrid[r][c]) {
        cell.el.setAttribute('fill', C.trayFilled);
        cell.el.setAttribute('opacity', '0.9');
      } else {
        cell.el.setAttribute('fill', C.trayCell);
        cell.el.setAttribute('opacity', '1');
      }
    }
  }
}

/* ---- 扫描线动画 ---- */
function renderScanLine(t) {
  if (state.scanActive) {
    const sx = L.convX + L.visX - L.convX;
    const animX = sx + Math.sin(t * 8) * L.visW * 0.4;
    scanLine.setAttribute('x1', animX);
    scanLine.setAttribute('x2', animX);
    scanLine.setAttribute('y1', L.convY - 4);
    scanLine.setAttribute('y2', L.convY + L.convH + 4);
    scanLine.setAttribute('opacity', 0.7 + Math.sin(t * 12) * 0.3);
    scanLine.setAttribute('filter', 'url(#glowCyan)');
  } else {
    scanLine.setAttribute('opacity', '0');
  }
}

/* ---- 翻转指示 ---- */
function renderFlipIndicators() {
  for (let i = 0; i < L.numLanes; i++) {
    const isFlipping = state.flipLanes.includes(i);
    flipIndicators[i].setAttribute('opacity', isFlipping ? '0.9' : '0');
  }
}

/* ---- 选取脉冲 ---- */
function renderPickPulse(opacity) {
  if (opacity > 0) {
    const px = L.convX + L.convW - L.laneH - 10;
    pickPulse.setAttribute('x', px - 4);
    pickPulse.setAttribute('y', L.convY);
    pickPulse.setAttribute('width', L.laneH + 8);
    pickPulse.setAttribute('height', L.convH);
    pickPulse.setAttribute('opacity', opacity);
    pickPulse.setAttribute('filter', 'url(#glowEmerald)');
  } else {
    pickPulse.setAttribute('opacity', '0');
  }
}

/* ---- 释放脉冲 ---- */
function renderReleasePulse(opacity) {
  if (opacity > 0) {
    releasePulse.setAttribute('opacity', opacity);
    releasePulse.setAttribute('filter', 'url(#glowAmber)');
  } else {
    releasePulse.setAttribute('opacity', '0');
  }
}

/* ---- 动态标注 ---- */
function renderDynLabel(text, opacity) {
  dynLabel.textContent = text;
  dynLabel.setAttribute('opacity', opacity);
}

/* ---- 时间线 ---- */
function renderTimeline() {
  const totalPhases = PHASES.length * 8 + POST_PHASES.length;
  let completedPhases = state.rowIdx * PHASES.length + state.phaseIdx;
  if (state.inPost) {
    completedPhases = 8 * PHASES.length + state.phaseIdx;
  }
  const progress = completedPhases / totalPhases;
  const pw = Math.max(0, Math.min(L.tlW, progress * L.tlW));
  tlProgress.setAttribute('width', pw);
  const mx = L.tlX + pw;
  tlMarker.setAttribute('x', mx - 1);
}

/* ---- UI更新 ---- */
function updateUI() {
  const phases = state.inPost ? POST_PHASES : PHASES;
  const phaseName = phases[state.phaseIdx]?.name || 'IDLE';
  document.getElementById('stPhase').textContent = phaseName;
  document.getElementById('stRow').textContent = `${state.rowIdx}/8`;
  document.getElementById('stTotal').textContent = `${state.totalPlaced} pcs`;
  document.getElementById('stRate').textContent = state.totalPlaced > 0 ? '0.03s/pcs' : '—';
  document.getElementById('stStop').textContent = '0s';
  const dot = document.getElementById('runDot');
  dot.className = state.playing ? 'dot on' : 'dot off';
}

/* ---- 阶段逻辑 ---- */
function advancePhase() {
  if (!state.inPost) {
    state.phaseIdx++;
    if (state.phaseIdx >= PHASES.length) {
      state.phaseIdx = 0;
      state.rowIdx++;
      if (state.rowIdx >= 8) {
        // 进入后期阶段
        state.inPost = true;
        state.phaseIdx = 0;
      } else {
        // 下一行, 重新生成物料
        spawnMaterials();
      }
    }
  } else {
    state.phaseIdx++;
    if (state.phaseIdx >= POST_PHASES.length) {
      // 完成一个完整周期
      resetCycle();
    }
  }
}

function resetCycle() {
  state.rowIdx = 0;
  state.phaseIdx = 0;
  state.inPost = false;
  state.moduleX = L.modConvX;
  state.moduleY = L.modConvY;
  state.moduleH = 0;
  state.moduleGrid = Array.from({length:8}, ()=>Array(8).fill(false));
  state.cyclesDone++;
  clearAllMats();
  spawnMaterials();
}

/* ---- 阶段更新 ---- */
let pickPulseOp = 0;
let releasePulseOp = 0;
let dynLabelOp = 0;
let dynLabelText = '';

function updatePhase(dt) {
  const phases = state.inPost ? POST_PHASES : PHASES;
  if (state.phaseIdx >= phases.length) return;
  
  const phase = phases[state.phaseIdx];
  const dur = phase.dur / state.speed;
  state.phaseTime += dt;
  
  const t = Math.min(1, state.phaseTime / dur);
  
  switch(phase.name) {
    case 'CONVEYOR':
      // 物料流向终端
      state.scanActive = false;
      for (const m of matPool) {
        if (m.active) {
          m.x += dt * 200 * state.speed;
          if (m.x > L.convX + L.convW - L.laneH - 10) {
            m.x = L.convX + L.convW - L.laneH - 10;
          }
        }
      }
      renderDynLabel('', 0);
      break;
      
    case 'SCAN':
      state.scanActive = true;
      // 决定哪些流道需要翻转
      state.flipLanes = [];
      for (const m of matPool) {
        if (m.active && m.flipped) {
          state.flipLanes.push(m.lane);
        }
      }
      renderDynLabel(`SCANNING · ${8-state.flipLanes.length} OK / ${state.flipLanes.length} FLIP`, t);
      break;
      
    case 'FLIP':
      state.scanActive = false;
      // 翻转动画(旋转指示器)
      for (const m of matPool) {
        if (m.active && m.flipped) {
          // 视觉翻转效果 - 用颜色变化表示
          m.el.setAttribute('fill', '#e88e0a');
          setTimeout(() => { if(m.active) m.el.setAttribute('fill', C.mat); }, 200);
        }
      }
      renderDynLabel(`FLIPPING ${state.flipLanes.length} PIECES`, t);
      break;
      
    case 'DESCEND':
      state.moduleH = easeInOut(t);
      state.scanActive = false;
      renderDynLabel('DESCENDING · GAP ' + state.gap + 'mm', t);
      break;
      
    case 'PICK':
      pickPulseOp = Math.sin(t * Math.PI);
      if (t >= 0.5 && !state.moduleGrid[state.rowIdx].some(v=>v)) {
        // 吸取! 将8个物料放入模组当前行
        for (let c = 0; c < 8; c++) {
          state.moduleGrid[state.rowIdx][c] = true;
          hideMat(c);
        }
        // 伯努利效应高亮
        for (let c = 0; c < 8; c++) {
          const cell = modCells[state.rowIdx][c];
          cell.inner.setAttribute('fill', C.modHeadActive);
        }
      }
      renderDynLabel('PICK · BERNOULLI NON-CONTACT', t);
      break;
      
    case 'ASCEND':
      pickPulseOp = Math.max(0, pickPulseOp - dt * 4);
      state.moduleH = 1 - easeInOut(t);
      renderDynLabel(`ROW ${state.rowIdx+1}/8 LOADED`, t);
      break;
      
    case 'SHIFT':
      // 内部平移 - 视觉上微微晃动表示移位
      pickPulseOp = 0;
      const shiftX = Math.sin(t * Math.PI * 2) * 2;
      state.moduleX = L.modConvX + shiftX;
      renderDynLabel(`SHIFTING · ROW ${state.rowIdx+1}/8 STORED`, t);
      break;
      
    case 'TRANSFER':
      state.moduleX = lerp(L.modConvX, L.modTrayX, easeInOut(t));
      renderDynLabel('TRANSFERRING 64 PCS → TRAY', t);
      break;
      
    case 'RELEASE_DESC':
      state.moduleH = easeInOut(t);
      renderDynLabel('DESCENDING TO TRAY', t);
      break;
      
    case 'RELEASE':
      releasePulseOp = Math.sin(t * Math.PI);
      if (t >= 0.3) {
        // 释放64个物料到Tray
        for (let r = 0; r < 8; r++) {
          for (let c = 0; c < 8; c++) {
            state.trayGrid[r][c] = true;
          }
        }
        state.totalPlaced += 64;
      }
      renderDynLabel('RELEASE · 64 PCS BATCH DROP', t);
      break;
      
    case 'RELEASE_ASC':
      releasePulseOp = Math.max(0, releasePulseOp - dt * 3);
      state.moduleH = 1 - easeInOut(t);
      if (t > 0.5) {
        // 清空模组
        state.moduleGrid = Array.from({length:8}, ()=>Array(8).fill(false));
      }
      renderDynLabel('ASCENDING · TRAY FILLED', t);
      break;
      
    case 'RETURN':
      state.moduleX = lerp(L.modTrayX, L.modConvX, easeInOut(t));
      renderDynLabel('RETURNING · TRAY SWITCHING', t);
      break;
      
    case 'SWITCH':
      // Tray切换 - 清空Tray准备下一轮
      if (t > 0.5) {
        state.trayGrid = Array.from({length:8}, ()=>Array(8).fill(false));
      }
      renderDynLabel('SWITCHING TRAY · ZERO PAUSE', t);
      break;
  }
  
  if (state.phaseTime >= dur) {
    state.phaseTime = 0;
    pickPulseOp = 0;
    advancePhase();
  }
}

/* ---- 主循环 ---- */
function animate(timestamp) {
  if (!lastTime) lastTime = timestamp;
  const dt = Math.min(0.05, (timestamp - lastTime) / 1000);
  lastTime = timestamp;
  
  if (state.playing) {
    updatePhase(dt);
  }
  
  updateMaterials(dt);
  renderModule();
  renderTray();
  renderScanLine(timestamp / 1000);
  renderFlipIndicators();
  renderPickPulse(pickPulseOp);
  renderReleasePulse(releasePulseOp);
  renderDynLabel(dynLabelText, dynLabelOp);
  renderTimeline();
  updateUI();
  
  requestAnimationFrame(animate);
}

// 覆盖renderDynLabel以保存状态
const origRenderDynLabel = renderDynLabel;
renderDynLabel = function(text, opacity) {
  dynLabelText = text;
  dynLabelOp = opacity;
};

/* ---- 初始化 ---- */
function init() {
  spawnMaterials();
  renderModule();
  renderTray();
  requestAnimationFrame(animate);
}

/* ---- 控件 ---- */
document.getElementById('btnPlay').addEventListener('click', function() {
  state.playing = !state.playing;
  this.textContent = state.playing ? 'RUN' : 'PAUSE';
  this.classList.toggle('on', state.playing);
});

document.getElementById('btnStep').addEventListener('click', function() {
  if (!state.playing) {
    // 单步: 推进当前阶段到结束
    state.phaseTime = 999;
  }
});

document.getElementById('btnReset').addEventListener('click', function() {
  state.rowIdx = 0;
  state.phaseIdx = 0;
  state.phaseTime = 0;
  state.inPost = false;
  state.moduleX = L.modConvX;
  state.moduleY = L.modConvY;
  state.moduleH = 0;
  state.moduleGrid = Array.from({length:8}, ()=>Array(8).fill(false));
  state.trayGrid = Array.from({length:8}, ()=>Array(8).fill(false));
  state.totalPlaced = 0;
  pickPulseOp = 0;
  releasePulseOp = 0;
  clearAllMats();
  spawnMaterials();
});

document.getElementById('spd').addEventListener('input', function() {
  state.speed = parseFloat(this.value);
  document.getElementById('spdV').textContent = state.speed.toFixed(1) + 'x';
});

document.getElementById('gapR').addEventListener('input', function() {
  state.gap = parseFloat(this.value);
  document.getElementById('gapV').textContent = state.gap.toFixed(1) + 'mm';
});

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

这个实现提供了一个完整的面阵式伯努利吸盘极速装盘原理动画,包含以下核心特性:

IFR 理想解视觉叙事:

  • 聚焦展示"8列并吸 → 8次步进 → 整体放置"的完整工作循环,直接呈现消除停顿后的理想状态
  • 底部节拍对比图直观呈现传统串行方案与并行方案的本质差异(停顿 vs 连续)
  • 关键资源复用逻辑:模组自身 8×8 框架即缓存空间,无需额外机构

动画核心流程:

  1. 8条流道物料并行流向终端
  2. 线扫视觉区扫描识别(青色扫描线动画)
  3. 翻转区对需要翻面的物料执行 180° 翻转(紫色指示器)
  4. 8×8 伯努利阵列模组下降 → 非接触吸取 8 个 → 上升 → 内部平移蓄位
  5. 循环 8 次吸满 64 个后,整体平移至 Tray 盘上方
  6. 一次性释放 64 个物料 → 模组返回 → 双工位无缝切换

交互控件:

  • RUN/PAUSE 播放控制、STEP 单步推进、RESET 重置
  • Speed 滑块调节动画速率(0.2x–3x)
  • Gap 滑块调节伯努利吸盘间隙参数(1–3mm),实时显示在下降阶段标注中

视觉设计:

  • 深色工业主题,青色/琥珀/翠绿/紫色四色编码区分功能区域
  • 吸取/释放瞬间的脉冲发光效果突出核心创新动作
  • 模组网格逐行填充过程清晰可见,吸头激活态以发光标识
  • 时间线进度条展示完整周期推进
积分规则:第一轮对话扣减6分,后续每轮扣4分