- src/parts/p01~p09 + src/part_tools.py:几何、件号/配色/说明与装配帧归属随各自件文件走,arm_model.py 只留装配流水线并对件级符号做只读转发;P04_CH / P07_CH / P06_WIN_XZ / FENCE_OPEN_CUT 这类开关必须写所在件文件,写 arm_model.X 不生效,A/B 判据为翻转后体积变化
- docs/sheet_{exploded,front,side,joint}.svg + draw_sheet.py:四张工程图进查看器页签,支持图层开关、件号高亮与 BOM/3D 联动跳转
- travel/travel_detail/collide/bom/screws 随最新几何重生成;补线束走线、拆卸定位点与 P04 承压面剖切验证图
- .gitignore 排除 .bak/ 与 *.bak_* 源码快照(旧版本 git 历史里已有)
1094 lines
61 KiB
JavaScript
1094 lines
61 KiB
JavaScript
/* ============================================================================
|
||
* 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; // 与静态不同(宽或窄)都标记
|
||
/* 连续轴没收紧时显示"整周可用",收紧了就换成区间:同一个 <em> 是唯一写手,
|
||
别让静态的 360 度连续徽标和实时夹取区间并存(状态灯与数字会打架)。 */
|
||
el.className = dyn ? '' : (t.cont ? 'ok' : '');
|
||
el.innerHTML = (t.cont && !dyn) ? '整周可用' :
|
||
((dyn ? '<b style="color:var(--warn)">' : '') +
|
||
lo.toFixed(0) + '\u00B0\u2026' + hi.toFixed(0) + '\u00B0' + (dyn ? ' \u25B2</b>' : ''));
|
||
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 = '<td>' + id + '</td><td>' + CN[id] + '</td>' +
|
||
'<td class="num">' + q[i].toFixed(2) + '\u00B0</td>' +
|
||
'<td class="num dim">' + Math.abs(q[i]).toFixed(1) + '</td>' +
|
||
'<td class="dim">' + (t.cont ? '\u00B1' + MOTOR
|
||
: (res.dynAxis === id ? t.lo + '..' + '<b style="color:var(--warn)">' + res.dynBound.toFixed(1) + '*</b>'
|
||
: t.lo + '..' + t.hi)) + '</td>';
|
||
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 ? '<i>本步行程 \u03A3|\u0394\u03B8|(判据)</i><b class="hi">' +
|
||
res.moveSum.toFixed(2) + '\u00B0</b>' : '') +
|
||
'<i>总关节角 \u03A3|\u03B8|</i><span>' + S.costOf(q, null).toFixed(2) + '\u00B0</span>' +
|
||
'<i>\u03A3\u03B8 代数和</i><span>' + sum.toFixed(1) + '\u00B0</span>' +
|
||
'<i>残差 闭式/场景图</i><span class="' + (dcl < 1e-4 && dsc < .05 ? 'good' : 'bad') + '">' +
|
||
dcl.toExponential(1) + ' / ' + dsc.toExponential(1) + ' mm</span>' +
|
||
(poseOn ? '<i>目标姿态</i><span>方位 ' + POSE.az.toFixed(1) + '\u00B0 · 仰角 ' + POSE.el.toFixed(1) +
|
||
'\u00B0 · 滚转 ' + POSE.roll.toFixed(1) + '\u00B0</span>' : '') +
|
||
(poseOn ? '<i>实际姿态</i><span>' + pa.az.toFixed(1) + '\u00B0 · ' + pa.el.toFixed(1) +
|
||
'\u00B0 · ' + pa.roll.toFixed(1) + '\u00B0</span>' : '') +
|
||
(poseOn ? '<i>姿态残差 闭式/场景图</i><span class="' + (dori < .05 && doriSc < .1 ? 'good' : 'bad') + '">' +
|
||
dori.toFixed(3) + '\u00B0 / ' + doriSc.toFixed(3) + '\u00B0</span>' : '') +
|
||
(poseOn && res.priority === 'mix' && res.mixP && res.mixO
|
||
? '<i>折中权重</i><span>位置 ' + Math.round((res.mixW == null ? 0.5 : res.mixW) * 100) + '% · 姿态 ' +
|
||
(100 - Math.round((res.mixW == null ? 0.5 : res.mixW) * 100)) + '%(按两端锚点误差归一)</span>' +
|
||
'<i>两端锚点</i><span>位置端 ' + res.mixP.e.toFixed(2) + ' mm / ' + res.mixP.o.toFixed(2) +
|
||
'\u00B0 · 姿态端 ' + res.mixO.e.toFixed(1) + ' mm / ' + res.mixO.o.toFixed(2) + '\u00B0</span>' : '') +
|
||
'<i>可行采样点</i><span>' + res.nSol + '</span>' +
|
||
'<i>耗时</i><span>' + res.ms.toFixed(0) + ' ms</span>' +
|
||
'<i>目标</i><span>(' + res.target.map(v => v.toFixed(1)).join(', ') + ')</span>' +
|
||
(res.resid != null ? '<i>距目标</i><span class="bad">' + res.resid.toFixed(2) + ' mm</span>' : '') +
|
||
(poseOn && res.resid != null && res.oerr != null
|
||
? '<i>姿态偏差</i><span class="bad">' + res.oerr.toFixed(2) + '\u00B0</span>' : '');
|
||
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 = '<b class="hi">\u03A3|\u03B8| ' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
|
||
'<span class="sep">|</span>\u6B8B\u5DEE ' +
|
||
'<span class="' + (dsc < .05 ? 'good' : 'bad') + '">' + dsc.toExponential(1) + ' mm</span>' +
|
||
(poseOn ? '<span class="sep">|</span>\u59FF\u6001\u6B8B\u5DEE <span class="' +
|
||
(dori < .05 && doriSc < .1 ? 'good' : 'bad') + '">' + dori.toFixed(3) + '\u00B0</span>' : '') +
|
||
'<span class="sep">|</span>\u53EF\u884C\u91C7\u6837\u70B9 ' + res.nSol +
|
||
'<span class="sep">|</span>' + 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 = '<span class="ai">' + (i + 1) + '</span><b>' + s.cost.toFixed(2) + '\u00B0</b>' +
|
||
'<span class="aq">' + s.q.map(v => v.toFixed(1)).join(' / ').replace(/ /g, '') + '</span>';
|
||
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 源码取自页面内嵌 <script>,
|
||
连同关节/限位/碰撞配置一次性注入 Blob Worker;拖动实时追踪同样走 Worker,
|
||
全量回落也不再卡顿。Worker 不可用(老浏览器/CSP/file 限制)时自动回落
|
||
主线程同步求解,行为与旧版逐位一致。碰撞 dynLimit/clampAxis 仍留主线程
|
||
(滑块交互需要同步边界),主线程的 S 实例保留给 FK/渲染/控制台 API。 */
|
||
let IKW = null, IKWseq = 0, IKWlast = 0;
|
||
/* 每个请求按 id 记账,回包必定派发一次;「是否还是最新一帧」用第二参交给调用方判断。
|
||
旧写法只留一个 IKWjob 槽:live 在飞时被 runSolve 插队,那一发的回调就彻底蒸发,
|
||
而它是唯一给 liveBusy 复位的地方——一次竞态即让实时追踪永久停摆。 */
|
||
const IKWjobs = new Map();
|
||
function ikWorker() {
|
||
if (IKW) return IKW;
|
||
if (IKW === false) return null;
|
||
try {
|
||
let src = null;
|
||
for (const sc of document.querySelectorAll('script')) {
|
||
if (sc.textContent.indexOf('六轴手臂逆运动学求解器') >= 0) { src = sc.textContent; break; }
|
||
}
|
||
if (!src) throw new Error('ArmIK script 未找到');
|
||
const boot = '\nconst CFGW = ' + JSON.stringify({ joints: JMap, travel: TR, tcp: TCP0 }) + ';\n' +
|
||
'const COLW = ' + JSON.stringify(COL) + ';\n' +
|
||
'const SW = ArmIK.make({ joints: CFGW.joints, travel: CFGW.travel, tcp: CFGW.tcp, collide: COLW });\n' +
|
||
'onmessage = e => { const d = e.data; let r;\n' +
|
||
' try { r = SW[d.op].apply(SW, d.args); } catch (err) { r = { __err: String(err && err.message || err) }; }\n' +
|
||
' postMessage({ id: d.id, r: r }); };\n';
|
||
/* Blob URL 只在构造 Worker 时被读取,构造后立即回收(失败路径同样不留常驻 Blob) */
|
||
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
|
||
let w;
|
||
try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
|
||
w.onmessage = e => {
|
||
const job = IKWjobs.get(e.data.id);
|
||
if (!job) return; // 已结清过(正常不会发生)
|
||
IKWjobs.delete(e.data.id);
|
||
job(e.data.r, e.data.id === IKWlast); // 过期请求也派发,只是标为不新鲜
|
||
};
|
||
w.onerror = () => { // Worker 挂了:把在飞的账全部结清
|
||
IKW = false;
|
||
const pend = Array.from(IKWjobs.values()); IKWjobs.clear();
|
||
for (const j of pend) j(null, false);
|
||
};
|
||
IKW = w;
|
||
} catch (err) { IKW = false; }
|
||
return IKW || null;
|
||
}
|
||
function solveAsync(op, args, job) {
|
||
const w = ikWorker();
|
||
if (!w) { job(S[op].apply(S, args), true); return; } // 同步兜底(结果结构完全一致)
|
||
const id = ++IKWseq; IKWlast = id;
|
||
IKWjobs.set(id, (r, fresh) => { // Worker 内异常 → 主线程重算一次
|
||
if (r && r.__err) {
|
||
if (fresh) { try { r = S[op].apply(S, args); } catch (e2) { r = null; } }
|
||
else r = null;
|
||
}
|
||
job(r, fresh);
|
||
});
|
||
w.postMessage({ id: id, op: op, args: args });
|
||
}
|
||
|
||
/* ---------- 5a. 求解入口 ---------- */
|
||
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(),
|
||
from: ORDER.map(id => ang[id]) }; // 松手求解同样按"直线能不能走"挑解
|
||
if (poseOn) { opt.pose = { R: Rsolver() }; opt.priority = prioVal(); if (opt.priority === 'mix') opt.mixW = mixWVal(); }
|
||
solveAsync('solveDyn', [TGT.slice(), opt], (res, fresh) => {
|
||
if (!res || !fresh) return; // 已被更新的一发取代:交给那一发渲染
|
||
renderResult(res, res.reachable
|
||
? (res.dynAxis ? '动态限位放宽重解 · ' + res.dynAxis + ' 边界 ' + res.dynBound.toFixed(1) + '°'
|
||
: (poseOn ? (prioVal() === 'mix'
|
||
? prioTxt() + ' · 两端锚点加权折中(位置精确族 + 姿态精确族)'
|
||
: prioTxt() + ' · 闭式全域扫描 + 孤立根枚举 · 位置+姿态联合')
|
||
: '闭式全域扫描 · 按行裁剪冗余维'))
|
||
: (res.note ? res.note
|
||
: (poseOn ? '超可达位姿空间 → 阻尼最小二乘最近位姿(位置 mm + 姿态联合)'
|
||
: '超工作空间 → 阻尼最小二乘最近位置')));
|
||
if (res.q) setJoints(res.q, true, res.via);
|
||
});
|
||
}, 0);
|
||
}
|
||
|
||
/* 实时追踪:拖动过程中即时求解、立即应用(无动画,跟在手后面)。
|
||
求解在 Worker 后台跑(全量回落也不冻结 UI);同一时刻只跑一个,
|
||
在飞期间后续 pointermove 只记待处理标志,结果回来后用最新 TGT 再解一次。
|
||
门闩由「派发」而非「渲染」复位(settle 放在 finally):被 runSolve 插队而作废的
|
||
那一发同样必须放行,否则一次竞态就永久停摆。作废时待处理标志一并交回——
|
||
插队者用的已是最新 TGT,此时重发只会把它再挤掉、连松手的全量精修一起丢掉。 */
|
||
let liveBusy = false, livePending = false;
|
||
function runSolveLive() {
|
||
if (liveBusy) { livePending = true; return; }
|
||
liveBusy = true;
|
||
const settle = (fresh) => {
|
||
liveBusy = false;
|
||
if (!fresh) { livePending = false; return; }
|
||
if (livePending) { livePending = false; runSolveLive(); }
|
||
};
|
||
try {
|
||
lastTool = +$('ikTool').value || 0;
|
||
/* 实时追踪原来走 solveTo —— 一个碰撞判定都不做:实测把目标球横拖过立柱,
|
||
1300 帧里 870 帧处于穿模状态。改用 solveRoute:按"从当前位形这条直线能不能走"
|
||
挑解,走不通就贴着障碍停(blocked),不再把手势直接映射进立柱里。 */
|
||
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), warm: curRef(),
|
||
from: ORDER.map(id => ang[id]), noVia: true, quick: true }; // 拖拽中不做抬高版绕行(要再解两次全量 IK)
|
||
if (poseOn) { opt.pose = { R: Rsolver() }; opt.priority = prioVal(); if (opt.priority === 'mix') opt.mixW = mixWVal(); }
|
||
solveAsync('solveRoute', [TGT.slice(), opt], (res, fresh) => { // Worker 后台:全量回落也不冻结主线程
|
||
try {
|
||
if (!res || !fresh) return;
|
||
renderResult(res, res.warm
|
||
? (res.dynAxis ? '实时追踪 · 动态限位 ' + res.dynAxis + '=' + res.dynBound.toFixed(1) + '°'
|
||
: (poseOn ? (res.priority === 'mix'
|
||
? '实时追踪 · 混合优先沿上一帧同盆地续解(加权折中局部跟随 · 松手全量精修)'
|
||
: '实时追踪 · 沿上一帧构型同支续解(位置精确 · 姿态牛顿收口 · 松手全量精修)')
|
||
: '实时追踪 · 沿上一帧构型同支续解(冗余维局部跟随 · 松手全量精修)'))
|
||
: (res.reachable
|
||
? (poseOn ? (prioVal() === 'mix'
|
||
? '实时追踪 · ' + prioTxt() + ' · 两端锚点加权折中'
|
||
: '实时追踪 · ' + prioTxt() + ' · 闭式全域扫描 + 孤立根枚举 · 位置+姿态联合')
|
||
: '实时追踪 · 闭式全域扫描 · 按行裁剪冗余维')
|
||
: (res.note ? '实时追踪 · ' + res.note
|
||
: (poseOn ? '实时追踪 · 超可达位姿空间(阻尼最小二乘最近位姿)'
|
||
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)'))));
|
||
/* 无绕行时立即落位(跟手);一旦给出绕行中间位形,就必须分段动画——
|
||
直接跳到 res.q 等于把"收臂→转 J1→伸展"三段压成一条直线,正是要避免的穿模。 */
|
||
if (res.q) setJoints(res.q, !!(res.via && res.via.length), res.via, 300);
|
||
} finally { settle(fresh); }
|
||
});
|
||
} catch (err) { settle(false); }
|
||
}
|
||
|
||
/* ---------- 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 null; // 错过平面:该帧不参与滚转(返回 0 会按 -a0 突跳)
|
||
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);
|
||
if (a1 == null) return; // 射线错过平面:跳过该帧,角度不变
|
||
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; });
|
||
/* 挂 window 而非画布:在画布外松手也要结束抓取,否则 grab 残留 → 光挪鼠标就拖着目标走 */
|
||
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();
|
||
/* 混合优先:比重滑杆(仅混合模式显示)。滑动即算即动(走混合快路,松手全量折中),
|
||
与拖动黄球同一套 live 机制;两端 0% / 100% 直接交回姿态优先 / 位置优先。 */
|
||
function syncMixUI() {
|
||
const box = $('ikMixBox'), v = $('ikMixV');
|
||
if (box) box.style.display = prioVal() === 'mix' ? 'flex' : 'none';
|
||
if (v) v.textContent = Math.round(mixWVal() * 100) + '% 位置';
|
||
}
|
||
if ($('ikPrio')) $('ikPrio').onchange = () => { syncMixUI(); if (poseOn) runSolve(); };
|
||
if ($('ikMixW')) {
|
||
$('ikMixW').addEventListener('input', () => { syncMixUI(); if (poseOn) runSolveLive(); });
|
||
$('ikMixW').addEventListener('change', () => { syncMixUI(); if (poseOn) runSolve(); });
|
||
}
|
||
syncMixUI();
|
||
$('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';
|
||
const isSheet = v === 'sheet';
|
||
document.body.classList.toggle('motion', isMotion);
|
||
document.body.classList.toggle('sheet-mode', isSheet);
|
||
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); // 静态模型:关节锁定零位
|
||
if (isSheet && typeof openSheetView === 'function') openSheetView();
|
||
}
|
||
requestRender();
|
||
}
|
||
document.querySelectorAll('.tab[data-view]').forEach(b =>
|
||
b.addEventListener('click', () => setView(b.dataset.view)));
|
||
|
||
/* 预置自检结果(离线用同一份求解器代码算出,样本量比在线默认更大)。
|
||
启动时不再重算:原先姿态自检 200 组要 ~14 s(400 组 ~23 s),是本页唯一的启动瓶颈。
|
||
指纹 = FNV-1a over(限位表 + K + TCP + 胶囊数 + 行程表 + 求解器块源码),
|
||
与当前代码不符时会标红提示重算。
|
||
重新生成:node scratch/compute_precomputed.cjs(模型或求解器改动后重跑一条命令) */
|
||
const PRECOMPUTED_CHK = {
|
||
stamp: '2026-10-14 04:02', sig: 'fd1f8bff',
|
||
fk: { n: 5000, err: 5.684341886080801e-13 },
|
||
ik: { n: 200, hit: 200, tried: 200, err: 8.320331044333469e-13 },
|
||
pose: { n: 400, hit: 398, err: 1.5596676084670544, ms: 86.40111575000003 },
|
||
ws: { n: 400000, x: [-377, 416], y: [-388, 643], z: [96, 1126], rmax: 517 }
|
||
};
|
||
/* 紧凑指纹:与离线脚本 compute_precomputed.cjs 里的算法逐字一致
|
||
(注意取 script.textContent——HTML 解析会把行尾归一化成 LF) */
|
||
function chkSig() {
|
||
const p = [];
|
||
for (const id of ORDER) { const t = LIM[id]; p.push(id + t.lo + ',' + t.hi + ',' + t.cont); }
|
||
p.push(TCP0.join(','));
|
||
p.push([S.K.X0, S.K.Y0, S.K.Z1, S.K.Z2, S.K.Z3, S.K.Z4, S.K.Z5].join(','));
|
||
p.push(String(typeof COLLIDE !== 'undefined' && COLLIDE.caps ? COLLIDE.caps.length : -1));
|
||
p.push(typeof TRAVEL !== 'undefined'
|
||
? TRAVEL.map(t => t.axis + t.lo + t.hi + (t.continuous ? 1 : 0)).join('') : '');
|
||
let str = p.join('|');
|
||
const scr = document.scripts; // 4 个内联脚本:0 three / 1 数据 / 2 求解器 / 3 界面
|
||
if (scr.length > 3 && scr[2]) str += '|' + scr[2].textContent;
|
||
let hh = 0x811c9dc5;
|
||
for (let i = 0; i < str.length; i++) { hh ^= str.charCodeAt(i); hh = Math.imul(hh, 0x01000193) >>> 0; }
|
||
return (hh >>> 0).toString(16).padStart(8, '0');
|
||
}
|
||
|
||
/* ---------- 9. 启动:自检 + 工作空间采样 ---------- */
|
||
function boot() {
|
||
/* 启动不再做任何重计算:自检数值全部取自预置结果,只用指纹复核是否仍适用。
|
||
「结构前提」仍取运行时 WARN(make() 时已算好,零额外代价)。 */
|
||
const SIG_NOW = chkSig();
|
||
const CHK = {
|
||
/* 结构前提是 make() 时定下的常量(求解器内部 WARN),与样本量无关:
|
||
用一次 1 样本的 selfTest 取出即可(~0 ms),其余字段一律用预置值。 */
|
||
st: { structWarn: S.selfTest(1, 0).structWarn, maxFkErr: PRECOMPUTED_CHK.fk.err,
|
||
ikHit: PRECOMPUTED_CHK.ik.hit, ikTried: PRECOMPUTED_CHK.ik.tried, maxIkErr: PRECOMPUTED_CHK.ik.err },
|
||
ws: { lo: [PRECOMPUTED_CHK.ws.x[0], PRECOMPUTED_CHK.ws.y[0], PRECOMPUTED_CHK.ws.z[0]],
|
||
hi: [PRECOMPUTED_CHK.ws.x[1], PRECOMPUTED_CHK.ws.y[1], PRECOMPUTED_CHK.ws.z[1]],
|
||
rmax: PRECOMPUTED_CHK.ws.rmax },
|
||
mode: 'pre', // 'pre' 用预置值 | 'live' 本机实测
|
||
pose: null, // null 用预置 | {running,done,total} | {hit,tried,err,ms}
|
||
sigOk: SIG_NOW === PRECOMPUTED_CHK.sig
|
||
};
|
||
let reRunning = false;
|
||
function poseLine() {
|
||
const P = PRECOMPUTED_CHK.pose, p = CHK.pose;
|
||
if (!p) {
|
||
if (!CHK.sigOk) return '<i>姿态 IK 校验</i><span class="bad">预置结果已失效,请点下方「重新计算」</span>';
|
||
return '<i>姿态 IK 校验</i><span class="' + (P.hit >= P.n * 0.9 ? 'good' : 'bad') + '">' +
|
||
P.hit + '/' + P.n + ' 命中 · 最大 ' + P.err.toFixed(2) + '° · ' + P.ms.toFixed(0) + ' ms/次</span>';
|
||
}
|
||
if (p.running) return '<i>姿态 IK 校验</i><span>本机实测中 ' + p.done + '/' + p.total + '…</span>';
|
||
return '<i>姿态 IK 校验</i><span class="' + (p.hit >= p.tried * 0.9 ? 'good' : 'bad') + '">' +
|
||
p.hit + '/' + p.tried + ' 命中 · 最大 ' + p.err.toFixed(2) + '° · ' + p.ms.toFixed(0) + ' ms/次</span>';
|
||
}
|
||
function renderChk() {
|
||
const st = CHK.st, ws = CHK.ws, P = PRECOMPUTED_CHK;
|
||
/* 指纹不符时把"为什么变慢/为什么值是新的"讲清楚:即使已自动转为实测,
|
||
也在来源行保留失效提示,避免用户不明所以。 */
|
||
const src = (CHK.mode === 'live')
|
||
? (CHK.sigOk
|
||
? '<span class="good">本机测试(FK ' + P.fk.n + ' · IK ' + P.ik.n + ' · 姿态 ' + P.pose.n + ' · 工作空间 ' + P.ws.n + ' 位形)</span>'
|
||
: '<span class="bad">⚠ 预置结果已失效(预置指纹 ' + P.sig + ' ≠ 当前 ' + SIG_NOW +
|
||
',模型或求解器已变更)· 以下为本次本机测试</span>')
|
||
: (CHK.sigOk
|
||
? '<span>预置 · 生成于 ' + P.stamp + '(FK ' + P.fk.n + ' · IK ' + P.ik.n + ' · 姿态 ' + P.pose.n +
|
||
' · 工作空间 ' + P.ws.n + ')· 指纹 ' + SIG_NOW + ' 匹配</span>'
|
||
: '<span class="bad">预置结果已失效:预置指纹 ' + P.sig + ' ≠ 当前 ' + SIG_NOW + '(模型或求解器已变更)</span>');
|
||
$('ikChk').innerHTML =
|
||
'<i>数据来源</i>' + src +
|
||
'<i>结构前提</i><span class="' + (st.structWarn.length ? 'bad' : 'good') + '">' +
|
||
(st.structWarn.length ? st.structWarn.join(';') : '自洽 ✓') + '</span>' +
|
||
'<i>FK 校验</i><span class="good">' + st.maxFkErr.toExponential(1) + ' mm</span>' +
|
||
'<i>IK 校验</i><span class="good">' + st.ikHit + '/' + st.ikTried + ' · ' + st.maxIkErr.toExponential(1) + ' mm</span>' +
|
||
'<i>TCP 法兰面</i><span>(' + TCP0.map(v => v.toFixed(1)).join(', ') + ')</span>' +
|
||
'<i>*运动学可达</i><span>限位内随机位形,未计自碰撞</span>' +
|
||
'<i>可达 x*</i><span>' + ws.lo[0].toFixed(0) + '…' + ws.hi[0].toFixed(0) +
|
||
' mm(前伸 ' + ws.hi[0].toFixed(0) + ')</span>' +
|
||
'<i>可达 y*</i><span>' + ws.lo[1].toFixed(0) + '…' + ws.hi[1].toFixed(0) + ' mm</span>' +
|
||
'<i>可达 z*</i><span>' + ws.lo[2].toFixed(0) + '…' + ws.hi[2].toFixed(0) + ' mm</span>' +
|
||
'<i>离 J1 轴*</i><span class="good">最远 ' + ws.rmax.toFixed(0) + ' mm</span>' +
|
||
poseLine() +
|
||
'<i>姿态结构</i><span>J5∩J6 垂直相交于腕心;θ6 为绕工具轴滚转,不影响 TCP 位置</span>' +
|
||
'<i>操作</i><span><b id="ikChkRe" style="cursor:pointer;text-decoration:underline;color:var(--ac)">' +
|
||
(reRunning ? '实测进行中…' : '重新计算(本机测试,姿态 ' + P.pose.n + ' 组分片执行 · 约 20–40 s,期间可继续操作)') +
|
||
'</b></span>';
|
||
const re = $('ikChkRe');
|
||
if (re) re.onclick = () => runRecompute();
|
||
}
|
||
/* 「重新计算」:便宜部分同步跑(FK/IK ~30 ms、工作空间 ~120 ms),
|
||
姿态部分 4 组一片 + 让出主线程,避免一次长任务冻住界面。 */
|
||
function runRecompute() {
|
||
if (reRunning) return;
|
||
reRunning = true;
|
||
const P = PRECOMPUTED_CHK;
|
||
CHK.st = S.selfTest(P.fk.n, 0);
|
||
CHK.ws = (function () {
|
||
let lo = [1e9, 1e9, 1e9], hi = [-1e9, -1e9, -1e9], rmax = 0;
|
||
for (let k = 0; k < P.ws.n; 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 };
|
||
})();
|
||
CHK.mode = 'live';
|
||
CHK.pose = { running: true, done: 0, total: P.pose.n };
|
||
const acc = { hit: 0, tried: 0, err: 0, msSum: 0 };
|
||
renderChk();
|
||
const step = () => {
|
||
const r = S.poseSelfTest(4);
|
||
acc.hit += r.poseHit; acc.tried += r.poseTried;
|
||
acc.err = Math.max(acc.err, r.poseErr);
|
||
acc.msSum += r.poseMs * r.poseTried;
|
||
CHK.pose.done += r.poseTried;
|
||
if (CHK.pose.done < CHK.pose.total) { renderChk(); setTimeout(step, 16); return; }
|
||
CHK.pose = { hit: acc.hit, tried: acc.tried, err: acc.err, ms: acc.msSum / Math.max(1, acc.tried) };
|
||
reRunning = false;
|
||
renderChk();
|
||
};
|
||
setTimeout(step, 16);
|
||
}
|
||
renderChk();
|
||
/* 展开自检栏本身不触发计算;只有"预置结果已失效"时才自动补算 */
|
||
{
|
||
const cb = $('ikChkBox');
|
||
if (cb) cb.addEventListener('toggle', () => { if (cb.open && !CHK.sigOk && !CHK.pose) runRecompute(); });
|
||
}
|
||
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */
|
||
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('<svg').length - 1 : 0;
|
||
const ok = (t) => '<span class="' + (t ? 'good' : 'bad') + '">';
|
||
const nScript = document.querySelectorAll('script:not([src])').length;
|
||
$('ikDep').innerHTML =
|
||
'<i>打包方式</i><span class="good">单文件自包含,可直接拷走</span>' +
|
||
'<i>three.js</i>' + ok(typeof THREE !== 'undefined') + '已内联 · r' +
|
||
((typeof THREE !== 'undefined' && THREE.REVISION) || '?') + '</span>' +
|
||
'<i>几何数据</i>' + ok(typeof DATA !== 'undefined') + '已内联 · ' + DATA.parts.length + ' 件零件</span>' +
|
||
'<i>关节 / 限位</i>' + ok(typeof JO !== 'undefined' && typeof TRAVEL !== 'undefined') +
|
||
'已内联 · ' + JO.length + ' 轴 / ' + TRAVEL.length + ' 条限位</span>' +
|
||
'<i>二维图纸</i>' + ok(nSheet > 0) + '已内联 · ' + nSheet + ' 张 SVG</span>' +
|
||
'<i>IK 求解器</i>' + ok(typeof ArmIK !== 'undefined') + '已内联(纯 JS,无依赖)</span>' +
|
||
'<i>内联脚本</i><span>' + nScript + ' 个(三维引擎 / 模型数据 / 求解器 / 界面)</span>' +
|
||
'<i>外部引用</i>' + ok(ext === 0) +
|
||
(ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + '</span>';
|
||
|
||
const q = new URLSearchParams(location.search);
|
||
const tgt = q.get('tgt'); // tgt=x,y,z:直接以该目标进入运动模拟(复现用)
|
||
if (tgt && /^-?[\d.]+,-?[\d.]+,-?[\d.]+$/.test(tgt)) TGT = tgt.split(',').map(Number);
|
||
syncInputs(); refreshTarget();
|
||
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(); },
|
||
clampAxis: clampAxis, refreshJointLabels: refreshJointLabelsLazy,
|
||
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 }),
|
||
/* 优先级 / 混合比重(调试与复现用):setPrio('mix', 0.3) */
|
||
setPrio: (p, w) => {
|
||
const e = $('ikPrio');
|
||
if (e && (p === 'pos' || p === 'ori' || p === 'mix')) e.value = p;
|
||
if (w != null && $('ikMixW')) $('ikMixW').value = Math.round(Math.max(0, Math.min(1, +w)) * 100);
|
||
syncMixUI();
|
||
if (poseOn) runSolve();
|
||
return { prio: prioVal(), mixW: mixWVal() };
|
||
},
|
||
/* 可拖控点的屏幕坐标(画布内像素):黄球 / 箭头头 / 滚转侧球 */
|
||
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 };
|
||
})(); |