Files
Robot_Arm/tests/ik_baseline.mjs
T
cbb 531a293688 refactor(viewer): 单文件查看器拆成 webapp/ 多文件,走本地 http;Worker 换成真文件
前端不再是"一个 16MB HTML + 一个 2955 行模板"。现在:
  webapp/index.html      343 行 DOM 骨架,零内联数据
  webapp/styles/*.css    811 行 CSS 按段切成 8 个文件
  webapp/js/app.js       原内联主脚本逐字搬出(仍是普通脚本,S5 才改 ESM)
  webapp/js/ik-solver.js / ik-glue.js / vendor/three.min.js(原 web/ 三个文件)
  webapp/workers/ik.worker.js  真 Worker 文件
  webapp/generated/00_data.js  生成物(网格/工程图/行程/碰撞/螺钉)
  dist/机器人手臂3D查看器.html  分发用单文件,由 build_viewer 内联上面这些文件产出

入口:python -m arm.viewer.serve 后开 http://127.0.0.1:8123/
(serve 带 no-store,改完刷新即新构建,不再需要 Ctrl+F5;显式 MIME,
 .js 被 Windows 注册表判成 text/plain 会让模块静默不执行)

删掉 src/viewer_tpl.html 与 web/ 目录(旧产物移入 backups/pre_split_20261015/)。
工程图不再维护第二份 sheets.jsdata —— 直接读 draw_sheet 写出的 4 个真 SVG。

实测中修掉的问题:
- serve 的 / 原先"内部映射"到 webapp/index.html,导致文档基址是 /、相对路径的
  8 个 CSS 与 4 个 JS 全部 404(整页无样式无脚本)。改成 302 跳转。
- file:// 下 new Worker('workers/…') 构造阶段不抛错、错误以异步 onerror 到达,
  所以"构造失败才试 Blob 兜底"永远不会触发;改为按协议选路。两条路径都已实测
  backend=worker(http 走文件、file:// 走 Blob)。
- Worker 握手 3s 在 headless 并发下会误判"没起来"(截图里红徽标即此),放宽到 10s,
  并把后端状态暴露成 window.__IK_BACKEND / __IK_BACKEND_LOG 供测试直读。
- 截图竞态:4 并发 × 每页 14MB 数据时,虚拟时间预算会在页面加载完成前到期,
  拍到半初始化画面(且能"稳定地坏",旧基线里 m9_motion 就是这么混进来的)。
  并发降到 2、预算 40s、serve 加 HTTP/1.1+大缓冲+字节缓存,并用"同一构建两轮"
  重新界定稳定性:99 张逐字节一致、6 张属噪声改走像素容差(mean<=0.02、单像素<=32)。
- 关卡自身两处假通过已修:node 输出被 GBK 解码炸掉时不再算 PASS;
  线束干涉测试读不到通道源码时会 exit 2,而不是"零通道=零穿透=通过"。

关卡结果:python -m arm.build_model 退出码 0;10 项数据哈希与基线一致;
IK exact 段逐字节一致;99 张截图逐字节一致 + 6 张在噪声阈内;线束干涉测试 1800 点 0 穿透。
2026-10-15 04:31:31 +08:00

172 lines
7.7 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'), // S5 之后
path.join(ROOT, 'webapp/js/ik-solver.js'), // S4 现在
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%,需人判)` : ''));