/* ============================================================================ * ik-glue.js —— 把 ArmIK 求解器接到「机器人手臂3D查看器」的场景与 UI 上 * 依赖查看器全局:JO / TRAVEL / meshes / JG / ang / applyJoint / scene / cam / * cv / VC / sel / requestRender / az,po,rd,tx,ty,tz * ==========================================================================*/ (function () { 'use strict'; if (typeof ArmIK === 'undefined' || typeof JO === 'undefined') return; const D2R = Math.PI / 180; const ORDER = ArmIK.ORDER; const $ = id => document.getElementById(id); const JJ = {}; for (const j of JO) JJ[j.id] = j; const CN = {}; for (const id of ORDER) CN[id] = (JJ[id] && JJ[id].cn) || id; /* ---------- 0. TCP:末端工具法兰安装面中心(由 J6 组内零件几何自动取得)---------- */ let tcpZ = 93; (function () { let lo = Infinity; for (const m of meshes) { const u = m.userData; if (!u || u.grp !== 'J6' || !u.printed || u.name.slice(0, 4) === 'ENV_') continue; const b = m.geometry.boundingBox; if (!b) continue; lo = Math.min(lo, b.min.y); // 查看器系 y = CAD 系 z } if (isFinite(lo)) tcpZ = lo; })(); const TCP0 = [JJ.J6.rear[0], JJ.J6.rear[1], tcpZ]; /* ---------- 1. 建立求解器 ---------- */ const JMap = {}; for (const j of JO) JMap[j.id] = { rear: j.rear, d: j.d }; const COL = (typeof COLLIDE !== 'undefined' && COLLIDE.caps && COLLIDE.caps.length) ? COLLIDE : null; /* 可行域不再取 docs/travel.json 的静态扫描值——那是"其它轴全零"时对固定立柱的一次 性结果,随姿态大变(电机行程现为 ±360°,见 collide.motor_limit_deg)。改由两处 真实几何决定:adj = 相邻打印件 1D 自由区间(CAD 实测、与姿态无关,胶囊模型故意 不检相邻对);跨 ≥2 组的零件对由 dynLimit 按当前姿态实时二分。 */ const ADJ = (COL && COL.adj) || {}; const MOTOR = +((COL && COL.motor_limit_deg) || 360); // 电机行程(滑块范围) const STATIC_TL = {}; for (const t of TRAVEL) STATIC_TL[t.axis] = { lo: t.lo, hi: t.hi }; const TR = {}; const noAdj = []; for (const t of TRAVEL) { const a = ADJ[t.axis]; if (!a && !t.continuous) noAdj.push(t.axis); TR[t.axis] = { lo: a ? a[0] : t.lo, hi: a ? a[1] : t.hi, continuous: !!t.continuous }; } /* 限位轴丢了 adj 就会退回 travel.json 的零位扫描值(表现为"又被锁回 −106"): fit_capsules.py 重写 collide.json 时已沿用 adj,若仍缺说明只跑了拟合没跑实测。 */ if (COL && noAdj.length) console.warn('[IK] collide.json 缺 ' + noAdj.join('/') + ' 的 adj 实测区间,这几轴退回零位扫描限位;' + '重跑 python scratch/probe_adj.py --workers 10'); const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0, collide: COL }); const LIM = S.LIM; let lastTool = 0; let TGT = [150, 128, 260]; /* 选解判据(用户指定):所有电机需要转过的角度之和最小 —— 以当前手臂姿态为参考。 连续轴 θ 与 θ±360 等价,角差取 wrap180;不给 ref 时退化为 Σ|θ| 最小(离线批算)。 (旧实现是 Σ|θ| + λ·ΣΔ² 的二次连续项,已被本判据取代。) */ const curRef = () => ORDER.map(id => ang[id]); /* ---------- 1b. 姿态控制状态 ---------- */ let poseOn = false; const POSE = { az: 0, el: 90, roll: 0 }; // 目标姿态:工具轴方位角 / 仰角 + 绕轴滚转(度) /* 双优先级:位置与姿态在远目标上不可兼得(实测某目标位置 0mm 时姿态差 44°,而姿态 0° 时位置差 116mm)。 'pos' 位置优先 —— 保 TCP 点位精确,姿态取最接近可达值; 'ori' 姿态优先 —— 保指定姿态精确,TCP 取最接近可达点; 'mix' 混合优先 —— 滑杆自由分配两端比重:0% = 姿态优先、100% = 位置优先(两端与纯算法逐位一致), 中间值按两端锚点误差归一的折中代价 J = w·(e/e_ref)² + (1−w)·(o/o_ref)² 搜索。 */ const prioVal = () => { const e = $('ikPrio'); const v = e && e.value; return v === 'ori' || v === 'mix' ? v : 'pos'; }; const mixWVal = () => { const e = $('ikMixW'); return e ? Math.max(0, Math.min(100, +e.value || 0)) / 100 : 0.5; }; const prioTxt = () => prioVal() === 'ori' ? '姿态优先' : (prioVal() === 'mix' ? '混合优先 ' + Math.round(mixWVal() * 100) + '% 位置' : '位置优先'); const Rdes = () => ArmIK.poseMat(POSE.az, POSE.el, POSE.roll); const syncPoseInputs = () => { $('ikAz').value = (+POSE.az).toFixed(1); $('ikEl').value = (+POSE.el).toFixed(1); $('ikRoll').value = (+POSE.roll).toFixed(1); }; const fillPoseFromCurrent = () => { // 用当前真实工具姿态回填(开启时避免姿态跳变) const a = ArmIK.poseAng(Uof(ORDER.map(id => ang[id]))); POSE.az = a.az; POSE.el = a.el; POSE.roll = a.roll; syncPoseInputs(); }; const CAD2V = v => [v[0], v[2], -v[1]]; // CAD 向量 → 查看器坐标(与查看器 VC 一致) const V2CAD = v => [v[0], -v[2], v[1]]; // 查看器坐标 → CAD 向量(CAD2V 的逆) /* 用户姿态 ↔ 求解器姿态的换算:求解器内部工具坐标系 z 轴取法兰面法向(朝臂内, 即 TCP 外伸的反向),而界面上的方位/仰角/滚转与箭头都以「工具朝外方向」描述。 两者相差绕工具 x 轴 180°(x 不变、y/z 取反),入栈/出栈各翻一次即可。 */ const RX180 = [1, 0, 0, 0, -1, 0, 0, 0, -1]; const mul3g = (A, B) => { const C = new Array(9); for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) C[i * 3 + j] = A[i * 3] * B[j] + A[i * 3 + 1] * B[3 + j] + A[i * 3 + 2] * B[6 + j]; return C; }; const Rsolver = () => mul3g(Rdes(), RX180); // 交给求解器/场景校验(列 2 = 朝臂内) const Uof = q => mul3g(S.orient(q), RX180); // 手臂当前姿态 → 用户系(列 2 = 朝臂外) /* ---------- 2. 场景标注 ---------- */ const gTgt = new THREE.Group(); scene.add(gTgt); gTgt.add(new THREE.Mesh(new THREE.SphereGeometry(8.5, 22, 16), new THREE.MeshStandardMaterial({ color: 0xffb454, emissive: 0x7a4d05, metalness: .25, roughness: .4 }))); const AXCOL = [0xff6b6b, 0x3ddc97, 0x4c9aff]; const AXDIR = [[1, 0, 0], [0, 0, -1], [0, 1, 0]]; // CAD 的 X / Y / Z 在查看器系中的方向 for (let i = 0; i < 3; i++) { const L = 55, d = AXDIR[i], bg = new THREE.BufferGeometry(); bg.setAttribute('position', new THREE.BufferAttribute(new Float32Array([ -d[0] * L, -d[1] * L, -d[2] * L, d[0] * L, d[1] * L, d[2] * L]), 3)); gTgt.add(new THREE.Line(bg, new THREE.LineBasicMaterial({ color: AXCOL[i] }))); } const RING = new THREE.Mesh(new THREE.RingGeometry(9.5, 12.5, 40), new THREE.MeshBasicMaterial({ color: 0xffb454, side: THREE.DoubleSide, transparent: true, opacity: .45 })); RING.rotation.x = -Math.PI / 2; scene.add(RING); const ballTcp = new THREE.Mesh(new THREE.SphereGeometry(6.5, 20, 14), new THREE.MeshStandardMaterial({ color: 0x3ddc97, emissive: 0x14603f, metalness: .3, roughness: .35 })); JG.J6.add(ballTcp); /* 当前工具轴线:TCP 球沿真实工具轴伸出(绿),与目标箭头对照看姿态差 */ const curGeo = new THREE.BufferGeometry(); curGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3)); const curLine = new THREE.Line(curGeo, new THREE.LineBasicMaterial({ color: 0x3ddc97, transparent: true, opacity: .95 })); scene.add(curLine); /* 姿态拖动箭头(挂目标球组,随目标移动): 拖箭头头部 = 调工具轴指向(方位/俯仰,弧球式);拖箭杆/捏环/粉色侧球 = 绕轴滚转。 轴向为本地 +Z:杆/锥几何预旋到 +Z,gArrow 再用四元数对准目标工具轴。 侧球挂在目标点上、沿工具 x 轴侧向偏移——箭头正对镜头时杆身投影缩成一团, 侧球始终在侧向,任何视角都抓得到。 */ const AXLEN = 150, HEADL = 26, HEADZ = AXLEN - HEADL / 2, GRIPZ = (AXLEN - HEADL) / 2, KNOBOFF = 0.72 * AXLEN; const gArrow = new THREE.Group(); gTgt.add(gArrow); const knob = new THREE.Mesh(new THREE.SphereGeometry(9, 18, 12), new THREE.MeshStandardMaterial({ color: 0xff8fb0, emissive: 0x5c1f38, metalness: .2, roughness: .4, transparent: true, opacity: .8 })); knob.userData.role = 'roll'; gTgt.add(knob); const arrowShaft = (function () { const g = new THREE.CylinderGeometry(3.0, 3.0, AXLEN - HEADL, 12); g.rotateX(Math.PI / 2); // 圆柱默认轴向 Y → +Z const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0xffb454, emissive: 0x6b4a10, metalness: .2, roughness: .4 })); m.position.z = (AXLEN - HEADL) / 2; m.userData.role = 'roll'; // 抓杆身 = 滚转 return m; })(); const arrowRing = (function () { const m = new THREE.Mesh(new THREE.TorusGeometry(10, 2.8, 10, 40), new THREE.MeshStandardMaterial({ color: 0xff8fb0, emissive: 0x5c1f38, metalness: .2, roughness: .4 })); m.rotation.x = Math.PI / 2; // 环面垂直于轴 m.position.z = GRIPZ; m.userData.role = 'roll'; return m; })(); const arrowHead = (function () { const g = new THREE.ConeGeometry(7.5, HEADL, 18); g.rotateX(Math.PI / 2); const m = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0xffd166, emissive: 0x7a5a10, metalness: .2, roughness: .35 })); m.position.z = HEADZ; m.userData.role = 'head'; return m; })(); gArrow.add(arrowShaft, arrowRing, arrowHead); const axisViewer = () => { // 目标工具轴在查看器系的方向(单位) const R = Rdes(), v = CAD2V([R[2], R[5], R[8]]); // 行主序:列 2 = 工具轴(朝臂外) return new THREE.Vector3(v[0], v[1], v[2]).normalize(); }; /* 姿态把手拾取返回 'axis' | 'roll' | null(同时不抢黄球的位置拖动): · 箭头头与侧球都做「屏幕就近」检测,谁近听谁的(箭头正对镜头时两者投影会靠近); · 都够近时黄球优先交回位置拖动;再往后才是箭杆/捏环/箭头锥体的几何射线。 */ const knobScreen = () => { const p = knob.getWorldPosition(new THREE.Vector3()).project(cam); const r = cv.getBoundingClientRect(); return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height }; }; function pickPoseHandle(e) { const pt = projTgt(); const db = Math.hypot(e.offsetX - pt.x, e.offsetY - pt.y); const hp = projArrow(HEADZ); const dh = Math.hypot(e.offsetX - hp.x, e.offsetY - hp.y); const kp = knobScreen(); const dk = Math.hypot(e.offsetX - kp.x, e.offsetY - kp.y); if (dh < 22 && (dk >= 26 || dh <= dk) && dh <= db) return 'axis'; if (dk < 26 && dk <= db) return 'roll'; if (db < 26) return null; // 黄球抓取区:交回位置拖动 ray2.setFromCamera(ndc2(e), cam); for (const h of ray2.intersectObjects([arrowShaft, arrowRing, arrowHead, knob], false)) { const r = h.object.userData.role; if (r === 'head') return 'axis'; if (r === 'roll') return 'roll'; } return null; } const linkGeo = new THREE.BufferGeometry(); linkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3)); const linkLine = new THREE.Line(linkGeo, new THREE.LineBasicMaterial({ color: 0xffb454, transparent: true, opacity: .9 })); scene.add(linkLine); const tcpCad = () => [TCP0[0], TCP0[1], TCP0[2] - lastTool]; function refreshTarget() { const v = VC(TGT); gTgt.position.set(v[0], v[1], v[2]); RING.position.set(-v[0], -v[1], -v[2]); RING.visible = VIEW === 'motion' && Math.abs(v[1]) > 14; ballTcp.position.set(...VC(tcpCad())); scene.updateMatrixWorld(true); // 必须先刷新 6 级矩阵链再读 TCP const w = new THREE.Vector3(...VC(tcpCad())).applyMatrix4(JG.J6.matrixWorld); const a = gTgt.position, pa = linkGeo.attributes.position; pa.array[0] = a.x; pa.array[1] = a.y; pa.array[2] = a.z; pa.array[3] = w.x; pa.array[4] = w.y; pa.array[5] = w.z; pa.needsUpdate = true; const d = Math.hypot(w.x - a.x, w.y - a.y, w.z - a.z); ballTcp.material.color.setHex(d < 1.2 ? 0x3ddc97 : 0xffb454); /* 目标箭头(指向工具轴)与当前工具轴线(绿,对照看姿态差) */ gArrow.visible = poseOn && VIEW === 'motion'; if (poseOn) { const R = Rdes(); gArrow.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), axisViewer()); const xv = CAD2V([R[0], R[3], R[6]]); // 行主序:列 0 = 工具 x 轴;侧球沿它侧偏,始终离开箭头 knob.position.set(xv[0] * KNOBOFF, xv[1] * KNOBOFF, xv[2] * KNOBOFF); } knob.visible = poseOn && VIEW === 'motion'; const Rcur = S.orient(ORDER.map(id => ang[id])); const avc = CAD2V([Rcur[2], Rcur[5], Rcur[8]]); // 求解器列 2 = 法兰法向(朝臂内),取反为外伸方向 const av = [-avc[0], -avc[1], -avc[2]]; const ca = curGeo.attributes.position; ca.array[0] = w.x; ca.array[1] = w.y; ca.array[2] = w.z; ca.array[3] = w.x + av[0] * 60; ca.array[4] = w.y + av[1] * 60; ca.array[5] = w.z + av[2] * 60; ca.needsUpdate = true; curLine.visible = VIEW === 'motion'; requestRender(); } /* ---------- 2b. 滑块动态限位夹取(边界缓存)---------- collideFree 一票 ~5ms(1995 胶囊、零位最坏),不允许每次 input 都二分。 缓存 [轴, 其余轴 1° 量化姿态] → {lo, hi}:拖同一轴时其余轴不动,直接取缓存; 只有拖过缓存边界(或换了姿态)才重算一次二分(~40ms,一次卡顿换正确边界)。 */ const bnd = { key: '', lo: 0, hi: 0 }; function clampAxis(id, v) { if (!COL) return v; const others = ORDER.map(x => x === id ? '-' : Math.round(ang[x])).join(','); const key = id + '|' + others; if (bnd.key !== key) { bnd.key = key; const q = ORDER.map(x => x === id ? ang[x] : Math.round(ang[x])); if (!S.collideFree(q)) { /* 当前姿本身已干涉(上次被拖越界后卡在界外):dynLimit 会把"当前值"当边界, 滑块就再也回不来了。此时退回该轴机械区间(相邻件实测自由区间,必然无自碰撞)。 */ bnd.lo = LIM[id].lo; bnd.hi = LIM[id].hi; } else { bnd.lo = S.dynLimit(id, q, -1); bnd.hi = S.dynLimit(id, q, 1); } } return Math.max(bnd.lo, Math.min(bnd.hi, v)); } /* 刷新滑块标签里的动态范围(异步执行,避免阻塞拖拽) */ /* 节流版:拖动/实时追踪中每次 setJoints 都会触发 3 次 dynLimit(每次 ~9 次 collideFree 二分 ≈ 28 ms), 高频下把 0.1 ms 的快路放大 280×。250 ms 节流 + 尾随补一次;动画结束/松手仍走立即版。 */ let _rjlLast = -1e9, _rjlTimer = 0; function refreshJointLabelsLazy(q) { const t = performance.now(); if (t - _rjlLast > 250) { _rjlLast = t; refreshJointLabels(q); return; } if (!_rjlTimer) _rjlTimer = setTimeout(() => { _rjlTimer = 0; _rjlLast = performance.now(); refreshJointLabels(ORDER.map(id => ang[id])); }, 260); } /* 六轴动态边界改到 Worker 里算:连续轴现在必须整周粗扫(实测 6 轴×2 向一次 175ms → 1131ms),留在主线程会把拖拽冻住。dynAsync 只记账、不写 IKWlast, 否则会把在飞的 solveTo/solveDyn 结果误标成过期(历史上丢过回调导致实时追踪停摆)。 */ function dynAsync(q, tol, job) { const w = ikWorker(); if (!w) { job(S.dynLimits(q, tol)); return; } // Worker 不可用:退回主线程同步算 const id = ++IKWseq; IKWjobs.set(id, r => { job(r && !r.__err ? r : S.dynLimits(q, tol)); }); w.postMessage({ id: id, op: 'dynLimits', args: [q, tol] }); } function refreshJointLabels(q) { dynAsync(q.slice(), 1.0, D => paintJointLabels(D)); } function paintJointLabels(D) { D = D || {}; for (const id of ORDER) { const el = $('e' + id); if (!el) continue; const t = LIM[id]; if (COL && D[id]) { /* 边界由几何说话:机械区间(adj 实测)之内,再按当前姿态二分胶囊碰撞。 lo 侧一律收紧;hi 侧只有"硬边"(相邻件自碰撞,配置无关)才受静态值封顶, 可放宽侧(撞固定立柱那类)由模型说话——动态边界本就可以远超静态 hi。 连续轴(J1/J3/J6)同样要查:折肘/折腕位形下末段会扫到固定立柱, 旧口径因为跳过连续轴而把它显示成整周可用。 */ const lo = Math.max(t.cont ? -180 : t.lo, D[id].lo); const hardHi = COL.hard && COL.hard[id] && COL.hard[id].hi; const hi = hardHi ? Math.min(t.hi, D[id].hi) : D[id].hi; const dyn = Math.abs(lo - t.lo) > 0.5 || Math.abs(hi - t.hi) > 0.5; // 与静态不同(宽或窄)都标记 /* 连续轴没收紧时显示"整周可用",收紧了就换成区间:同一个 是唯一写手, 别让静态的 360 度连续徽标和实时夹取区间并存(状态灯与数字会打架)。 */ el.className = dyn ? '' : (t.cont ? 'ok' : ''); el.innerHTML = (t.cont && !dyn) ? '整周可用' : ((dyn ? '' : '') + lo.toFixed(0) + '\u00B0\u2026' + hi.toFixed(0) + '\u00B0' + (dyn ? ' \u25B2' : '')); const st = STATIC_TL[id] || {}; el.title = (t.cont ? '连续轴(整周)' : '机械区间(相邻件实测)') + t.lo + '\u2026' + t.hi + '\u00B0;电机行程 ±' + MOTOR + '\u00B0;当前姿态动态边界 ' + lo.toFixed(1) + '\u2026' + hi.toFixed(1) + '\u00B0(胶囊碰撞模型实时算)' + (st.lo != null ? '\n零位静态扫描参考 ' + st.lo + '\u2026' + st.hi + '\u00B0' : ''); } else { el.textContent = t.lo + '\u00B0\u2026' + t.hi + '\u00B0'; } } } /* ---------- 3. 关节写入(同步滑块与标签)---------- */ let anim = null; function setJoints(q, doAnim, via, ms) { if (doAnim) { animTo(q, ms || 620, via); return; } anim = null; // 直接写入必须取消在飞动画,否则入场动画会覆写实时解 for (let i = 0; i < 6; i++) { const id = ORDER[i]; ang[id] = q[i]; const s = $('s' + id), v = $('v' + id); if (s) s.value = q[i]; if (v) v.textContent = (+q[i]).toFixed(2) + '\u00B0'; applyJoint(id); } refreshTarget(); if (COL) refreshJointLabelsLazy(ORDER.map(id => ang[id])); } function animTo(q, ms, via) { if (anim) { anim = null; } const from = ORDER.map(id => ang[id]); /* 连续轴(J1 肩抬举 / J3 大臂自转 / J6 腕自转)走「短弧」。 三者物理上无机械限位,θ 与 θ±360 是同一物理位形(applyJoint 走 cos/sin); 若按数值直接线性插值,+151.9° → −167.0° 会反向扫 318.9° 绕一整圈(实测)。 这里先求「离起始角最近的等价角」作为插值终点,运动变成 41.1° 的短弧; end 保存求解器给的代表角,末帧落回它——与插值终点恰好相差 360° 的整数倍, 同一物理角,视觉零跳变,同时保证滑块/关节表数值与求解结果一致。 via:绕行中间位形(solveRoute 判直线被挡时给的两段式路径),逐段同样按短弧折算, 每段各 ms 毫秒——渲染走的段与碰撞校验走的段是同一批,不存在"校验过了但动画穿模"。 */ const segs = []; let p = from; for (const raw of (via || []).concat([q])) { const t = raw.slice(); for (let i = 0; i < 6; i++) { const id = ORDER[i]; if (LIM[id].cont) t[i] = p[i] + S.wrap180(raw[i] - p[i]); } segs.push(t); p = t; } const end = q.slice(); const total = ms * segs.length; anim = { from: from, to: segs[segs.length - 1], t0: performance.now(), ms: total }; const tick = () => { if (!anim) return; const el = performance.now() - anim.t0; const si = Math.min(segs.length - 1, Math.floor(el / ms)); const k = Math.min(1, (el - si * ms) / ms); const e = k * k * (3 - 2 * k); const a0 = si === 0 ? from : segs[si - 1], a1 = segs[si]; for (let i = 0; i < 6; i++) { const id = ORDER[i], a = a0[i] + (a1[i] - a0[i]) * e; ang[id] = a; const sl = $('s' + id), v = $('v' + id); if (sl) sl.value = a; if (v) v.textContent = a.toFixed(2) + '\u00B0'; applyJoint(id); } refreshTarget(); if (k < 1) requestAnimationFrame(tick); else { anim = null; /* 落回代表角(与插值终点等价,视觉无变化),使 ang 与 res.q 对齐 */ for (let i = 0; i < 6; i++) { const id = ORDER[i]; ang[id] = end[i]; const sl = $('s' + id), v = $('v' + id); if (sl) sl.value = end[i]; if (v) v.textContent = end[i].toFixed(2) + '\u00B0'; applyJoint(id); } if (COL) refreshJointLabels(ORDER.map(id => ang[id])); requestRender(); } }; requestAnimationFrame(tick); } /* ---------- 4. 结果渲染 ---------- */ const badge = $('ikBadge'); /* 场景图矩阵链复算姿态偏差(度): · 场景侧:直接取 J6 组世界旋转作用在工具 x/z 轴上的方向(transformDirection, 免去列主序手工换算),正交化后组装成查看器系旋转 Rs; · 目标侧:CAD 系旋转 Rtar 经坐标旋转 Rv(CAD→查看器)共轭到查看器系 R_viewer = Rv·R_cad·Rvᵀ(单边变换列向量不够——基变了整个旋转都要转)。 */ const RV3 = [1, 0, 0, 0, 0, 1, 0, -1, 0]; // CAD→查看器 3×3(行主序) const RV3T = [1, 0, 0, 0, 0, -1, 0, 1, 0]; // 转置 function sceneOrientErr(q, Rtar) { const save = ORDER.map(id => ang[id]); for (let i = 0; i < 6; i++) { ang[ORDER[i]] = q[i]; applyJoint(ORDER[i]); } scene.updateMatrixWorld(true); const M = JG.J6.matrixWorld; const zs = new THREE.Vector3(0, 0, 1).transformDirection(M); let xs = new THREE.Vector3(1, 0, 0).transformDirection(M); xs.sub(zs.clone().multiplyScalar(xs.dot(zs))).normalize(); // 正交化(防场景缩放引入的分量) const ys = new THREE.Vector3().crossVectors(zs, xs); const Rs = [xs.x, ys.x, zs.x, xs.y, ys.y, zs.y, xs.z, ys.z, zs.z]; // 列 = 工具 x/y/z(查看器系) const Rt = []; // 目标:共轭到查看器系 for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) { let s = 0; for (let k = 0; k < 3; k++) s += RV3[i * 3 + k] * Rtar[k * 3 + j]; Rt.push(s); } const Rt2 = []; for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) { let s = 0; for (let k = 0; k < 3; k++) s += Rt[i * 3 + k] * RV3T[k * 3 + j]; Rt2.push(s); } for (let i = 0; i < 6; i++) { ang[ORDER[i]] = save[i]; applyJoint(ORDER[i]); } return ArmIK.rotErrDeg(Rs, Rt2); } function renderResult(res, src) { const q = res.q || [0, 0, 0, 0, 0, 0]; const tb = $('ikTab').tBodies[0]; tb.innerHTML = ''; for (let i = 0; i < 6; i++) { const id = ORDER[i], t = LIM[id]; const tr = document.createElement('tr'); tr.innerHTML = '' + id + '' + CN[id] + '' + '' + q[i].toFixed(2) + '\u00B0' + '' + Math.abs(q[i]).toFixed(1) + '' + '' + (t.cont ? '\u00B1' + MOTOR : (res.dynAxis === id ? t.lo + '..' + '' + res.dynBound.toFixed(1) + '*' : t.lo + '..' + t.hi)) + ''; if (i === 5 && !res.tool) tr.title = poseOn ? '姿态控制开启:θ6 为绕工具轴的滚转角,由目标姿态(方位/仰角/滚转)解算' : 'TCP 位于 J6 轴线上,转动 J6 不改变 TCP 位置,故取 0 使总角最小'; tb.appendChild(tr); } lastTool = res.tool || 0; const dcl = res.err != null ? res.err : 0; const dori = res.oerr != null ? res.oerr : 0; let doriSc = 0; const sc = (function () { // 用场景图真实矩阵链复算 const save = ORDER.map(id => ang[id]); for (let i = 0; i < 6; i++) { ang[ORDER[i]] = q[i]; applyJoint(ORDER[i]); } scene.updateMatrixWorld(true); const w = new THREE.Vector3(...VC(tcpCad())).applyMatrix4(JG.J6.matrixWorld); if (poseOn) doriSc = sceneOrientErr(q, Rsolver()); for (let i = 0; i < 6; i++) { ang[ORDER[i]] = save[i]; applyJoint(ORDER[i]); } return [w.x, -w.z, w.y]; })(); const dsc = Math.hypot(sc[0] - res.target[0], sc[1] - res.target[1], sc[2] - res.target[2]); const sum = q.reduce((a, b) => a + b, 0); const pa = poseOn ? ArmIK.poseAng(Uof(q)) : null; $('ikSum').innerHTML = (res.moveSum != null ? '本步行程 \u03A3|\u0394\u03B8|(判据)' + res.moveSum.toFixed(2) + '\u00B0' : '') + '总关节角 \u03A3|\u03B8|' + S.costOf(q, null).toFixed(2) + '\u00B0' + '\u03A3\u03B8 代数和' + sum.toFixed(1) + '\u00B0' + '残差 闭式/场景图' + dcl.toExponential(1) + ' / ' + dsc.toExponential(1) + ' mm' + (poseOn ? '目标姿态方位 ' + POSE.az.toFixed(1) + '\u00B0 · 仰角 ' + POSE.el.toFixed(1) + '\u00B0 · 滚转 ' + POSE.roll.toFixed(1) + '\u00B0' : '') + (poseOn ? '实际姿态' + pa.az.toFixed(1) + '\u00B0 · ' + pa.el.toFixed(1) + '\u00B0 · ' + pa.roll.toFixed(1) + '\u00B0' : '') + (poseOn ? '姿态残差 闭式/场景图' + dori.toFixed(3) + '\u00B0 / ' + doriSc.toFixed(3) + '\u00B0' : '') + (poseOn && res.priority === 'mix' && res.mixP && res.mixO ? '折中权重位置 ' + Math.round((res.mixW == null ? 0.5 : res.mixW) * 100) + '% · 姿态 ' + (100 - Math.round((res.mixW == null ? 0.5 : res.mixW) * 100)) + '%(按两端锚点误差归一)' + '两端锚点位置端 ' + res.mixP.e.toFixed(2) + ' mm / ' + res.mixP.o.toFixed(2) + '\u00B0 · 姿态端 ' + res.mixO.e.toFixed(1) + ' mm / ' + res.mixO.o.toFixed(2) + '\u00B0' : '') + '可行采样点' + res.nSol + '' + '耗时' + res.ms.toFixed(0) + ' ms' + '目标(' + res.target.map(v => v.toFixed(1)).join(', ') + ')' + (res.resid != null ? '距目标' + res.resid.toFixed(2) + ' mm' : '') + (poseOn && res.resid != null && res.oerr != null ? '姿态偏差' + res.oerr.toFixed(2) + '\u00B0' : ''); const oriOk = !poseOn || (dori < 0.05 && doriSc < 0.1); /* 以残差判到达:阻尼最小二乘兜底也可能精确到达(扫描漏掉的奇异刀尖解),不能只看 reachable */ const posOk = dsc < .05; /* 姿态优先/混合姿态端:姿态精确、TCP 落在最近可达点——与「不可达」语义不同,单独标出并给出距离 */ const oriPrio = poseOn && (res.priority === 'ori' || res.priority === 'mix') && oriOk && !posOk; /* 混合折中:位置与姿态都有残差(两端都不可兼得时的加权折中点) */ const mixMid = poseOn && res.priority === 'mix' && !posOk && !oriOk; badge.textContent = res.unsafe ? '位姿与底座/立柱近似干涉(目标可能不可达或位于立柱区内)' : res.blocked ? '路径被立柱挡住 · 已停在边界(目标差 ' + dsc.toFixed(1) + ' mm)' : res.detour ? (res.detour === 'retract_pass' ? '避障到达 · 模拟人类回退绕过立柱' : (res.detour === 'gentle' ? '避障到达 · 微调协同避开立柱' : (res.detour === 'coordinated' ? '避障到达 · 平滑协同跨越立柱' : '绕行立柱后到达'))) : (posOk && oriOk) ? '已到达' : oriPrio ? '姿态到达·位置最近 ' + dsc.toFixed(1) + ' mm' : mixMid ? '混合折中 · 位置差 ' + dsc.toFixed(1) + ' mm · 姿态残差 ' + dori.toFixed(2) + '\u00B0' : (posOk && poseOn && dori > 0.05 ? '位置到达·姿态残差 ' + dori.toFixed(2) + '\u00B0' : '不可达·最近位姿'); badge.className = 'badge ' + (res.unsafe || (!posOk && !oriPrio && !mixMid) ? 'badb' : (posOk && oriOk) ? 'goodb' : 'warnb'); $('ikSrc').textContent = src; $('ikBar').innerHTML = '\u03A3|\u03B8| ' + S.costOf(q, null).toFixed(2) + '\u00B0' + '|\u6B8B\u5DEE ' + '' + dsc.toExponential(1) + ' mm' + (poseOn ? '|\u59FF\u6001\u6B8B\u5DEE ' + dori.toFixed(3) + '\u00B0' : '') + '|\u53EF\u884C\u91C7\u6837\u70B9 ' + res.nSol + '|' + res.ms.toFixed(0) + ' ms'; /* 备选互异构型 */ const alt = []; for (let i = 0; i < res.list.length && alt.length < 6; i++) { const s = res.list[i]; let dup = false; for (const a of alt) { let dd = 0; for (let c = 0; c < 6; c++) dd += Math.abs(a.q[c] - s.q[c]); if (dd < 12) { dup = true; break; } } if (!dup) alt.push(s); } $('ikAltN').textContent = '\u4E92\u5F02\u6784\u578B ' + alt.length + ' \u4E2A\uFF08\u89E3\u65CF\u5171 ' + res.nSol + ' \u91C7\u6837\u70B9\uFF09\u00B7 \u70B9\u884C\u5E94\u7528'; const box = $('ikAlts'); box.innerHTML = ''; alt.forEach((s, i) => { const d = document.createElement('div'); d.className = 'altrow'; d.innerHTML = '' + (i + 1) + '' + s.cost.toFixed(2) + '\u00B0' + '' + s.q.map(v => v.toFixed(1)).join(' / ').replace(/ /g, '') + ''; d.title = ORDER.map((id, k) => id + ' = ' + s.q[k].toFixed(2) + '\u00B0').join(' '); d.onclick = () => setJoints(s.q, true); box.appendChild(d); }); } /* ---------- 5. 求解(Worker 化)---------- solveDyn/solveTo 的全量扫描(40~430 ms)挪进后台线程,主线程只收结果渲染, 松手/按钮不再冻结 UI。单文件自包含:ArmIK 源码取自页面内嵌