Compare commits
3
Commits
main
...
c78ec0b48e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c78ec0b48e | ||
|
|
919e704771 | ||
|
|
8ad5b3b4fa |
+4
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
|
||||
# 避障路径规划优化报告(2026-10-15,基于 1dfdd48)
|
||||
|
||||
## 现状问题(旧 routeOrVia 阶梯)
|
||||
1. **穿模**:`routeAroundPlan` 每段只查 ≤8 点(长段采样间隔 15°+),`coordinated` 二分松弛只查 6 点。
|
||||
60 个"直线被挡"随机场景里 18 条路径在 1° 细验下实际穿模(13 条为 coordinated)。
|
||||
2. **怪动作**:`CRUISE` 巡航位形把 J3/J5/J6 强制拉到固定值(0/-60/0),与障碍无关的腕部和大臂自转也被
|
||||
"归零再转回";回退绕行(retract_pass)先收臂再伸展,路径绕远。
|
||||
3. **单目标**:绕行只认一个目标解 qT,换个肘/腕构型就能直走的情况被忽略。
|
||||
4. **动画顿挫**:`animTo` 每个中间位形各占固定 ms 并各自 smoothstep —— 每过一个 via 都减速到 0;
|
||||
5° 小段与 150° 大段用同样时间。
|
||||
|
||||
## 改动
|
||||
- `ik-solver.js` 新增 6c 节:
|
||||
- `gatherGoals`:候选表里端点安全的 IK 解按加权关节距离取前 6 个(同盆地去重)作为目标集;
|
||||
- 先试多目标直线(1.5° 细验)→ 再试弦中点 J2/J4 微调单中间点 → 再上 **Lazy RRT-Connect**
|
||||
(起点树 ↔ 多根目标树;生长时只查节点 + 边中点,连通后整条路径细验,坏边判死子树);
|
||||
- `shortcut`:随机捷径 + 贪心最远可达 + 逐轴拉直(去掉与障碍无关轴的来回摆);
|
||||
- 加权度量 `PW=[1.6,1.4,1,1,0.7,0.5]`(近端轴代价高),定种子 LCG,结果可复现;
|
||||
- 连续轴段内变化 <179°,保证与查看器短弧插值走的是同一条线;
|
||||
- 规划失败时仍回落旧阶梯与 routeVia,最坏行为不劣于旧版。
|
||||
- 碰撞窄相位前加**胶囊级包围球预筛**(保守、零漏判):`collideFree` 2.9 → 1.8 ms/次,
|
||||
4000 个随机位形与旧实现判定 0 差异。
|
||||
- `ik-glue.js animTo`:各段时长按"最慢轴所需时间"(轴速比 VREL)分配,整条折线一次全局缓入缓出,
|
||||
中间位形不停顿;总时长按路径长度伸缩。
|
||||
|
||||
## 基准(`node tests/route_bench.cjs [solver] 60`,同一批 60 个直线被挡场景)
|
||||
| 指标 | 旧 | 新 |
|
||||
|---|---|---|
|
||||
| 1° 细验无穿模的路径 | 42/60 | 58/60(剩 2 条最大侵入 0.28/0.08 mm,在 0.3 mm 安全裕度内) |
|
||||
| 到达且无穿模 | 37 | 53 |
|
||||
| 两版都成功的 37 例:平均关节总行程 | 417° | 388°(−7%) |
|
||||
| 同上:估算执行时间 | 2.56 s | 2.46 s |
|
||||
| 平均规划耗时(Node 单线程) | 0.72 s | 1.23 s(全集)/ 0.94 s(37 例) |
|
||||
|
||||
到不了的 5 例两版相同,属于目标超出安全可达区(IK 残差 4~235 mm),不是规划问题。
|
||||
|
||||
## 已知取舍 / 后续
|
||||
- 规划耗时上升约 0.3~0.5 s(Worker 里跑,不卡界面);拖拽实时模式预算 150 ms,超时回落旧阶梯。
|
||||
- 执行层仍是关节空间折线;若要上真机,下一步应做带加速度约束的时间最优参数化(TOPP)+ 拐角抛物线过渡,
|
||||
过渡段需重新做碰撞校验。
|
||||
|
||||
## 二期:规划提速(2026-10-15)
|
||||
|
||||
逐段计时(20 个被挡场景):时间主要花在"单中间点微调"(~350 ms/例)、RRT(~280 ms)和平滑(~250 ms 截止);
|
||||
每次碰撞判定约 1.5 ms,其中 80% 的线段距离计算来自 (2,4) 类零件对(P05/M3 × P07/M5,长期相距 ~5 mm)。
|
||||
|
||||
改动:
|
||||
1. **两关节零件对类间隙表**:(2,4) 类的相对位姿只取决于 J3、J4,离线按 1° 网格生成最小间隙表
|
||||
(`webapp/tools/gen_pair2d.cjs` → `docs/collide.json` 的 `pair2d`)。运行时取最近网格点,按杠杆界
|
||||
(J3 3.24 mm/°、J4 2.54 mm/°)给出上下界,能定论就跳过/判撞,否则回退精算。2 万随机姿态与精算判定 0 差异;
|
||||
单次判定 1.60 → 1.25 ms。指纹校验:重新拟合胶囊后未重生成表会自动弃表(只变慢不出错)。
|
||||
2. **前移启发**:撞上的零件对/簇对移到表头,规划中反复撞同一处时首查即短路。
|
||||
3. **单次求解内判定记忆**:routeFrom / gatherGoals / 规划器对同一批 IK 候选不再重复判定。
|
||||
4. **routeFrom 惰性回退**:被挡时先不算"贴边停"位形(findSafeTarget + 二分),规划失败才补算。
|
||||
5. 微调只试前 2 个目标;平滑 50 次随机捷径 / 160 ms 截止,逐轴拉直 1 轮。
|
||||
|
||||
同一批 60 例(`node tests/route_bench.cjs <solver> 60`):
|
||||
|
||||
| 指标 | 一期 | 二期 |
|
||||
|---|---:|---:|
|
||||
| 平均规划耗时 | 1239 ms | 837 ms(−32%) |
|
||||
| 到达目标 | 55 | 58 |
|
||||
| 1° 细验不穿模 | 58 | 58 |
|
||||
| 关节总行程均值 | 438° | 436° |
|
||||
| 往返次数均值 | 2.08 | 1.98 |
|
||||
|
||||
尝试过但未采用:基于间隙 + 杠杆界的保守连续检测。(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 改为分帧增量。
|
||||
- 未做真实浏览器交互与实体电机测试。
|
||||
@@ -0,0 +1,43 @@
|
||||
const setup=require('./route_bench_setup.cjs');
|
||||
const solverPath=process.argv[2];const N=+process.argv[3]||40;const live=process.argv[4]==='live';
|
||||
const {S}=setup(solverPath);
|
||||
const L=S.LIM,O=S.ORDER,w=S.wrap180;
|
||||
let s=12345;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);
|
||||
function 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}
|
||||
// build cases: free start, free goal config, straight blocked
|
||||
const cases=[];let tries=0;
|
||||
while(cases.length<N&&tries<20000){tries++;const a=rq(),b=rq();if(!S.collideFree(a)||!S.collideFree(b))continue;
|
||||
if(segOK(a,b))continue; // only obstacle cases
|
||||
cases.push({from:a,T:S.fk(b,0)});}
|
||||
const W=[1,1,1,1,1,1];
|
||||
let res={n:cases.length,reached:0,valid:0,blocked:0,len:0,lenDirect:0,ms:0,rev:0,segs:0,timeExec:0,fail:[]};
|
||||
const VMAX=[60,60,90,90,120,180]; // deg/s assumed
|
||||
function execTime(pts){let t=0;for(let i=1;i<pts.length;i++){const dd=d(pts[i-1],pts[i]);t+=Math.max(...dd.map((x,k)=>Math.abs(x)/VMAX[k]))+ (pts.length>2&&i<pts.length-1?0.25:0)}return t}
|
||||
for(const c of cases){
|
||||
const t0=performance.now();
|
||||
const opt={tool:0,step:0.5,ref:c.from,from:c.from.slice()};
|
||||
if(live){opt.noVia=true;opt.quick=true;opt.warm=c.from}
|
||||
const r=live?S.solveRoute(c.T,opt):S.solveDyn(c.T,opt);
|
||||
const ms=performance.now()-t0;res.ms+=ms;
|
||||
const pts=[c.from].concat(r.via||[],[r.q]);
|
||||
// unwrap
|
||||
for(let i=1;i<pts.length;i++)pts[i]=pts[i-1].map((v,k)=>L[O[k]].cont?v+w(pts[i][k]-v):pts[i][k]);
|
||||
let ok=true;for(let i=1;i<pts.length;i++)if(!segOK(pts[i-1],pts[i])){ok=false;break}
|
||||
const tcp=S.fk(r.q,0);const err=Math.hypot(tcp[0]-c.T[0],tcp[1]-c.T[1],tcp[2]-c.T[2]);
|
||||
const reached=err<1&&!r.blocked;
|
||||
if(reached)res.reached++; if(ok)res.valid++; if(r.blocked)res.blocked++;
|
||||
let len=0;for(let i=1;i<pts.length;i++)len+=d(pts[i-1],pts[i]).reduce((a,x)=>a+Math.abs(x),0);
|
||||
const direct=d(c.from,r.q).reduce((a,x)=>a+Math.abs(x),0);
|
||||
// reversals
|
||||
let rev=0;for(let k=0;k<6;k++){let sg=0;for(let i=1;i<pts.length;i++){const x=d(pts[i-1],pts[i])[k];if(Math.abs(x)<2)continue;const s2=Math.sign(x);if(sg&&s2!==sg)rev++;sg=s2}}
|
||||
res.per=res.per||[];res.per.push({ms:Math.round(ms),reached,ok,len:Math.round(len),rev,segs:pts.length-1,exec:+execTime(pts).toFixed(2),detour:r.detour||null});
|
||||
if(reached&&ok){res.len+=len;res.lenDirect+=direct;res.rev+=rev;res.segs+=pts.length-1;res.timeExec+=execTime(pts)}
|
||||
else res.fail.push({reached,ok,err:+err.toFixed(1),detour:r.detour||null,blocked:!!r.blocked});
|
||||
}
|
||||
const g=res.reached;const both=cases.length-res.fail.length;
|
||||
require('fs').writeFileSync(process.argv[5]||require('path').join(require('os').tmpdir(),'route_bench_per.json'),JSON.stringify(res.per));console.log(JSON.stringify({n:res.n,reached:res.reached,validPath:res.valid,goodBoth:both,blocked:res.blocked,
|
||||
avgPlanMs:+(res.ms/res.n).toFixed(0),avgJointTravel:+(res.len/both).toFixed(0),avgDirect:+(res.lenDirect/both).toFixed(0),
|
||||
avgReversals:+(res.rev/both).toFixed(2),avgSegs:+(res.segs/both).toFixed(2),avgExecS:+(res.timeExec/both).toFixed(2),
|
||||
failTypes:res.fail.slice(0,12)}));
|
||||
@@ -0,0 +1,12 @@
|
||||
/* 路径规划基准的求解器装配(Node):node tests/route_bench.cjs [solver.js] [N] */
|
||||
const fs=require('fs'),path=require('path');
|
||||
const ROOT=path.resolve(__dirname,'..');
|
||||
module.exports=function(solverPath,collidePath){
|
||||
const ArmIK=require(solverPath||path.join(ROOT,'webapp/js/ik-solver.js'));
|
||||
const JMap=/* 关节表取自 arm/kinematics.py JOINTS(无需 mesh_arm.json) */ {"J1": {"rear": [-51.0, 128.0, 610.0], "d": [1, 0, 0]}, "J2": {"rear": [19.5, 100.0, 490.0], "d": [0, 1, 0]}, "J3": {"rear": [19.5, 128.0, 445.0], "d": [0, 0, -1]}, "J4": {"rear": [19.5, 100.0, 332.0], "d": [0, 1, 0]}, "J5": {"rear": [19.5, 100.0, 222.0], "d": [0, 1, 0]}, "J6": {"rear": [19.5, 128.0, 176.0], "d": [0, 0, -1]}};
|
||||
const travel=JSON.parse(fs.readFileSync(ROOT+'/docs/travel.json','utf8'));
|
||||
const COL=JSON.parse(fs.readFileSync(collidePath||ROOT+'/docs/collide.json','utf8'));
|
||||
const TR={};for(const t of travel){const a=COL.adj[t.axis];TR[t.axis]={lo:a?a[0]:t.lo,hi:a?a[1]:t.hi,continuous:!!t.continuous};}
|
||||
const S=ArmIK.make({joints:JMap,travel:TR,tcp:[19.5,128,93],collide:COL});
|
||||
return {S,ArmIK};
|
||||
};
|
||||
@@ -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 }));
|
||||
+50
-8
@@ -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° 绕一整圈(实测)。
|
||||
@@ -340,7 +361,7 @@ function animTo(q, ms, via) {
|
||||
end 保存求解器给的代表角,末帧落回它——与插值终点恰好相差 360° 的整数倍,
|
||||
同一物理角,视觉零跳变,同时保证滑块/关节表数值与求解结果一致。
|
||||
via:绕行中间位形(solveRoute 判直线被挡时给的两段式路径),逐段同样按短弧折算,
|
||||
每段各 ms 毫秒——渲染走的段与碰撞校验走的段是同一批,不存在"校验过了但动画穿模"。 */
|
||||
渲染走的段与碰撞校验走的段是同一批,不存在"校验过了但动画穿模"。 */
|
||||
const segs = [];
|
||||
let p = from;
|
||||
for (const raw of (via || []).concat([q])) {
|
||||
@@ -352,17 +373,37 @@ function animTo(q, ms, via) {
|
||||
segs.push(t); p = t;
|
||||
}
|
||||
const end = q.slice();
|
||||
const total = ms * segs.length;
|
||||
/* 时间参数化(2026-10-15):旧版每段固定 ms 且每段各自 smoothstep —— 每个中间位形都
|
||||
减速到 0 再起步(顿挫),且 5° 的小段和 150° 的大段用同样时间(小段慢吞吞、大段猛甩)。
|
||||
现在:各段时长 ∝ 该段"最慢轴所需时间"(按轴速比 VMAX 折算,近端轴更慢),
|
||||
整条折线只做一次全局 smoothstep:只在起点/终点加减速,途经中间位形不停顿。
|
||||
走的仍是校验过的那条折线(中间位形经捷径平滑后通常只剩 1~2 个,折角很少)。 */
|
||||
const VREL = [0.6, 0.6, 0.9, 0.9, 1.2, 1.8];
|
||||
const pts = [from].concat(segs), dur = [];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
let t = 0;
|
||||
for (let j = 0; j < 6; j++) t = Math.max(t, Math.abs(pts[i][j] - pts[i - 1][j]) / VREL[j]);
|
||||
dur.push(Math.max(t, 1e-3));
|
||||
}
|
||||
const sum = dur.reduce((a, b) => a + b, 0);
|
||||
/* 总时长:ms 视为"标准 90° 移动"的时长,按路径实际长度伸缩,夹在 [0.6ms, 3.5ms] */
|
||||
const total = Math.max(ms * 0.6, Math.min(ms * 3.5, ms * sum / 150));
|
||||
const cum = [0]; for (const d of dur) cum.push(cum[cum.length - 1] + d / sum);
|
||||
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];
|
||||
const u = Math.min(1, el / total);
|
||||
const k = 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]) * e;
|
||||
const id = ORDER[i], a = qNow[i];
|
||||
ang[id] = a;
|
||||
const sl = $('s' + id), v = $('v' + id);
|
||||
if (sl) sl.value = a;
|
||||
@@ -492,7 +533,8 @@ function renderResult(res, src) {
|
||||
: res.blocked
|
||||
? '路径被立柱挡住 · 已停在边界(目标差 ' + dsc.toFixed(1) + ' mm)'
|
||||
: res.detour
|
||||
? (res.detour === 'retract_pass' ? '避障到达 · 模拟人类回退绕过立柱'
|
||||
? (res.detour === 'planned' ? '避障到达 · 规划绕行(' + ((res.via || []).length) + ' 个中间位形)'
|
||||
: res.detour === 'retract_pass' ? '避障到达 · 模拟人类回退绕过立柱'
|
||||
: (res.detour === 'gentle' ? '避障到达 · 微调协同避开立柱'
|
||||
: (res.detour === 'coordinated' ? '避障到达 · 平滑协同跨越立柱'
|
||||
: '绕行立柱后到达')))
|
||||
|
||||
+658
-20
@@ -175,6 +175,8 @@ function make(cfg) {
|
||||
const HARD = (COL && COL.hard) || {};
|
||||
const ADJ = (COL && COL.adj) || {}; // 相邻打印件对的 1D 自由角区间(度)
|
||||
const PART = [], PARTPAIR = []; // 零件级(宽相位)与零件对
|
||||
/* 胶囊半长(窄相位前的胶囊级包围球预筛用,2026-10-15) */
|
||||
const HLEN = CAPG ? CAPG.map(c => 0.5 * Math.hypot(c.b[0] - c.a[0], c.b[1] - c.a[1], c.b[2] - c.a[2])) : null;
|
||||
if (COL) {
|
||||
const pidx = {};
|
||||
for (const nm in COL.parts) {
|
||||
@@ -260,12 +262,86 @@ function make(cfg) {
|
||||
const ca = A.clus[ia], cb = B.clus[ib];
|
||||
const pr = [];
|
||||
for (const i of ca.caps) for (const j of cb.caps)
|
||||
pr.push([i, j, CAPG[i].r + CAPG[j].r + CAPM]);
|
||||
pr.push([i, j, CAPG[i].r + CAPG[j].r + CAPM, HLEN[i] + HLEN[j] + CAPG[i].r + CAPG[j].r + CAPM]);
|
||||
if (pr.length) cl.push([ia, ib, pr, ca.rad + cb.rad, ca.rmax + cb.rmax]);
|
||||
}
|
||||
if (cl.length) PARTPAIR.push([A, B, cl, A.rad + B.rad, A.rmax + B.rmax]);
|
||||
}
|
||||
}
|
||||
/* ---------- 0c. 两关节零件对类的预计算间隙表(2026-10-15) ----------
|
||||
零件对类 (ga, gb) 且 gb−ga=2 时,两组的相对位姿只由中间两个关节决定(如 (2,4):P05/M3 ×
|
||||
P07/M5 只取决于 J3、J4)。实测 (2,4) 类长期相距 ~5 mm、平面相对,簇级宽相位筛不掉,
|
||||
独占每次 collideFree 约 80% 的线段距离计算。离线把该类最小间隙按 1° 网格打成表
|
||||
(collide.json 的 pair2d 字段,webapp/tools/gen_pair2d.cjs 生成),运行时取最近网格点,
|
||||
按 Lipschitz 界给出间隙上下界:下界 > 0 → 该类整类跳过;上界 < 0 → 直接判撞;
|
||||
夹在中间才回退精算。结果与精算逐位一致(保守),只省时间。
|
||||
指纹不匹配(重新拟合过胶囊但没重生成表)时整表弃用,自动回退精算。 */
|
||||
function classOf(A, B) { return A.g < B.g ? A.g * 7 + B.g : B.g * 7 + A.g; }
|
||||
function classFingerprint(ci) {
|
||||
let h = 2166136261 >>> 0;
|
||||
const mix = v => { h = Math.imul(h ^ (Math.round(v * 1000) | 0), 16777619) >>> 0; };
|
||||
mix(CAPM);
|
||||
const inCls = new Set();
|
||||
for (const pp of PARTPAIR) if (classOf(pp[0], pp[1]) === ci) { inCls.add(pp[0]._i); inCls.add(pp[1]._i); }
|
||||
for (const pt of PART) { // 按 PART 固定顺序(PARTPAIR 运行期会前移重排)
|
||||
if (!inCls.has(pt._i)) continue;
|
||||
for (const i of pt.caps) { const c = CAPG[i]; mix(c.g); mix(c.r); for (const v of c.a) mix(v); for (const v of c.b) mix(v); }
|
||||
}
|
||||
return h.toString(16);
|
||||
}
|
||||
const P2D = {}; // ci → 表
|
||||
if (COL && COL.pair2d) {
|
||||
for (const key in COL.pair2d) {
|
||||
const T = COL.pair2d[key], ci = +key;
|
||||
const ga = Math.floor(ci / 7), gb = ci % 7;
|
||||
if (gb - ga !== 2) continue;
|
||||
if (T.fp !== classFingerprint(ci)) { if (typeof console !== 'undefined') console.warn('[IK] pair2d 类 ' + key + ' 指纹不符,弃用(重跑 webapp/tools/gen_pair2d.cjs)'); continue; }
|
||||
let gap;
|
||||
if (typeof atob === 'function') { const bin = atob(T.b64); gap = new Int16Array(bin.length >> 1); for (let i = 0; i < gap.length; i++) gap[i] = (bin.charCodeAt(2 * i) | (bin.charCodeAt(2 * i + 1) << 8)) << 16 >> 16; }
|
||||
else { const buf = Buffer.from(T.b64, 'base64'); gap = new Int16Array(buf.length >> 1); for (let i = 0; i < gap.length; i++) gap[i] = buf.readInt16LE(2 * i); }
|
||||
/* 杠杆界(mm/°):高组胶囊轴端点 p 在低组坐标系里的速度 ≤ |ω_a|·dist(p, 轴a) + |ω_b|·dist(p, 轴b),
|
||||
轴b 与高组同刚体 → dist(p,轴b) 不变;dist(p,轴a) ≤ dist(枢轴b, 轴a) + |p − 枢轴b|(两者对两关节转动都不变)。
|
||||
只用轴端点(半径不随运动变),距离对线段凸 → 端点取极值即可。 */
|
||||
const ja = ga, jb = ga + 1; // 关节下标(0 基):带动组 ga+1 与 ga+2
|
||||
const Pa = P[ORDER[ja]], Da = D[ORDER[ja]], Pb = P[ORDER[jb]], Db = D[ORDER[jb]];
|
||||
const perp = (p, P0, d) => { const v = [p[0] - P0[0], p[1] - P0[1], p[2] - P0[2]]; const t = v[0] * d[0] + v[1] * d[1] + v[2] * d[2]; return Math.hypot(v[0] - t * d[0], v[1] - t * d[1], v[2] - t * d[2]); };
|
||||
const pbA = perp(Pb, Pa, Da);
|
||||
let La = 0, Lb = 0;
|
||||
for (const pp of PARTPAIR) {
|
||||
if (classOf(pp[0], pp[1]) !== ci) continue;
|
||||
const hi = pp[0].g === gb ? pp[0] : pp[1];
|
||||
for (const i of hi.caps) for (const p of [CAPG[i].a, CAPG[i].b]) {
|
||||
La = Math.max(La, pbA + Math.hypot(p[0] - Pb[0], p[1] - Pb[1], p[2] - Pb[2]));
|
||||
Lb = Math.max(Lb, perp(p, Pb, Db));
|
||||
}
|
||||
}
|
||||
P2D[ci] = { ja: ja, jb: jb, lo: T.lo, h: T.h, n: T.n, ca: !!LIM[ORDER[ja]].cont, gap: gap,
|
||||
La: La * D2R, Lb: Lb * D2R, st: 0, gen: -1 };
|
||||
}
|
||||
}
|
||||
/* 查表:1=该类必无干涉,−1=必干涉,0=不确定(需精算) */
|
||||
function p2dStatus(T, q) {
|
||||
let a = q[T.ja], b = q[T.jb];
|
||||
if (T.ca) a = wrap180(a);
|
||||
let ia = Math.round((a - T.lo[0]) / T.h), ib = Math.round((b - T.lo[1]) / T.h);
|
||||
if (T.ca) ia = ((ia % T.n[0]) + T.n[0]) % T.n[0];
|
||||
if (ia < 0 || ia >= T.n[0] || ib < 0 || ib >= T.n[1]) return 0;
|
||||
let da = Math.abs(a - (T.lo[0] + ia * T.h)); if (T.ca) da = Math.abs(wrap180(a - (T.lo[0] + ia * T.h)));
|
||||
const db = Math.abs(b - (T.lo[1] + ib * T.h));
|
||||
const g = T.gap[ia * T.n[1] + ib] * 0.01, sl = T.La * da + T.Lb * db + 1e-6;
|
||||
if (g - sl > 0) return 1;
|
||||
if (g + sl < 0 && g > -327) return -1;
|
||||
return 0;
|
||||
}
|
||||
/* 某类在姿态 q 下的精确最小间隙(生成表用):需 cumXf 之后的变换缓冲,故走一次独立扫描 */
|
||||
function classGap(q, ci) {
|
||||
let best = Infinity;
|
||||
const save = P2D[ci]; delete P2D[ci];
|
||||
try {
|
||||
scanPairs(q, (na, nb, d, need) => {}, ci, g => { if (g < best) best = g; });
|
||||
} finally { if (save) P2D[ci] = save; }
|
||||
return Math.min(best, 20); // ≥20 mm 一律记 20(下界,保守)
|
||||
}
|
||||
/* 组累积刚体变换 cum[k]:(R,t) 使 world = R·local + t,k=1..6(0 为恒等) */
|
||||
/* 碰撞热路径预分配(一次性):替代每次 check 重建 clc/cache/pcc(约 2500 个小数组/次,
|
||||
GC 占单次判定 ~13%)。普通数组 + 代际戳,元素类型统一(数字 + 一个字符串 cn)——
|
||||
@@ -292,36 +368,43 @@ function make(cfg) {
|
||||
}
|
||||
/* 逐对扫描(簇级宽相位 → 胶囊线段距离)。visit(cnA,cnB,d,need) 对每个距离
|
||||
< need 的胶囊对回调;无回调(collideFree)时首对即短路返回。 */
|
||||
function scanPairs(q, visit) {
|
||||
function scanPairs(q, visit, onlyCls, gapCb) {
|
||||
if (!COL) return false;
|
||||
const { R: Rs, T: Ts } = cumXf(q);
|
||||
/* 两关节类查表(只在纯判定时用;明细/生成表走精算) */
|
||||
const INF = gapCb ? 20 : 0; // 生成表:宽相位外扩 20 mm,表内 <20 mm 的间隙为精确值
|
||||
if (!visit) for (const k in P2D) {
|
||||
const T = P2D[k], st = p2dStatus(T, q);
|
||||
if (st < 0) return true;
|
||||
T.st = st;
|
||||
}
|
||||
/* clc/cache/pcc 全部预分配(声明见 cumXf 前):CGEN 代际戳标记"本 check 已变换" */
|
||||
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];
|
||||
@@ -329,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 才可筛除。
|
||||
@@ -340,25 +423,39 @@ function make(cfg) {
|
||||
let hit = false;
|
||||
for (let k = 0; k < PARTPAIR.length; k++) {
|
||||
const [A, B, cl, rsum0, rmax0] = PARTPAIR[k];
|
||||
if (onlyCls !== undefined) { if (classOf(A, B) !== onlyCls) continue; }
|
||||
else if (!visit) { const T = P2D[classOf(A, B)]; if (T && T.st === 1) continue; }
|
||||
const pa = pcOf(A._i), pb = pcOf(B._i);
|
||||
const ddx = pa[0] - pb[0], ddy = pa[1] - pb[1], ddz = pa[2] - pb[2];
|
||||
const lim0 = rsum0 + rmax0 + CAPM;
|
||||
if (ddx * ddx + ddy * ddy + ddz * ddz > lim0 * lim0) continue; // 零件级分离
|
||||
if (ddx * ddx + ddy * ddy + ddz * ddz > (lim0 + INF) * (lim0 + INF)) continue; // 零件级分离
|
||||
for (let m = 0; m < cl.length; m++) {
|
||||
const [ia, ib, pr, rsum, rmaxS] = cl[m];
|
||||
const p1 = clOf(A._i, ia), p2 = clOf(B._i, ib);
|
||||
const dx = p1[0] - p2[0], dy = p1[1] - p2[1], dz = p1[2] - p2[2];
|
||||
const lim = rsum + rmaxS + CAPM;
|
||||
if (dx * dx + dy * dy + dz * dz > lim * lim) continue; // 簇球分离
|
||||
if (dx * dx + dy * dy + dz * dz > (lim + INF) * (lim + INF)) continue; // 簇球分离
|
||||
for (let n = 0; n < pr.length; n++) {
|
||||
const e1 = xf(pr[n][0]), e2 = xf(pr[n][1]);
|
||||
const need = pr[n][2];
|
||||
/* 胶囊级包围球预筛:中点距 > 半长和 + 半径和 + margin 时线段距离必然 ≥ need(保守、无漏判),
|
||||
实测 segSegDistRaw 占碰撞判定 ~45% 耗时,绝大多数胶囊对在这一步就能跳过 */
|
||||
const mx = 0.5 * (e1[0] + e1[3] - e2[0] - e2[3]), my = 0.5 * (e1[1] + e1[4] - e2[1] - e2[4]),
|
||||
mz = 0.5 * (e1[2] + e1[5] - e2[2] - e2[5]), bl = pr[n][3];
|
||||
if (mx * mx + my * my + mz * mz > (bl + INF) * (bl + INF)) continue;
|
||||
const d = segSegDistRaw(e1[0], e1[1], e1[2], e1[3], e1[4], e1[5],
|
||||
e2[0], e2[1], e2[2], e2[3], e2[4], e2[5]);
|
||||
if (gapCb) { gapCb(d - need); continue; }
|
||||
if (d < need) {
|
||||
hit = true;
|
||||
if (visit) visit(e1[6], e2[6], d, need);
|
||||
else return true;
|
||||
else {
|
||||
/* 前移启发:撞上的零件对 / 簇对挪到表头。规划中大量判定落在同一处障碍
|
||||
(典型:立柱 × 腕/法兰),下次首查即命中、提前短路。只改顺序,不改结果。 */
|
||||
if (k > 0) { const e = PARTPAIR[k]; PARTPAIR.copyWithin(1, 0, k); PARTPAIR[0] = e; }
|
||||
if (m > 0) { const e = cl[m]; cl.copyWithin(1, 0, m); cl[0] = e; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,8 +463,17 @@ function make(cfg) {
|
||||
return hit;
|
||||
}
|
||||
/* 全姿态动态碰撞判定:True = 无动态干涉(模型近似,含 margin 外扩) */
|
||||
const PST = { n: 0 };
|
||||
/* 单次求解内的判定记忆:routeFrom / gatherGoals / 规划器会对同一批 IK 候选反复判定 */
|
||||
let CFMEMO = null;
|
||||
function collideFree(q) {
|
||||
return !COL || !scanPairs(q, null);
|
||||
if (!COL) return true;
|
||||
let key;
|
||||
if (CFMEMO) { key = q[0] + ',' + q[1] + ',' + q[2] + ',' + q[3] + ',' + q[4] + ',' + q[5]; const v = CFMEMO.get(key); if (v !== undefined) return v; }
|
||||
PST.n++;
|
||||
const v = !scanPairs(q, null);
|
||||
if (CFMEMO && CFMEMO.size < 20000) CFMEMO.set(key, v);
|
||||
return v;
|
||||
}
|
||||
/* 干涉明细(调参/界面提示用):返回 [{cnA, cnB, d, need}],按 d−need 升序
|
||||
(最贴合的排前面)。空数组 = 无动态干涉。 */
|
||||
@@ -1994,7 +2100,7 @@ function make(cfg) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function routeFrom(from, cur) {
|
||||
function routeFrom(from, cur, lazy) {
|
||||
if (!COL || !cur || !cur.q || !from) return cur;
|
||||
if (!collideFree(from)) return cur; // 起点已在干涉里:先把自己拔出来再说
|
||||
const seen = new Set(), cand = [];
|
||||
@@ -2025,6 +2131,9 @@ function make(cfg) {
|
||||
}
|
||||
/* 没有一条直线能干净抵达:沿最优候选(优先端点安全的那个)方向二分退回安全边界,
|
||||
让臂贴着障碍停住,而不是穿过去。 */
|
||||
/* lazy:调用方接下来要跑规划器,贴边停的回退位形先不算(findSafeTarget + 二分 + 细验
|
||||
约占被挡场景 routeFrom 耗时的一半),规划失败时再补算。 */
|
||||
if (lazy) return Object.assign({}, cur, { routed: true, blocked: true, lazyBlocked: true });
|
||||
const safeT = findSafeTarget(cur, from);
|
||||
let bestQ = safeT ? safeT.q : null;
|
||||
if (!bestQ) {
|
||||
@@ -2046,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 个拖拽步里
|
||||
@@ -2193,13 +2324,520 @@ function make(cfg) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* ---------- 6c. 采样式避障规划:多目标 RRT-Connect + 捷径平滑(2026-10-15) ----------
|
||||
取代原先"微调偏移 → 固定巡航位形 → 回退绕行 → 收臂转 J1"的手工阶梯:
|
||||
· 旧阶梯的 CRUISE 位形把 J3/J5/J6 强制拉回固定值(0/-60/0),哪怕它们和障碍无关,
|
||||
所以每次绕行都会出现"腕部无故甩一圈、大臂自转归零再转回去"的怪动作;
|
||||
· 旧阶梯每段只查 ≤8 个点(长段间隔可达 15°+),且只认一个目标解;
|
||||
· 找不到就贴边停(blocked),用户看到的是"卡住/抖动"。
|
||||
新做法:
|
||||
1) 目标集 = 候选表里所有端点安全的 IK 解(按加权关节距离取前 GOAL_K 个),
|
||||
而不是单一 qT —— 换一个肘/腕构型往往就有直线可走;
|
||||
2) 双向 RRT-Connect(起点树 ↔ 多根目标树),关节空间按加权距离扩展,
|
||||
连续轴(J1/J3/J6)在起点 ±180° 的展开坐标里采样,天然走短弧;
|
||||
3) 边检测按 EDGE_RES 度二分顺序(先中点再细分,撞点通常 1~3 次就被否决);
|
||||
4) 捷径平滑:随机两点直连 + 贪心最远可达,去掉 RRT 的锯齿,
|
||||
最终路径通常只剩 1~2 个中间位形,且只动"必须动"的轴;
|
||||
5) 定种子 LCG:同一起点/目标结果可复现,不会每次松手走不同的路。 */
|
||||
const PW = [1.6, 1.4, 1.0, 1.0, 0.7, 0.5]; // 加权:近端轴惯量大、扫掠体积大,动它代价更高
|
||||
const EDGE_RES = 1.5, RRT_STEP = 20, GOAL_K = 6;
|
||||
function dq(a, b) { // 展开坐标下的差(连续轴取短弧)
|
||||
const o = new Array(6);
|
||||
for (let i = 0; i < 6; i++) o[i] = LIM[ORDER[i]].cont ? wrap180(b[i] - a[i]) : b[i] - a[i];
|
||||
return o;
|
||||
}
|
||||
function wdist(a, b) { // 加权 L2(最近邻 / 代价)
|
||||
let s = 0;
|
||||
for (let i = 0; i < 6; i++) { const x = (a[i] - b[i]) * PW[i]; s += x * x; }
|
||||
return Math.sqrt(s);
|
||||
}
|
||||
function pathCost(pts) { let c = 0; for (let i = 1; i < pts.length; i++) c += wdist(pts[i - 1], pts[i]); return c; }
|
||||
/* 二分顺序的边检测:端点由调用侧保证;先查中点、再 1/4、3/4……撞点早否决 */
|
||||
function edgeFree(a, b, res) {
|
||||
res = res || EDGE_RES;
|
||||
let m = 0;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = Math.abs(b[i] - a[i]);
|
||||
if (d >= 179 && LIM[ORDER[i]].cont) return false; // 动画按短弧插值:段内连续轴不得 ≥180°
|
||||
m = Math.max(m, d);
|
||||
}
|
||||
const n = Math.max(1, Math.ceil(m / res));
|
||||
if (n <= 1) return true;
|
||||
const q = new Array(6), Q = [[0, n]];
|
||||
for (let h = 0; h < Q.length; h++) { // 广度优先二分:中点 → 四分点 → …
|
||||
const [lo, hi] = Q[h];
|
||||
if (hi - lo < 2) continue;
|
||||
const k = (lo + hi) >> 1, t = k / n;
|
||||
for (let i = 0; i < 6; i++) q[i] = a[i] + (b[i] - a[i]) * t;
|
||||
if (!collideFree(q)) return false;
|
||||
Q.push([lo, k], [k, hi]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/* 把 q 展开到离 ref 最近的等价角(连续轴),供规划在连续坐标里工作 */
|
||||
function unwrapTo(ref, q) { return q.map((v, i) => LIM[ORDER[i]].cont ? ref[i] + wrap180(v - ref[i]) : v); }
|
||||
function inBox(q) {
|
||||
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, 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;
|
||||
const u = unwrapTo(from, q);
|
||||
const key = u.map(v => Math.round(v)).join(',');
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key); out.push(u);
|
||||
};
|
||||
add(cur.q);
|
||||
const L = cur.list || [];
|
||||
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 = [], tried = [];
|
||||
for (const g of out) {
|
||||
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;
|
||||
}
|
||||
function shortcut(pts, deadline, rnd) {
|
||||
pts = pts.map(p => p.slice());
|
||||
/* 随机捷径:在两段上各取一点直连 */
|
||||
for (let it = 0; it < 50 && pts.length > 2 && nowMs() < deadline; it++) {
|
||||
const i = Math.floor(rnd() * (pts.length - 1)), j = Math.floor(rnd() * (pts.length - 1));
|
||||
if (Math.abs(i - j) < 1) continue;
|
||||
const a = Math.min(i, j), b = Math.max(i, j);
|
||||
const ta = rnd(), tb = rnd();
|
||||
const pa = pts[a].map((v, k) => v + (pts[a + 1][k] - v) * ta);
|
||||
const pb = pts[b].map((v, k) => v + (pts[b + 1][k] - v) * tb);
|
||||
if (wdist(pa, pb) >= pathCost(pts.slice(a, b + 2).concat()) - 1e-6) continue;
|
||||
if (!edgeFree(pa, pb)) continue;
|
||||
pts = pts.slice(0, a + 1).concat([pa, pb], pts.slice(b + 1));
|
||||
}
|
||||
/* 贪心:从当前点直连最远可达点,删掉多余中间位形 */
|
||||
const out = [pts[0]];
|
||||
let i = 0;
|
||||
while (i < pts.length - 1) {
|
||||
let j = pts.length - 1;
|
||||
while (j > i + 1 && !edgeFree(pts[i], pts[j])) j--;
|
||||
out.push(pts[j]); i = j;
|
||||
}
|
||||
/* 逐轴拉直:中间位形的每个轴若能取"前后两点按段长插值"的值且两侧边仍无碰撞,就拉直它。
|
||||
去掉 RRT 留下的"某轴来回摆"(与障碍无关的轴不该动),这正是视觉上"怪动作"的来源。 */
|
||||
for (let pass = 0; pass < 1 && out.length > 2; pass++) {
|
||||
for (let i = 1; i < out.length - 1 && nowMs() < deadline; i++) {
|
||||
const a = out[i - 1], b = out[i + 1];
|
||||
const la = wdist(a, out[i]), lb = wdist(out[i], b), t = la / (la + lb || 1);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const v = a[k] + (b[k] - a[k]) * t;
|
||||
if (Math.abs(v - out[i][k]) < 0.5) continue;
|
||||
const q = out[i].slice(); q[k] = v;
|
||||
if (collideFree(q) && edgeFree(a, q) && edgeFree(q, b)) out[i] = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/* Lazy RRT-Connect:生长时只查新节点本身 + 边中点(2 次判定),连通后再对整条路径
|
||||
逐边 2° 细验;不通的边把子节点判死(连同其子树),继续生长。碰撞判定是唯一的大头
|
||||
(~3 ms/次),惰性验证把每次迭代从 ~25 ms 降到 ~6 ms。 */
|
||||
function rrtConnect(from, goals, budgetMs, seed) {
|
||||
const t0 = nowMs(), deadline = t0 + budgetMs;
|
||||
let s = seed >>> 0 || 1;
|
||||
const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
||||
const A = [{ q: from.slice(), p: -1, ok: true, dead: false }];
|
||||
const B = goals.map(g => ({ q: g.slice(), p: -1, ok: true, dead: false }));
|
||||
const alive = (T, i) => { while (i >= 0) { if (T[i].dead) return false; i = T[i].p; } return true; };
|
||||
const nearest = (T, q) => {
|
||||
let bi = -1, bd = Infinity;
|
||||
for (let i = 0; i < T.length; i++) { const d = wdist(T[i].q, q); if (d < bd && alive(T, i)) { bd = d; bi = i; } }
|
||||
return bi;
|
||||
};
|
||||
const steer = (a, b) => {
|
||||
let m = 0; for (let i = 0; i < 6; i++) m = Math.max(m, Math.abs(b[i] - a[i]));
|
||||
if (m <= RRT_STEP) return b.slice();
|
||||
const k = RRT_STEP / m; return a.map((v, i) => v + (b[i] - v) * k);
|
||||
};
|
||||
const extend = (T, q) => { // 0=trapped 1=advanced 2=reached
|
||||
const ni = nearest(T, q);
|
||||
if (ni < 0) return [0, -1];
|
||||
const qn = steer(T[ni].q, q);
|
||||
if (!inBox(qn) || !collideFree(qn)) return [0, -1];
|
||||
const mid = T[ni].q.map((v, i) => 0.5 * (v + qn[i]));
|
||||
if (!collideFree(mid)) return [0, -1];
|
||||
T.push({ q: qn, p: ni, ok: false, dead: false }); // ok=false:边尚未细验
|
||||
return [wdist(qn, q) < 1e-6 ? 2 : 1, T.length - 1];
|
||||
};
|
||||
/* 细验一条树枝(叶→根),返回 true=全通;失败则判死第一条坏边的子端 */
|
||||
const verify = (T, i) => {
|
||||
while (i >= 0 && T[i].p >= 0) {
|
||||
const n = T[i];
|
||||
if (!n.ok) {
|
||||
if (!edgeFree(T[n.p].q, n.q)) { n.dead = true; return false; }
|
||||
n.ok = true;
|
||||
}
|
||||
i = n.p;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const trace = (T, i) => { const o = []; while (i >= 0) { o.push(T[i].q); i = T[i].p; } return o; };
|
||||
let Ta = A, Tb = B, swapped = false, it = 0;
|
||||
while (nowMs() < deadline) {
|
||||
it++;
|
||||
let qr;
|
||||
if (rnd() < 0.1) qr = Tb[Math.floor(rnd() * Tb.length)].q.slice();
|
||||
else qr = ORDER.map((id, i) => { const t = LIM[id]; return t.cont ? from[i] - 180 + 360 * rnd() : t.lo + (t.hi - t.lo) * rnd(); });
|
||||
const [st, ia] = extend(Ta, qr);
|
||||
if (st) {
|
||||
const qa = Ta[ia].q;
|
||||
let r = [1, -1];
|
||||
while (r[0] === 1) r = extend(Tb, qa);
|
||||
if (r[0] === 2) {
|
||||
/* 连接点在两棵树里是同一位形:两侧树枝都细验通过才算路径 */
|
||||
const okA = verify(Ta, ia), okB = okA && verify(Tb, r[1]);
|
||||
if (okA && okB) {
|
||||
const pa = trace(Ta, ia), pb = trace(Tb, r[1]);
|
||||
let path = pa.reverse().concat(pb.slice(1));
|
||||
if (swapped) path = path.reverse();
|
||||
return { path: path, iters: it, ms: nowMs() - t0, rnd: rnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
const t = Ta; Ta = Tb; Tb = t; swapped = !swapped;
|
||||
if (Ta.length + Tb.length > 6000) break;
|
||||
}
|
||||
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° 细验。
|
||||
贴边擦碰的大多数场景这里就能解决,动作最接近直线、最像人。 */
|
||||
for (const g of goals.slice(0, 2)) {
|
||||
const mid = from.map((v, i) => 0.5 * (v + g[i]));
|
||||
for (const [d2, d4] of GENTLE_OFFSETS) {
|
||||
const m = mid.slice(); m[1] += d2; m[3] += d4;
|
||||
if (!inBox(m) || !collideFree(m)) continue;
|
||||
if (edgeFree(from, m) && edgeFree(m, g)) return { goal: g, via: [m], detour: 'planned' };
|
||||
}
|
||||
}
|
||||
let seed = 2166136261;
|
||||
for (const v of from.concat(goals[0])) seed = Math.imul(seed ^ Math.round(v * 10), 16777619);
|
||||
const r = rrtConnect(from, goals, budget, seed);
|
||||
if (!r) return null;
|
||||
const sm = shortcut(r.path, nowMs() + (quick ? 40 : 160), r.rnd);
|
||||
const goal = sm[sm.length - 1];
|
||||
return { goal: goal, via: sm.slice(1, -1), detour: 'planned', iters: r.iters, ms: r.ms };
|
||||
}
|
||||
function routeOrVia(from, target, cur, opt) {
|
||||
const r = routeFrom(from, cur);
|
||||
if (!r.blocked || !from) return r;
|
||||
const around = opt.noAround ? null : 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' });
|
||||
CFMEMO = new Map();
|
||||
try { return routeOrViaImpl(from, target, cur, opt); } finally { CFMEMO = null; }
|
||||
}
|
||||
function routeOrViaImpl(from, target, cur, opt) {
|
||||
const lazy = !opt.noAround;
|
||||
let r = routeFrom(from, cur, lazy);
|
||||
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);
|
||||
if (pl) {
|
||||
const res = Object.assign({}, cur, { q: pl.goal.map((v, i) => LIM[ORDER[i]].cont ? wrap180(v) : v), routed: true, blocked: false, kept: false });
|
||||
if (pl.via.length) Object.assign(res, { via: pl.via, detour: pl.detour, planIters: pl.iters, planMs: pl.ms });
|
||||
return res;
|
||||
}
|
||||
if (r.lazyBlocked) r = routeFrom(from, cur, false);
|
||||
/* 规划预算内未连通(极少):退回旧的启发式阶梯 */
|
||||
const around = routeAroundPlan(from, cur, opt.quick, r.safeT);
|
||||
/* 旧阶梯的段只抽查少量点(实测拖拽仿真里 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. 动态限位求解:静态解 + 贴边轴放宽重解 ----------
|
||||
@@ -2313,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,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* 生成两关节零件对类的间隙表,写入 docs/collide.json 的 pair2d 字段。
|
||||
* node webapp/tools/gen_pair2d.cjs 默认生成 (2,4) 类(P05/M3 × P07/M5,J3×J4)
|
||||
* 重新拟合胶囊(scratch/fit_capsules.py 重写 collide.json)后必须重跑;
|
||||
* 求解器按指纹校验,不符会自动弃表回退精算(只是变慢,不会算错)。 */
|
||||
const fs = require('fs'), path = require('path');
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const setup = require(path.join(ROOT, 'tests/route_bench_setup.cjs'));
|
||||
const COLF = path.join(ROOT, 'docs/collide.json');
|
||||
/* 文本级追加/替换,保持 fit_capsules.py 写出的原格式不动(避免整文件 diff) */
|
||||
let text = fs.readFileSync(COLF, 'utf8');
|
||||
const cut = text.indexOf(',\n "pair2d":');
|
||||
if (cut >= 0) text = text.slice(0, cut) + '\n}\n';
|
||||
fs.writeFileSync(COLF + '.tmp', text);
|
||||
const { S } = setup(undefined, COLF + '.tmp');
|
||||
fs.unlinkSync(COLF + '.tmp');
|
||||
const H = 1.0, classes = [[2, 4]];
|
||||
const out = {};
|
||||
for (const [ga, gb] of classes) {
|
||||
const ci = ga * 7 + gb, ja = ga, jb = ga + 1;
|
||||
const la = S.LIM[S.ORDER[ja]], lb = S.LIM[S.ORDER[jb]];
|
||||
const lo = [la.cont ? -180 : la.lo, lb.cont ? -180 : lb.lo];
|
||||
const n = [la.cont ? Math.round(360 / H) : Math.round((la.hi - la.lo) / H) + 1,
|
||||
lb.cont ? Math.round(360 / H) : Math.round((lb.hi - lb.lo) / H) + 1];
|
||||
const buf = Buffer.alloc(n[0] * n[1] * 2);
|
||||
const t0 = Date.now();
|
||||
for (let i = 0; i < n[0]; i++) {
|
||||
for (let j = 0; j < n[1]; j++) {
|
||||
const q = [0, 0, 0, 0, 0, 0];
|
||||
q[ja] = lo[0] + i * H; q[jb] = lo[1] + j * H;
|
||||
const g = S.classGap(q, ci);
|
||||
buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.floor(g * 100))), 2 * (i * n[1] + j)); // 向下取整:保守
|
||||
}
|
||||
if (i % 60 === 0) console.error('class', ga + '' + gb, i + '/' + n[0], ((Date.now() - t0) / 1000).toFixed(0) + 's');
|
||||
}
|
||||
out[ci] = { joints: [S.ORDER[ja], S.ORDER[jb]], lo: lo, h: H, n: n, fp: S.classFingerprint(ci), b64: buf.toString('base64') };
|
||||
}
|
||||
const body = text.replace(/\s*\}\s*$/, '');
|
||||
fs.writeFileSync(COLF, body + ',\n "pair2d": ' + JSON.stringify(out) +
|
||||
',\n "pair2d_generated": ' + JSON.stringify(new Date().toISOString()) + '\n}\n');
|
||||
console.log('pair2d written', Object.keys(out));
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user