













(function () {
  'use strict';

  
  
  var DEFAULT_SPEED = 1.0;
  var MIN_SPEED = 0.05;
  var MAX_SPEED = 1.0;

  var DEFAULT_WAIT_S = 0.5;
  var MAX_WAIT_S = 600;
  var GRIPPER_MAX_DEG = 110;

  

  var SLOT = { vacuum: 0, gripper: 1, wait: 2 };

  
  var SEG_KIND = { vacuum: 'vacuum', servo: 'gripper', pause: 'wait' };

  
  var ACTION_BG = 'rgba(143,151,158,.10)';

  






  function pathFailMsg(w) {
    var tr = function (k, d) { return VizI18n.tr(k, d); };
    var info = w.detail || {};
    var seg = (w.fail_index === null || w.fail_index === undefined)
      ? tr('viz_pf_seg_this', '该段')
      : tr('viz_pf_seg_n', '段 {n}').replace('{n}', w.fail_index + 1);
    var put = function (key, def) {
      var step = (info.step === undefined || info.step === null) ? ''
        : tr('viz_pf_step', '第 {i}/{n} 步').replace('{i}', info.step + 1)
            .replace('{n}', info.total === undefined ? '?' : info.total);
      return tr(key, def).replace('{seg}', seg).replace('{step}', step);
    };
    if (w.error === 'no_dll') return put('viz_pf_no_dll', '{seg}：IK 求解库不可用');
    if (w.error === 'no_solution') return put('viz_pf_no_solution', '{seg} 直线不可达：{step} 无 IK 解');
    if (w.error === 'self_collision') return put('viz_pf_collide', '{seg} 直线不可执行：{step} 自我干涉');
    if (w.error === 'discontinuity') {
      
      
      var axes = (info.joints || []).map(function (j) { return 'J' + (j + 1); })
        .join(tr('viz_pf_axis_sep', '、'));
      return put('viz_pf_discont',
                 '{seg} 不可执行：{what}\n  {step} {axes} 突跳 {deg}°' +
                 '（每个采样点都在直线上，但两步之间末端会甩出直线）\n' +
                 '  多为某轴到达行程极限后只剩翻转解，换选解策略无法解决\n' +
                 '  建议：改用关节模式 / 把该段拆成两段 / 调整起始或目标姿态')
        .replace('{what}', info.wrist_flip
          ? tr('viz_pf_wrist_flip', '路径中途翻腕')
          : tr('viz_pf_config_jump', '路径中途构型突变'))
        .replace('{axes}', axes)
        .replace('{deg}', info.max_delta_deg === undefined ? '?' : info.max_delta_deg);
    }
    return put('viz_pf_other', '{seg} 不可执行：{reason}')
      .replace('{reason}', w.error || '');
  }

  
  function kindOf(e) { return e.kind || 'move'; }
  function isMove(e) { return kindOf(e) === 'move'; }

  function MotionsUI(rootEl, opts) {
    opts = opts || {};
    this.root = rootEl;
    this.motions = [];            
    this.getServerRunning = opts.getServerRunning || function () { return false; };
    this.getExecuting = opts.getExecuting || function () { return false; };
    this.onMenu = opts.onMenu || function () {};     
    this.getFromJoints = opts.getFromJoints || function () { return null; };
    this.startPlayback = opts.startPlayback || function () {};
    this.stopPlayback = opts.stopPlayback || function () {};
    
    
    this.getToolKind = opts.getToolKind || function () { return null; };
    
    this.getToolAngle = opts.getToolAngle || function () { return 0; };
    


    this.getOtherBusy = opts.getOtherBusy || function () { return false; };
    this.getTargets = opts.getTargets || function () { return []; };
    
    this.getPicked = opts.getPicked || function () { return []; };
    this.onToggleFold = opts.onToggleFold || function () {};  
    
    
    this.getItemFolded = opts.getItemFolded || function () { return false; };
    this.onItemFold = opts.onItemFold || function () {};
    
    this.onChanged = opts.onChanged || function () {};
    


    this.onPlayingChanged = opts.onPlayingChanged || function () {};
    this.showMenu = opts.showMenu || function () {};
    
    
    this.confirmDelete = opts.confirmDelete || function (m, ok) { ok(); };
    this.running = null;          
    this.runningLoop = false;     
    this.playing = false;         
    this._lockedNow = false;      
    this._busyNow = false;        
    this._seq = 0;                
    this._armed = null;           
    this._armTimer = 0;
    this.runCycles = 0;           
    
    
    this.preparing = null;
    this._calcReq = 0;            
    
    
    this.prep = new PrepBar({ variant: 'banner' });
    this._build();
  }

  MotionsUI.prototype._build = function () {
    var self = this;
    
    
    this.section = PanelSection.make({
      key: 'motions', i18nKey: 'viz_motion_title', title: '运动',
      listId: 'mo-list',
      buttons: [{
        id: 'mo-add', text: '+',
        i18nTitleKey: 'viz_motion_add', title: '新建运动',
        onClick: function (e) { self._openCreateMenu(e.clientX, e.clientY); }
      }],
      onToggle: this.onToggleFold
    });
    this.section.sec.style.marginTop = '8px';
    this.root.appendChild(this.section.sec);
    this.listEl = this.section.listEl;
    this._render();
  };

  










  MotionsUI.prototype._openCreateMenu = function (x, y) {
    var self = this;
    var targets = this.getTargets() || [];
    var picked = (this.getPicked && this.getPicked()) || [];
    var items = [];
    if (picked.length) {
      items.push({
        label: VizI18n.tr('viz_motion_add_picked', '用选中的 {n} 个目标点创建')
          .replace('{n}', String(picked.length)),
        onClick: function () { self.create(picked); }
      });
    }
    if (targets.length) {
      items.push({
        label: VizI18n.tr('viz_motion_add_from_target', '新建运动，从目标点开始'),
        children: targets.map(function (it) {
          return {
            label: it.name,
            onClick: function () { self.create([it]); }
          };
        })
      });
    }
    if (!items.length) {
      
      items.push({
        label: VizI18n.tr('viz_motion_add_need_target', '先建一个目标点'),
        enabled: false
      });
    }
    var last = this.motions[this.motions.length - 1];
    if (last) {
      items.push({ sep: true });
      items.push({
        label: VizI18n.tr('viz_motion_append_to', '加到 {name} 末尾')
          .replace('{name}', last.name),
        enabled: false                       
      });
      this._addMenuItems(last, last.entries.length - 1).forEach(function (it) {
        items.push(it);
      });
    }
    this.showMenu(items, x, y);
  };

  
  MotionsUI.prototype.create = function (items) {
    if (!items || items.length === 0) return null;
    this._seq++;
    var m = {
      name: 'motion' + this._seq,
      
      syncReal: false,          
      failIndex: -1,            
      entries: items.map(function (it) {
        return { kind: 'move', item: it, motion: 'joint', speed: DEFAULT_SPEED };
      })
    };
    this.motions.push(m);
    this._render();
    return m;
  };

  MotionsUI.prototype.remove = function (m) {
    var i = this.motions.indexOf(m);
    if (i !== -1) {
      this.motions.splice(i, 1);
      if (this.running === m) this.running = null;
      this._render();
    }
  };

  


  MotionsUI.prototype._confirmRemove = function (m) {
    var self = this;
    if (this.editLocked()) return;
    this.confirmDelete(m, function () { self.remove(m); });
  };

  

  





  MotionsUI.prototype.refsOf = function (item) {
    var out = { segs: 0, motions: [], doomed: [] };
    this.motions.forEach(function (m) {
      var hit = m.entries.filter(function (e) {
        return isMove(e) && e.item === item;
      }).length;
      if (!hit) return;
      out.segs += hit;
      out.motions.push(m.name);
      var left = m.entries.some(function (e) {
        return isMove(e) && e.item !== item;
      });
      if (!left) out.doomed.push(m.name);
    });
    return out;
  };

  MotionsUI.prototype.onTargetRemoved = function (item) {
    var changed = false;
    for (var i = this.motions.length - 1; i >= 0; i--) {
      var m = this.motions[i];
      var before = m.entries.length;
      m.entries = m.entries.filter(function (e) {
        return !(isMove(e) && e.item === item);
      });
      if (m.entries.length !== before) changed = true;
      if (!m.entries.some(isMove)) {
        if (this.running === m) this.running = null;
        this.motions.splice(i, 1);
      }
    }
    if (changed) this._render();
  };

  

  














  MotionsUI.prototype.editLocked = function () {
    return !!(this.playing || this.preparing || this.getExecuting());
  };

  




  MotionsUI.prototype.syncLock = function () {
    if (this.editLocked() === this._lockedNow) return;
    this._render();
  };

  function lockTip() {
    return VizI18n.tr('viz_edit_locked',
      '正在运行：这一段只能看，不能改。真机跑的是按下播放那一刻的副本。要改先停下来。');
  }

  


  function lockCtl(el, lk) {
    if (!lk) return el;
    if (!el.disabled) el.title = lockTip();
    el.disabled = true;
    el.style.opacity = '.45';
    return el;
  }

  

  MotionsUI.prototype.insertAfter = function (m, index, entry) {
    if (this.editLocked()) return;
    m.entries.splice(index + 1, 0, entry);
    this._invalidate(m);
    this._render();
  };

  


  MotionsUI.prototype.moveEntry = function (m, index, dir) {
    if (this.editLocked()) return false;
    var to = index + dir;
    if (to < 0 || to >= m.entries.length) return false;
    if (isMove(m.entries[index])) return false;
    if (to === 0) return false;                    
    var e = m.entries.splice(index, 1)[0];
    m.entries.splice(to, 0, e);
    this._invalidate(m);
    this._render();
    return true;
  };

  MotionsUI.prototype.removeEntry = function (m, index) {
    if (this.editLocked()) return;
    m.entries.splice(index, 1);
    if (!m.entries.some(isMove)) { this.remove(m); return; }
    this._invalidate(m);
    this._render();
  };

  
  MotionsUI.prototype._invalidate = function (m) {
    m.failIndex = -1;
    m.failMsg = null;
    m.failWhy = null;
  };

  

  


  

  MotionsUI.prototype.serialize = function (targetItems) {
    var out = [];
    this.motions.forEach(function (m) {
      var entries = [];
      m.entries.forEach(function (e) {
        if (isMove(e)) {
          var idx = targetItems.indexOf(e.item);
          if (idx === -1) {
            VizChannel.log(VizI18n.tr('viz_log_export_tag', '导出：') +
              VizI18n.tr('viz_log_export_orphan',
              '运动 {name} 有一段引用了已不存在的目标点，已跳过').replace('{name}', m.name));
            return;
          }
          entries.push({ target: idx, motion: e.motion, speed: e.speed });
        } else if (e.kind === 'wait') {
          entries.push({ type: 'wait', seconds: e.seconds });
        } else if (e.kind === 'gripper') {
          entries.push({ type: 'gripper', angle_deg: e.angleDeg });
        } else if (e.kind === 'vacuum') {
          entries.push({ type: 'vacuum', state: e.on ? 'on' : 'off' });
        }
      });
      
      while (entries.length && entries[0].type) entries.shift();
      if (entries.length) {
        out.push({ name: m.name, sync_real: !!m.syncReal, entries: entries });
      }
    });
    return out;
  };

  MotionsUI.prototype.clearAll = function () {
    this.stop();                  
    this.motions = [];
    this._render();
  };

  

  MotionsUI.prototype.restore = function (list, targetItems) {
    this.clearAll();
    var self = this;
    (list || []).forEach(function (raw) {
      var entries = [];
      raw.entries.forEach(function (e) {
        
        if (!e.type || e.type === 'move') {
          var it = targetItems[e.target];
          if (!it) return;        
          var sp = Math.max(MIN_SPEED, Math.min(MAX_SPEED, e.speed));
          entries.push({ kind: 'move', item: it, motion: e.motion, speed: sp });
        } else if (e.type === 'wait') {
          entries.push({ kind: 'wait',
                         seconds: clamp(e.seconds, 0, MAX_WAIT_S, DEFAULT_WAIT_S) });
        } else if (e.type === 'gripper') {
          entries.push({ kind: 'gripper',
                         angleDeg: clamp(e.angle_deg, 0, GRIPPER_MAX_DEG, 0) });
        } else if (e.type === 'vacuum') {
          entries.push({ kind: 'vacuum', on: e.state === 'on' });
        }
      });
      
      while (entries.length && !isMove(entries[0])) {
        VizChannel.log(VizI18n.tr('viz_log_import_tag', '导入：') +
          VizI18n.tr('viz_log_import_lead',
            '运动 {name} 的前导动作行已丢弃（首行必须是移动段）').replace('{name}', raw.name));
        entries.shift();
      }
      if (!entries.length) return;
      self.motions.push({
        name: raw.name,
        syncReal: !!raw.sync_real,
        failIndex: -1,            
        entries: entries
      });
    });
    
    this._seq = this.motions.reduce(function (mx, m) {
      var g = /(\d+)$/.exec(m.name);
      return g ? Math.max(mx, parseInt(g[1], 10)) : mx;
    }, this.motions.length);
    this._render();
  };

  

  MotionsUI.prototype.refreshButtons = function () {
    this._render();
  };

  
  MotionsUI.prototype.refresh = function () { this._render(); };

  


  MotionsUI.prototype._entryOfWaypoint = function (m, wpIndex, segType) {
    if (wpIndex == null || wpIndex < 0) return -1;
    var kind = SEG_KIND[segType];
    if (kind) {
      var rows = (m && m._wpActionRows) ? m._wpActionRows[wpIndex] : null;
      
      if (rows && rows[kind] !== undefined) return rows[kind];
    }
    var map = m && m._wpToEntry;
    if (!map) return wpIndex;          
    return (wpIndex < map.length) ? map[wpIndex] : -1;
  };

  


  MotionsUI.prototype.highlight = function (index, segType) {
    if (!this.running || !this.running.segEls) return;
    var row = this._entryOfWaypoint(this.running, index, segType);
    var els = this.running.segEls;
    for (var i = 0; i < els.length; i++) {
      els[i].style.borderColor = (i === row) ? '#4da3ff' : 'transparent';
      els[i].style.background = (i === row) ? 'rgba(77,163,255,.12)'
                                            : (els[i]._baseBg || '');
    }
  };

  
















  MotionsUI.prototype._compile = function (m) {
    var wps = [];
    var map = [];
    var rows = [];           
    var cur = null;          
    var lastRank = -1;       
    var curJoints = null;    

    function openStay(entryIdx) {
      cur = { joints_deg: curJoints.slice(), pose: null,
              motion: 'joint', speed_ratio: MAX_SPEED };
      wps.push(cur);
      map.push(entryIdx);
      rows.push({});
      lastRank = -1;
    }

    m.entries.forEach(function (e, i) {
      if (isMove(e)) {
        curJoints = e.item.joints_deg;
        cur = {
          joints_deg: e.item.joints_deg.slice(),
          pose: e.item.tcp || null,
          
          motion: (wps.length === 0) ? 'joint' : e.motion,
          speed_ratio: e.speed
        };
        wps.push(cur);
        map.push(i);
        rows.push({});
        lastRank = -1;
        return;
      }
      if (!cur) return;      
      var rank = SLOT[e.kind];
      if (rank === undefined) return;
      
      if (rank <= lastRank) openStay(i);
      if (e.kind === 'wait') cur.pause_s = e.seconds;
      else if (e.kind === 'gripper') cur.servo_angle = Math.round(e.angleDeg);
      else if (e.kind === 'vacuum') cur.vacuum = e.on ? 'on' : 'off';
      rows[rows.length - 1][e.kind] = i;
      lastRank = rank;
    });
    return { waypoints: wps, wpToEntry: map, wpActionRows: rows };
  };

  MotionsUI.prototype._waypoints = function (m) {
    var c = this._compile(m);
    m._wpToEntry = c.wpToEntry;
    m._wpActionRows = c.wpActionRows;
    return c.waypoints;
  };

  

  MotionsUI.prototype.play = function (m, loop) {
    if (!m || m.entries.length === 0 || !VizChannel.bridge) return;
    
    
    
    if (this.getOtherBusy()) return;
    var self = this;
    var wps = this._waypoints(m);
    var from = this.getFromJoints();
    var reqId = VizChannel.request(function (p) {
      self._calcReq = 0;
      if (!p.ok) {
        self.preparing = null;
        self.prep.clear();
        
        
        m.failIndex = self._entryOfWaypoint(m, p.fail_index);
        
        
        m.failWhy = p.error
          ? { error: p.error, fail_index: p.fail_index, detail: p.detail }
          : null;
        m.failMsg = (m.failWhy && pathFailMsg(m.failWhy)) || p.message ||
                    (VizI18n.tr('viz_log_check_failed', '校验失败') + ': ' + p.error);
        self._render();
        VizChannel.log(VizI18n.tr('viz_log_motion_failed', '运动 {name} {msg}')
          .replace('{name}', m.name).replace('{msg}', m.failMsg));
        return;
      }
      m.failIndex = -1;
      m.failMsg = null;
      m.failWhy = null;
      self.running = m;
      self.runningLoop = !!loop;
      self.runCycles = 0;
      self.playing = true;
      
      var startFrom = p.from_joints_deg || from;
      var args = [p.segments, startFrom, !!loop,
                  p.loop_wrap_duration_s, p.loop_wrap_ramp_s,
                  { syncMachine: !!m.syncReal }];
      if (!m.syncReal) {
        self.preparing = null;                      
        self.prep.clear();
        self.startPlayback.apply(self, args);       
      } else {
        
        
        var paths = {};
        p.segments.forEach(function (s) {
          if (s.type === 'path') paths[s.index] = s.points;
        });
        VizChannel.bridge.run_motion(JSON.stringify({
          waypoints: wps, loop: !!loop, linear_paths: paths
        }));
        
        
        
        
        
        self.prep.setEta(VizI18n.tr('viz_teach_arming', '已发给真机，等它开始…'), 0);
        self._armPending(args);
      }
      VizChannel.log(VizI18n.tr('viz_log_play_motion', '播放运动 {name}{loop}{robot}，{segs}')
        .replace('{name}', m.name)
        .replace('{loop}', loop ? VizI18n.tr('viz_log_loop_tag', '（循环）') : '')
        .replace('{robot}', m.syncReal ? VizI18n.tr('viz_log_with_robot', ' + 真机')
                                       : VizI18n.tr('viz_log_virtual_only', '（仅虚拟）'))
        .replace('{segs}', VizI18n.tr('viz_log_n_segs', '{n} 段')
          .replace('{n}', wps.length)));
      self._render();
    });
    




    this._calcReq = reqId;
    this.preparing = m;
    this.prep.setFraction(VizI18n.tr('viz_motion_checking', '正在校验轨迹…'), 0);
    this._render();
    VizChannel.bridge.request_sim_motion(JSON.stringify({
      req_id: reqId, from_joints_deg: from, waypoints: wps,
      loop: !!loop,                 
      use_robot_pose: !!m.syncReal  
    }));
  };

  

  MotionsUI.prototype.noteChecking = function (p) {
    if (!p || !this._calcReq || p.req_id !== this._calcReq || !this.preparing) return;
    var what = VizI18n.tr('viz_motion_checking', '正在校验轨迹…');
    if (p.total > 1) {
      what = VizI18n.tr('viz_motion_checking_seg', '正在校验第 {i}/{n} 段…')
        .replace('{i}', (p.index || 0) + 1).replace('{n}', p.total);
    }
    this.prep.setFraction(what, p.k);
  };

  

  MotionsUI.prototype.notePreparing = function (etaS, cycle) {
    if (!this.preparing) return;
    var eta = Math.max(0, Number(etaS) || 0);
    var what = VizI18n.tr('viz_motion_goto_start', '正在把真机移到程序起始点…');
    if (cycle > 1) {
      what = VizI18n.tr('viz_teach_goto_cycle', '第 {n} 圈：').replace('{n}', cycle) + what;
    }
    
    this.prep.clear();
    this.prep.setEta(what, eta);
    
    this._setArmTimer(eta * 1000 + ARM_TIMEOUT_MS,
      VizI18n.tr('viz_arm_why_eta', '对位预计 {s}s 已过').replace('{s}', eta.toFixed(1)));
  };

  
  MotionsUI.prototype.endPreparing = function () {
    if (!this.preparing) return;
    this.preparing = null;
    this.prep.clear();
    this._render();
  };

  
  MotionsUI.prototype.tickHints = function () {
    this.prep.tick();
  };

  
  MotionsUI.prototype.prepEl = function () { return this.prep.el(); };

  


  MotionsUI.prototype.setCycles = function (n) {
    this.runCycles = n;
    if (this.running) this._paintCycles(this.running);
  };

  MotionsUI.prototype._paintCycles = function (m) {
    if (!m || !m.cycleEl) return;
    var on = (this.running === m && this.playing && this.runningLoop);
    m.cycleEl.style.display = on ? '' : 'none';
    if (on) {
      m.cycleEl.textContent = VizI18n.tr('viz_motion_cycles', '↻ 已循环 {n} 次')
        .replace('{n}', this.runCycles);
    }
  };

  


  var ARM_TIMEOUT_MS = 3000;

  MotionsUI.prototype._armPending = function (args) {
    this._clearArm();
    this._armed = args;
    this._setArmTimer(ARM_TIMEOUT_MS,
      VizI18n.tr('viz_arm_why_ack', '{ms}ms 内没等到真机回话').replace('{ms}', ARM_TIMEOUT_MS));
  };

  


  MotionsUI.prototype._setArmTimer = function (ms, why) {
    var self = this;
    if (this._armTimer) clearTimeout(this._armTimer);
    this._armTimer = setTimeout(function () {
      if (!self._armed) return;
      VizChannel.log(VizI18n.tr('viz_log_arm_timeout',
        '未等到真机起跑事件（{why}），按原方式起播').replace('{why}', why));
      self.armPlayback();
    }, ms);
  };

  MotionsUI.prototype._clearArm = function () {
    if (this._armTimer) { clearTimeout(this._armTimer); this._armTimer = 0; }
    this._armed = null;
  };

  

  MotionsUI.prototype.armPlayback = function () {
    var args = this._armed;
    if (!args) return;
    this._clearArm();
    this.startPlayback.apply(this, args);
  };

  




  MotionsUI.prototype.stop = function () {
    this._clearArm();      
    this.stopPlayback();
    if (VizChannel.bridge) VizChannel.bridge.stop_execution();
    this.playing = false;
    this.running = null;
    this.runningLoop = false;
    this.runCycles = 0;
    
    this.preparing = null;
    this._calcReq = 0;
    this.prep.clear();
    this._render();
  };

  
  MotionsUI.prototype.onPlaybackDone = function () {
    this.playing = false;
    this.running = null;
    this.runningLoop = false;
    this.runCycles = 0;
    this.preparing = null;
    this.prep.clear();
    this._render();
  };

  

  MotionsUI.prototype.isBusy = function () {
    return !!(this.playing || this.preparing);
  };

  



  MotionsUI.prototype._noteBusy = function () {
    var busy = this.isBusy();
    if (busy === this._busyNow) return;
    this._busyNow = busy;
    this.onPlayingChanged(busy);
  };

  MotionsUI.prototype._render = function () {
    var self = this;
    this._lockedNow = this.editLocked();   
    this._noteBusy();                      
    this.section.setCount(this.motions.length);   
    this.listEl.innerHTML = '';
    if (this.motions.length === 0) {
      var empty = document.createElement('div');
      empty.style.cssText = 'color:#8f979e; font-size:11px; padding:4px 0;';
      empty.textContent = VizI18n.tr('viz_motion_empty',
        '（暂无运动。Ctrl/Shift 多选目标点后右键"创建运动"）');
      this.listEl.appendChild(empty);
      this.onChanged();
      return;
    }
    this.motions.forEach(function (m) {
      self.listEl.appendChild(self._renderMotion(m));
    });
    
    
    this.onChanged();
  };

  



  MotionsUI.prototype._foldOf = function (m) {
    if (m.folded === undefined) m.folded = !!this.getItemFolded(m.name);
    if ((this.running === m && this.playing) || this.preparing === m) return false;
    return m.folded;
  };

  MotionsUI.prototype._toggleFold = function (m) {
    m.folded = !this._foldOf(m);
    this.onItemFold(m.name, m.folded);
    this._render();
  };

  MotionsUI.prototype._renderMotion = function (m) {
    var self = this;
    var folded = this._foldOf(m);
    var box = document.createElement('div');
    box.style.cssText = 'border:1px solid #3a4148; border-radius:4px; padding:4px 6px; ' +
      'margin:3px 0; font-size:11px;';
    m.rowEl = box;
    
    
    if ((this.running === m && this.playing) || this.preparing === m) {
      box.style.borderColor = '#4da3ff';
    }

    var head = document.createElement('div');
    head.style.cssText = 'display:flex; align-items:center; justify-content:space-between; gap:6px;';

    
    
    var caret = document.createElement('span');
    caret.setAttribute('data-role', 'fold-motion');
    caret.textContent = folded ? '▸' : '▾';
    caret.title = VizI18n.tr('viz_item_fold', '折叠/展开这一条');
    caret.style.cssText = 'flex:0 0 auto; width:10px; color:#8ab4f8; cursor:pointer;';
    caret.addEventListener('click', function (e) {
      e.stopPropagation();
      self._toggleFold(m);
    });
    head.appendChild(caret);

    var title = document.createElement('b');
    title.textContent = m.name;
    
    if (m.syncReal) {
      var tag = document.createElement('span');
      tag.textContent = ' ⚙' + VizI18n.tr('viz_tag_robot', '真机');
      tag.style.cssText = 'color:#ffcc44; font-weight:400; font-size:10px;';
      title.appendChild(tag);
    }
    title.style.cssText = 'flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
    head.appendChild(title);

    
    
    if (folded) {
      var sum = document.createElement('span');
      sum.style.cssText = 'flex:0 0 auto; color:#8f979e; font-size:10px; white-space:nowrap;';
      sum.textContent = VizI18n.tr('viz_motion_folded_n', '{n} 段')
        .replace('{n}', m.entries.length);
      head.appendChild(sum);
    }

    
    
    
    
    m.cycleEl = document.createElement('span');
    m.cycleEl.style.cssText = 'flex:0 0 auto; color:#8fd08f; font-size:10px; ' +
      'white-space:nowrap;';
    m.cycleEl.title = VizI18n.tr('viz_motion_cycles_tip', '已完成的循环圈数');
    this._paintCycles(m);
    head.appendChild(m.cycleEl);

    
    var more = document.createElement('button');
    more.textContent = '⋯';
    more.title = VizI18n.tr('viz_more_ops', '更多操作（同右键）');
    more.style.cssText = 'flex:0 0 auto; padding:0 6px; line-height:1.4;';
    more.addEventListener('click', function (e) {
      e.stopPropagation();
      var r = more.getBoundingClientRect();
      self.onMenu(m, r.left, r.bottom);      
    });
    head.appendChild(more);

    var del = document.createElement('button');
    del.textContent = '×';
    del.title = VizI18n.tr('viz_wp_del', '删除');
    del.style.cssText = 'flex:0 0 auto; padding:0 6px; line-height:1.4;';
    del.addEventListener('click', function (e) {
      e.stopPropagation();
      self._confirmRemove(m);
    });
    
    head.appendChild(lockCtl(del, this.editLocked()));
    box.appendChild(head);

    m.segEls = [];
    if (!folded) {
      m.entries.forEach(function (entry, i) {
        var seg = isMove(entry) ? self._renderSeg(m, entry, i)
                                : self._renderAction(m, entry, i);
        m.segEls.push(seg);
        box.appendChild(seg);
      });
    }

    
    
    
    if (m.failWhy || m.failMsg) {
      var note = document.createElement('div');
      note.style.cssText = 'margin:4px 0 2px; padding:4px 5px; border-radius:3px; ' +
        'background:rgba(231,76,60,.12); border:1px solid #e74c3c; color:#ff9a90; ' +
        'font-size:10px; line-height:1.5; white-space:pre-wrap;';
      
      note.textContent = m.failWhy ? pathFailMsg(m.failWhy) : m.failMsg;
      box.appendChild(note);
    }

    
    
    

    
    box.addEventListener('contextmenu', function (e) {
      e.preventDefault();
      e.stopPropagation();
      self.onMenu(m, e.clientX, e.clientY);
    });
    return box;
  };

  



  MotionsUI.prototype._addButton = function (m, index) {
    var self = this;
    var btn = document.createElement('button');
    btn.className = 'mo-btn';
    btn.textContent = '＋';
    btn.title = VizI18n.tr('viz_act_add_tip', '在这一行之后插入延时 / 工具动作 / 目标点');
    btn.style.cssText = 'flex:0 0 auto; padding:0 5px; line-height:1.3;';
    btn.addEventListener('click', function (e) {
      e.stopPropagation();
      var r = btn.getBoundingClientRect();
      self._openAddMenu(m, index, r.left, r.bottom);
    });
    return lockCtl(btn, this.editLocked());
  };

  




  MotionsUI.prototype._addMenuItems = function (m, index) {
    var self = this;
    var items = [{
      label: VizI18n.tr('viz_act_wait', '延时'),
      onClick: function () {
        self.insertAfter(m, index, { kind: 'wait', seconds: DEFAULT_WAIT_S });
      }
    }];
    var tool = this.getToolKind();
    if (tool === 'gripper') {
      items.push({
        label: VizI18n.tr('viz_act_gripper', '夹爪开合'),
        onClick: function () {
          
          self.insertAfter(m, index, { kind: 'gripper', angleDeg: self.getToolAngle() });
        }
      });
    } else if (tool === 'suction') {
      
      items.push({
        label: VizI18n.tr('viz_act_vacuum', '吸盘吸/放'),
        children: [
          { label: VizI18n.tr('viz_suction_vacuum', '吸附'),
            onClick: function () {
              self.insertAfter(m, index, { kind: 'vacuum', on: true });
            } },
          { label: VizI18n.tr('viz_act_release', '放开'),
            onClick: function () {
              self.insertAfter(m, index, { kind: 'vacuum', on: false });
            } }
        ]
      });
    }
    var targets = this.getTargets() || [];
    if (targets.length) {
      
      items.push({
        label: VizI18n.tr('viz_act_insert_target', '插入目标点'),
        children: targets.map(function (it) {
          return {
            label: it.name,
            onClick: function () {
              self.insertAfter(m, index,
                { kind: 'move', item: it, motion: 'joint', speed: DEFAULT_SPEED });
            }
          };
        })
      });
    }
    return items;
  };

  MotionsUI.prototype._openAddMenu = function (m, index, x, y) {
    this.showMenu(this._addMenuItems(m, index), x, y);
  };

  
  MotionsUI.prototype._renderAction = function (m, entry, i) {
    var self = this;
    var lk = this.editLocked();      
    var row = document.createElement('div');
    row.className = 'mo-seg';
    row.style.cssText = 'display:flex; align-items:center; gap:5px; margin:3px 0 3px 8px; ' +
      'padding:2px 3px; border:1px solid transparent; border-radius:3px; ' +
      'background:' + ACTION_BG + ';';
    row._baseBg = ACTION_BG;      

    
    
    var icon = ActIcon.of(entry.kind, entry.on !== false);
    var name = { wait: VizI18n.tr('viz_act_wait', '延时'),
                 gripper: VizI18n.tr('viz_act_gripper', '夹爪开合'),
                 vacuum: VizI18n.tr('viz_act_vacuum', '吸盘吸/放') }[entry.kind];
    var label = document.createElement('span');
    label.textContent = icon + ' ' + name;
    label.style.cssText = 'flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; ' +
      'white-space:nowrap; color:#c5c8c6;';
    
    var tool = this.getToolKind();
    if ((entry.kind === 'gripper' && tool !== 'gripper') ||
        (entry.kind === 'vacuum' && tool !== 'suction')) {
      label.style.color = '#ffcc44';
      row.title = VizI18n.tr('viz_act_tool_off', '当前没装这个工具，该动作在虚拟播放里看不到效果');
    }
    row.appendChild(label);

    if (entry.kind === 'wait') {
      var sec = document.createElement('input');
      sec.type = 'number';
      sec.min = '0'; sec.max = String(MAX_WAIT_S); sec.step = '0.1';
      sec.value = entry.seconds;
      sec.title = VizI18n.tr('viz_act_wait_tip', '延时秒数');
      sec.style.cssText = 'flex:0 0 auto; width:46px;';
      sec.addEventListener('change', function () {
        entry.seconds = clamp(sec.value, 0, MAX_WAIT_S, DEFAULT_WAIT_S);
        sec.value = entry.seconds;
        self._invalidate(m);
      });
      sec.addEventListener('click', function (e) { e.stopPropagation(); });
      row.appendChild(lockCtl(sec, lk));
      var unit = document.createElement('span');
      unit.textContent = 's';
      unit.style.cssText = 'flex:0 0 auto; color:#8f979e;';
      row.appendChild(unit);
    } else if (entry.kind === 'gripper') {
      var ang = document.createElement('input');
      ang.type = 'number';
      ang.min = '0'; ang.max = String(GRIPPER_MAX_DEG); ang.step = '1';
      ang.value = entry.angleDeg;
      ang.title = VizI18n.tr('viz_act_gripper_tip', '舵机指令角 0~110°');
      ang.style.cssText = 'flex:0 0 auto; width:46px;';
      ang.addEventListener('change', function () {
        entry.angleDeg = clamp(ang.value, 0, GRIPPER_MAX_DEG, 0);
        ang.value = entry.angleDeg;
        self._invalidate(m);
      });
      ang.addEventListener('click', function (e) { e.stopPropagation(); });
      row.appendChild(lockCtl(ang, lk));
      var deg = document.createElement('span');
      deg.textContent = '°';
      deg.style.cssText = 'flex:0 0 auto; color:#8f979e;';
      row.appendChild(deg);
    } else if (entry.kind === 'vacuum') {
      var sel = document.createElement('select');
      sel.style.cssText = 'flex:0 0 auto;';
      sel.innerHTML =
        '<option value="on">' + VizI18n.tr('viz_suction_vacuum', '吸附') + '</option>' +
        '<option value="off">' + VizI18n.tr('viz_act_release', '放开') + '</option>';
      sel.value = entry.on ? 'on' : 'off';
      sel.addEventListener('change', function () {
        entry.on = (sel.value === 'on');
        
        label.textContent = ActIcon.of(entry.kind, entry.on) + ' ' + name;
        self._invalidate(m);
      });
      sel.addEventListener('click', function (e) { e.stopPropagation(); });
      row.appendChild(lockCtl(sel, lk));
    }

    
    [['↑', -1, 'viz_act_up', '上移'], ['↓', 1, 'viz_act_down', '下移']]
      .forEach(function (spec) {
        var b = document.createElement('button');
        b.textContent = spec[0];
        b.title = VizI18n.tr(spec[2], spec[3]);
        var to = i + spec[1];
        b.disabled = (to <= 0 || to >= m.entries.length);
        b.style.cssText = 'flex:0 0 auto; padding:0 4px; line-height:1.3;' +
          (b.disabled ? ' opacity:.35;' : '');
        b.addEventListener('click', function (e) {
          e.stopPropagation();
          self.moveEntry(m, i, spec[1]);
        });
        row.appendChild(lockCtl(b, lk));
      });

    var del = document.createElement('button');
    del.className = 'mo-btn';
    del.textContent = '×';
    del.title = VizI18n.tr('viz_wp_del', '删除');
    del.style.cssText = 'flex:0 0 auto; padding:0 5px; line-height:1.3;';
    del.addEventListener('click', function (e) {
      e.stopPropagation();
      self.removeEntry(m, i);
    });
    row.appendChild(lockCtl(del, lk));
    row.appendChild(this._addButton(m, i));
    return row;
  };

  MotionsUI.prototype._renderSeg = function (m, entry, i) {
    var self = this;
    var lk = this.editLocked();      
    var row = document.createElement('div');
    row.className = 'mo-seg';
    row.style.cssText = 'display:flex; align-items:center; gap:5px; margin:3px 0; ' +
      'padding:2px 3px; border:1px solid transparent; border-radius:3px;';
    if (m.failIndex === i) {          
      row.style.borderColor = '#e74c3c';
      row.title = VizI18n.tr('viz_seg_fail_tip', '该段直线不可达（中间插值点无 IK 解或自我干涉）');
    }

    
    var prev = null;
    for (var k = i - 1; k >= 0; k--) {
      if (isMove(m.entries[k])) { prev = m.entries[k]; break; }
    }
    var isFirstMove = (prev === null);
    var from = isFirstMove ? '' : prev.item.name;
    var label = document.createElement('span');
    label.style.cssText = 'flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; ' +
      'white-space:nowrap; color:#c5c8c6;';
    label.textContent = (from ? from + ' → ' : '→ ') + entry.item.name;
    row.appendChild(label);

    if (isFirstMove) {
      
      var fixed = document.createElement('span');
      fixed.style.cssText = 'flex:0 0 auto; color:#8f979e;';
      fixed.textContent = VizI18n.tr('viz_wp_joint', '关节');
      fixed.title = VizI18n.tr('viz_motion_first_joint', '首段从当前位姿出发，固定关节模式');
      row.appendChild(fixed);
    } else {
      var sel = document.createElement('select');
      sel.style.cssText = 'flex:0 0 auto;';
      sel.innerHTML =
        '<option value="joint">' + VizI18n.tr('viz_wp_joint', '关节') + '</option>' +
        '<option value="linear">' + VizI18n.tr('viz_wp_linear', '直线') + '</option>';
      sel.value = entry.motion;
      sel.addEventListener('change', function () {
        entry.motion = sel.value;
        self._invalidate(m);       
        self._render();            
      });
      sel.addEventListener('click', function (e) { e.stopPropagation(); });
      row.appendChild(lockCtl(sel, lk));
    }

    
    
    var isLinear = (!isFirstMove && entry.motion === 'linear');
    var sp = document.createElement('input');
    sp.type = 'number';
    sp.min = String(MIN_SPEED); sp.max = String(MAX_SPEED); sp.step = '0.05';
    sp.value = entry.speed;
    sp.disabled = isLinear;
    sp.title = isLinear
      ? VizI18n.tr('viz_speed_linear_tip', '直线模式速度固定，速度比例仅对关节模式有效')
      : VizI18n.tr('viz_speed_tip', '速度比例 {lo}~{hi}')
          .replace('{lo}', MIN_SPEED).replace('{hi}', MAX_SPEED);
    sp.style.cssText = 'flex:0 0 auto; width:40px;' + (isLinear ? ' opacity:.4;' : '');
    sp.addEventListener('change', function () {
      var v = parseFloat(sp.value);
      if (isNaN(v)) v = DEFAULT_SPEED;
      v = Math.max(MIN_SPEED, Math.min(MAX_SPEED, v));   
      entry.speed = v;
      sp.value = v;
    });
    sp.addEventListener('click', function (e) { e.stopPropagation(); });
    row.appendChild(lockCtl(sp, lk));

    var del = document.createElement('button');
    del.className = 'mo-btn';
    del.textContent = '×';
    del.title = VizI18n.tr('viz_wp_del', '删除');
    del.style.cssText = 'flex:0 0 auto; padding:0 5px; line-height:1.3;';
    del.addEventListener('click', function (e) {
      e.stopPropagation();
      self.removeEntry(m, i);       
    });
    row.appendChild(lockCtl(del, lk));
    row.appendChild(this._addButton(m, i));
    return row;
  };

  

  function clamp(v, lo, hi, fallback) {
    var n = parseFloat(v);
    if (isNaN(n)) return fallback;
    return Math.max(lo, Math.min(hi, n));
  }

  window.MotionsUI = MotionsUI;
})();
