Files
Robot_Arm/tests/ik_baseline.mjs
T
cbb b32d44efe0 test: 加 IK/数据/截图三重基线关卡,并刷新过期的截图基线
重构 2955 行的单文件查看器必须有"行为没变"的可判据,否则改到什么程度全靠感觉。
tests/ik_baseline.mjs 在 Node 里直接跑求解器(它本就是 UMD,带 module.exports 分支,
不需要浏览器),输出分两段:
  exact  —— 件数/顶点/三角、TCP 推断、限位、固定 FK/fkChain/orient、固定目标的
            solveDyn 解向量、selfTest/poseSelfTest 的误差与命中数。selfTest 内部走
            Math.random,这里锁成定 LCG,所以 exact 跨进程逐字节可复现。
  timing —— solveDyn 中位耗时(9 次去头 3 次预热)。本机进程间抖动实测约 ±28%,
            因此只按 ±10% 报 WARN,不当硬门。
关卡灵敏度已验:指向 scratch/arm-ik-solver_before.js 时报 5 处 exact 差异并 FAIL。

docs/shots/ 里 92 张是 10-09 生成的,早于 10-14 的重建,属过期基线;重跑 96 视图
全部 OK、0 FAIL。另发现既有非幂等问题:committed 的 mesh 是"端到端构建后再单跑一次
screw_mesh.py"的产物(螺钉 idx 118..214、ENV 件排在第 19 位),端到端构建则为
20..116、ENV 排在末尾,两者零件集合相同但顺序不同,导致 2 张线束透视图的透明排序
像素级不同。本提交以端到端构建为准。
2026-10-15 02:24:38 +08:00

171 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* IK 数值基线探针(Node,不需要浏览器)。
*
* node tests/ik_baseline.mjs 生成基线到 stdout
* node tests/ik_baseline.mjs --out scratch/_gate/ik.json
* node tests/ik_baseline.mjs --compare scratch/_gate/ik.json 重构后跑这条,FAIL 就退出码 1
* node tests/ik_baseline.mjs --solver web/arm-ik-solver.js 指定求解器文件
*
* 输出分两段,因为它们的判定方式不同:
* exact —— 件数/顶点数、TCP 推断、限位、固定 FK、固定目标的解向量、selfTest 误差与命中数。
* 这些必须逐字节相同;用定种子 RNG 保证可复现(selfTest 内部原本走 Math.random)。
* timing —— solveDyn 中位耗时。机器负载会变,只按 ±10% 报 WARN,不作 FAIL。
*
* 求解器配置构造与查看器里的 ik-glue 逐行对齐(JMap/COL/TR/TCP0),
* 否则两边算的不是同一个问题,对比无意义。
*/
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
const ROOT = path.resolve(import.meta.dirname, '..');
const first = (...cands) => cands.find(p => fs.existsSync(p));
const argv = process.argv.slice(2);
const argVal = (name, dflt) => {
const i = argv.indexOf('--' + name);
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt;
};
const SOLVER = argVal('solver', first(
path.join(ROOT, 'webapp/js/ik/solver.js'),
path.join(ROOT, 'web/arm-ik-solver.js')));
const MESH = first(path.join(ROOT, 'webapp/data/mesh_arm.json'),
path.join(ROOT, 'web/mesh_arm.json'));
const TRAVEL = path.join(ROOT, 'docs/travel.json');
const COLLIDE = path.join(ROOT, 'docs/collide.json');
const mod = await import(pathToFileURL(SOLVER).href);
const ArmIK = mod.ArmIK || mod.default;
if (!ArmIK) throw new Error('求解器没有导出 ArmIK: ' + SOLVER);
const mesh = JSON.parse(fs.readFileSync(MESH, 'utf8'));
const travel = JSON.parse(fs.readFileSync(TRAVEL, 'utf8'));
const collide = fs.existsSync(COLLIDE) ? JSON.parse(fs.readFileSync(COLLIDE, 'utf8')) : null;
/* ---- 与 ik-glue.js:16-52 同构的配置 ---- */
const JO = mesh.joints;
const JJ = {}; for (const j of JO) JJ[j.id] = j;
const JMap = {}; for (const j of JO) JMap[j.id] = { rear: j.rear, d: j.d };
const COL = (collide && collide.caps && collide.caps.length) ? collide : null;
const ADJ = (COL && COL.adj) || {};
const MOTOR = +((COL && COL.motor_limit_deg) || 360);
const TR = {}; const noAdj = [];
for (const t of travel) {
const a = ADJ[t.axis];
if (!a && !t.continuous) noAdj.push(t.axis);
TR[t.axis] = { lo: a ? a[0] : t.lo, hi: a ? a[1] : t.hi, continuous: !!t.continuous };
}
/* TCP 高度:J6 组内实打印件(排除 ENV_)顶点的最小查看器 y,查看器 y = CAD z。
与 ik-glue.js:17-27 同语义:只有真找到才覆盖默认值 93,不是拿 93 当下界。 */
let tcpZ = 93, loFound = Infinity;
for (const p of mesh.parts) {
if (p.grp !== 'J6' || !p.printed || String(p.name).slice(0, 4) === 'ENV_') continue;
if (!p.v || !p.v.length) continue;
for (const q of p.v) loFound = Math.min(loFound, q[2]);
}
if (isFinite(loFound)) tcpZ = loFound;
const TCP0 = [JJ.J6.rear[0], JJ.J6.rear[1], tcpZ];
const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0, collide: COL });
const ORDER = ArmIK.ORDER;
/* selfTest/poseSelfTest 内部用 Math.random(),不锁种子的话两次跑的误差数字就不同,
关卡会退化成"看大概"。这里换成定LCG,让 exact 段逐字节可复现。 */
const _rand = Math.random;
function seeded(seed, fn) {
let s = seed >>> 0;
Math.random = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
try { return fn(); } finally { Math.random = _rand; }
}
const parts = mesh.parts;
const r6 = v => +v.toFixed(6);
/* ---- 固定输入(不含随机)的可复现输出 ---- */
const Q0 = [10, -20, 30, 40, -50, 15];
const zeros = ORDER.map(() => 0);
const targets = [[150, 128, 260], [180, 128, 300], [120, -150, 320], [60, 128, 420]];
const solveRuns = targets.map(T => {
const xs = [];
for (let k = 0; k < 9; k++) {
const t0 = performance.now();
const r = S.solveDyn(T.slice(), { tool: 0, step: 0.25, ref: zeros, from: zeros.slice() });
xs.push({ ms: performance.now() - t0, q: r && r.q ? r.q.map(v => +v.toFixed(3)) : null,
ok: !!(r && r.q), err: r && r.err != null ? +r.err.toFixed(9) : null });
}
/* 头 3 次是 JIT 预热,留中位数也去不掉进程间的预热差;实测取 9 次里后 6 次的中位,
自比抖动从 >50% 降到可直接判定的量级。 */
const warm = xs.slice(3).sort((a, b) => a.ms - b.ms);
const m = warm[Math.floor(warm.length / 2)];
return { T, medianMs: +m.ms.toFixed(2), q: m.q, ok: m.ok, err: m.err };
});
const st = seeded(20261015, () => S.selfTest(400, 8));
const pst = seeded(777, () => S.poseSelfTest(8));
const exact = {
counts: {
parts: parts.length,
verts: parts.reduce((n, p) => n + ((p.v && p.v.length) || 0), 0),
tris: parts.reduce((n, p) => n + ((p.t && p.t.length) || 0), 0),
printed: parts.filter(p => p.printed).length,
env: parts.filter(p => String(p.name).startsWith('ENV_')).length,
joints: JO.length,
},
tcpZ: +tcpZ.toFixed(3),
TCP0: TCP0.map(v => +v.toFixed(3)),
motorTravel: MOTOR,
noAdjAxes: noAdj,
caps: COL ? COL.caps.length : 0,
lim: Object.fromEntries(ORDER.map(id => [id, [r6(S.LIM[id].lo), r6(S.LIM[id].hi), !!S.LIM[id].cont]])),
fkQ0: S.fk(Q0, 0).map(r6),
fkChainQ0: S.fkChain(Q0, 0).map(r6),
orientQ0: S.orient(Q0).map(r6),
solveDyn: solveRuns.map(r => ({ T: r.T, ok: r.ok, err: r.err, q: r.q })),
selfTest: { maxFkErr: st.maxFkErr, maxIkErr: st.maxIkErr, ikHit: st.ikHit, ikTried: st.ikTried,
structWarn: st.structWarn, poseHit: st.poseHit, poseTried: st.poseTried,
poseErr: st.poseErr, K: st.K, LIM: st.LIM },
poseSelfTest: { poseHit: pst.poseHit, poseTried: pst.poseTried, poseErr: pst.poseErr },
};
const timing = {
solveDynMedianMs: solveRuns.map(r => r.medianMs),
poseSelfTestAvgMs: +pst.poseMs.toFixed(2),
};
const doc = { meta: { solver: path.relative(ROOT, SOLVER).replace(/\\/g, '/'),
mesh: path.relative(ROOT, MESH).replace(/\\/g, '/') },
exact, timing };
const text = JSON.stringify(doc, null, 1);
const out = argVal('out', null);
if (out) fs.writeFileSync(out, text + '\n');
const base = argVal('compare', null);
if (!base) { if (!out) console.log(text); process.exit(0); }
const b = JSON.parse(fs.readFileSync(base, 'utf8'));
const diffs = [];
(function walk(x, y, p) {
if (x === null || y === null || typeof x !== 'object' || typeof y !== 'object') {
if (JSON.stringify(x) !== JSON.stringify(y)) diffs.push(p + ': ' + JSON.stringify(y) + ' -> ' + JSON.stringify(x));
return;
}
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) walk(x[k], y[k], p ? p + '.' + k : k);
})(doc.exact, b.exact, '');
console.log('== exact(必须逐字节相同)==');
console.log(diffs.length ? diffs.map(d => ' DIFF ' + d).join('\n') : ' 全部一致 ✓');
console.log('== timing(±10% 只报 WARN)==');
const tb = b.timing;
let warn = 0;
doc.timing.solveDynMedianMs.forEach((v, i) => {
const o = tb.solveDynMedianMs[i];
const d = (v - o) / o * 100;
if (Math.abs(d) > 10) { warn++; console.log(` WARN solveDyn#${i}: ${o} -> ${v} ms (${d.toFixed(1)}%)`); }
else console.log(` ok solveDyn#${i}: ${o} -> ${v} ms (${d.toFixed(1)}%)`);
});
if (diffs.length) { console.log('RESULT: FAIL(exact 有 %d 处差异)', diffs.length); process.exit(1); }
console.log('RESULT: PASS' + (warn ? `(timing ${warn} 项超 ±10%,需人判)` : ''));