/* ============================================================================ * 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 TR = {}; for (const t of TRAVEL) TR[t.axis] = { lo: t.lo, hi: t.hi, continuous: !!t.continuous }; const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0 }); const LIM = S.LIM; let lastTool = 0; let TGT = [150, 128, 260]; /* 参考位姿连续项权重:以当前手臂姿态为参考解算,目标微动时锁定同族分支, 避免某轴在等价分支间大幅倒摆(实测 4mm 步长过边界最多跳 81.9° -> 1.2°) */ const REF_W = 0.02; const curRef = () => ORDER.map(id => ang[id]); /* ---------- 1b. 姿态控制状态 ---------- */ let poseOn = false; const POSE = { az: 0, el: 90, roll: 0 }; // 目标姿态:工具轴方位角 / 仰角 + 绕轴滚转(度) 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(); } /* ---------- 3. 关节写入(同步滑块与标签)---------- */ let anim = null; function setJoints(q, doAnim) { if (doAnim) { animTo(q, 620); return; } 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(); } function animTo(q, ms) { if (anim) { anim = null; } const from = ORDER.map(id => ang[id]); anim = { from: from, to: q.slice(), t0: performance.now(), ms: ms }; const tick = () => { if (!anim) return; const k = Math.min(1, (performance.now() - anim.t0) / anim.ms); const e = k * k * (3 - 2 * k); for (let i = 0; i < 6; i++) { const id = ORDER[i], a = anim.from[i] + (anim.to[i] - anim.from[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; for (let i = 0; i < 6; i++) applyJoint(ORDER[i]); 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 ? '\u00B1180' : 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 = '总关节角 \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' : '') + '可行采样点' + 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; badge.textContent = (posOk && oriOk) ? '已到达' : (posOk && poseOn && dori > 0.05 ? '位置到达·姿态残差 ' + dori.toFixed(2) + '\u00B0' : '不可达·最近位姿'); badge.className = 'badge ' + ((posOk && oriOk) ? 'goodb' : (posOk ? 'warnb' : 'badb')); $('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. 求解 ---------- */ function runSolve() { const X = +$('ikX').value, Y = +$('ikY').value, Z = +$('ikZ').value; if (!isFinite(X) || !isFinite(Y) || !isFinite(Z)) { badge.textContent = '坐标非法'; badge.className = 'badge badb'; return; } TGT = [X, Y, Z]; lastTool = +$('ikTool').value || 0; refreshTarget(); badge.textContent = '计算中…'; badge.className = 'badge wait'; setTimeout(() => { const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), refW: REF_W }; if (poseOn) opt.pose = { R: Rsolver() }; const res = S.solveTo(TGT, opt); renderResult(res, res.reachable ? (poseOn ? '闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖' : '闭式全域扫描 · 冗余维精确覆盖') : (poseOn ? '超可达位姿空间 → 阻尼最小二乘最近位姿(位置 mm + 姿态联合)' : '超工作空间 → 阻尼最小二乘最近位姿')); if (res.q) setJoints(res.q, true); }, 0); } /* 实时追踪:拖动过程中同步求解、立即应用(无动画,跟在手后面)。 求解是同步的,同一时刻只跑一个;拖动中的后续 pointermove 只记待处理标志, 当前求解结束后用最新 TGT 再解一次,避免事件排队越拖越卡。 */ let liveBusy = false, livePending = false; function runSolveLive() { if (liveBusy) { livePending = true; return; } liveBusy = true; try { lastTool = +$('ikTool').value || 0; const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), refW: REF_W }; if (poseOn) opt.pose = { R: Rsolver() }; const res = S.solveTo(TGT, opt); renderResult(res, res.reachable ? (poseOn ? '实时追踪 · 闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖' : '实时追踪 · 闭式全域扫描 · 冗余维精确覆盖') : (poseOn ? '实时追踪 · 超可达位姿空间(阻尼最小二乘最近位姿)' : '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)')); if (res.q) setJoints(res.q, false); } finally { liveBusy = false; } if (livePending) { livePending = false; runSolveLive(); } } /* ---------- 6. 目标拖动 / 拾取 ---------- */ let grab = null, camSnap = null, dragged = false, justPicked = false; const ray2 = new THREE.Raycaster(); const snapCam = () => ({ az: az, po: po, tx: tx, ty: ty, tz: tz, rd: rd }); const putCam = s => { az = s.az; po = s.po; tx = s.tx; ty = s.ty; tz = s.tz; rd = s.rd; }; let mode = 'drag'; function setMode(m) { mode = m; $('ikPick').classList.toggle('on', m === 'pick'); $('ikRt').classList.toggle('on', m === 'rt'); $('ikHint').textContent = (m === 'pick' ? '视图单击:目标投到当前高度的水平面' : (m === 'rt' ? '实时追踪中:拖黄球/箭头即算即动,手臂跟着走(松手精修)' : '拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解')) + (poseOn ? ' · 姿态控制中:箭头/粉球已激活,数字框可精确微调' : ' · 开「姿态控制」后出现可拖箭头'); } function projTgt() { const p = gTgt.position.clone().project(cam); const r = cv.getBoundingClientRect(); return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height }; } /* 箭头头部 / 滚转圆环的世界坐标 → 屏幕(用于命中判定) */ function projArrow(localZ) { const p = new THREE.Vector3(0, 0, localZ).applyMatrix4(gArrow.matrixWorld).project(cam); const r = cv.getBoundingClientRect(); return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height }; } const ndc2 = e => { const r = cv.getBoundingClientRect(); return new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); }; /* 指针在「目标处、垂直于工具轴的平面」上绕轴的转角(度):滚转把手拖动用 */ function rollAngleAt(e) { const n = axisViewer(); const pl = new THREE.Plane().setFromNormalAndCoplanarPoint(n, gTgt.position); const hit = new THREE.Vector3(); ray2.setFromCamera(ndc2(e), cam); // 该版本 setFromCamera 不返回 this,勿链式 if (!ray2.ray.intersectPlane(pl, hit)) return 0; const v = hit.sub(gTgt.position); const up = Math.abs(n.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); const u = new THREE.Vector3().crossVectors(n, up).normalize(); const w = new THREE.Vector3().crossVectors(n, u); return Math.atan2(v.dot(w), v.dot(u)) * 180 / Math.PI; } /* 弧球式瞄准:指针射线与「以目标为球心、半径 = 箭长」的球求交,方向 = 交点 − 球心 */ function aimAxis(e) { ray2.setFromCamera(ndc2(e), cam); const o = ray2.ray.origin, d = ray2.ray.direction, C = gTgt.position; const ox = o.x - C.x, oy = o.y - C.y, oz = o.z - C.z; const b = ox * d.x + oy * d.y + oz * d.z; const c = ox * ox + oy * oy + oz * oz - AXLEN * AXLEN; const disc = b * b - c; const t = Math.max(0, disc >= 0 ? -b - Math.sqrt(disc) : -b); // 未命中取射线上距球心最近点 const v = new THREE.Vector3(o.x + d.x * t - C.x, o.y + d.y * t - C.y, o.z + d.z * t - C.z); if (v.lengthSq() < 1e-6) return; v.normalize(); const vc = V2CAD([v.x, v.y, v.z]); POSE.az = Math.atan2(vc[1], vc[0]) * 180 / Math.PI; POSE.el = Math.acos(Math.max(-1, Math.min(1, vc[2]))) * 180 / Math.PI; syncPoseInputs(); refreshTarget(); } cv.addEventListener('pointerdown', e => { if (e.button !== 0 || VIEW !== 'motion') return; // 仅运动模拟模式可操作目标点 justPicked = false; if (mode === 'pick') { grab = { pick: true }; dragged = false; return; } const pt = projTgt(); const db = Math.hypot(e.offsetX - pt.x, e.offsetY - pt.y); if (poseOn) { // 箭头头 / 滚转把手(与黄球就近仲裁,见 pickPoseHandle) const part = pickPoseHandle(e); if (part === 'axis') { grab = { pose: 'axis' }; dragged = false; camSnap = snapCam(); return; } if (part === 'roll') { grab = { pose: 'roll', a0: rollAngleAt(e) }; dragged = false; camSnap = snapCam(); return; } } if (!(db < 26 || e.shiftKey)) return; grab = { sx: e.offsetX, sy: e.offsetY }; camSnap = snapCam(); dragged = false; }); cv.addEventListener('pointermove', e => { if (!grab || grab.pick) return; if (camSnap) putCam(camSnap); // 撤销查看器自身的轨道 / 平移 cam.updateMatrixWorld(); // 轨道参数已还原,射线/列向量要用新矩阵 if (grab.pose === 'axis') { // 拖箭头头:工具轴指向 dragged = true; aimAxis(e); if (mode === 'rt') runSolveLive(); return; } if (grab.pose === 'roll') { // 拖圆环:绕轴滚转 dragged = true; const a1 = rollAngleAt(e); POSE.roll = S.wrap180(POSE.roll + S.wrap180(a1 - grab.a0)); grab.a0 = a1; syncPoseInputs(); refreshTarget(); if (mode === 'rt') runSolveLive(); return; } const dx = e.offsetX - grab.sx, dy = e.offsetY - grab.sy; if (Math.abs(dx) + Math.abs(dy) > 2) dragged = true; const k = (2 * rd * Math.tan(cam.fov * Math.PI / 360)) / Math.max(1, cv.clientHeight); const right = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 0); const up = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 1); const cur = VC(TGT), v = new THREE.Vector3(cur[0], cur[1], cur[2]); if (e.shiftKey || e.altKey) { // 约束到世界竖直(CAD +Z) const dot = Math.max(0.25, Math.abs(up.dot(new THREE.Vector3(0, 1, 0)))); v.addScaledVector(new THREE.Vector3(0, 1, 0), -dy * k / dot); } else v.addScaledVector(right, dx * k).addScaledVector(up, -dy * k); TGT = [v.x, -v.z, Math.max(0, v.y)]; grab.sx = e.offsetX; grab.sy = e.offsetY; syncInputs(); refreshTarget(); if (mode === 'rt' && dragged) runSolveLive(); // 实时追踪:拖动过程中即算即动 }); addEventListener('pointerup', () => { camSnap = null; }); cv.addEventListener('pointerup', e => { if (!grab) return; if (grab.pick) { // 视图拾取:射线打到当前高度的水平面 grab = null; justPicked = true; const r = cv.getBoundingClientRect(); ray2.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), cam); const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -TGT[2]); // 查看器 y = CAD z const hit = new THREE.Vector3(); if (ray2.ray.intersectPlane(plane, hit)) { TGT = [hit.x, -hit.z, Math.max(0, TGT[2])]; syncInputs(); refreshTarget(); runSolve(); } return; } const moved = dragged; grab = null; if (moved) runSolve(); }); cv.addEventListener('click', () => { // 撤销交互对查看器选中态 / 高亮的副作用 if (justPicked) justPicked = false; else if (!dragged) return; dragged = false; if (sel) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; sel = null; } const hud = $('hud'); if (hud) hud.textContent = ''; requestRender(); }, true); /* ---------- 7. 控件 ---------- */ function syncInputs() { $('ikX').value = TGT[0].toFixed(1); $('ikY').value = TGT[1].toFixed(1); $('ikZ').value = TGT[2].toFixed(1); } ['ikX', 'ikY', 'ikZ', 'ikTool'].forEach(id => { const el = $(id); if (!el) return; el.addEventListener('change', () => runSolve()); }); $('ikStep').onchange = () => runSolve(); $('ikSolve').onclick = () => runSolve(); $('ikPick').onclick = () => setMode(mode === 'pick' ? 'drag' : 'pick'); $('ikRt').onclick = () => setMode(mode === 'rt' ? 'drag' : 'rt'); $('ikHome').onclick = () => { setJoints([0, 0, 0, 0, 0, 0], true); badge.textContent = '已复位'; badge.className = 'badge wait'; $('ikSrc').textContent = '已复零位(下表为上一次求解结果,滑块与模型为当前真实姿态)'; }; $('ikZero').onclick = () => { TGT = [150, 128, 260]; syncInputs(); refreshTarget(); runSolve(); }; /* --- 姿态控制:开关 / 参数 / 快捷 --- */ const poseEls = ['ikAz', 'ikEl', 'ikRoll'].map($); if ($('ikPose')) $('ikPose').onclick = () => { poseOn = !poseOn; $('ikPose').classList.toggle('on', poseOn); $('ikPoseBox').style.display = poseOn ? 'flex' : 'none'; if (poseOn) fillPoseFromCurrent(); // 开启即用当前工具姿态,避免求解跳变 refreshTarget(); setMode(mode); runSolve(); }; poseEls.forEach((el, i) => { if (!el) return; el.addEventListener('change', () => { POSE.az = +$('ikAz').value || 0; POSE.el = Math.max(0, Math.min(180, +$('ikEl').value || 0)); POSE.roll = +$('ikRoll').value || 0; syncPoseInputs(); if (poseOn) runSolve(); }); }); if ($('ikPoseCur')) $('ikPoseCur').onclick = () => { fillPoseFromCurrent(); if (poseOn) runSolve(); }; if ($('ikPoseDown')) $('ikPoseDown').onclick = () => { POSE.az = 0; POSE.el = 180; POSE.roll = 0; syncPoseInputs(); if (poseOn) runSolve(); }; /* ---------- 8. 视图模式:三维模型(静态) / 装配图 / 运动模拟 ---------- */ let VIEW = 'model'; const MOTION_UI = ['ikbar', 'ikpanel', 'joints', 'jointsHead', 'footIk']; function setView(v, keepPose) { VIEW = v; const isMotion = v === 'motion'; document.body.classList.toggle('motion', isMotion); for (const id of MOTION_UI) { const e = $(id); if (e) e.style.display = isMotion ? '' : 'none'; } gTgt.visible = isMotion; RING.visible = isMotion; ballTcp.visible = isMotion; linkLine.visible = isMotion; curLine.visible = isMotion; if (isMotion) { if (+exp.value) document.getElementById('bAsm').click(); // 运动模拟须在装配状态下进行 runSolve(); // 进入即求解并动画到目标点 } else { if (mode !== 'drag') setMode('drag'); // 离开运动模拟:交互模式复位 if (v === 'model' && !keepPose) setJoints([0, 0, 0, 0, 0, 0], true); // 静态模型:关节锁定零位 } requestRender(); } document.querySelectorAll('.tab[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); /* ---------- 9. 启动:自检 + 工作空间采样 ---------- */ function boot() { const st = S.selfTest(600, 6); const ws = (function () { let lo = [1e9, 1e9, 1e9], hi = [-1e9, -1e9, -1e9], rmax = 0; for (let k = 0; k < 120000; k++) { const q = []; for (let i = 0; i < 5; i++) { const t = LIM[ORDER[i]]; q.push(t.lo + Math.random() * (t.hi - t.lo)); } q.push(0); const p = S.fk(q, 0); for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], p[i]); hi[i] = Math.max(hi[i], p[i]); } rmax = Math.max(rmax, Math.hypot(p[1] - S.K.Y0, p[2] - S.K.Z1)); } return { lo: lo, hi: hi, rmax: rmax }; })(); $('ikChk').innerHTML = '结构前提' + (st.structWarn.length ? st.structWarn.join(';') : '自洽 ✓') + '' + 'FK 校验' + st.maxFkErr.toExponential(1) + ' mm' + 'IK 校验' + st.ikHit + '/' + st.ikTried + ' · ' + st.maxIkErr.toExponential(1) + ' mm' + 'TCP 法兰面(' + TCP0.map(v => v.toFixed(1)).join(', ') + ')' + '*运动学可达限位内 12 万随机位形,未计自碰撞' + '可达 x*' + ws.lo[0].toFixed(0) + '…' + ws.hi[0].toFixed(0) + ' mm(前伸 ' + ws.hi[0].toFixed(0) + ')' + '可达 y*' + ws.lo[1].toFixed(0) + '…' + ws.hi[1].toFixed(0) + ' mm' + '可达 z*' + ws.lo[2].toFixed(0) + '…' + ws.hi[2].toFixed(0) + ' mm' + '离 J1 轴*最远 ' + ws.rmax.toFixed(0) + ' mm' + '姿态 IK 校验' + st.poseHit + '/' + st.poseTried + ' 命中 · 最大 ' + st.poseErr.toExponential(1) + '° · ' + st.poseMs.toFixed(0) + ' ms/次' + '姿态结构J5∩J6 垂直相交于腕心;θ6 为绕工具轴滚转,不影响 TCP 位置'; /* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */ const ext = document.querySelectorAll( 'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length; const nSheet = (typeof SHEETS !== 'undefined' && SHEETS) ? String(SHEETS).split(' ''; const nScript = document.querySelectorAll('script:not([src])').length; $('ikDep').innerHTML = '打包方式单文件自包含,可直接拷走' + 'three.js' + ok(typeof THREE !== 'undefined') + '已内联 · r' + ((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '' + '几何数据' + ok(typeof DATA !== 'undefined') + '已内联 · ' + DATA.parts.length + ' 件零件' + '关节 / 限位' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') + '已内联 · ' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位' + '二维图纸' + ok(nSheet > 0) + '已内联 · ' + nSheet + ' 张 SVG' + 'IK 求解器' + ok(typeof ArmIK !== 'undefined') + '已内联(纯 JS,无依赖)' + '内联脚本' + nScript + ' 个(三维引擎 / 模型数据 / 求解器 / 界面)' + '外部引用' + ok(ext === 0) + (ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + ''; syncInputs(); refreshTarget(); const q = new URLSearchParams(location.search); const hasJ = ['j1', 'j2', 'j3', 'j4', 'j5', 'j6'].some(k => q.has(k)); const v0 = q.get('view'); if ((v0 === 'motion' || v0 === 'sheet') && document.querySelector('.tab[data-view="' + v0 + '"]')) { document.querySelector('.tab[data-view="' + v0 + '"]').click(); } else setView('model', hasJ); } boot(); window.IK = { solver: S, setTarget: p => { TGT = p.slice(); syncInputs(); refreshTarget(); }, getTarget: () => TGT.slice(), run: runSolve, TCP0: TCP0, LIM: LIM, setView: setView, getView: () => VIEW, setPose: (az, el, roll, solve) => { POSE.az = +az || 0; POSE.el = Math.max(0, Math.min(180, +el || 0)); POSE.roll = +roll || 0; syncPoseInputs(); refreshTarget(); if (solve !== false) runSolve(); }, getPose: () => ({ on: poseOn, az: POSE.az, el: POSE.el, roll: POSE.roll }), /* 可拖控点的屏幕坐标(画布内像素):黄球 / 箭头头 / 滚转侧球 */ pickPts: () => poseOn ? { ball: projTgt(), head: projArrow(HEADZ), knob: knobScreen() } : null, /* 调试:某视口坐标下的把手拾取结果('axis'/'roll'/null) */ _pickHandle: (cx, cy) => poseOn ? pickPoseHandle({ clientX: cx, clientY: cy, offsetX: cx - cv.getBoundingClientRect().left, offsetY: cy - cv.getBoundingClientRect().top }) : null }; })();