feat(ik): 实时避障版:离线路网 PRM + 在线 Lazy A* + 终身增广 + 拖拽热启动;实时管线全程细验(拖拽穿模帧 430→0,重规划 P50 5 ms);前端每帧碰撞守护

This commit is contained in:
Hark
2026-10-15 16:36:37 +00:00
parent 919e704771
commit c78ec0b48e
6 changed files with 512 additions and 21 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -66,3 +66,37 @@
尝试过但未采用:基于间隙 + 杠杆界的保守连续检测。(2,4) 类常年仅 ~5 mm 间隙把步长压到 <1°,
单次间隙查询又比布尔判定贵,实测比 1.5° 离散检测慢 25%。
## 第三轮:实时版(2026-10-16)
### 思路
立柱和自身零件都是**静态障碍**,关节空间的自由区永远不变,所以把"安全路网"离线算好、随碰撞模型发布;运行时只做"接入 + 查图"。
1. **离线路网(PRM)**:`webapp/tools/gen_roadmap.cjs` 均匀采样 2500 个无干涉位形,连 10 近邻,每条边按 1.5° 细验(双线程约 9 分钟)。结果:15103 条有效边、**单一连通分量**。写入 `docs/collide.json` 的 `roadmap` 字段(约 130 KB),按碰撞模型 + 限位指纹校验,不符自动弃用、退回在线 RRT。
- 重新拟合胶囊或改限位后,按顺序重跑:`node webapp/tools/gen_pair2d.cjs`,再跑 `node webapp/tools/gen_roadmap.cjs`(pair2d 生成会截掉其后的 roadmap 字段)。
2. **在线 Lazy A\***:起点和各 IK 目标接到 12 近邻,只细验路径上的"接入边",失败删边重搜;路网内部边零成本。
3. **会话内终身增广**:每条细验通过的接入边连同起点/目标并入内存路网;**超时也保留已验进度**,下一帧接着用(跨帧随时可停)。
4. **拖拽热启动**:重规划起点就在上一条已验路径上,"起点 → 段终点"免检;新目标只需验 1~2 条短边。
5. **实时管线 `routeRT`**(拖拽 quick 模式自动启用):直连 → 热启动 → 路网 A\*(40 ms)→ 沿旧路径继续走 → 都不行就停在直线上最后一个安全采样点。**不再进入旧的启发式阶梯**(仿真里它的段只抽查少量点,会穿模);松手口径的旧阶梯 / 抬高 via 结果执行前也补了整条 1.5° 细验。
6. 热跟随解撞了时补一次 `solveDyn` 全量解(同一目标缓存;拖动中 40 mm 内复用附近目标的安全解,目标停下再精解)。
7. 碰撞判定:立柱组(组 0)的胶囊/簇/零件中心变换只算一次永久缓存(4000 个随机位形与上一版判定差异 0)。
8. 前端 `ik-glue.js`:动画每帧对显示位形做一次碰撞判定(实时碰撞守护,约 1.2 ms/帧,命中即停并亮红标,`window.__RT_GUARD()` 看统计);被新规划打断时从运动中接续(只缓出),拖拽跟随不再一顿一顿。
### 实时拖拽仿真(`tests/rt_drag_bench.cjs`,12 个横穿立柱场景 × 480 帧 @60 fps)
| 指标 | 原版 1dfdd48 | 上一版 919e704 | 实时版 |
|---|---:|---:|---:|
| 重规划耗时 P50 | 14.9 ms | 2.6 ms | **5.3 ms** |
| 重规划耗时 P90 | 43.7 ms | 14.5 ms | **18.2 ms** |
| 重规划耗时 P99 | 464 ms | 171 ms | **169 ms** |
| 穿模帧(1° 细验) | 15 | 430 | **0** |
| 拖完 6 s 内到达 | 9/12 | 10/12 | 10/12 |
上一版 P50 更低是因为它在拖拽中几乎不绕行(直接停/直走),代价是 430 帧穿模。
### 松手全量规划(`tests/route_bench.cjs`,60 场景)
有效路径 **60/60**(上一版 58),到达 58,平均关节行程 458°(上一版 436°),往返 1.86 次,执行 2.60 s。平均规划耗时 662 ms(与拖拽仿真并行跑测得,偏高)。
### 剩余问题
- 尾部尖峰(最大约 2 s)来自目标刚进入"热跟随解会撞"区域时的首次全量 IK,以及偶发长接入边细验;下一步可把全量 IK 改为分帧增量。
- 未做真实浏览器交互与实体电机测试。
+49
View File
@@ -0,0 +1,49 @@
/* 实时拖拽仿真基准:目标沿直线从 A 的 TCP 拖到 B 的 TCP(中间被立柱挡住),每帧 16.7 ms。
* 每帧:手臂沿当前路径按各轴速度上限前进;求解器空闲就用"此刻位形"作起点重规划(quick 模式)。
* 统计:重规划耗时分布、穿模帧(1° 细验)、拖完 6 s 后的到达误差。
* node tests/rt_drag_bench.cjs <solver.js> [场景数=12] [帧数=120] */
const setup = require('./route_bench_setup.cjs');
const { S } = setup(process.argv[2]);
const NC = +process.argv[3] || 12, NF = +process.argv[4] || 120, DT = 1 / 60, TAIL = 360;
const L = S.LIM, O = S.ORDER, w = S.wrap180;
let s = 777; const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
const d = (a, b) => a.map((v, i) => L[O[i]].cont ? w(b[i] - v) : b[i] - v);
const segOK = (a, b) => { const dd = d(a, b); const m = Math.max(...dd.map(Math.abs)); const n = Math.max(1, Math.ceil(m / 1)); for (let i = 1; i <= n; i++) if (!S.collideFree(a.map((v, k) => v + dd[k] * i / n))) return false; return true; };
const VMAX = [60, 60, 90, 90, 120, 180];
const cases = []; let tries = 0;
while (cases.length < NC && tries < 50000) { tries++; const a = rq(), b = rq(); if (!S.collideFree(a) || !S.collideFree(b) || segOK(a, b)) continue; cases.push([a, b]); }
const lat = []; let badFrames = 0, frames = 0, reached = 0, stuck = 0;
for (const [a, b] of cases) {
const TA = S.fk(a, 0), TB = S.fk(b, 0);
let q = a.slice(), path = [], busyUntil = 0, tNow = 0;
for (let f = 0; f < NF + TAIL; f++) {
tNow = f * DT * 1000;
const u = Math.min(1, f / NF), T = TA.map((v, i) => v + (TB[i] - v) * u);
if (tNow >= busyUntil) { // 求解器空闲:派发一次重规划(结果在耗时后才生效)
const t0 = performance.now();
const r = S.solveRoute(T, { tool: 0, step: 0.5, ref: q, warm: q, from: q.slice(), noVia: true, quick: true });
const ms = performance.now() - t0; lat.push(ms);
busyUntil = tNow + ms;
if (r && r.q) { path = (r.via || []).concat([r.q]); }
}
/* 按轴速上限沿折线前进 */
let budget = 1;
const q0 = q.slice();
while (path.length && budget > 1e-9) {
const tgt = q.map((v, i) => L[O[i]].cont ? v + w(path[0][i] - v) : path[0][i]);
const dd = tgt.map((v, i) => v - q[i]);
const need = Math.max(...dd.map((x, i) => Math.abs(x) / (VMAX[i] * DT)));
if (need <= budget) { q = tgt; path.shift(); budget -= need; }
else { q = q.map((v, i) => v + dd[i] * budget / need); budget = 0; }
}
frames++;
if (!segOK(q0, q)) badFrames++;
}
const tcp = S.fk(q, 0), err = Math.hypot(tcp[0] - TB[0], tcp[1] - TB[1], tcp[2] - TB[2]);
if (err < 1) reached++; else stuck++;
}
lat.sort((x, y) => x - y);
const pct = p => +lat[Math.min(lat.length - 1, Math.floor(lat.length * p))].toFixed(1);
console.log(JSON.stringify({ cases: cases.length, replans: lat.length, latP50: pct(0.5), latP90: pct(0.9), latP99: pct(0.99), latMax: pct(1),
latMean: +(lat.reduce((x, y) => x + y, 0) / lat.length).toFixed(1), collidingFrames: badFrames, frames, reachedAfter6s: reached }));
+26 -2
View File
@@ -330,9 +330,30 @@ function setJoints(q, doAnim, via, ms) {
refreshTarget();
if (COL) refreshJointLabelsLazy(ORDER.map(id => ang[id]));
}
/* 实时碰撞守护(2026-10-15):动画每帧对当前显示位形做一次碰撞判定(~1.2 ms/帧),
命中立即停住并亮红标。规划给的折线本已按 1.5° 细验,这里是最后一道独立防线,
也让"此刻是否碰撞"实时可见。统计供测试/控制台:window.__RT_GUARD()。 */
const RTG = { frames: 0, hits: 0, ms: 0, last: null };
window.__RT_GUARD = () => Object.assign({}, RTG, { avgMs: RTG.frames ? RTG.ms / RTG.frames : 0 });
function rtGuard(qNow) {
if (!S || !S.collideFree) return true;
const t = performance.now();
const ok = S.collideFree(qNow);
RTG.ms += performance.now() - t; RTG.frames++;
if (!ok) {
RTG.hits++; RTG.last = qNow.slice();
const b = document.getElementById('ikBadge');
if (b) { b.textContent = '实时碰撞守护:检测到干涉,已停住'; b.className = 'badge badb'; }
}
return ok;
}
function animTo(q, ms, via) {
/* 实时跟随:上一段动画还在走时被新规划打断,新动画从"正在运动"接续——
不再从零速缓入(否则每次重规划都顿一下),只在终点缓出。 */
const moving = !!anim;
if (anim) { anim = null; }
const from = ORDER.map(id => ang[id]);
const guardOn = !S.collideFree || S.collideFree(from); // 起点已在干涉里(脱困动作)不设守护
/* 连续轴(J1 肩抬举 / J3 大臂自转 / J6 腕自转)走「短弧」。
三者物理上无机械限位,θ 与 θ±360 是同一物理位形(applyJoint 走 cos/sin);
若按数值直接线性插值,+151.9° → −167.0° 会反向扫 318.9° 绕一整圈(实测)。
@@ -374,12 +395,15 @@ function animTo(q, ms, via) {
const el = performance.now() - anim.t0;
const u = Math.min(1, el / total);
const k = u;
const e = u * u * (3 - 2 * u); // 全局一次缓入缓出
const e = moving ? 1 - (1 - u) * (1 - u) // 接续运动:只缓出
: u * u * (3 - 2 * u); // 静止起步:全局一次缓入缓出
let si = 0; while (si < dur.length - 1 && e > cum[si + 1]) si++;
const f = Math.min(1, Math.max(0, (e - cum[si]) / (cum[si + 1] - cum[si])));
const a0 = pts[si], a1 = pts[si + 1];
const qNow = a0.map((v, i) => v + (a1[i] - v) * f);
if (guardOn && !rtGuard(qNow)) { anim = null; return; } // 停在上一帧(已判无干涉)的位形
for (let i = 0; i < 6; i++) {
const id = ORDER[i], a = a0[i] + (a1[i] - a0[i]) * f;
const id = ORDER[i], a = qNow[i];
ang[id] = a;
const sl = $('s' + id), v = $('v' + id);
if (sl) sl.value = a;
+319 -18
View File
@@ -382,29 +382,29 @@ function make(cfg) {
CGEN++;
const clOf = (pi, ci) => {
const e = CLBUF[pi][ci];
if (e[3] !== CGEN) {
if (e[3] !== CGEN && e[3] !== -2) {
const pt = PART[pi], R = Rs[pt.g], t = Ts[pt.g], c = pt.clus[ci].c;
e[0] = R[0] * c[0] + R[1] * c[1] + R[2] * c[2] + t[0];
e[1] = R[3] * c[0] + R[4] * c[1] + R[5] * c[2] + t[1];
e[2] = R[6] * c[0] + R[7] * c[1] + R[8] * c[2] + t[2];
e[3] = CGEN;
e[3] = pt.g === 0 ? -2 : CGEN; // 组 0(立柱/底座)静止:变换一次永久有效
}
return e;
};
const pcOf = pi => {
const e = PCBUF[pi];
if (e[3] !== CGEN) {
if (e[3] !== CGEN && e[3] !== -2) {
const pt = PART[pi], R = Rs[pt.g], t = Ts[pt.g], c = pt.c;
e[0] = R[0] * c[0] + R[1] * c[1] + R[2] * c[2] + t[0];
e[1] = R[3] * c[0] + R[4] * c[1] + R[5] * c[2] + t[1];
e[2] = R[6] * c[0] + R[7] * c[1] + R[8] * c[2] + t[2];
e[3] = CGEN;
e[3] = pt.g === 0 ? -2 : CGEN;
}
return e;
};
const xf = i => {
const e = XFBUF[i];
if (e[7] === CGEN) return e;
if (e[7] === CGEN || e[7] === -2) return e;
const c = CAPG[i], R = Rs[c.g], t = Ts[c.g];
e[0] = R[0] * c.a[0] + R[1] * c.a[1] + R[2] * c.a[2] + t[0];
e[1] = R[3] * c.a[0] + R[4] * c.a[1] + R[5] * c.a[2] + t[1];
@@ -412,7 +412,7 @@ function make(cfg) {
e[3] = R[0] * c.b[0] + R[1] * c.b[1] + R[2] * c.b[2] + t[0];
e[4] = R[3] * c.b[0] + R[4] * c.b[1] + R[5] * c.b[2] + t[1];
e[5] = R[6] * c.b[0] + R[7] * c.b[1] + R[8] * c.b[2] + t[2];
e[7] = CGEN;
e[7] = c.g === 0 ? -2 : CGEN;
return e;
};
/* 宽相位阈值必须保守:球心距 > rad_A+rad_B+rmax_A+rmax_B+margin 才可筛除。
@@ -462,9 +462,6 @@ function make(cfg) {
}
return hit;
}
/* 间隙下界(mm,封顶 cap):所有零件对 min(线段距 − need)。宽相位被筛掉的对按
"球心距 − 阈值"计下界,所以返回值 ≤ 真实最小间隙(保守)。< 0 即干涉。
供 edgeFree 做保守连续检测:间隙 c 内,任何关节运动只要扫掠位移 < c 就不可能撞。 */
/* 全姿态动态碰撞判定:True = 无动态干涉(模型近似,含 margin 外扩) */
const PST = { n: 0 };
/* 单次求解内的判定记忆:routeFrom / gatherGoals / 规划器会对同一批 IK 候选反复判定 */
@@ -2158,10 +2155,32 @@ function make(cfg) {
return Object.assign({}, cur, { q: q, safeT: safeT, routed: true, blocked: lo < 0.98, kept: false });
}
/* 拖拽/松手统一入口:solveTo + 路径路由(opt.from = 当前位形) */
const FULLIK = { key: null, cur: null, T: null, at: 0 };
function solveRoute(target, opt) {
opt = opt || {};
if (opt.from && !opt.ref) opt.ref = opt.from;
return routeOrVia(opt.from, target, solveTo(target, opt), opt);
const rt = opt.quick && RM && !opt.noAround;
const t0 = nowMs();
let cur = solveTo(target, opt);
/* 实时管线:热跟随只给一个"同支续解",它若撞了(目标那一支被立柱挡住),就得看别的构型——
补一次全量 IK(同一目标缓存复用:松手后目标不动,不必每帧重解) */
if (rt && cur && (!cur.q || !collideFree(cur.q))) {
const key = target.map(v => v.toFixed(2)).join(',') + '|' + (opt.tool || 0) + '|' + (opt.pose ? JSON.stringify(opt.pose) : '');
const near = FULLIK.T && FULLIK.key && Math.hypot(target[0] - FULLIK.T[0], target[1] - FULLIK.T[1], target[2] - FULLIK.T[2]) < 40
&& nowMs() - FULLIK.at < 400;
if (FULLIK.key === key) cur = FULLIK.cur;
else if (near) cur = Object.assign({}, FULLIK.cur, { approx: true }); // 拖动中:先朝附近目标的安全解走,目标停下再精解(避免每帧全量 IK 尖峰)
else {
const o2 = Object.assign({}, opt); delete o2.warm; o2.quick = false;
/* solveDyn:按动态限位挑"本身无干涉"的解(全量表按代价截断在 4000 条,安全支常排在很后面甚至被截掉) */
const full = solveDyn(target, o2);
if (full && full.q) { cur = Object.assign({}, full); FULLIK.key = key; FULLIK.cur = cur; FULLIK.T = target.slice(); FULLIK.at = nowMs(); }
}
}
const t1 = nowMs();
const r = rt ? routeRT(opt.from, cur) : routeOrVia(opt.from, target, cur, opt);
if (r && typeof r === 'object') { r.tIk = t1 - t0; r.tRoute = nowMs() - t1; }
return r;
}
/* 两段式绕行:直线被立柱挡住、但目标本身有安全解时,先解一个"抬高版"的中间位形
(目标正上方 VIA_DZ),走直线过去,再从那里落回目标。实测 100 个拖拽步里
@@ -2361,7 +2380,8 @@ function make(cfg) {
for (let i = 0; i < 6; i++) { const t = LIM[ORDER[i]]; if (!t.cont && (q[i] < t.lo - 1e-6 || q[i] > t.hi + 1e-6)) return false; }
return true;
}
function gatherGoals(cur, from) {
function gatherGoals(cur, from, kMax, listMax, cfMax) {
kMax = kMax || GOAL_K; listMax = listMax || 160; cfMax = cfMax || 1e9;
const seen = new Set(), out = [];
const add = q => {
if (!q || q.length !== 6) return;
@@ -2372,12 +2392,17 @@ function make(cfg) {
};
add(cur.q);
const L = cur.list || [];
for (let i = 0; i < Math.min(L.length, 160); i++) add(L[i] && L[i].q);
for (let i = 0; i < Math.min(L.length, listMax); i++) add(L[i] && L[i].q);
out.sort((a, b) => wdist(from, a) - wdist(from, b));
const goals = [];
const goals = [], tried = [];
for (const g of out) {
if (goals.length >= GOAL_K) break;
if (goals.length >= kMax || cfMax <= 0) break;
if (goals.some(h => wdist(g, h) < 8)) continue; // 同一盆地只留一个
if (cfMax < 1e8) { // 限量模式:撞过的盆地也不再重复判定
if (tried.some(h => wdist(g, h) < 8)) continue;
tried.push(g);
}
cfMax--;
if (collideFree(g)) goals.push(g);
}
return goals;
@@ -2490,10 +2515,272 @@ function make(cfg) {
}
return null;
}
/* ---------- 6d. 离线路网(PRM)+ 在线 Lazy A*(2026-10-15,实时版) ----------
障碍全是静态的(立柱 + 自身零件),关节空间的自由区不随时间变化,所以把"安全路网"离线建好:
webapp/tools/gen_roadmap.cjs 均匀采样无干涉节点,连 k 近邻并按 EDGE_RES 细验每条边,
随 collide.json 的 roadmap 字段发布(指纹校验,碰撞模型变了自动弃用)。
在线:起点 / 各目标接到路网的 k 个近邻(这些边"待验"),A* 求最短路,只细验路径上的待验边,
失败就删边重搜(Lazy PRM)。路网内部的边离线已验,在线零成本。最后限时贪心捷径拉直。 */
function wd(a, b) { // 展开无关的加权距离(连续轴短弧)
let s = 0;
for (let i = 0; i < 6; i++) { const d = LIM[ORDER[i]].cont ? wrap180(b[i] - a[i]) : b[i] - a[i]; const x = d * PW[i]; s += x * x; }
return Math.sqrt(s);
}
function modelFingerprint() {
let h = 2166136261 >>> 0;
const mix = v => { h = Math.imul(h ^ (Math.round(v * 1000) | 0), 16777619) >>> 0; };
mix(CAPM);
for (const c of CAPG) { mix(c.g); mix(c.r); for (const v of c.a) mix(v); for (const v of c.b) mix(v); }
for (const id of ORDER) { const t = LIM[id]; mix(t.lo); mix(t.hi); mix(t.cont ? 1 : 0); }
return h.toString(16);
}
const RM = (() => {
if (!COL || !COL.roadmap) return null;
const R = COL.roadmap;
if (R.fp !== modelFingerprint()) { if (typeof console !== 'undefined') console.warn('[IK] roadmap 指纹不符,弃用(重跑 webapp/tools/gen_roadmap.cjs)'); return null; }
const dec = (b64, Ctor) => {
if (typeof atob === 'function') { const bin = atob(b64), u8 = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); return new Ctor(u8.buffer); }
const b = Buffer.from(b64, 'base64'); const u8 = new Uint8Array(b.length); u8.set(b); return new Ctor(u8.buffer);
};
const qi = dec(R.q, Int16Array), off = dec(R.off, Uint32Array), adj = dec(R.adj, Uint16Array);
const N = R.n, Q = [];
for (let i = 0; i < N; i++) { const q = new Array(6); for (let k = 0; k < 6; k++) q[k] = qi[i * 6 + k] / 100; Q.push(q); }
return { N: N, Q: Q, off: off, adj: adj };
})();
/* 会话内"终身"增广:每次在线细验通过的接入边连同起点 / 目标位形一起并入路网(只存在内存里)。
拖拽时起点和目标集中在一小片区域,几次之后那里路网变密、接入边变短且多半已验,
后续查询基本只剩 A*(亚毫秒)。上限 RMX_MAX 个节点,满了就不再并入。 */
const RMX = { Q: [], adj: [] }, RMX_MAX = 4000;
function rmKnn(q, k) {
const best = [];
const M = RM.N + RMX.Q.length;
for (let i = 0; i < M; i++) {
const d = wd(q, i < RM.N ? RM.Q[i] : RMX.Q[i - RM.N]);
if (best.length < k || d < best[best.length - 1][1]) {
let j = best.length; best.push(null);
while (j > 0 && best[j - 1][1] > d) { best[j] = best[j - 1]; j--; }
best[j] = [i, d]; if (best.length > k) best.pop();
}
}
return best;
}
const RM_K = 12;
/* 返回 { path:[from, ..., goal] (展开坐标) } 或 null */
function rmPlan(from, goals, budgetMs) {
if (!RM) return null;
const t0 = nowMs(), deadline = t0 + budgetMs, N = RM.N, NX = N + RMX.Q.length, S = NX, G0 = NX + 1;
const pos = i => i < N ? RM.Q[i] : i < NX ? RMX.Q[i - N] : i === S ? from : goals[i - G0];
const ekey = (a, b) => a < b ? a * 65536 + b : b * 65536 + a;
const bad = new Set(), good = new Set();
const extra = new Map(); // 本次查询的待验边:节点 → [[邻, 代价], ...]
const link = (a, b, c) => { if (!extra.has(a)) extra.set(a, []); if (!extra.has(b)) extra.set(b, []); extra.get(a).push([b, c]); extra.get(b).push([a, c]); };
for (const [i, d] of rmKnn(from, RM_K)) link(S, i, d);
goals.forEach((g, j) => {
for (const [i, d] of rmKnn(g, RM_K)) link(G0 + j, i, d);
link(S, G0 + j, wd(from, g)); // 直连也是候选:能直走 A* 自然选它
});
const isGoal = i => i >= G0;
const h = i => { let m = Infinity; const p = pos(i); for (const g of goals) m = Math.min(m, wd(p, g)); return m; };
let timedOut = false;
const edgeOk = (a, b) => { // 只对本次查询的待验边做在线细验
const k = ekey(a, b);
if (good.has(k)) return true;
if (bad.has(k)) return false;
if (nowMs() > deadline) { timedOut = true; return false; }
const pa = pos(a), pb = unwrapTo(pa, pos(b));
const ok = edgeFree(pa, pb);
(ok ? good : bad).add(k);
return ok;
};
const augment = () => {
/* 增广(成功或超时都做:已验的接入边是下一帧的资产,跨帧"随时可停"地累积进度):起点 / 用到的目标并入,带上所有已验通过的接入边 */
if (RMX.Q.length + 2 <= RMX_MAX) {
const newId = new Map();
const addNode = id => { if (newId.has(id)) return newId.get(id); const nid = N + RMX.Q.length; RMX.Q.push(pos(id).map((v, k) => LIM[ORDER[k]].cont ? wrap180(v) : v)); RMX.adj.push([]); newId.set(id, nid); return nid; };
const mapId = id => id >= NX ? addNode(id) : id;
RMX.adjR = RMX.adjR || new Map();
const addEdge = (a, b, c) => {
for (const [x, y] of [[a, b], [b, a]]) {
if (x >= N) RMX.adj[x - N].push([y, c]);
else { if (!RMX.adjR.has(x)) RMX.adjR.set(x, []); RMX.adjR.get(x).push([y, c]); }
}
};
for (const k of good) {
const a = Math.floor(k / 65536), b = k % 65536;
addEdge(mapId(a), mapId(b), wd(pos(a), pos(b)));
}
}
};
let rounds = 0;
while (nowMs() < deadline && rounds < 60) {
rounds++;
const gsc = new Map(), par = new Map(), heap = [];
const push = (f, n) => { heap.push([f, n]); let i = heap.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (heap[p][0] <= heap[i][0]) break; [heap[p], heap[i]] = [heap[i], heap[p]]; i = p; } };
const pop = () => { const top = heap[0], last = heap.pop(); if (heap.length) { heap[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < heap.length && heap[l][0] < heap[m][0]) m = l; if (r < heap.length && heap[r][0] < heap[m][0]) m = r; if (m === i) break; [heap[m], heap[i]] = [heap[i], heap[m]]; i = m; } } return top; };
gsc.set(S, 0); push(h(S), S);
let found = -1;
while (heap.length) {
const [f, u] = pop();
const gu = gsc.get(u);
if (f > gu + h(u) + 1e-9) continue;
if (isGoal(u)) { found = u; break; }
const relax = (v, c, lazy) => {
if (lazy && bad.has(ekey(u, v))) return;
const gv = gu + c;
if (gv < (gsc.has(v) ? gsc.get(v) : Infinity)) { gsc.set(v, gv); par.set(v, [u, lazy]); push(gv + h(v), v); }
};
if (u < N) for (let e = RM.off[u]; e < RM.off[u + 1]; e++) { const v = RM.adj[e]; relax(v, wd(RM.Q[u], RM.Q[v]), false); }
if (u < NX) { const ax = u < N ? null : RMX.adj[u - N]; if (ax) for (const [v, c] of ax) relax(v, c, false); }
if (u < N) { const ax = RMX.adjR && RMX.adjR.get(u); if (ax) for (const [v, c] of ax) relax(v, c, false); }
const ex = extra.get(u); if (ex) for (const [v, c] of ex) relax(v, c, true);
}
if (found < 0) { augment(); return null; }
const ids = [], lz = [];
for (let v = found; v !== S; ) { const [u, lazy] = par.get(v); ids.push(v); lz.push(lazy); v = u; }
ids.push(S); ids.reverse(); lz.reverse();
let ok = true;
for (let i = 0; i + 1 < ids.length; i++) if (lz[i] && !edgeOk(ids[i], ids[i + 1])) { ok = false; break; }
if (timedOut) { augment(); return null; }
if (!ok) continue;
const path = [from.slice()];
for (let i = 1; i < ids.length; i++) path.push(unwrapTo(path[i - 1], pos(ids[i])));
augment();
return { path: path, ms: nowMs() - t0, rounds: rounds };
}
augment();
return null;
}
/* 限时贪心拉直:从当前点往后找最远可直达的点(由远到近试) */
function rmSmooth(path, deadline) {
if (path.length <= 2) return path;
const out = [path[0]];
let i = 0;
while (i < path.length - 1) {
let j = path.length - 1;
if (nowMs() < deadline) {
for (; j > i + 1; j--) {
if (edgeFree(out[out.length - 1], unwrapTo(out[out.length - 1], path[j]))) break;
if (nowMs() > deadline) { j = i + 1; break; }
}
} else j = i + 1;
out.push(unwrapTo(out[out.length - 1], path[j]));
i = j;
}
return out;
}
/* ---------- 6e. 实时热启动(拖拽连续重规划) ----------
拖拽时每次重规划的起点 = 手臂此刻在上一条路径上的位置。上一条路径每段都已细验,
"起点 → 所在段终点"是已验段的子段,免检;目标每帧只挪一点,新目标接到旧目标只需验一条短边。
于是一次重规划通常只验 1~2 条短边(几毫秒),而不是重新接入路网。
路径尾巴会累积小折线:每次先试"倒数第二点 → 新目标"直连,能连就替换掉旧目标。 */
let RT = null; // { path:[展开位形...] }
function onPath(path, q, tol) { // q 是否在折线上:返回 [段号, 参数] 或 null
for (let i = 0; i + 1 < path.length; i++) {
const a = path[i], b = unwrapTo(a, path[i + 1]);
const qa = unwrapTo(a, q);
let num = 0, den = 0;
for (let k = 0; k < 6; k++) { const d = b[k] - a[k]; num += (qa[k] - a[k]) * d; den += d * d; }
const t = den > 1e-12 ? Math.min(1, Math.max(0, num / den)) : 0;
let err = 0;
for (let k = 0; k < 6; k++) err = Math.max(err, Math.abs(a[k] + (b[k] - a[k]) * t - qa[k]));
if (err <= tol) return [i, t];
}
return null;
}
function rtWarm(from, goals, deadline) {
if (!RT || RT.path.length < 2) return null;
const hit = onPath(RT.path, from, 0.05);
if (!hit) return null;
const rest = [from.slice()];
for (let i = hit[0] + 1; i < RT.path.length; i++) rest.push(unwrapTo(rest[rest.length - 1], RT.path[i]));
if (rest.length >= 2 && wd(rest[0], rest[1]) < 1e-6) rest.splice(1, 1);
const last = rest[rest.length - 1];
for (const g0 of goals) {
if (nowMs() > deadline) break;
const g = unwrapTo(last, g0);
/* 1) 直连(整段,能走就最好) 2) 倒数第二点 → 新目标 3) 旧目标 → 新目标(短边) */
if (edgeFree(rest[0], unwrapTo(rest[0], g))) return [rest[0], unwrapTo(rest[0], g)];
if (rest.length >= 2) {
const pre = rest[rest.length - 2];
if (edgeFree(pre, unwrapTo(pre, g))) return rest.slice(0, -1).concat([unwrapTo(pre, g)]);
}
if (rest.length >= 2 && edgeFree(last, g)) return rest.concat([g]);
}
return null;
}
/* 实时管线(拖拽 quick 模式且路网可用时):只走"每步都细验过"的分支,并且每一步都有硬时限。
直连 → 热启动 → 路网 Lazy A*(40 ms)→ 都不行就沿直线走到最后一个安全点停住。
不再进入旧的启发式阶梯(慢,且其段只抽查少量点,拖拽仿真里实测会穿模)。 */
function routeRT(from, cur) {
if (!COL || !cur || !cur.q || !from) return cur;
if (!collideFree(from)) return cur; // 起点已在干涉里:交给脱困逻辑
CFMEMO = new Map();
try {
const t0 = nowMs();
const goals = gatherGoals(cur, from, 3, 4000, 30);
const done = (path, detour, blocked) => {
RT = { path: path };
const q = path[path.length - 1];
return Object.assign({}, cur, { q: q.map((v, i) => LIM[ORDER[i]].cont ? wrap180(v) : v), via: path.slice(1, -1),
routed: true, blocked: !!blocked, kept: false, detour: detour, planMs: nowMs() - t0 });
};
if (goals.length) {
if (edgeFree(from, goals[0])) return done([from.slice(), goals[0]], null);
const w = rtWarm(from, goals, t0 + 25);
if (w) return done(w, w.length > 2 ? 'planned' : null);
const rp = rmPlan(from, goals, 40);
if (rp) return done(rmSmooth(rp.path, nowMs() + 15), 'planned');
/* 本帧没规划完:沿上一条(已验)路径继续走,下一帧接着规划——不急停、不乱走 */
if (RT && RT.path.length >= 2) {
const hit = onPath(RT.path, from, 0.05);
if (hit) {
const rest = [from.slice()];
for (let i = hit[0] + 1; i < RT.path.length; i++) rest.push(unwrapTo(rest[rest.length - 1], RT.path[i]));
if (rest.length >= 2) {
const q = rest[rest.length - 1];
return Object.assign({}, cur, { q: q.map((v, i) => LIM[ORDER[i]].cont ? wrap180(v) : v), via: rest.slice(1, -1),
routed: true, blocked: false, kept: false, detour: 'continue', pending: true, planMs: nowMs() - t0 });
}
}
}
}
/* 规划不到:沿"朝最近目标的直线"按 EDGE_RES 前进,停在最后一个无干涉采样点 */
const g = goals.length ? goals[0] : unwrapTo(from, cur.q);
let m = 0; for (let i = 0; i < 6; i++) m = Math.max(m, Math.abs(g[i] - from[i]));
const n = Math.max(1, Math.ceil(m / EDGE_RES));
let last = from.slice();
for (let k = 1; k <= n; k++) {
const q = from.map((v, i) => v + (g[i] - v) * k / n);
if (!collideFree(q)) break;
last = q;
}
return done([from.slice(), last], null, true);
} finally { CFMEMO = null; }
}
function planAround(from, cur, quick) {
const goals = gatherGoals(cur, from);
if (!goals.length) return null;
const budget = quick ? 150 : 1500;
/* 实时版:有离线路网就直接 Lazy A*(典型几十毫秒),直连边也在图里(A* 自然优先),失败再走下面的旧流程 */
if (RM) {
const t0 = nowMs();
if (quick) {
const w = rtWarm(from, goals, t0 + 40);
if (w) { RT = { path: w }; return { goal: w[w.length - 1], via: w.slice(1, -1), detour: w.length > 2 ? 'planned' : null, ms: nowMs() - t0, rm: 'warm' }; }
}
const rp = rmPlan(from, goals, quick ? 60 : 300);
if (rp) {
let sm = rmSmooth(rp.path, nowMs() + (quick ? 25 : 80));
/* 松手精修:再做一轮随机捷径 + 逐轴拉直(路网路径节点稀疏,有可观的多余行程) */
if (!quick && sm.length > 2) {
let seed = 2166136261; for (const v of from) seed = Math.imul(seed ^ Math.round(v * 10), 16777619);
let st = seed >>> 0 || 1; const rnd = () => { st = (st * 1664525 + 1013904223) >>> 0; return st / 4294967296; };
sm = shortcut(sm, nowMs() + 120, rnd);
}
RT = { path: sm };
return { goal: sm[sm.length - 1], via: sm.slice(1, -1), detour: 'planned', ms: nowMs() - t0, rm: true };
}
}
/* 先试直线(多目标、2° 细验):换个构型能直走就不绕 */
for (const g of goals) if (edgeFree(from, g)) return { goal: g, via: [], detour: null };
/* 再试"单中间点微调":弦中点抬肩 / 收肘(只动 J2/J4,其余轴保持直线插值),2° 细验。
@@ -2521,7 +2808,7 @@ function make(cfg) {
function routeOrViaImpl(from, target, cur, opt) {
const lazy = !opt.noAround;
let r = routeFrom(from, cur, lazy);
if (!r.blocked || !from) return r;
if (!r.blocked || !from) { RT = from && r && r.q ? { path: [from.slice(), unwrapTo(from, r.q)] } : null; return r; }
/* 首选:采样规划(多目标 + 平滑),给出的路径已按 EDGE_RES 细验 */
if (!opt.noAround) {
const pl = planAround(from, cur, opt.quick);
@@ -2533,10 +2820,24 @@ function make(cfg) {
if (r.lazyBlocked) r = routeFrom(from, cur, false);
/* 规划预算内未连通(极少):退回旧的启发式阶梯 */
const around = routeAroundPlan(from, cur, opt.quick, r.safeT);
if (around) return Object.assign({}, around.res, { via: around.via, routed: true, blocked: false, detour: around.detour || 'around' });
/* 旧阶梯的段只抽查少量点(实测拖拽仿真里 coordinated 段会穿模):执行前按 EDGE_RES 整条细验,
不过就宁可贴边停下(blocked),绝不把没验透的折线交给动画 */
if (around && polyFree(from, around.via, around.res.q))
return Object.assign({}, around.res, { via: around.via, routed: true, blocked: false, detour: around.detour || 'around' });
}
if (opt.noVia) return r;
return routeVia(from, target, opt) || r;
const rv = routeVia(from, target, opt);
if (rv && rv.q && polyFree(from, rv.via || [], rv.q)) return rv;
return r;
}
function polyFree(from, via, q) { // 整条折线(展开后)按 EDGE_RES 细验
let p = from;
for (const raw of (via || []).concat([q])) {
const t = unwrapTo(p, raw);
if (!edgeFree(p, t)) return false;
p = t;
}
return true;
}
/* ---------- 6b. 动态限位求解:静态解 + 贴边轴放宽重解 ----------
@@ -2650,7 +2951,7 @@ function make(cfg) {
}
return { fk: fk, fkChain: fkChain, solve: solve, solveTo: solveTo, solveWarm: solveWarm, solveDyn: solveDyn,
solveRoute: solveRoute, routeFrom: routeFrom, transitPt: transitPt, planAround: planAround, edgeFree: edgeFree, PST: PST, classGap: classGap, classFingerprint: classFingerprint, P2D: P2D,
solveRoute: solveRoute, routeFrom: routeFrom, transitPt: transitPt, planAround: planAround, edgeFree: edgeFree, PST: PST, unwrapTo: unwrapTo, rtWarm: rtWarm, rtReset: () => { RT = null; }, rmPlan: rmPlan, rmSmooth: rmSmooth, modelFingerprint: modelFingerprint, RM: RM, wd: wd, classGap: classGap, classFingerprint: classFingerprint, P2D: P2D,
mixSolve: mixSolve, closest: closest, closest6: closest6, closestOri: closestOri, orient: orient,
branch: branch, selfTest: selfTest, poseSelfTest: poseSelfTest,
collideFree: collideFree, collideDetail: collideDetail,
+82
View File
@@ -0,0 +1,82 @@
/* 离线生成关节空间安全路网(PRM),写入 docs/collide.json 的 roadmap 字段。
* node webapp/tools/gen_roadmap.cjs [节点数=2500] [k=10]
* 障碍是静态的(立柱 + 自身零件),路网只依赖碰撞模型与限位;重新拟合胶囊 / 改限位后必须重跑
* (求解器按指纹校验,不符自动弃用路网,退回在线 RRT —— 只是变慢,不会算错)。
* 多线程:worker_threads 按 CPU 数并行细验边。 */
const fs = require('fs'), path = require('path'), os = require('os');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const ROOT = path.resolve(__dirname, '..', '..');
const COLF = path.join(ROOT, 'docs/collide.json');
function stripped() { // 去掉旧 roadmap 字段(文本级,保持原格式)
let text = fs.readFileSync(COLF, 'utf8');
const cut = text.indexOf(',\n "roadmap":');
if (cut >= 0) text = text.slice(0, cut) + '\n}\n';
return text;
}
if (!isMainThread) {
const { S } = require(path.join(ROOT, 'tests/route_bench_setup.cjs'))(undefined, workerData.tmp);
parentPort.on('message', ({ Q, edges }) => {
const ok = edges.map(([a, b]) => S.edgeFree(Q[a], S.unwrapTo ? S.unwrapTo(Q[a], Q[b]) : Q[b]) ? 1 : 0);
parentPort.postMessage(ok);
});
return;
}
(async () => {
const N = +process.argv[2] || 2500, K = +process.argv[3] || 10;
const text = stripped(), tmp = COLF + '.rmtmp';
fs.writeFileSync(tmp, text);
const { S } = require(path.join(ROOT, 'tests/route_bench_setup.cjs'))(undefined, tmp);
const O = S.ORDER, L = S.LIM;
let seed = 20261015;
const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; };
const Q = [];
let tries = 0;
while (Q.length < N) {
tries++;
const q = O.map(id => { const t = L[id]; return t.cont ? -180 + 360 * rnd() : t.lo + (t.hi - t.lo) * rnd(); });
const qr = q.map(v => Math.round(v * 100) / 100);
if (S.collideFree(qr)) Q.push(qr);
}
console.error('nodes', N, 'free ratio', (N / tries).toFixed(3));
const E = new Set();
for (let i = 0; i < N; i++) {
const d = [];
for (let j = 0; j < N; j++) if (j !== i) d.push([j, S.wd(Q[i], Q[j])]);
d.sort((a, b) => a[1] - b[1]);
for (let m = 0; m < K; m++) { const j = d[m][0]; E.add(i < j ? i * 65536 + j : j * 65536 + i); }
}
const edges = [...E].map(k => [Math.floor(k / 65536), k % 65536]);
console.error('candidate edges', edges.length);
const nw = Math.max(1, os.cpus().length), ws = [];
for (let w = 0; w < nw; w++) ws.push(new Worker(__filename, { workerData: { tmp } }));
const CH = 200, okAll = new Uint8Array(edges.length);
let next = 0, done = 0; const t0 = Date.now();
await new Promise(res => {
const feed = w => {
if (next >= edges.length) { if (done >= edges.length) res(); return; }
const lo = next; next = Math.min(edges.length, next + CH);
w.once('message', ok => { okAll.set(ok, lo); done += ok.length;
if ((done / CH) % 10 === 0) console.error('edges', done + '/' + edges.length, ((Date.now() - t0) / 1000).toFixed(0) + 's');
if (done >= edges.length) res(); else feed(w); });
w.postMessage({ Q, edges: edges.slice(lo, next) });
};
ws.forEach(feed);
});
ws.forEach(w => w.terminate());
fs.unlinkSync(tmp);
const adjL = Array.from({ length: N }, () => []);
let ne = 0;
edges.forEach(([a, b], i) => { if (okAll[i]) { adjL[a].push(b); adjL[b].push(a); ne++; } });
/* 连通分量统计 */
const comp = new Int32Array(N).fill(-1); let nc = 0, big = 0;
for (let i = 0; i < N; i++) if (comp[i] < 0) { let st = [i], sz = 0; comp[i] = nc; while (st.length) { const u = st.pop(); sz++; for (const v of adjL[u]) if (comp[v] < 0) { comp[v] = nc; st.push(v); } } big = Math.max(big, sz); nc++; }
console.error('valid edges', ne, 'components', nc, 'largest', big);
const qb = Buffer.alloc(N * 12); Q.forEach((q, i) => q.forEach((v, k) => qb.writeInt16LE(Math.round(v * 100), (i * 6 + k) * 2)));
const off = Buffer.alloc((N + 1) * 4), adj = Buffer.alloc(ne * 2 * 2);
let p = 0; for (let i = 0; i < N; i++) { off.writeUInt32LE(p, i * 4); for (const v of adjL[i]) { adj.writeUInt16LE(v, p * 2); p++; } } off.writeUInt32LE(p, N * 4);
const rm = { n: N, k: K, res: 1.5, fp: S.modelFingerprint(), q: qb.toString('base64'), off: off.toString('base64'), adj: adj.toString('base64'),
generated: new Date().toISOString(), stats: { edges: ne, components: nc, largest: big } };
const body = text.replace(/\s*\}\s*$/, '');
fs.writeFileSync(COLF, body + ',\n "roadmap": ' + JSON.stringify(rm) + '\n}\n');
console.log('roadmap written', rm.stats);
})();