feat(arm): 建模代码拆成每件一模块,查看器新增工程图与装配步骤页

- src/parts/p01~p09 + src/part_tools.py:几何、件号/配色/说明与装配帧归属随各自件文件走,arm_model.py 只留装配流水线并对件级符号做只读转发;P04_CH / P07_CH / P06_WIN_XZ / FENCE_OPEN_CUT 这类开关必须写所在件文件,写 arm_model.X 不生效,A/B 判据为翻转后体积变化
- docs/sheet_{exploded,front,side,joint}.svg + draw_sheet.py:四张工程图进查看器页签,支持图层开关、件号高亮与 BOM/3D 联动跳转
- travel/travel_detail/collide/bom/screws 随最新几何重生成;补线束走线、拆卸定位点与 P04 承压面剖切验证图
- .gitignore 排除 .bak/ 与 *.bak_* 源码快照(旧版本 git 历史里已有)
This commit is contained in:
cbb
2026-10-14 22:03:18 +08:00
parent c754521bde
commit b0efb44368
72 changed files with 94603 additions and 90905 deletions
+76 -29
View File
@@ -269,24 +269,43 @@ function refreshJointLabelsLazy(q) {
refreshJointLabels(ORDER.map(id => ang[id]));
}, 260);
}
/* 六轴动态边界改到 Worker 里算:连续轴现在必须整周粗扫(实测 6 轴×2 向一次
175ms → 1131ms),留在主线程会把拖拽冻住。dynAsync 只记账、不写 IKWlast,
否则会把在飞的 solveTo/solveDyn 结果误标成过期(历史上丢过回调导致实时追踪停摆)。 */
function dynAsync(q, tol, job) {
const w = ikWorker();
if (!w) { job(S.dynLimits(q, tol)); return; } // Worker 不可用:退回主线程同步算
const id = ++IKWseq;
IKWjobs.set(id, r => { job(r && !r.__err ? r : S.dynLimits(q, tol)); });
w.postMessage({ id: id, op: 'dynLimits', args: [q, tol] });
}
function refreshJointLabels(q) {
dynAsync(q.slice(), 1.0, D => paintJointLabels(D));
}
function paintJointLabels(D) {
D = D || {};
for (const id of ORDER) {
const el = $('e' + id);
if (!el) continue;
const t = LIM[id];
if (COL && !t.cont) {
if (COL && D[id]) {
/* 边界由几何说话:机械区间(adj 实测)之内,再按当前姿态二分胶囊碰撞。
lo 侧一律收紧;hi 侧只有"硬边"(相邻件自碰撞,配置无关)才受静态值封顶,
可放宽侧(撞固定立柱那类)由模型说话——动态边界本就可以远超静态 hi。 */
const lo = Math.max(t.lo, S.dynLimit(id, q, -1, 1.0));
可放宽侧(撞固定立柱那类)由模型说话——动态边界本就可以远超静态 hi。
连续轴(J1/J3/J6)同样要查:折肘/折腕位形下末段会扫到固定立柱,
旧口径因为跳过连续轴而把它显示成整周可用。 */
const lo = Math.max(t.cont ? -180 : t.lo, D[id].lo);
const hardHi = COL.hard && COL.hard[id] && COL.hard[id].hi;
const hi = hardHi ? Math.min(t.hi, S.dynLimit(id, q, 1, 1.0))
: S.dynLimit(id, q, 1, 1.0);
const hi = hardHi ? Math.min(t.hi, D[id].hi) : D[id].hi;
const dyn = Math.abs(lo - t.lo) > 0.5 || Math.abs(hi - t.hi) > 0.5; // 与静态不同(宽或窄)都标记
el.innerHTML = (dyn ? '<b style="color:var(--warn)">' : '') +
lo.toFixed(0) + '\u00B0\u2026' + hi.toFixed(0) + '\u00B0' + (dyn ? ' \u25B2</b>' : '');
/* 连续轴没收紧时显示"整周可用",收紧了就换成区间:同一个 <em> 是唯一写手,
别让静态的 360 度连续徽标和实时夹取区间并存(状态灯与数字会打架)。 */
el.className = dyn ? '' : (t.cont ? 'ok' : '');
el.innerHTML = (t.cont && !dyn) ? '整周可用' :
((dyn ? '<b style="color:var(--warn)">' : '') +
lo.toFixed(0) + '\u00B0\u2026' + hi.toFixed(0) + '\u00B0' + (dyn ? ' \u25B2</b>' : ''));
const st = STATIC_TL[id] || {};
el.title = '机械区间(相邻件实测)' + t.lo + '\u2026' + t.hi +
el.title = (t.cont ? '连续轴(整周)' : '机械区间(相邻件实测)') + t.lo + '\u2026' + t.hi +
'\u00B0;电机行程 ±' + MOTOR + '\u00B0;当前姿态动态边界 ' +
lo.toFixed(1) + '\u2026' + hi.toFixed(1) + '\u00B0(胶囊碰撞模型实时算)' +
(st.lo != null ? '\n零位静态扫描参考 ' + st.lo + '\u2026' + st.hi + '\u00B0' : '');
@@ -298,8 +317,8 @@ function refreshJointLabels(q) {
/* ---------- 3. 关节写入(同步滑块与标签)---------- */
let anim = null;
function setJoints(q, doAnim) {
if (doAnim) { animTo(q, 620); return; }
function setJoints(q, doAnim, via, ms) {
if (doAnim) { animTo(q, ms || 620, via); return; }
anim = null; // 直接写入必须取消在飞动画,否则入场动画会覆写实时解
for (let i = 0; i < 6; i++) {
const id = ORDER[i]; ang[id] = q[i];
@@ -311,7 +330,7 @@ function setJoints(q, doAnim) {
refreshTarget();
if (COL) refreshJointLabelsLazy(ORDER.map(id => ang[id]));
}
function animTo(q, ms) {
function animTo(q, ms, via) {
if (anim) { anim = null; }
const from = ORDER.map(id => ang[id]);
/* 连续轴(J1 肩抬举 / J3 大臂自转 / J6 腕自转)走「短弧」。
@@ -319,20 +338,31 @@ function animTo(q, ms) {
若按数值直接线性插值,+151.9° → −167.0° 会反向扫 318.9° 绕一整圈(实测)。
这里先求「离起始角最近的等价角」作为插值终点,运动变成 41.1° 的短弧;
end 保存求解器给的代表角,末帧落回它——与插值终点恰好相差 360° 的整数倍,
同一物理角,视觉零跳变,同时保证滑块/关节表数值与求解结果一致。 */
const to = q.slice();
for (let i = 0; i < 6; i++) {
const id = ORDER[i];
if (LIM[id].cont) to[i] = from[i] + S.wrap180(q[i] - from[i]);
同一物理角,视觉零跳变,同时保证滑块/关节表数值与求解结果一致。
via:绕行中间位形(solveRoute 判直线被挡时给的两段式路径),逐段同样按短弧折算,
每段各 ms 毫秒——渲染走的段与碰撞校验走的段是同一批,不存在"校验过了但动画穿模"。 */
const segs = [];
let p = from;
for (const raw of (via || []).concat([q])) {
const t = raw.slice();
for (let i = 0; i < 6; i++) {
const id = ORDER[i];
if (LIM[id].cont) t[i] = p[i] + S.wrap180(raw[i] - p[i]);
}
segs.push(t); p = t;
}
const end = q.slice();
anim = { from: from, to: to, t0: performance.now(), ms: ms };
const total = ms * segs.length;
anim = { from: from, to: segs[segs.length - 1], t0: performance.now(), ms: total };
const tick = () => {
if (!anim) return;
const k = Math.min(1, (performance.now() - anim.t0) / anim.ms);
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];
for (let i = 0; i < 6; i++) {
const id = ORDER[i], a = anim.from[i] + (anim.to[i] - anim.from[i]) * e;
const id = ORDER[i], a = a0[i] + (a1[i] - a0[i]) * e;
ang[id] = a;
const sl = $('s' + id), v = $('v' + id);
if (sl) sl.value = a;
@@ -459,6 +489,13 @@ function renderResult(res, src) {
const mixMid = poseOn && res.priority === 'mix' && !posOk && !oriOk;
badge.textContent = res.unsafe
? '位姿与底座/立柱近似干涉(目标可能不可达或位于立柱区内)'
: res.blocked
? '路径被立柱挡住 · 已停在边界(目标差 ' + dsc.toFixed(1) + ' mm)'
: res.detour
? (res.detour === 'retract_pass' ? '避障到达 · 模拟人类回退绕过立柱'
: (res.detour === 'gentle' ? '避障到达 · 微调协同避开立柱'
: (res.detour === 'coordinated' ? '避障到达 · 平滑协同跨越立柱'
: '绕行立柱后到达')))
: (posOk && oriOk) ? '已到达'
: oriPrio ? '姿态到达·位置最近 ' + dsc.toFixed(1) + ' mm'
: mixMid ? '混合折中 · 位置差 ' + dsc.toFixed(1) + ' mm · 姿态残差 ' + dori.toFixed(2) + '\u00B0'
@@ -569,7 +606,8 @@ function runSolve() {
refreshTarget();
badge.textContent = '计算中…'; badge.className = 'badge wait';
setTimeout(() => {
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef() };
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(),
from: ORDER.map(id => ang[id]) }; // 松手求解同样按"直线能不能走"挑解
if (poseOn) { opt.pose = { R: Rsolver() }; opt.priority = prioVal(); if (opt.priority === 'mix') opt.mixW = mixWVal(); }
solveAsync('solveDyn', [TGT.slice(), opt], (res, fresh) => {
if (!res || !fresh) return; // 已被更新的一发取代:交给那一发渲染
@@ -582,7 +620,7 @@ function runSolve() {
: (res.note ? res.note
: (poseOn ? '超可达位姿空间 → 阻尼最小二乘最近位姿(位置 mm + 姿态联合)'
: '超工作空间 → 阻尼最小二乘最近位置')));
if (res.q) setJoints(res.q, true);
if (res.q) setJoints(res.q, true, res.via);
});
}, 0);
}
@@ -604,9 +642,13 @@ function runSolveLive() {
};
try {
lastTool = +$('ikTool').value || 0;
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), warm: curRef() };
/* 实时追踪原来走 solveTo —— 一个碰撞判定都不做:实测把目标球横拖过立柱,
1300 帧里 870 帧处于穿模状态。改用 solveRoute:按"从当前位形这条直线能不能走"
挑解,走不通就贴着障碍停(blocked),不再把手势直接映射进立柱里。 */
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), warm: curRef(),
from: ORDER.map(id => ang[id]), noVia: true, quick: true }; // 拖拽中不做抬高版绕行(要再解两次全量 IK)
if (poseOn) { opt.pose = { R: Rsolver() }; opt.priority = prioVal(); if (opt.priority === 'mix') opt.mixW = mixWVal(); }
solveAsync('solveTo', [TGT.slice(), opt], (res, fresh) => { // Worker 后台:全量回落也不冻结主线程
solveAsync('solveRoute', [TGT.slice(), opt], (res, fresh) => { // Worker 后台:全量回落也不冻结主线程
try {
if (!res || !fresh) return;
renderResult(res, res.warm
@@ -623,7 +665,9 @@ function runSolveLive() {
: (res.note ? '实时追踪 · ' + res.note
: (poseOn ? '实时追踪 · 超可达位姿空间(阻尼最小二乘最近位姿)'
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)'))));
if (res.q) setJoints(res.q, false);
/* 无绕行时立即落位(跟手);一旦给出绕行中间位形,就必须分段动画——
直接跳到 res.q 等于把"收臂→转 J1→伸展"三段压成一条直线,正是要避免的穿模。 */
if (res.q) setJoints(res.q, !!(res.via && res.via.length), res.via, 300);
} finally { settle(fresh); }
});
} catch (err) { settle(false); }
@@ -835,7 +879,9 @@ const MOTION_UI = ['ikbar', 'ikpanel', 'joints', 'jointsHead', 'footIk'];
function setView(v, keepPose) {
VIEW = v;
const isMotion = v === 'motion';
const isSheet = v === 'sheet';
document.body.classList.toggle('motion', isMotion);
document.body.classList.toggle('sheet-mode', isSheet);
for (const id of MOTION_UI) {
const e = $(id);
if (e) e.style.display = isMotion ? '' : 'none';
@@ -849,6 +895,7 @@ function setView(v, keepPose) {
} else {
if (mode !== 'drag') setMode('drag'); // 离开运动模拟:交互模式复位
if (v === 'model' && !keepPose) setJoints([0, 0, 0, 0, 0, 0], true); // 静态模型:关节锁定零位
if (isSheet && typeof openSheetView === 'function') openSheetView();
}
requestRender();
}
@@ -861,11 +908,11 @@ document.querySelectorAll('.tab[data-view]').forEach(b =>
与当前代码不符时会标红提示重算。
重新生成:node scratch/compute_precomputed.cjs(模型或求解器改动后重跑一条命令) */
const PRECOMPUTED_CHK = {
stamp: '2026-09-30 00:35', sig: '5153f522',
fk: { n: 5000, err: 5.749691446267182e-13 },
ik: { n: 200, hit: 200, tried: 200, err: 9.94353722747741e-13 },
pose: { n: 400, hit: 398, err: 1.7922893504241644, ms: 77.80773925 },
ws: { n: 400000, x: [-378, 415], y: [-388, 642], z: [94, 1125], rmax: 517 }
stamp: '2026-10-14 04:02', sig: 'fd1f8bff',
fk: { n: 5000, err: 5.684341886080801e-13 },
ik: { n: 200, hit: 200, tried: 200, err: 8.320331044333469e-13 },
pose: { n: 400, hit: 398, err: 1.5596676084670544, ms: 86.40111575000003 },
ws: { n: 400000, x: [-377, 416], y: [-388, 643], z: [96, 1126], rmax: 517 }
};
/* 紧凑指纹:与离线脚本 compute_precomputed.cjs 里的算法逐字一致
(注意取 script.textContent——HTML 解析会把行尾归一化成 LF) */