前端不再是"一个 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 穿透。
172 lines
7.7 KiB
JavaScript
172 lines
7.7 KiB
JavaScript
/**
|
||
* 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%,需人判)` : ''));
|