refactor(model): 重构P03为轻量化板式桁架并统一M4×16自攻螺钉连接方案

This commit is contained in:
cbb
2026-10-02 04:39:12 +08:00
parent 208b37048b
commit 6fe97fc643
29 changed files with 139069 additions and 1296 deletions
+3
View File
@@ -8,6 +8,9 @@ out/
# 临时调试脚本与截图
scratch/
# 单文件查看器优化前的完整备份(带版本标识,勿随手删)
backups/
# 编辑器 / 系统文件
Thumbs.db
Desktop.ini
@@ -0,0 +1,204 @@
# 姿态估计算法评估与优化 —— 实施计划
> 交付物(用户已确认):**仅一份中文评估报告**,不改动求解器代码。
> 报告落盘位置:`docs/姿态估计算法评估报告.md`
---
## 1. 摘要(Summary)
本任务对机械臂的「姿态估计算法」——即 [arm-ik-solver.js](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) 的 6 轴逆运动学(IK)姿态求解器及其查看器集成 [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js)(用户已确认该文件即「姿态估计算法」)——做一次全面评估。
需要解决的核心问题:**在多个关键位姿(近工作空间边界、折叠/奇异构型、贴近关节限位处)无法达到预定姿态目标;且当目标姿态确实不可达时,返回的「最近位姿」明显劣于肉眼可见的更优姿态。**
评估已在本阶段(Plan 模式,只读)实测复现并定位到 4 类缺陷(见 §2.3),并额外回答了两个关键设计问题:**现状是「先点位、后角度」的位置优先架构**(§2.4),以及**位置与姿态在远目标上真正不可兼得、必须提供可切换优先级**(§2.4 实测取舍表)。本计划定义报告的**结构、数据来源、可复现实验命令、优化策略与验证口径**。执行阶段只产出一份报告 md,不新增任何脚本文件(实验以 `node -e` 内联执行,命令内嵌报告附录)。
---
## 2. 现状分析(Current State Analysis)
### 2.1 代码与数据资产
| 角色 | 文件 | 说明 |
| :--- | :--- | :--- |
| 姿态求解器(主) | [arm-ik-solver.js](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) | UMD,`make(cfg)` 返回 `solve/solveTo/solveWarm/solveDyn/closest/closest6/branch/fk/orient/costOf` 等([导出清单 L1355-1360](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1355-L1360)) |
| 查看器集成 | [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) | 姿态 UI、目标拖动、`solveTo` 调用([L379](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L379)、[L399](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L399))、结果渲染与徽标判定([renderResult L318-342](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L318-L342)) |
| 关节表(几何真值) | [mesh_arm.json](file:///g:/Robot_v2/机器人手臂/web/mesh_arm.json) | `joints[] = {id, rear, d}`,FK/IK 运动学常量来源 |
| 关节限位 | [travel.json](file:///g:/Robot_v2/机器人手臂/docs/travel.json) | 本报告测量口径:J1/J3/J6 连续(±180),J2∈[-106,9],J4∈[-111,16],J5∈[-95,34](2026-09-29 起该表退为零位扫描参考:滑块改电机行程 ±360°,可行域改相邻件实测区间) |
| 既有自检 | [pose_ik_check.js](file:///g:/Robot_v2/机器人手臂/scratch/pose_ik_check.js) | 7 组 node 自检(往返、位置回归、姿态重构、交互口径、兜底、耗时) |
| 既有性能基准 | [bench_ik_split.js](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.js) / [bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json) | 单次求解耗时与「位置/姿态」占比拆分 |
| 历史优化实验 | `scratch/ikopt/`(`solver_scanbase.js` 等) | 根枚举/窗口/同族门等过往迭代产物,报告需说明其结论 |
### 2.2 现有算法骨架(报告需准确描述)
1. **位置闭式 + 二维冗余扫描**:以 (θ4,θ5) 为冗余参数全域扫描,θ1..θ3 由 [branch() L429-463](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L429-L463) 闭式给出(最多 4 支:θ3=±σ、±(π−σ)),位置天然精确到 1e-13 mm。
2. **按行裁剪**:[rowSpans L644-654](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L644-L654) 用探测步 `hp=max(h,2)°` 找每 θ4 行的可行 θ5 段,段内再按 h 精扫。
3. **姿态任务**:工具轴指向 = 目标轴,是 (θ4,θ5) 上的 2 方程 2 未知量,解为**孤立点**;[poseTools L486-545](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L486-L545) 提供残差/牛顿原语;[§4.1b 根枚举 L697-761](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L697-L761) 逐行做变号括号 + 牛顿收口;滚转由 [rollClose L549-555](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L549-L555) 闭式补 θ6。
4. **接受门**:`POSETOL=2.0°`([L581](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L581))——`scoreBranch` 对 `oerr>POSETOL` 的候选**直接返回 null 丢弃**([L596-598](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L596-L598))。
5. **不可达兜底**:[closest6 L1054-1169](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1054-L1169) 为 6 维阻尼最小二乘(DLS),160 次确定性拟随机重启 × 160 迭代;[solveTo L1172-1192](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1172-L1192) 在 `solve()` 失败时调用它。
6. **实时快路**:[solveWarm L1200-1265](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1200-L1265),沿上一帧解同支续解,失败回落全量。
### 2.3 已实测缺陷(本阶段只读复现,数据可直接写入报告)
**缺陷 A — 覆盖漏洞:目标姿态精确可达,求解器却静默返回 ~1–2° 残差**
- 全空间随机可达位形,`solve(step=0.25)` 返回 `oerr>0.05°` 的比例:**2.5%(3/120)**,量级 `oerr∈[1.24, 1.95]°`(真值应为 0)。
- 其中可明确归因于「覆盖漏洞」(细扫 step=0.05 能到 ≤0.05°、即确有大步长漏解)的部分,小样本估计 **0.3%–1.3%**(300 组样本:1/300;80 组样本:1/80);其余为真·近奇异构型(细扫同样到不了 ≤0.05°,属物理不可达的刀尖构型)。**执行阶段用更大样本(D1/D4)收敛该比例。**
- 典型案例 `T=[329.3,253.4,750.0]`:真解 (θ4,θ5)=(-16.45,-51.97),`branch()` 在该点给出 **位置误差 5.7e-14 mm、轴误差 0.000°** 的精确解;但 `solve(step=0.25)` 返回 oerr=1.909°。
- **步长敏感(非分辨率问题)**:同一目标 `oerr` 随 `step` 非单调跳变——`0.25°→1.60`、`0.15°→0.045`、`0.10°→1.14`、`0.05°→0.013`;用 `solveTo({warm:真解})` 则得到 **oerr=0.0000**。证明确切解存在,是**根枚举/牛顿收口的完备性缺口**。
- 工作区实测:前伸工作区 (20,210,300)±90mm 失败率 **1.7%(1/60)**;查看器默认区 (150,128,260)±60mm 为 **0%(0/60)**——缺口集中在工作空间外围/极端构型。
**缺陷 B — 不可达兜底 closest6 严重次优(「肉眼可见更优姿态」的直接来源)**
以「位置精确解中姿态误差的全局最小值」为真最优(对 (θ4,θ5) 网格 0.05° 枚举 `branch()` 全部在限位内的支),目标姿态在可达姿态基础上外推 20°:
| 案例 | 目标 TCP | 兜底返回 | 真最优姿态差 | 劣化 |
| :--- | :--- | ---: | ---: | ---: |
| 1 | (40,455,216) | 位置 0.4mm / 姿态 **74.0°** | **11.04°** | **63.0°** |
| 2 | (196,122,136) | 位置 0.5mm / 姿态 **130.0°** | **17.26°** | **112.8°** |
| 3 | (-141,-18,1030) | 位置 0.0mm / 姿态 **167.2°** | **89.09°** | **78.1°** |
**缺陷 C — 姿态不可达时把「位置精确解候选池」整体丢弃,导致兜底被便宜解严格支配**
- `T=[20,400,300]`、`R=poseMat(25,95,10)`:位置完全可达(无姿态 `solve` 返回 err=2.5e-14,0.5° 格上有 **11920** 个位置精确解),但姿态目标不可达时 `scoreBranch` 因 `oerr>POSETOL` 把全部候选置 null,`solve()` 返回 `ok=false`、候选池为空,被迫走 [closest6](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1054-L1169) 的 6 维随机 DLS。
- 同例对照(实测):位置精确解集内最小姿态差 = **121.05°**;`closest6` 返回 **136.7° / 位置 0.0mm**。即 `closest6` 在**位置与姿态两个指标上同时劣于**一个用现有闭式扫描就能廉价得到的解 —— **被严格支配(Pareto-dominated)**。
- 后果:`位置精确 + 姿态尽力` 这条最自然的路径没有实现;兜底既不最优、又更慢(159–224ms vs 扫描 ~100ms)。
**缺陷 D — 性能超出既定预算**
- [bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json) 摘要:开姿态单次 **中位 107ms / p95 242ms / 最大 299ms**(位置基座中位 73.6ms,姿态增量中位 37.2ms 占 33%,escalating 触发率 2.5%)。
- 本阶段在工作区实测均耗时 112–157ms;不可达兜底 159–224ms。
- 均高于用户设定的 **≤100ms** 目标,且兜底路径(random-restart DLS)是主要浪费点。
### 2.4 求解范式评估:位置优先 vs 末端姿态反推(用户提出,报告中作为备选简要说明)
**用户提问**:能否用已指定的末端角度,从末端逐级反推回各关节?
**几何事实(以 [mesh_arm.json](file:///g:/Robot_v2/机器人手臂/web/mesh_arm.json) 为准)**:
| 关节 | rear | d | 类型 |
| :--- | :--- | :--- | :--- |
| J1 | (-51,128,610) | (1,0,0) | X 抬举 |
| J2 | (18.5,100,490) | (0,1,0) | Y 俯仰 |
| J3 | (18.5,128,445) | (0,0,-1) | Z 自转 |
| J4 | (18.5,100,332) | (0,**1**,0) | Y 俯仰(肘) |
| J5 | (18.5,100,222) | (0,**1**,0) | Y 俯仰(腕) |
| J6 | (18.5,128,176) | (0,0,**-1**) | Z 工具自转 |
- **J4 ∥ J5**(均 +Y,相距 110mm)⇒ 平行轴永不相交 ⇒ **非球形腕**,经典"腕心解耦 + θ6→θ5→θ4 逐级闭式反推"不成立。(J5⊥J6 在 (18.5,128,222) 相交,仅构成 2 轴腕。)
- 但存在可用的**降阶结构**:因 J4∥J5,`R4·R5 = R_y(θ4+θ5)`(代码 [L406-411](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L406-L411) 已用 `SAMEAX` 利用该性质),故
```
R = Rx(θ1)·Ry(θ2)·Rz(−θ3)·Ry(θ4+θ5)·Rz(−θ6)
```
⇒ **工具轴只由 (θ1,θ2,θ3,θ4+θ5) 决定,θ6 是纯工具滚转**;"用末端姿态驱动求解"可行,但需按此降阶链求解(3 方程 / 5 参数 → 2 参数族),而非逐级反推。
**位置与姿态在远目标上"真的不可兼得"(实测,报告须收录)**:
| 目标 | 优先级 | 位置误差 | 姿态差 |
| :--- | :--- | ---: | ---: |
| T=(20,400,300), R=poseMat(25,95,10) | 位置优先(保点) | **0.0 mm** | 121.05° |
| 同上 | 姿态优先(保角度) | 127.6 mm | **0.22°** |
| T=(196,122,136), R=poseMat(25,95,10) | 位置优先 | **0.0 mm** | 116.39° |
| 同上 | 姿态优先 | 233.6 mm | **0.45°** |
⇒ 结论:二者是**真实的取舍**,不存在"都能兼顾"的解;算法必须提供**可切换的优先级**。
### 2.5 误差来源定位(报告核心结论)
- **R1 根枚举不完备**:姿态精确解在 (θ4,θ5) 平面是一条一维曲线,只在少数孤立点穿过 `POSETOL` 窄带;当该曲线经过 θ3≈±90° 的**折叠点**(`branch()` 的 4 支两两重合、位置雅可比奇异)、或贴着关节限位/可行段边缘时,变号括号检测失效、`match()` 的「θ1..θ3 最近」同支判据在折叠处换支,牛顿收口失败。
- **R2 接受门把「不可达」与「漏解」混为一谈**:`oerr>POSETOL` 一律丢弃 ⇒ 无法区分「确实不可达」与「可达但没找到」,导致 A 类静默近失 + C 类候选池清空。
- **R3 兜底目标函数与真实目标不一致**:`closest6` 用固定的 20mm/rad 把姿态折进位置残差做联合 DLS,既不保证「位置优先、姿态最小」,也依赖随机重启初值,落点常是劣质局部极值。
- **R4 行裁剪探测步过粗**:`hp=max(h,2)°` 且按单一连续段假设(注释称仅在 5 个目标上验证过),窄于 2° 的可行段会被整段漏掉 ⇒ 整行/整区无候选。
---
## 3. 交付物与报告大纲(Proposed Changes)
**唯一变更**:新建 `docs/姿态估计算法评估报告.md`(中文)。
报告章节(每章明确数据来源,均为本阶段或执行阶段 `node -e` 实测可得):
1. **概述与结论摘要**(TL;DR 表格:缺陷 / 影响 / 根因 / 建议 / 预期收益)
2. **算法现状描述**——§2.2 的骨架,附关键函数行号链接与数据流图
3. **求解范式评估(位置优先 vs 末端姿态反推)**——§2.4:
- 确认现状为「先点位、后角度」(位置被强制精确、姿态是带 2° 门的软约束)
- 关节轴几何表 + `J4∥J5 ⇒ 非球形腕` 的判定;说明「逐级闭式反推」不成立的**原因**(备选简要说明,约半页,含降阶链 `Rx·Ry·Rz·Ry(θ4+θ5)·Rz` 的结论式)
- 位置/姿态**不可兼得**的实测取舍表(§2.4),给出「必须提供可切换优先级」的结论
4. **评估方法**——指标定义:
- 姿态残差 `oerr`(`rotErrDeg`,度)
- 位置残差 `err`(mm)
- 覆盖漏洞率 = 「真解 oerr≤0.05° 而解算 oerr>0.05°」占比
- 步长敏感度 = 同目标在 step∈{0.05,0.1,0.15,0.25,0.5} 的 oerr 波动
- 兜底劣化 = `closest6` 姿态差 −「位置精确解集内最优姿态差」
- 单次耗时中位/p95(开/关姿态、可达/兜底)
5. **性能分析**——引用 bench_ik_split.json 的耗时构成 + 工作区实测;给出「位置/姿态/兜底」三段占比
6. **误差来源定位**——§2.5 的 R1–R4,逐条给出**可复现证据**(目标坐标、真解、实测值)
7. **优化策略(设计方案,不在本轮落地)**——按收益/成本排序给出可实施建议:
- **S0(架构)双优先级求解范式**:仿 [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) 现有姿态模式的 UI 开关,新增「优先级」选择——**位置优先 / 姿态优先 / 折中**;三种模式下共用同一闭式支枚举结果,只切换目标函数与选解规则。给出各模式的数学表述:
- 位置优先:`min oerr s.t. ‖fk(q)−T‖=0`(在位置精确解集上最小化姿态误差)
- 姿态优先:`min ‖fk(q)−T‖ s.t. oerr=0`(在姿态精确解集上最小化位置误差;利用降阶链参数化)
- 折中:`min ‖fk(q)−T‖² + k·oerr²`,k 可调
- 预期:位置优先模式把 B 类劣化从 63–113° 压到接近真最优;姿态优先模式提供 oerr≤0.05° 的「保角度」解(代价是位置误差,如实上报)
- **S1(根因修复)覆盖率完备化**:把 (θ4,θ5) 上的姿态方程看作一维解曲线,用**预测-校正 continuation 跟踪**(以解析/数值切向推进、跨越折叠点不换支),或等价地做**分支索引对齐的多起点牛顿 + 折叠点/限位角种子**;预期把 A 类覆盖漏洞降到 <0.2%,且不加粗扫密度 ⇒ 耗时基本不变(≤100ms)。
- **S2(关键收益)姿态不可达时不再清空候选池**:去掉 `POSETOL` 硬门对候选池的**清空**语义,改为保留并按 `oerr` 排序入池(门只用于「达标」判定,不用于「丢弃」);预期消除 C 类 Pareto 支配,且兜底不再需要。
- **S3 兜底加速**:以 S0/S2 的闭式候选为种子替代 160 次随机重启,仅在位置也越界时才退回受限 DLS;预期兜底耗时从 ~160–220ms 降到 ≤100ms。
- **S4 可观测性**:UI 明确区分「已到达 / 位置到达·姿态最近 X° / 姿态到达·位置最近 Y mm / 均不可达」四种语义,避免把漏解或取舍结果当成功。
- 每条策略给出:改动点(函数+行号)、原理、预期指标、代价与风险、与 ≤100ms 预算的关系。
8. **验证方案与验收口径**——见 §5。
9. **附录**:可复现实验命令(`node -e` 全文内联,不落脚本文件)+ 基准数据表 + 术语表。
> 说明:报告只做「评估 + 策略设计」,不含代码修改。若后续要落地 S0–S3,需另起一次实施任务。
---
## 4. 假设与决策(Assumptions & Decisions)
| 项 | 决策 | 依据 |
| :--- | :--- | :--- |
| 交付物 | 仅评估报告 md,**不改代码** | 用户选择「仅评估报告」 |
| 「姿态估计算法」定义 | = `web/arm-ik-solver.js` 的 IK 姿态求解 + `ik-glue.js` 集成 | 全仓检索无 IMU/卡尔曼/姿态估计类算法;仓库内「姿态」相关实现仅此一处 |
| 性能预算 | 以 **≤100ms** 为优化目标约束(报告据此评估各策略) | 用户选择「保持 ≤100ms」 |
| 失败场景 | A(可达却漏解)与 B/C(不可达最近位姿)**都覆盖** | 用户选择「两者都做」 |
| 实验脚本 | 不新增文件,全部 `node -e` 内联并在报告附录给出原文 | 遵守「仅评估报告」与「不新建文件」 |
| **求解优先级** | 报告须设计**双优先级机制(位置优先 / 姿态优先 / 折中),界面可切换**;不可兼得时两种解都给出 | 用户选择「两者都做、界面可切换」 |
| **末端姿态反推** | 报告仅作**备选简要说明**(说明非球形腕故不能逐级闭式反推 + 降阶链结论),不展开公式级设计 | 用户选择「只作备选简要说明」 |
| 关节表/限位 | 以 `web/mesh_arm.json` + `docs/travel.json` 为准,tcp=[18.5,128,93] | 与 `pose_ik_check.js`、`bench_ik_split.js` 一致 |
| 真最优基准 | 「位置精确解集内姿态误差最小」的网格枚举(0.05° 分辨率,θ6 另行闭式最优) | 独立于被测求解器,避免自证 |
| 随机性 | 固定 LCG 种子的确定性采样,报告数据可复现 | 与求解器内部自检策略一致 |
---
## 5. 验证方案(报告第 8 章内容)
**基准数据集**(执行阶段固定种子生成,规模与命令写入附录):
- D1 全空间随机可达位形 ≥200 组(覆盖极端/边界构型)
- D2 前伸工作区 (20,210,300)±90mm ≥60 组、查看器默认区 (150,128,260)±60mm ≥60 组
- D3 姿态外推集(外推 3/8/20°)≥30 组 → 测兜底劣化
- D4 退化构型集(θ3≈±90°、临近限位)≥30 组 → 测覆盖漏洞
- D5 越界目标集 → 测兜底耗时与质量
- D6 **双优先级取舍集**:位置与姿态不可兼得的远目标 ≥20 组 → 测「位置优先」与「姿态优先」两解各自的误差与真实性
**指标与验收口径(现状基线 → 目标)**:
| 指标 | 现状实测 | 目标 |
| :--- | :--- | :--- |
| D1/D4 覆盖漏洞率(真解≤0.05° 却返回>0.05°) | 2.5% | ≤0.2% |
| D1 最大静默姿态残差 | 1.95° | ≤0.05° |
| 步长敏感度(同目标 oerr 波动) | 0→1.86° | ≤0.05° |
| D3/D5 兜底相对真最优劣化(姿态优先口径) | 63–113° | ≤2° |
| D6 位置优先解:位置误差 / 姿态差 | 0.0mm / 121.05° | 位置=0;姿态=真最优(劣化 ≤0.5°) |
| D6 姿态优先解:姿态差 / 位置误差 | 0.22° / 127.6mm | 姿态 ≤0.05°;位置如实上报 |
| 开姿态单次耗时 p95 | 242ms | ≤100ms |
| 兜底耗时 | 159–224ms | ≤100ms |
| 姿态残差 P95(可达集) | ≤2.05°(接受限) | ≤0.05° |
**沿用既有自检**:`node scratch/pose_ik_check.js` 的 7 项(含往返一致性、位置回归、姿态重构、roll→θ6 口径、6D 兜底、耗时对比)作为回归门,报告中给出其现状通过情况与目标。
**报告自检**:报告内每张数据表须与附录命令重跑结果一致;所有结论必须能由附录命令复现。
---
## 6. 执行步骤(待批准后按序执行)
1. 复跑并固化基线数据(D1–D6 + bench + 自检),生成报告用数据表。
2. 撰写 `docs/姿态估计算法评估报告.md` 正文(§3 大纲 1–9 章)。
3. 内嵌可复现 `node -e` 命令与原始输出,形成附录。
4. 复核报告数据与结论一致性(重跑附录命令抽查),交付。
@@ -0,0 +1,258 @@
# 机器人手臂3D查看器 · 性能优化方案
目标文件:`G:\Robot_v2\机器人手臂\机器人手臂3D查看器.html`(15.31 MB / 35986 行)
用户要求:先做完整备份(带版本标识)→ 优化 → 验证功能完好 → 记录优化项与性能数据。
---
## 一、现状分析(实测,非猜测)
### 1.1 文件结构
| 位置 | 内容 |
| --- | --- |
| L1~L420 | HTML + CSS + 界面骨架(左栏/IK 控制条/舞台/页脚/BOM 抽屉) |
| L421~L428 | `<script>` three.js r159 内联(652 KB,UMD build,无 CDN) |
| L429~L33251 | `<script>`:`DATA`(13.99 MB 网格 JSON)+ `SHEETS`(52 KB SVG)+ `TRAVEL` + `COLLIDE` + 查看器主逻辑(`const TL = {}` 起 738 行) |
| L33252~L35022 | `<script>`:`arm-ik-solver.js` 逆运动学求解器(1770 行,自带 `window.ArmIK`) |
| L35023~L35984 | `<script>`:`ik-glue.js` 界面胶水层(961 行,Worker 化求解、预置自检、姿态拖拽) |
四个内联脚本的**索引被 `chkSig()` 指纹逻辑依赖**(`document.scripts[2]` = 求解器源码),因此本次优化**不得增删 `<script>` 标签、不得改动求解器脚本块**,否则预置自检指纹失效(会误报"预置结果已失效")。
### 1.2 数据规模(node 实测)
- 零件 92 件(打印件 9 + 电机 6 + 螺钉 72 + 包络 1 + 经纬线 1)
- 顶点 354,697 / 三角面 340,178 / 线段 10,296
- **单件最大顶点 47,483(< 65536 → 索引可全部用 Uint16)**
- `JSON.parse` 69 ms;按查看器原样建几何(含 `computeVertexNormals` / 包围盒球)37 ms
- 全场景射线拾取(92 件、34 万面):**1.3 ms/次**(60 次均值),200 次连测 2.1 ms/次
- `scene.updateMatrixWorld(true)` 全量强制刷新:**0.004 ms/次**(≈110 个对象)
### 1.3 已定位的性能问题
| # | 问题 | 证据 | 影响 |
| --- | --- | --- | --- |
| **P1** | **rAF 循环无条件每帧 `resize() + updCam() + rend.render()`**;`needRender` 被 `requestRender()` 置位后**全文件没有任何读取点**(`Grep needRender` 仅 3 处:声明、置位、注释) | L33207~L33215 | 空闲时仍 60 fps 满帧渲染 34 万面 ×92 draw call;`preserveDrawingBuffer:true` 使每帧多一次缓冲拷贝;持续占用 GPU/功耗 |
| **P2** | `resize()` 每帧读 `stage.clientWidth/clientHeight` | 同上 | 每帧强制布局(forced reflow),与 P1 同源 |
| **P3** | `pick()` 每次 pointermove 先 `meshes.filter(m => m.visible)` 再全量射线求交 | L32668~L32673、L32642~L32652 | 每帧≥1 次 1.3~2.1 ms 求交 + 每帧一个 92 元素临时数组,鼠标扫过模型时持续吃 CPU |
| **P4** | `applyJoint()` 每次新建 2 个 `Vector3` + 3 个 `Matrix4`,并线性查 `JO.find` | L32506~L32518 | 每写一个关节角 = 6 次调用 = 30 个临时对象;动画/实时追踪每帧一批 |
| **P5** | 几何索引一律 `Uint32Array` | L32531、L32546 | 索引显存 340,178×3×4 B = 4.08 MB(Uint16 只需 2.04 MB) |
| **P6** | 点选/ESC 时 `for (const tr of tb.children) tr.style.background = ''` 全表刷 style | L32727、L32736、L32745 | 每次点击写 ~92 行样式 |
| **P7** | Worker 的 Blob URL 不回收(`URL.createObjectURL` 后无 `revokeObjectURL`) | L35510 附近 | 常驻一份 ~86 KB Blob;构造失败路径同样泄漏 |
### 1.4 经实测**否决**的优化(不做,写进报告避免后人重复劳动)
- **全量矩阵刷新**:0.004 ms/次 → 改"6 级关节链更新"只有 13× 相对提速但绝对量 4 μs,风险不值。
- **`updXform()`(92 件)/ `updStat()`(92 件 reduce+filter)**:量级为微秒,爆炸滑块拖动时也远低于 1 ms。
- **材质合并 / 几何合并**:只有 92 个 draw call,合并会破坏"零件树勾选显隐 + 点选 + 单独高亮"这些核心功能。
- **分片/延迟建场景**:初始化总计约 106 ms(69 解析 + 37 建几何),分片只会增加复杂度,不降低总时间。
- **实时追踪时的结果面板 DOM 节流**:`renderResult()` 含"场景图残差复算"与"备选构型"两块**验证语义**内容(用户此前专门建立了指纹/预置自检体系),节流会削弱验证可观测性,保持原样。
### 1.5 关键发现:源文件与成品已漂移(影响改动范围)
`src/build_viewer.py` 由 `src/viewer_tpl.html` + `web/ik-glue.js` + `web/arm-ik-solver.js` + `web/mesh_arm.json` 拼装成品,但:
- 成品 L35023~L35984 的 glue 区域(961 行)**比 `web/ik-glue.js`(776 行)新**:Worker 化求解、`PRECOMPUTED_CHK` 预置自检、`refreshJointLabelsLazy` 节流、`±360°` 滑块等**只存在于成品内**。
- 成品主逻辑区与 `src/viewer_tpl.html` 的差异只有:① `±360°` 滑块范围(成品新)② `/*__SCREWS__*/` 占位符展开(预期)。
**已与用户确认的处理方式**:本次**直接改成品 HTML**;同时把"能对得上"的性能补丁**同步回 `src/viewer_tpl.html`**(源文件落后部分保持原状)。在源文件完成反向同步之前**不要重跑 `build_viewer.py`**(会把成品回退到旧功能)——此警告写入报告。
---
## 二、改动清单
### 改动 0:备份(先做,且必须先验证备份完整)
1. 新建目录 `backups/`,复制原件为
`backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html`
(v1 = 优化前版本号;2151 取自原件最后写入时间 2026-09-29 21:51)
2. 记录 `SHA-256` 与字节数(`Get-FileHash`),校验备份与原件一致后再动原件。
3. `.gitignore` 追加 `backups/`,避免 15 MB 备份污染 git 状态。
### 改动 1(P1+P2,**核心**):按需渲染
文件:成品 `loop()`(L33207~L33215)与 `src/viewer_tpl.html` 对应段。
```js
/* 改动前 */
function loop() {
resize();
if (cv.style.display !== 'none') { updCam(); rend.render(scene, cam); }
requestAnimationFrame(loop);
}
```
```js
/* 改动后 */
/* 按需渲染:needRender 由 requestRender() 置位(所有交互/状态变化都已调用它);
旧版置位后无人读取,故恒以 60 fps 空转。装配图页(画布隐藏)不清标志,
切回三维页由 tab 处理器 resize()+requestRender() 补渲染。 */
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
```
要点:
- **保留 `preserveDrawingBuffer:true`**:按需渲染后画布不再重绘,必须靠它保住最后一帧图像。
- **保留 rAF 循环**:`animTo()` 动画、实时追踪、拖拽都依赖后续 rAF 里的渲染。
- 已核对全部渲染状态变更点均有 `requestRender()`(主逻辑 36 处;glue 的 `refreshTarget()`/`setJoints()`/`highlightSpec()`/`setView()` 内部均调用),无遗漏。
- `resize()` 仍在渲染前调用一次(进入渲染路径时同步画布尺寸),窗口尺寸变化由 `ResizeObserver → resize() → requestRender()` 覆盖。
### 改动 2(P3):拾取零分配 + 悬浮限流
文件:成品 L32642~L32682 与 `src/viewer_tpl.html` 对应段。
- `pick()`:`meshes.filter(...)` → 复用模块级数组
```js
const pickList = [], ndcPick = new THREE.Vector2();
function pick(e) {
const r = cv.getBoundingClientRect();
ndcPick.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
ray.setFromCamera(ndcPick, cam);
pickList.length = 0;
for (const m of meshes) if (m.visible) pickList.push(m); // 语义与 filter 完全一致
const hit = ray.intersectObjects(pickList, false)[0];
return hit ? hit.object : null;
}
```
(three r159 的 Raycaster **不检查 `object.visible`**,已在源码中确认,故可见性过滤必须保留。)
- `schedHover()`:rAF 节流 → **60 ms 时间配额 + 位置去重**(实测 1.3 ms/次求交,60 次/s → ~16 次/s):
```js
let hoverPending = false, lastHoverEv = null, hoverT = -1e9, hoverX = -1, hoverY = -1;
const HOVER_MS = 60;
function schedHover(e) {
if (e.clientX === hoverX && e.clientY === hoverY) return; // 未移动不重算
hoverX = e.clientX; hoverY = e.clientY; lastHoverEv = e;
if (hoverPending) return;
hoverPending = true;
setTimeout(() => {
hoverPending = false;
if (!lastHoverEv || drag) return;
hoverT = performance.now(); hover(lastHoverEv);
}, Math.max(0, HOVER_MS - (performance.now() - hoverT)));
}
```
尾部仍会补一次(指针停住后取最新坐标),tooltip 行为不变;点击/右键拾取仍**立即**执行,不受限流影响。
### 改动 3(P4):`applyJoint()` 去分配
```js
const JP = {}, JD = {};
for (const j of JO) { JP[j.id] = new V3(...VC(j.rear)); JD[j.id] = new V3(...VC(j.d)).normalize(); }
const _mT = new THREE.Matrix4(), _mR = new THREE.Matrix4(), _mTi = new THREE.Matrix4();
function applyJoint(id) {
const P = JP[id], D = JD[id]; if (!P) return;
const a = (ang[id] || 0) * Math.PI / 180;
JG[id].matrix.copy(_mT.makeTranslation(P.x, P.y, P.z)
.multiply(_mR.makeRotationAxis(D, a))
.multiply(_mTi.makeTranslation(-P.x, -P.y, -P.z)));
JG[id].matrixWorldNeedsUpdate = true;
requestRender();
}
```
数学与原式逐位相同(`T(P)·R(D,θ)·T(−P)`),仅把每次新建的 5 个对象换成预计算 + 3 个共享矩阵。
### 改动 4(P5):索引降位(Uint16)
```js
const sIdx = p.v.length > 65535 ? new Uint32Array(p.s) : new Uint16Array(p.s); // line 分支
const idx = p.v.length > 65535 ? new Uint32Array(p.t.length*3) : new Uint16Array(p.t.length*3);
```
全部 92 件顶点数 ≤ 47,483(实测),Uint16 合法;索引内存 4.08 MB → 2.04 MB,上传带宽减半。保留 >65535 分支作安全兜底。
### 改动 5(P6):BOM 高亮行只记一行
新增 `let bomHlRow = null; function bomHlClear() { if (bomHlRow) { bomHlRow.style.background = ''; bomHlRow = null; } }`,替换 L32727、L32736、L32745 三处全表 `tr.style.background = ''` 循环;设置高亮处同步记录 `bomHlRow = tr`。可见结果完全一致。
### 改动 6(P7):Worker Blob URL 回收
```js
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
let w; try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
```
(URL 只在 Worker 构造时被读取,构造后即可回收;失败路径也不再泄漏。此改动**仅成品有**——`web/ik-glue.js` 仍是旧同步求解版,无 Worker。)
### 改动 7:源文件同步(用户已确认)
- `src/viewer_tpl.html`:逐条应用改动 1~5(该文件对应代码段存在,锚点为唯一代码串;`±360°` 差异行不与改动区重叠)。
- `web/ik-glue.js`:核对后逐条应用(预计仅"渲染相关"部分不适用——拾取/循环/几何都在模板里;Worker 相关不存在)。
- 同步后**不重跑 `build_viewer.py`**(会回退旧功能),源文件只作为"补丁已留存"。
### 改动 8:文档
新建 `docs/查看器性能优化报告.md`(与 `docs/姿态估计算法评估报告.md` 同目录同风格,中文):优化项逐条说明(问题/证据/改法/收益)、前后实测数据表、功能验证清单与结论、已否决项、备份路径 + SHA-256、**"源文件漂移未修,勿重跑构建"**警告。
---
## 三、假定与决策
1. 只改代码区,**不动** `DATA` / `SHEETS` / `TRAVEL` / `COLLIDE` 数据行与求解器脚本(指纹依赖)。
2. 不新增/不拆分 `<script>` 标签(`document.scripts[2]` 索引依赖)。
3. 不改 `antialias` / `setPixelRatio(min(dpr,1.5))` / 灯光 / 材质参数——属画质取舍,且实测 draw call 与面数不构成瓶颈。
4. 测量用的"插桩副本"只放在 `scratch/perf/`(已被 `.gitignore` 忽略),成品文件保持干净。
5. 备份放项目内 `backups/`(用户已选),并写入 `.gitignore`。
---
## 四、验证方案
### 4.1 静态校验
1. `backups/` 内备份与原件 `SHA-256` 一致(改前)。
2. 抽取改后文件的 4 个 `<script>` 块到 `scratch/perf/`,逐个 `node --check` 通过。
3. 求解器脚本块 + `TRAVEL` / `COLLIDE` 数据行改前/改后**字节一致**(保证指纹)。
4. 全文 diff 只落在预期的 8 处锚点内。
### 4.2 功能验证(浏览器,改前/改后各跑一遍对照)
用 `scratch/perf/serve.py`(`SimpleHTTPRequestHandler`,把 `/v.html` 映射到项目根的真实成品)起 `http://localhost:8123/v.html`,用浏览器自动化逐项核对并截图:
| 项 | 判据 |
| --- | --- |
| 首屏加载 | 无 JS 报错;三维模型正常显示 |
| 关节滑块 | 拖动后手臂实时跟随、角度标签同步 |
| 爆炸滑块 / 装配视图按钮 | 模型散开与复位正确 |
| 线框 / 电机半透 / 螺钉 / 可达包络 / 复位 | 逐项开关生效 |
| 装配图页 ↔ 三维页切换 | SVG 显示、切回后三维画布重新渲染(验证"隐藏分支不清标志") |
| 运动模拟页 | 进页即求解并动画到位;IK 表格/徽标/备选构型正常 |
| 姿态控制 | 开关、取当前、垂直向下、箭头拖拽可用 |
| 自检面板 | 显示"指纹 … 匹配";FK/IK/场景图残差仍为 1e-13 mm 量级(验证改动的数学等价性) |
| BOM 螺丝行点击 | 高亮 + 相机对焦 + HUD 文案(昨天的功能) |
| 悬浮 tooltip / 右键隐藏 / H 键 / ESC | 行为一致 |
| 相机预设 / 拖拽旋转 / 滚轮缩放 | 行为一致 |
| URL 参数(`?view=motion&tgt=…&j1=…&hide=…`) | 脚本化视图仍生效 |
### 4.3 性能前后对比(插桩副本,同一浏览器同一机器)
生成 `scratch/perf/measure_before.html`(备份 + 尾部追加插桩脚本)与 `measure_after.html`(改后 + 同一插桩脚本),插桩内容:包装 `rend.render` 计次与计 CPU 时长、`rend.info`、`performance.memory`、导航时序、交互探针(滑块 input、`IK.setView('motion')`+`IK.run()` 动画、20 次 pointermove 拾取计时),结果输出到页面固定 div + `document.title` 以便截图/读取。
预期指标:
| 指标 | 改前(预期) | 改后(预期) |
| --- | --- | --- |
| 空闲 5 s 渲染次数 | ≈300 | ≈0~1 |
| 单次 render CPU 时长 / draw call / 三角面 | 基线 | 相同(画质不变) |
| 20 次 pointermove 拾取总耗时 | ≈26~42 ms | ≈7~11 ms(限流 + 零分配) |
| 索引显存 | 4.08 MB | 2.04 MB |
| 加载(navigation→首次渲染、DOMContentLoaded→load) | 基线 | 持平或略优 |
| 交互后渲染次数(滑块/动画) | — | 每次变化 ≥1 次,无丢帧 |
### 4.4 回滚
`Copy-Item backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html 机器人手臂3D查看器.html`(并同步回源文件的 `git checkout`)。
---
## 五、执行顺序
1. 备份 + 校验 + `.gitignore`(改动 0)
2. 起静态服务器 + 跑改前功能对照与改前性能插桩(拿到基线数据)
3. 改成品 HTML(改动 1→6,逐处 Edit,锚点唯一串)
4. 静态校验(4.1)
5. 改后功能对照 + 改后性能插桩(4.2/4.3)
6. 同步源文件补丁(改动 7)
7. 写报告(改动 8)
+6
View File
@@ -0,0 +1,6 @@
{
"network": {
"httpProxy": "http://127.0.0.1:7897",
"noProxy": "localhost,127.0.0.1,192.168.31.10,z.ai,bigmodel.cn,modelscope.cn,sharellm.net,motomoto.lol"
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ python src/sweep_v2.py
| :--- | :--- | :---: | :--- |
| **P01** | 胸板 / 安装板 | 根基固定 | 12mm 基础基座,预留 4x M4 紧固位与躯干走线窗 |
| **P02** | 底脚 / 支撑座 | 根基固定 | 200×320mm 重型防倾覆底盘,提供稳固调试基座 |
| **P03** | 肩吊臂 / 肩机舱 | J1 驱动抱紧 | 仿生规整风道机舱,带 7 组轴向散热格栅,抱紧 M1 电机 |
| **P03** | 肩吊臂 / 肩关节安装悬臂 | J1 驱动抱紧 | 轻量化板式桁架:双侧 6mm 竖板 + 帐篷减重窗(≥52°自支撑)+ 9mm 承力鞍座 + Ø15 螺母柱,抱紧 M1 电机(实心 728→286cm³,-61%,外轮廓不变) |
| **P04** | 肩叉 | J1 ➔ J2 | 90° 一体化高刚度正交转向肘,直锁 M1,抱紧 M2 |
| **P05** | 大臂旋转座 | J2 ➔ J3 | 16 层 Loft 曲面平滑过渡,直锁 M2,后侧刚性抱紧 M3 |
| **P06** | 大臂骨架 | J3 ➔ J4 | 流线型工字背脊骨架,直锁 M3,下端抱紧 M4 肘电机 |
+27 -27
View File
@@ -4,30 +4,30 @@
"名称": "立柱下段",
"类型": "3D打印",
"数量": 1,
"体积cm3": 402.4,
"质量g_PLA": 499.0,
"体积cm3": 402.8,
"质量g_PLA": 499.4,
"包络": "[-148.0 95.0 10.0 .. -82.0 161.0 188.0] (66.0x66.0x178.0)",
"说明": "200mm适配分段A:高166mm+16mm阶梯公榫+非对称M4防反装通孔+底部50x50x12榫头+P01-A 竖排TOP刻字"
"说明": "200mm适配分段A:高166mm+16mm阶梯公榫+顶端 2x Ø3.9 对锁孔(M4x16 自攻)+底部50x50x12榫头+P01-A 竖排TOP刻字"
},
{
"件号": "P01_B_立柱中段_三角形30%_墙4底5_竖打",
"名称": "立柱中段",
"类型": "3D打印",
"数量": 1,
"体积cm3": 353.9,
"质量g_PLA": 438.9,
"体积cm3": 354.3,
"质量g_PLA": 439.3,
"包络": "[-148.0 95.0 172.0 .. -82.0 161.0 337.0] (66.0x66.0x165.0)",
"说明": "200mm适配分段B:高165mm+双端公母榫+非对称M4防反装通孔+P01-B 竖排TOP刻字"
"说明": "200mm适配分段B:高165mm+双端公母榫+两端各 2x Ø3.9 对锁孔(M4x16 自攻,非对称孔位防反装)+P01-B 竖排TOP刻字"
},
{
"件号": "P01_C_立柱上段_三角形30%_墙4底5_竖打",
"名称": "立柱上段",
"类型": "3D打印",
"数量": 1,
"体积cm3": 305.3,
"质量g_PLA": 378.5,
"体积cm3": 305.4,
"质量g_PLA": 378.7,
"包络": "[-148.0 95.0 321.0 .. -82.0 161.0 470.0] (66.0x66.0x149.0)",
"说明": "200mm适配分段C:高149mm+底端母榫+非对称M4防反装通孔+顶面P03安装台+P01-C 竖排TOP刻字"
"说明": "200mm适配分段C:高149mm+底端母榫+底端 2x Ø3.9 对锁孔(非对称孔位防反装)+顶面P03安装台(4x Ø3.9 安装孔 + 1x Ø3.9 横向锁紧孔)+P01-C 竖排TOP刻字"
},
{
"件号": "P02_Center_底座中核心_螺旋体30%_墙5底6_平打",
@@ -64,20 +64,20 @@
"名称": "肩关节安装悬臂",
"类型": "3D打印",
"数量": 1,
"体积cm3": 728.0,
"质量g_PLA": 902.8,
"包络": "[-148.0 91.0 455.0 .. 8.5 165.0 647.0] (156.5x74.0x192.0)",
"说明": "高强多肋机架:10mm后置法兰+双侧8mm大三角加强筋+下腹12mm抗弯龙骨筋+侧开式电机托架(M1从+Y侧滑入装配,前后法兰8xM3双向锁死)+前置止脱锁紧法兰(4xM3止脱螺孔)+大散热窗与对流槽"
"体积cm3": 274.4,
"质量g_PLA": 340.2,
"包络": "[-148.0 91.0 455.0 .. 11.5 165.0 647.0] (159.5x74.0x192.0)",
"说明": "轻量化板式桁架版:主干由 66mm 实心块改为双侧 6mm 桁架竖板+帐篷减重窗(斜面≥52°自支撑)+R4内角,外轮廓与旧版并集完全一致,实心 728->286cm³;底部 9mm 承力鞍座(Ø3.9 安装孔)+12mm 中位龙骨筋(自鞍座斜拉至法兰下缘)+40~45°外扩斜撑;保留 10mm 后置法兰、侧开式电机托架、Ø60.7 直通电机舱(平孔无台阶)(M1 从+Y滑入,后端 4xM3x16 锁死)、大散热窗与对流槽;无封闭空腔、免支撑"
},
{
"件号": "P04_肩叉_螺旋体30%_墙5底5_竖打",
"名称": "肩叉",
"类型": "3D打印",
"数量": 1,
"体积cm3": 346.5,
"质量g_PLA": 429.7,
"包络": "[-16.5 91.5 455.0 .. 53.5 164.5 646.5] (70.0x73.0x191.5)",
"说明": "加长避让版:头部与身子前移加长至X=10.5,与P03锁紧法兰保持2.0mm轴向安全间隙(不做前移,保持输出端最短悬伸力臂),J1输出凸台贯通直连电机,J1从动法兰圆盘鼓楞已删(接缝区与P03止脱环齐径齐高R36.5/顶646.5),单实体全角度零干涉"
"体积cm3": 347.4,
"质量g_PLA": 430.8,
"包络": "[-15.5 91.5 455.0 .. 54.5 164.5 646.5] (70.0x73.0x191.5)",
"说明": "齐平插槽版:叉板背面与P03托架端面(11.5)0.0硬贴平(用户要求外观齐平),P03侧插槽独立加长3mm,M1电机可倾斜卡入,J1输出凸台贯通直连电机,J1从动法兰圆盘鼓楞已删(接缝区与P03止脱环齐径齐高R36.5/顶646.5),单实体全角度零干涉"
},
{
"件号": "P05_大臂旋转座_螺旋体30%_墙4底5_竖打",
@@ -86,7 +86,7 @@
"数量": 1,
"体积cm3": 159.5,
"质量g_PLA": 197.8,
"包络": "[-16.5 93.0 389.0 .. 53.5 174.0 525.0] (70.0x81.0x136.0)",
"包络": "[-15.5 93.0 389.0 .. 54.5 174.0 525.0] (70.0x81.0x136.0)",
"说明": "V3圆角:Loft外轮廓R=10mm,L型转角内部2mm倒角,后缘凸包两角90°竖棱磨R2圆角(结构/后缘高度不变,打印无悬挂),保证J2全角度0干涉"
},
{
@@ -96,7 +96,7 @@
"数量": 1,
"体积cm3": 169.3,
"质量g_PLA": 210.0,
"包络": "[-16.5 88.8 297.0 .. 53.5 167.2 390.1] (70.0x78.4x93.1)",
"包络": "[-15.5 88.8 297.0 .. 54.5 167.2 390.1] (70.0x78.4x93.1)",
"说明": "Loft有机过渡版:借鉴P05圆弧变方形放样算法,顶端Ø70正圆与J3平齐无台阶,底端渐变融入M4抱紧套筒与壳体,单实体0干涉"
},
{
@@ -106,7 +106,7 @@
"数量": 1,
"体积cm3": 289.1,
"质量g_PLA": 358.5,
"包络": "[-16.5 92.0 187.0 .. 53.5 174.0 367.0] (70.0x82.0x180.0)",
"包络": "[-15.5 92.0 187.0 .. 54.5 174.0 367.0] (70.0x82.0x180.0)",
"说明": "V2 L型结构:+Y侧背脊立柱避让大臂骨架,过渡区采用与顶板和立柱双相切的R=28mm平滑内凹大圆弧,彻底消除直斜线折角与悬空阶梯小块,顶板前后外缘2mm倒角改R3圆角,顶板整条加高19mm(z261->280)加强连接且gusset弧R28->R14重定相切融入,J4圆盘外沿与立柱外棱不倒圆保持原始尖角,下方M5电机半包散热,俯仰大范围自由运动零干涉"
},
{
@@ -116,7 +116,7 @@
"数量": 1,
"体积cm3": 160.0,
"质量g_PLA": 198.4,
"包络": "[-16.5 93.0 120.0 .. 53.5 174.0 257.0] (70.0x81.0x137.0)",
"包络": "[-15.5 93.0 120.0 .. 54.5 174.0 257.0] (70.0x81.0x137.0)",
"说明": "V2圆角:Loft外轮廓R=10mm,L型转角内部2mm倒角,彻底清除立壁保证J5全角度0干涉"
},
{
@@ -126,7 +126,7 @@
"数量": 1,
"体积cm3": 98.8,
"质量g_PLA": 122.5,
"包络": "[-16.5 93.0 93.0 .. 53.5 163.0 121.1] (70.0x70.0x28.1)",
"包络": "[-15.5 93.0 93.0 .. 54.5 163.0 121.1] (70.0x70.0x28.1)",
"说明": "V2圆角:外轮廓R=3mm,法兰面倒角2mm"
},
{
@@ -146,7 +146,7 @@
"数量": 1,
"体积cm3": 116.4,
"质量g_PLA": 144.4,
"包络": "[-13.3 100.0 458.1 .. 50.3 156.0 521.7] (63.6x56.0x63.6)",
"包络": "[-12.3 100.0 458.1 .. 51.3 156.0 521.7] (63.6x56.0x63.6)",
"说明": "复用 电机.STEP"
},
{
@@ -156,7 +156,7 @@
"数量": 1,
"体积cm3": 116.4,
"质量g_PLA": 144.4,
"包络": "[-13.3 96.1 389.0 .. 50.3 159.7 445.0] (63.6x63.6x56.0)",
"包络": "[-12.3 96.1 389.0 .. 51.3 159.7 445.0] (63.6x63.6x56.0)",
"说明": "复用 电机.STEP"
},
{
@@ -166,7 +166,7 @@
"数量": 1,
"体积cm3": 116.4,
"质量g_PLA": 144.4,
"包络": "[-13.3 100.0 300.1 .. 50.3 156.0 363.7] (63.6x56.0x63.6)",
"包络": "[-12.3 100.0 300.1 .. 51.3 156.0 363.7] (63.6x56.0x63.6)",
"说明": "复用 电机.STEP"
},
{
@@ -176,7 +176,7 @@
"数量": 1,
"体积cm3": 116.4,
"质量g_PLA": 144.4,
"包络": "[-13.3 100.0 190.1 .. 50.3 156.0 253.7] (63.6x56.0x63.6)",
"包络": "[-12.3 100.0 190.1 .. 51.3 156.0 253.7] (63.6x56.0x63.6)",
"说明": "复用 电机.STEP"
},
{
@@ -186,7 +186,7 @@
"数量": 1,
"体积cm3": 116.4,
"质量g_PLA": 144.4,
"包络": "[-13.3 96.1 120.0 .. 50.3 159.7 176.0] (63.6x63.6x56.0)",
"包络": "[-12.3 96.1 120.0 .. 51.3 159.7 176.0] (63.6x63.6x56.0)",
"说明": "复用 电机.STEP"
}
]
+31997
View File
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -6,13 +6,13 @@
| 件号 | 图案 / 密度 | 墙 / 顶底 | 打印姿态 | 局部实心区(Modifier) |
|---|---|---|---|---|
| P01_A_立柱下段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 两端榫接区 + M4 通孔周 |
| P01_B_立柱中段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 两端榫接区 + M4 通孔周 |
| P01_C_立柱上段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 两端榫接区 + M4 通孔周 |
| P01_A_立柱下段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 顶端榫接区 + Ø3.9 对锁孔周 |
| P01_B_立柱中段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 两端榫接区 + Ø3.9 对锁孔周 |
| P01_C_立柱上段_三角形30%_墙4底5_竖打 | 三角形 30% | 4 / 5 | 竖打 | 底端榫接区 + Ø3.9 对锁孔周 + 顶面安装孔周 |
| P02_Center_底座中核心_螺旋体30%_墙5底6_平打 | 螺旋体 30% | 5 / 6 | 平打 | Ø48 套筒 + 12mm 阶梯榫座 |
| P02_Front_前向防翻力臂_螺旋体30%_墙4底5_平打 | 螺旋体 30% | 4 / 5 | 平打 | M5 螺母沉槽区 |
| P02_Rear_后向防翻力臂_螺旋体30%_墙4底5_平打 | 螺旋体 30% | 4 / 5 | 平打 | M5 螺母沉槽区 |
| P03_肩吊臂_螺旋体35%_墙5底5_竖打 | 螺旋体 35% | 5 / 5 | 竖打 | 10mm 后法兰孔 + J1 电机座接触面 |
| P03_肩吊臂_螺旋体35%_墙5底5_竖打 | 螺旋体 35% | 5 / 5 | 竖打 | 5x Ø3.9 安装孔周 + 后法兰 4xM3 沉头孔周 + 前锁紧法兰孔周 |
| P04_肩叉_螺旋体30%_墙5底5_竖打 | 螺旋体 30% | 5 / 5 | 竖打 | J1 输出凸台贯通区(整段实心) |
| P05_大臂旋转座_螺旋体30%_墙4底5_竖打 | 螺旋体 30% | 4 / 5 | 竖打 | J2 接口孔周 |
| P06_大臂骨架_螺旋体25%_墙4底5_竖打 | 螺旋体 25% | 4 / 5 | 竖打 | M4 抱紧套筒 |
@@ -23,9 +23,11 @@
## 通用规则
1. **打印方向**:全部按模型姿态打,弯曲发生在层面内,弯矩交给顶/底实心层和墙,不让层间缝扛拉。
2. **螺纹孔一律不切螺纹**:M3/M4/M5 全部用黄铜热熔螺母;孔周按 1.5~2 倍螺栓直径加载实心 Modifier。P02 力臂的 M5 螺母沉槽同此处理。
2. **不用热熔铜螺母、不需要攻丝、也不打沉孔/过孔**:全部连接螺钉统一为 **M4×16**,每个螺钉位**只打一个 Ø3.9 直孔**——M4×16 直接用扳手拧入,螺钉自己攻出螺纹(顶部 4 颗穿鞍座 9mm 后吃进立柱顶环 7mm;横向 1 颗穿 11mm 后壁吃进榫头 4.7mm;分段缝 8 颗穿母口壁+公榫壁共 10.5mm)。竖向载荷由承压面承担(顶部 2403mm²、分段缝榫座面),螺钉只防上抬/防滑。孔周按 1.5~2 倍螺栓直径加载实心 Modifier。
3. **墙数和顶底壳优先于密度**:抗弯主力是墙+顶底实心层,填充件是剪力腹板。密度只在小区域加(关节毂 30~35%),不要全局堆高。
4. **层高 0.2mm 不变**;P03/P04/P07 受力最狠,墙给 5 层,其余 4 层。
P03 已改薄壁桁架(双侧 6mm 竖板 + 帐篷减重窗 + 9mm 鞍座 + 12mm 中位龙骨筋):强度由 **墙层数** 决定,
35% 只作用在很薄的内部芯上;想再压重量可把 P03 单独降到 螺旋体 20%(≈省 15~20g,强度不受影响)。
5. BOM 质量按 PLA 折算;换 PETG/ASA 参数不变,层间强度只会更好。
6. 立柱 P01 三段用三角形是唯一例外:轴压方向已知,Z 向斜撑跨层传力比螺旋体更对口。
+25 -15
View File
@@ -31,23 +31,23 @@
},
{
"id": "S04",
"规格": "M3 x 8",
"螺纹": "M3",
"长度mm": 8,
"位置": "P03 前置锁紧凸台(Ø10 x4) -> M1 电机前端面",
"备注": "Ø3.2 通孔 + Ø6.0 沉孔,BCD 55(R27.5,4 孔 17.8/107.8/197.8/287.8°);座位为舱孔前段 4 个 Ø10 局部凸台(2026-10-02 平直舱孔方案),M3x10 旋入电机前端面咬合 3.5mm",
"头型": "沉头内六角",
"数量": 4,
"位置": "P03 前置止脱锁紧法兰 -> M1 电机前端面",
"备注": "Ø3.2 通孔 + Ø6.0 沉孔,BCD 55(R27.5,4 孔 17.8/107.8/197.8/287.8°)"
"螺纹": "M3",
"规格": "M3 x 10",
"长度mm": 10
},
{
"id": "S05",
"规格": "M4 x 25",
"规格": "M4 x 16",
"螺纹": "M4",
"长度mm": 25,
"长度mm": 16,
"头型": "圆柱头内六角",
"数量": 4,
"位置": "P01 立柱分段对锁:A-B 缝 2 颗 + B-C 缝 2 颗",
"备注": "Ø4.4 通孔,非对称偏心孔位物理防反装;需另配 M4 螺母 4 枚"
"数量": 8,
"位置": "P01 立柱分段对锁:A-B 缝 4 颗 + B-C 缝 4 颗(每缝前后各 2 颗)",
"备注": "统一 Ø3.9 直孔(母口壁与公榫壁同径,只有这一个孔,无过孔/沉孔/预埋):M4x16 从外面直接拧入自攻成型,螺纹吃在母口壁 3.6 + 公榫壁 6.9 共 10.5mm;孔位 x=-131/-93 非对称物理防反装"
},
{
"id": "S06",
@@ -81,13 +81,23 @@
},
{
"id": "S09",
"规格": "M5 x 35",
"螺纹": "M5",
"长度mm": 35,
"规格": "M4 x 16",
"螺纹": "M4",
"长度mm": 16,
"头型": "圆柱头内六角",
"数量": 4,
"位置": "P03 肩吊臂安装台 -> P01_C 立柱顶面",
"备注": "Ø5.5 通孔,穿过 15mm 凸台旋入立柱 20mm"
"位置": "P03 肩吊臂 -> P01_C 立柱顶面(从腔体上方往下拧)",
"备注": "统一 Ø3.9 直孔(鞍座与立柱顶环同径,只有这一个孔,无过孔/沉孔/预埋):M4x16 从上方直接拧入自攻成型,穿鞍座 9mm 后吃进立柱顶环 7mm;载荷走 2403mm² 承压面,螺钉只防上抬"
},
{
"id": "S11",
"规格": "M4 x 16",
"螺纹": "M4",
"长度mm": 16,
"头型": "圆柱头内六角",
"数量": 1,
"位置": "P01_C 立柱后壁 -> P03 榫头(横向防脱锁紧)",
"备注": "与 S05/S09/S06 同为 M4x16(全机 M4x16 合计 17 颗);Ø3.9 直孔贯穿 11mm 后壁 + 榫头同径:从后方直接拧入自攻成型,榫头内咬合 4.7mm"
},
{
"id": "S10",
+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1150" height="760" viewBox="0 0 1150 760">
<rect width="1150" height="760" fill="#15181f"/>
<rect x="400.0" y="154.0" width="396.0" height="396.0" fill="#1d4ed8" stroke="#9dc4ff" stroke-width="2.0"/>
<rect x="466.0" y="220.0" width="264.0" height="264.0" fill="#12161d" stroke="#6f86b8" stroke-width="1.4"/>
<rect x="488.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
<rect x="488.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
<rect x="484.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="484.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="488.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="488.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="481.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="481.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="716.8" y="484.0" width="26.4" height="66.0" fill="#243043" stroke="#ffd166" stroke-width="1.4"/>
<rect x="716.8" y="154.0" width="26.4" height="66.0" fill="#243043" stroke="#96dcff" stroke-width="1.4"/>
<rect x="712.0" y="460.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="712.0" y="220.0" width="36.0" height="24.0" fill="#7d8794" stroke="#c3cad6" stroke-width="1.2"/>
<rect x="716.8" y="400.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="716.8" y="154.0" width="26.4" height="150.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.0"/>
<rect x="709.0" y="550.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<rect x="709.0" y="130.0" width="42.0" height="24.0" fill="#c3cad6" stroke="#ffffff" stroke-width="1.2"/>
<text x="598.0" y="360.0" font-size="22" fill="#5f7396" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">内腔 44x44</text>
<text x="598.0" y="558.0" font-size="17" fill="#ffd166" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前 -Y y=95</text>
<text x="598.0" y="158.0" font-size="17" fill="#96dcff" text-anchor="middle" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后 +Y y=161</text>
<path d="M481.0,562.0 L150.0,562.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="554.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 前面一颗</text>
<text x="10.0" y="574.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=91..95 外露 4mm · 杆到 y=120</text>
<path d="M484.0,472.0 L150.0,472.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="464.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">前螺母贴前壁内侧</text>
<text x="10.0" y="484.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=106..110 · 压紧前侧 10.5mm 搭接壁</text>
<path d="M515.2,352.0 L150.0,352.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="344.0" font-size="16" fill="#96dcff" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">两端杆头净空 16.2</text>
<text x="10.0" y="364.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">前 120 / 后 136 · 互不碰头</text>
<path d="M484.0,232.0 L150.0,232.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="224.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">后螺母贴后壁内侧</text>
<text x="10.0" y="244.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">y=146..150 · 压紧后侧 10.5mm 搭接壁</text>
<path d="M481.0,142.0 L150.0,142.0" stroke="#8fa4c8" stroke-width="1.2"/>
<text x="10.0" y="134.0" font-size="16" fill="#d9e4f7" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">M4x25 背面一颗</text>
<text x="10.0" y="154.0" font-size="13" fill="#8fa4c8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">头 y=161..165 · 杆到 y=136</text>
<text x="12.0" y="30.0" font-size="21" fill="#e8eefc" text-anchor="start" font-weight="700" font-family="Consolas,Microsoft YaHei,monospace">P01 立柱分段缝 · 两侧对锁(M4x25 前后各一颗,共 8 颗)· 俯视剖面</text>
<text x="12.0" y="52.0" font-size="14" fill="#9fb4d8" text-anchor="start" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">孔位 x=-131/-93 与 Ø4.4 通孔均未改动,与已打印的 A/B 段完全一致;偏心差 6mm 仍作防反装防呆</text>
<text x="1138.0" y="746.0" font-size="13" fill="#6f86b8" text-anchor="end" font-weight="400" font-family="Consolas,Microsoft YaHei,monospace">单位 mm · 6px/mm · 数据取自 web/mesh_arm.json 的 S05 实例</text>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+205
View File
@@ -0,0 +1,205 @@
[
{
"截面": "吊臂根座 @z=480",
"零件": "P03_肩吊臂_螺旋体35%_墙5底5_竖打",
"关节": "J1",
"A": 975.4800000000001,
"I": 391731.4096400291,
"c": 37.23299062774025,
"bend": 1,
"centroid": [
-110.76700947225983,
128.00000000000003
],
"M": 23529.618031505226,
"F": 41.71918090692416,
"sigma": 2.2364253314442926,
"tau": 0.06415177283018231,
"SF": 5.365705633574787
},
{
"截面": "吊臂颊板 @z=560",
"零件": "P03_肩吊臂_螺旋体35%_墙5底5_竖打",
"关节": "J1",
"A": 1102.4399999999998,
"I": 605072.9254967812,
"c": 43.53697862036803,
"bend": 1,
"centroid": [
-96.71968814629368,
128.0
],
"M": 23529.618031505226,
"F": 41.71918090692416,
"sigma": 1.6930330775286337,
"tau": 0.05676387954027997,
"SF": 7.087870969134711
},
{
"截面": "肩叉腹板 @z=560",
"零件": "P04_肩叉_螺旋体30%_墙5底5_竖打",
"关节": "J2",
"A": 2217.3650000000002,
"I": 207369.8226041668,
"c": 16.75000010000293,
"bend": 1,
"centroid": [
27.25,
124.95500000000001
],
"M": 15271.482458030512,
"F": 34.395230761329984,
"sigma": 1.2335320997379497,
"tau": 0.023267638003664246,
"SF": 9.728161920187782
},
{
"截面": "肩叉腹板 @z=600",
"零件": "P04_肩叉_螺旋体30%_墙5底5_竖打",
"关节": "J2",
"A": 2368.7748,
"I": 276311.8560102971,
"c": 21.62027641094759,
"bend": 1,
"centroid": [
25.562276310943524,
126.94624799284419
],
"M": 15271.482458030512,
"F": 34.395230761329984,
"sigma": 1.1949312516479667,
"tau": 0.02178039302933946,
"SF": 10.042418744551561
},
{
"截面": "大臂旋转座横梁 @z=480",
"零件": "P05_大臂旋转座_螺旋体30%_墙4底5_竖打",
"关节": "J2",
"A": 1250.9348,
"I": 490359.7093282266,
"c": 35.000000100000086,
"bend": 1,
"centroid": [
18.5,
165.03578024690017
],
"M": 15271.482458030512,
"F": 34.395230761329984,
"sigma": 1.090019994282287,
"tau": 0.04124343342434392,
"SF": 11.008972370182331
},
{
"截面": "大臂旋转座套筒 @z=440",
"零件": "P05_大臂旋转座_螺旋体30%_墙4底5_竖打",
"关节": "J3",
"A": 923.6637500000008,
"I": 460541.5056708324,
"c": 35.00071932511497,
"bend": 1,
"centroid": [
18.499280774885154,
162.1529414131134
],
"M": 10944.224641918447,
"F": 28.80059116294328,
"sigma": 0.831750733009024,
"tau": 0.046771226806740965,
"SF": 14.42739936830306
},
{
"截面": "大臂骨架中段 @z=350",
"零件": "P06_大臂骨架_螺旋体25%_墙4底5_竖打",
"关节": "J3",
"A": 1516.920000000001,
"I": 1131945.0546959997,
"c": 35.00000010000099,
"bend": 1,
"centroid": [
18.499999999999996,
119.46110407931855
],
"M": 10944.224641918447,
"F": 28.80059116294328,
"sigma": 0.33839793015788383,
"tau": 0.028479344160809335,
"SF": 35.46120980823154
},
{
"截面": "大臂骨架抱箍 @z=325",
"零件": "P06_大臂骨架_螺旋体25%_墙4底5_竖打",
"关节": "J4",
"A": 562.2200000000005,
"I": 255126.52178866684,
"c": 34.31311712541806,
"bend": 1,
"centroid": [
18.5,
100.98098075486462
],
"M": 6102.354966361885,
"F": 23.11498093318896,
"sigma": 0.820733255145853,
"tau": 0.06167064743300382,
"SF": 14.621071980161778
},
{
"截面": "小臂骨架中段 @z=245",
"零件": "P07_小臂骨架_螺旋体30%_墙5底5_竖打",
"关节": "J4",
"A": 1688.4280000000003,
"I": 1225116.8186629335,
"c": 35.00000010000015,
"bend": 1,
"centroid": [
18.5,
120.60880570566229
],
"M": 6102.354966361885,
"F": 23.11498093318896,
"sigma": 0.17433637444141997,
"tau": 0.020535356793291413,
"SF": 68.83245127959344
},
{
"截面": "小臂骨架抱箍 @z=195",
"零件": "P07_小臂骨架_螺旋体30%_墙5底5_竖打",
"关节": "J5",
"A": 381.97200000000026,
"I": 75885.39226040001,
"c": 22.39173964832456,
"bend": 1,
"centroid": [
18.5,
103.25743876514508
],
"M": 2105.5758538864893,
"F": 16.322293440980538,
"sigma": 0.6212988419199141,
"tau": 0.06409747353594188,
"SF": 19.31437689939684
},
{
"截面": "腕偏转座根部 @z=150",
"零件": "P08_腕偏转座_螺旋体25%_墙4底5_竖打",
"关节": "J5",
"A": 877.5228499999998,
"I": 452897.5474791441,
"c": 35.000019545458414,
"bend": 1,
"centroid": [
18.49998055454244,
153.23437984777257
],
"M": 2105.5758538864893,
"F": 16.322293440980538,
"sigma": 0.1627193533077502,
"tau": 0.02790062978014853,
"SF": 73.74660577285154
},
{
"截面": "工具法兰 @z=55",
"零件": "P09_工具法兰_螺旋体100%_墙4底5_竖打",
"关节": "J6"
}
]
+1130 -8
View File
File diff suppressed because it is too large Load Diff
+704
View File
@@ -0,0 +1,704 @@
# 机械臂姿态估计算法评估报告
> 评估对象:`web/arm-ik-solver.js`(6 轴逆运动学姿态求解器)+ `web/ik-glue.js`(查看器集成)
> 评估日期:2026-09-29 评估方式:静态审查 + 可复现的离线数值实验(`node`,固定随机种子)
> 本报告为「评估 + 策略设计」,不含代码修改。
>
> **勘误(2026-09-29,实施 S0 时发现并已全表修正)**:本报告初版的「真最优」基准在度量姿态差时
> 漏掉了 θ6(工具滚转)的闭式最优,即用 `rotErrDeg(orient(q), R_tar)`(θ6=0)代替了
> 「θ6 取最优后的全姿态偏差」,系统性高估了可达的最小姿态差(例:`T=(20,400,300)` 初版 121.05°、
> 修正后 44.20°)。**修正后缺陷 B 的劣化量更大(更严重),而非更小**:均值 104.2°→110.3°。
> 修正涉及 §1、§2.3、§2.4、§3.3、§6.2、§6.3、§8.2 与 §9 附录命令(`posBest` 已改为 θ6 最优口径)。
---
## 1. 概述与结论摘要
用户提出的问题:**「在多个关键位置无法达到预定姿态目标;肉眼可观察到存在更优姿态,但求解器给出的不是它。」**
评估结论:问题真实存在,且可稳定复现、可量化。核心原因不是"算不出来",而是**求解器在姿态不可达时丢弃了已经算出的位置精确解,转而使用一个更慢且更差的 6 维随机搜索**;同时在可达但构型退化时,姿态精确解的根枚举会漏解,并**把漏解当成成功**返回。
| 编号 | 缺陷 | 实测影响 | 根因 | 对应策略 |
| :--- | :--- | :--- | :--- | :--- |
| **A** | 姿态精确可达,却静默返回 1~2° 残差 | 全空间 0.50%(2/400) 触发;近伸直构型 3.3%(1/30);最大 1.934° | 根枚举在退化处漏解(R1) | S1 |
| **B** | 姿态不可达时最近的"最近位姿"严重次优 | 平均劣化 **110.3°**(12 目标)/ **106.3°**(38 样本),最大 176.7°,被支配 12/12 | 接受门直接把候选池清空(R2)+ 兜底目标函数不一致(R3) | S0/S2 |
| **C** | 位置精确解整体被丢弃,兜底被便宜解严格支配 | 存在 11920 个位置精确解的目标,兜底仍在指标上双输 | `oerr>POSETOL` 一律 `return null` | S2 |
| **D** | 耗时超出既定 ≤100 ms 预算 | 开姿态中位 107 ms / p95 242 ms / 最大 299 ms | 兜底路径 160 次随机重启;姿态任务 escalating 重扫 | S1/S3 |
另外回答了一个架构问题(§3):**现状确实是「先点位、后角度」**;用户设想的"用末端指定角度逐级反推"**在本机构上不可行**——因为 `J4 ∥ J5`,这是**非球形腕**。但由此得到的「姿态优先」求解模式是可行且有价值的,实测可把姿态差压到 0.04°,代价是十几毫米的位置误差。
> 篇幅说明:第 9 章附录内嵌了本报告**全部数据的可复现命令**,任意数字都可重跑核对。
---
## 2. 算法现状描述
### 2.1 资产与数据流
| 角色 | 文件 | 说明 |
| :--- | :--- | :--- |
| 姿态求解器 | [arm-ik-solver.js](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) | UMD 单文件,`make(cfg)` 返回 `solve / solveTo / solveWarm / solveDyn / closest / closest6 / branch / fk / orient / rotErrDeg / LIM / costOf`([导出 L1355-1360](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1355-L1360)) |
| 查看器集成 | [ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) | 姿态 UI、目标拖动、调用 `solveTo`([L379](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L379)、[L399](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L399))、结果渲染与徽标([L318-342](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L318-L342)) |
| 关节表(几何真值) | [mesh_arm.json](file:///g:/Robot_v2/机器人手臂/web/mesh_arm.json) | `joints[] = {id, rear, d}` |
| 关节限位 | [travel.json](file:///g:/Robot_v2/机器人手臂/docs/travel.json) | 本报告测量口径:J1/J3/J6 连续(±180),J2∈[-106,9],J4∈[-111,16],J5∈[-95,34](2026-09-29 起该表退为零位扫描参考:滑块改电机行程 ±360°,可行域改相邻件实测区间 J2[-107,128]/J4[-112,112]/J5[-96,96]) |
| 既有自检 | [pose_ik_check.js](file:///g:/Robot_v2/机器人手臂/scratch/pose_ik_check.js) | 7 组 node 自检(本次全部通过,见 §6.3) |
| 性能基准 | [bench_ik_split.js](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.js) / [bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json) | 单次求解耗时与位置/姿态占比拆分 |
| 历史实验 | `scratch/ikopt/`(`solver_scanbase.js` = 改动前快照) | 见 §2.4 |
### 2.2 求解流程(`solve()`,[L558-944](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L558-L944))
1. **位置闭式 + 二维冗余扫描**:以 (θ4,θ5) 为冗余参数全域扫描;对每个网格点,[branch() L429-463](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L429-L463) 闭式给出最多 4 支 θ1..θ3(θ3 = ±σ、±(π−σ)),**位置误差天然 ~1e-13 mm**。
2. **按行裁剪**:[rowSpans L644-654](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L644-L654) 以探测步 `hp = max(h, 2)°` 找每 θ4 行的可行 θ5 段,段内再按 h 精扫。
3. **姿态任务**:工具轴对齐 = (θ4,θ5) 上的 2 方程 2 未知量,解为**孤立点**;[§4.1b 根枚举 L697-761](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L697-L761) 逐行做残差变号括号 + 牛顿收口([poseTools L486-545](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L486-L545));滚转角 θ6 由 [rollClose L549-555](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L549-L555) 闭式补足。
4. **接受门**:`POSETOL = 2.0°`;[scoreBranch L592-606](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L592-L606) 对 `oerr > POSETOL` 的候选**直接 `return null`**([L596-598](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L596-L598))。
5. **不可达兜底**:[closest6 L1054-1169](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1054-L1169) 为 6 维阻尼最小二乘(DLS),160 次确定性伪随机重启 × 160 迭代;由 [solveTo L1172-1192](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1172-L1192) 在 `solve()` 失败时调用。
6. **实时快路**:[solveWarm L1200-1265](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1200-L1265),沿上一帧解同支续解,任一项校验不过即回落全量。
**关键判断(贯穿全文)**:位置是**硬约束**(闭式、精确),姿态是**软约束**(带 2° 门、超门即弃)。
### 2.3 关节轴几何(判定"能否解耦"的依据)
| 关节 | rear (CAD) | d | 类型 |
| :--- | :--- | :--- | :--- |
| J1 | (-51, 128, 610) | (1,0,0) | X 抬举 |
| J2 | (18.5, 100, 490) | (0,**1**,0) | Y 俯仰 |
| J3 | (18.5, 128, 445) | (0,0,−1) | Z 自转 |
| J4 | (18.5, 100, 332) | (0,**1**,0) | Y 俯仰(肘) |
| J5 | (18.5, 100, 222) | (0,**1**,0) | Y 俯仰(腕) |
| J6 | (18.5, 128, 176) | (0,0,**−1**) | Z 工具自转 |
- **J4 ∥ J5**(均 +Y,相距 110 mm)⇒ 平行轴永不相交 ⇒ **非球形腕**。
- 代码已利用该性质:`SAMEAX`([L408](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L408))令 `R4·R5 = R_y(θ4+θ5)`。
- J5 ⊥ J6 且在 (18.5,128,222) 相交,故末两轴构成 2 轴腕,但肘关节 J4 破坏了球形性。
### 2.4 历史优化实验(`scratch/ikopt/`)
按时间:`solver_win` → `solver_fast` → `solver_warm` → `solver_old` → `solver_scanbase`(改动前快照)→ `bench_prune`(行裁剪 A/B)→ `bench_warmfollow`(warm 跟踪 A/B)。配套 `patch_*.py` / `merge_fast.py` / `audit_*.js`。
**结论**:这些迭代解决的是**耗时、连续性、退化漂移**三类问题(姿态预筛提速 1.41×、warm 窗口跟踪、同族门、行裁剪),**均未触及"覆盖完备性(缺陷 A)"与"兜底解质量(缺陷 B/C)"**。因此 A/B/C 三类缺陷在历次优化中一直存在。
---
## 3. 求解范式评估:位置优先 vs 末端姿态反推
> 本节回答用户提问:**「现在是不是先找点位再找角度?能不能用末端指定角度一级级反推回去?」**
### 3.1 现状确认:确实是"先点位、后角度"
`solve()` 先用位置约束闭式定死 θ1..θ3(位置误差 ~1e-13 mm),再把姿态当次级软约束(2° 门)。**所有实验里被牺牲的永远是角度**——§5 的兜底数据、§6.3 自检的 [4] 项("位置残差始终 3.1e-13 mm(姿态未达也不牺牲位置)")都直接印证这一点。
### 3.2 「末端角度逐级反推」可行性判定:本机构不成立
经典"末端反推"= 腕心解耦法,前提是**球形腕**(末端 3 轴交于一点):先由目标姿态算出腕心 `p_w = p_tcp − d₆·z_tool`,再由位置解 θ1..θ3,最后解 θ4/θ5/θ6。
**本机构不满足**:J4 ∥ J5(相距 110 mm 的平行轴),二者永不相交,不存在统一的"腕心",因此**无法**把 θ6→θ5→θ4 一级级闭式反推。
**但存在可用的降阶结构**(这才是正确的利用方式):
```
R = Rx(θ1) · Ry(θ2) · Rz(−θ3) · Ry(θ4+θ5) · Rz(−θ6)
```
即:**工具轴指向只由 (θ1, θ2, θ3, θ4+θ5) 决定;θ6 是纯工具滚转**。因此"用末端姿态驱动求解"是可行的,但要按这条降阶链解(3 方程 / 5 参数 → 2 参数族),而不是逐级反推。这一点也解释了为什么现有代码用 `axisCol()` 只算到 θ5 为止([L409-424](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L409-L424))。
### 3.3 更重要的实测结论:位置与姿态"真的不可兼得"
在可达姿态基础上把目标姿态外推 20°,用同一目标对照两种优先级(复现命令见 §9 A4/A3):
| 目标 TCP | 兜底 closest6 | 位置优先(保点) | 姿态优先(保角度) |
| :--- | :--- | :--- | :--- |
| (308,−182,372) | 90.9° / 0.5 mm | **4.26° / 0.0 mm** | **0.04°** / 3.4 mm |
| (180,−111,422) | 138.7° / 0.0 mm | **2.81° / 0.0 mm** | **0.01°** / 6.1 mm |
| (83,192,1032) | 179.8° / 0.0 mm | **9.63° / 0.0 mm** | **0.06°** / 23.7 mm |
| (−46,48,430) | 129.0° / 0.0 mm | **2.87° / 0.0 mm** | **0.01°** / 6.4 mm |
| …(共 12 组,见附录) | 均值 **115.3°** | 均值 **5.7°** / 位置恒 0.0 mm | 均值 **0.04°** / 位置均 11.6 mm |
**结论**:位置与姿态在远目标是**真实的取舍关系**,不存在"都能兼顾"的解。
- 位置优先:位置误差恒为 0,姿态差从 2.81° 到 9.63° 不等;
- 姿态优先:姿态差 0.01°~0.10°(等价于精确到达),位置误差 3.4~27.7 mm;
- 二者差值可达数十毫米 / 上百度。
⇒ **求解器必须提供可切换的优先级**,这正是用户诉求"无法精确达到目标姿态时输出最接近的角度解"的正确落点(→ 策略 S0)。
---
## 4. 评估方法
### 4.1 指标定义
| 指标 | 定义 |
| :--- | :--- |
| 位置残差 `err` | `‖fk(q) − T‖`,mm |
| 姿态残差 `oerr` | `rotErrDeg(orient(q), R_des)`,度(求解器 `rotErrDeg` 口径) |
| 达标线 | 位置 ≤ 1e-6 mm(闭式恒满足);姿态 ≤ 0.05° |
| **覆盖漏洞率** | 目标姿态精确可达(细扫 step=0.05° 能到 ≤0.05°)而模拟默认档 step=0.25° 返回 >0.05° 的比例 |
| **静默残差** | 上述情况下返回的 `oerr`(求解器仍报 `reachable=true`,不报错) |
| **步长敏感度** | 同一目标在 step ∈ {0.5,0.4,0.3,0.25,0.2,0.15,0.1,0.05} 下 `oerr` 的波动 |
| **兜底劣化** | `closest6` 的姿态差 − 「位置精确解集内能达到的最小姿态差」 |
| 耗时 | 单次 `solve` / `solveTo` 墙钟时间,ms |
### 4.2 独立真值基准(避免自证)
被测求解器自报的 `err/oerr` 与被测算法同源,不能自证。本报告的"真最优"用**独立于求解器决策路径**的枚举得到:
对 (θ4,θ5) 做网格枚举,取 `branch()` 全部在限位内的支(位置天然精确),再对姿态差取最小;并对最优的前 64 个候选**解析优化 θ6**(绕 J6 轴的滚转,闭式取到最优),得到更紧的真值。
注:该基准对 (θ4,θ5) 仍是离散网格(0.5°),故给出的真最优是**上界**,据此算出的"劣化"是**下界**——结论偏保守。
### 4.3 基准数据集与随机性
- 采样一律使用**固定种子的 mulberry32**(`seed` 已在附录命令中写死),任意数据可逐位复现。
- 早期探索阶段曾用 `Math.random()`(不可复现)得到过更高的触发率(最高 2.5%/120 样本);本报告**只采用可复现数据**。
- 数据集:D1 全空间随机可达位形 N=400;D2 工作区两区各 N=60;D3 姿态外推 20°(120 目标);D4b 近伸直构型 N=30;D5 越界目标 4 个;D6 双优先级 12 组。
---
## 5. 性能分析
### 5.1 单次求解耗时构成([bench_ik_split.json](file:///g:/Robot_v2/机器人手臂/scratch/bench_ik_split.json),40 样本,step 0.25 / tol 1e-6 / 开姿态)
| 指标 | 数值 |
| :--- | ---: |
| 冗余维网格 | J4 509 点 × J5 517 点 ≈ 26.3 万粗扫点 |
| 开姿态单次 · 中位 | **107.1 ms** |
| 开姿态单次 · p95 | **242.5 ms** |
| 开姿态单次 · 最大 | **299 ms** |
| 其中"位置基座"中位 | 73.6 ms |
| 其中"姿态增量"中位 | 37.2 ms(占 **33.3%**) |
| escalating 触发率 | 2.5% |
| 关姿态同目标中位 | 73.4 ms |
### 5.2 本次实测耗时
| 场景 | 耗时 |
| :--- | ---: |
| D1 全空间可达目标 `solve(0.25)` 均值 | 100 ms |
| D2 前伸工作区 / 默认区 均值 | 98 ms / 98 ms |
| D5 越界目标 `solveTo`(含兜底) | 85 ~ 125 ms |
| 参考:`solve(0.05)` 均值(D1) | ~835 ms |
### 5.3 结论
- **已超预算**:用户既定目标为 ≤100 ms,实测中位 107 ms、p95 242 ms、最大 299 ms。
- **姿态任务是主要波动源**:占总耗时 33%,且 escalating(×100 权重重扫)在 2.5% 的目标上会额外付出整轮精修。
- **兜底路径性价比最低**:160 次随机重启的 6 维 DLS 在 85~125 ms 内给出的是 §6 里"平均劣化 104°"的解——**又慢又差**。
---
## 6. 误差来源定位
### 6.1 R1 根枚举不完备(对应缺陷 A)
姿态精确解在 (θ4,θ5) 平面上是一条**一维曲线**,只在少数孤立点穿过 `POSETOL` 窄带。当该曲线经过退化构型时,变号括号检测与"θ1..θ3 最近"同支匹配会失效:
- **退化点 1:θ3 → 0 或 ±180(手臂近伸直)**。此时 `branch()` 中 `Vx → 0`,4 支两两重合、位置雅可比奇异,牛顿收口停摆。
- **退化点 2:关节贴限位**(如 θ5 ≈ J5 上限)。
**可复现证据(见 §9 命令 F,seed=424242)**:
| 项 | 值 |
| :--- | :--- |
| 目标 TCP | **(26.386, −3.334, 1019.998)** |
| 真实关节角 | (198.03, −46.76, **−180.52**, −92.79, **33.48**, 102.36) ← θ3≈−180 近伸直、θ5≈J5 上限 34 |
| 真解在 `branch()` 下 | 位置误差 **1.4e-13 mm** 的精确解存在,且在限位内 |
| `solveTo({warm: 真解})` | `oerr = 1.21e-6°`(**精确解确实存在**) |
| `solve(step=0.25)` | `oerr = 1.9881°`(**漏解,且报 reachable=true 不报错**) |
**步长敏感度(同一目标)**——非单调、非收敛,直接证明这不是"精度不够"而是"没找到":
| step | 0.5 | 0.4 | 0.3 | 0.25 | 0.2 | 0.15 | 0.1 | 0.05 |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `oerr` (°) | 1.9881 | 1.9119 | **0.0307** | 1.9881 | 1.9881 | 1.9652 | **0.0258** | 0.0491 |
> **顺带发现**:查看器"扫描精度"下拉框的悬停说明写着「姿态任务的精确解由孤立根枚举给出、**与步长无关**」([viewer_tpl.html L229](file:///g:/Robot_v2/机器人手臂/src/viewer_tpl.html#L229))。上表**直接证伪**了这一说明。
**发生率(可复现)**:
| 数据集 | 样本 | `oerr>0.05°` | 其中覆盖漏洞 | 最大静默残差 |
| :--- | ---: | ---: | ---: | ---: |
| D1 全空间(seed=20260929) | 400 | 2(0.50%) | 1(0.25%) | 1.934° |
| D4b 近伸直 θ3≈0/±180(seed=909090) | 30 | 1(3.3%) | 1(3.3%) | 1.860° |
| D2 前伸工作区 (20,210,300)±90mm | 60 | 0 | 0 | 0° |
| D2 查看器默认区 (150,128,260)±60mm | 60 | 0 | 0 | 0° |
**结论**:缺陷 A 是**构型相关**的——常规工作区(默认目标附近)几乎不触发,**集中在外围/极端位姿与近伸直、贴限位构型**。这解释了为什么肉眼只在"关键位置"看到问题。
### 6.2 R2 接受门把"不可达"与"漏解"混为一谈(对应缺陷 C)
`scoreBranch` 对 `oerr > POSETOL(2°)` 的候选一律 `return null`([L596-598](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L596-L598))。后果是**候选池被整体清空**,`solve()` 报 `ok=false`,转入 `closest6`。
**可复现证据**:目标 `T=(20,400,300)`、`R=poseMat(25,95,10)`:
- 位置**完全可达**:无姿态 `solve` 返回 `err = 2.5e-14 mm`;0.5° 网格上有 **11920** 个位置精确解;
- 姿态目标不可达(位置精确解集内最优姿态差 **44.20°** > 2°)⇒ 候选池被清空 ⇒ 走 `closest6`;
- `closest6` 返回 **136.7° / 位置 0.0 mm** —— **在位置与姿态两个指标上都不优于**一个用现有闭式扫描就能廉价得到的解(劣化 **92.5°**)。**被严格支配(Pareto-dominated)。**
另一例(离查看器默认目标很近,仅抬高 Z):`T=(150,128,600)` 位置精确可达(0.0 mm,2580 个位置精确解),位置精确解集内最优姿态差 **14.72°**,而 `closest6` 给出 **173.4°** —— 劣化 **158.7°**。
> 注:`T=(20,400,300)` 这一例另有独立复核——初版基准给的 121.05° 是 θ6=0 口径(见文首勘误),
> 按 θ6 最优口径重扫全域 0.5° 网格(258×254 ≈ 6.6 万点)得 **44.200°**,与 θ6 最优口径一致。
### 6.3 R3 兜底目标函数与真实目标不一致(对应缺陷 B)
`closest6` 用固定 `OW = 20 mm/rad` 把姿态折进位置残差做联合 DLS([L1055](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L1055)),既不保证"位置优先",也不保证"姿态最小",且依赖随机重启初值。
**严格真最优基准下的兜底劣化(§9 命令 O2,12 目标)**:
| 目标 TCP | closest6 姿态差 | 位置优先真最优(θ6 最优口径) | 劣化 |
| :--- | ---: | ---: | ---: |
| (364,336,843) | 114.2° | 5.98° | 108.2° |
| (288,−16,595) | 166.8° | 7.94° | 158.9° |
| (72,376,161) | 107.2° | 5.30° | 101.9° |
| (311,10,701) | 138.2° | 14.72° | 123.4° |
| (−52,242,897) | 121.1° | 3.18° | 117.9° |
| (4,−218,303) | 156.3° | 13.02° | 143.3° |
| (279,−155,765) | 173.0° | 3.15° | **169.9°** |
| (402,202,425) | 81.2° | 16.48° | 64.7° |
| (−4,472,883) | 136.3° | 4.92° | 131.4° |
| (189,−298,766) | 54.4° | 16.61° | 37.8° |
| (259,−284,528) | 44.8° | 5.84° | 38.9° |
| (137,−315,829) | 133.5° | 6.20° | 127.3° |
| **均值** | **118.9°** | **8.6°** | **110.3°** |
**被支配 12/12**:每一个目标上,`closest6` 的姿态差都大于一个"位置同样精确、只是换了构型"的解。
**更大样本的兜底劣化(§9 命令 A3,120 目标,38 个不可达样本)**:平均劣化 **106.3°**,最大 **176.7°**,最小 16.1°。
### 6.4 R4 行裁剪探测步过粗
[rowSpans](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L644-L654) 以 `hp = max(h,2)°` 探测每 θ4 行的可行 θ5 段,并按"单一连续段"假设(注释称仅在 5 个目标上验证过)。窄于 2° 的可行段会被整段漏掉,导致整行无候选——这是 §6.1 漏解的另一条通路,也与"θ5 贴着限位"的退化构型吻合。
### 6.5 既有自检为何没有拦住这些缺陷
`node scratch/pose_ik_check.js` 本次**全部通过**:
| 项 | 结果 |
| :--- | :--- |
| [1] poseMat ↔ poseAng 往返 | ✓ 最大偏差 2.4e-6° |
| [2] 位置 IK 回归(200 组) | ✓ 200/200,最大残差 3.5e-13 mm,均耗时 68 ms |
| [3] 姿态重构(200 组,step 0.5) | ✓ 达标 195/200;**允许 4 个"奇异刀尖"不达标**;最差 0.90° |
| [4] 工作区 ±45mm(48 个) | ✓ 48/48 精确,最差 0.02° |
| [5] roll 0→90 口径 | ✓ Δθ6 = 90.000° |
| [6] 超工作空间 → closest6 | ✓ **只检查"返回了最近位姿",不检查其最优性**(返回 0.0 mm / **136.7°**) |
| [7] 耗时对比 | 关 41 ms / 开 15 ms(**开比关还快,异常,说明该计时项不可靠**) |
**盲区诊断**:
1. [3] 把 2° 以内的残差视为"通过",且显式豁免"奇异刀尖"——与缺陷 A 的静默残差(1.2~1.95°)**正好落在盲区内**;
2. [4] 只在 `(20,210,300)±45mm` 这一处良性区域采样(与 D2 的 0 失败一致),碰不到外围退化构型;
3. [6] 只验证"有返回",不验证"最近"——与缺陷 B/C 完全错开。
---
## 7. 优化策略
按"收益 / 成本"排序。所有策略共用一份闭式支枚举结果,不改变运动学口径。
> **实施状态**:**S0(位置优先 / 姿态优先切换)已实施并验证**,见 §7.1;
> S2 中"不再清空候选池"的部分已随 S0 的位置优先模式一并落地。S1 / S3 / S4 及 S0 的"折中"模式尚未实施。
### 7.1 S0(已实施)双优先级求解范式
**问题**:位置与姿态在远目标上不可兼得(§3.3),单一目标函数无法兼顾两种使用意图。
**已实现**:
| 项 | 实现 |
| :--- | :--- |
| 求解器入口 | `solve()/solveWarm()/solveTo()` 新增 `opt.priority ∈ {'pos','ori'}`;**省略该选项时行为与改动前逐字一致**(既有自检/基准不受影响) |
| 位置优先 `'pos'` | `min oerr s.t. ‖fk(q)−T‖ = 0`。扫描时额外记录「轴夹角最小」的廉价种子(复用 AXREJ 已算的点积,零额外开销);若 POSETOL 硬门把候选全挡下,则从这些种子出发在 (θ4,θ5) 上模式搜索最小化 oerr(新增 §4.5,每点仍走 `branch()` 闭式支 ⇒ 位置保持精确) |
| 姿态优先 `'ori'` | `min ‖fk(q)−T‖ s.t. oerr = 0`。新增 [closestOri](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js) §5c:利用降阶链 `w = Rx(θ1)·Ry(θ2)·Rz(−θ3)·Ry(s)·ẑ`(s=θ4+θ5),给定 (θ3,s) 对 (θ1,θ2) 闭式求解,再在 (θ3,s,θ4) 三维上粗扫 + 坐标下降精修;θ6 由 `rollClose` 闭式补足 ⇒ 轴对齐后 oerr 恒为 0 |
| 实时快路 | `solveWarm` 在 `'pos'` 下不再要求 oerr ≤ 0.05°(否则拖进"姿态不可达区"后每帧回落全量),改以 oerr 为主键选解 |
| 界面 | [viewer_tpl.html](file:///g:/Robot_v2/机器人手臂/src/viewer_tpl.html) 姿态控制框内新增「优先级」下拉(位置优先 / 姿态优先,默认位置优先);[ik-glue.js](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js) 传入该选项、结果栏标注模式、并对姿态优先的"姿态到达·位置最近 X mm"单独置徽标 |
| 未实施 | 「折中」`min ‖fk−T‖² + k·oerr²`(k 可调)——需要 6 维联合优化,留待 S3 一并处理 |
**验证结果(本次实测)**:
| 检查 | 结果 |
| :--- | :--- |
| `axisSolve` 闭式解正确性(20000 组随机位形回代) | 未命中 0,最大角差 **4.99e-9°** |
| `fkChain` 独立复核位置精度(位置优先) | 恒 **~1e-13 mm**(如 1.66e-13 mm) |
| 姿态可达时与原行为一致 | `'pos'` 59/60、`'ori'` **40/40** 与默认解逐位一致(差 1 例落在 0.05° 达标边界) |
| 位置优先 vs θ6 最优 1° 网格基准(10 个不可达目标) | 劣于基准 **0/10**,最大劣化 **0.000°**;姿态差均值 **11.4°** vs 默认 DLS 的 **121.0°**(改善 **109.6°**) |
| 姿态优先的精确性(8 个不可达目标) | 姿态差 **0.0000°**(最大 1.21e-6°);位置均值 **24.35 mm** vs 惩罚下降参考的 24.76 mm |
| 姿态优先耗时 | 18~100 ms(均值 **52 ms**),低于其替代的 DLS(85~125 ms) |
| 既有自检 `pose_ik_check.js` 7 项 | **全部通过**(默认路径零回归) |
| 关键目标实测 | `T=(20,400,300)`:默认 DLS 136.7° → 位置优先 **44.19°**(基准 44.20°)、姿态优先 **0.0000°/116.4mm** |
**收益**:位置优先把 §6.3 的平均劣化 **110.3°** 压到接近 0(实测不劣于基准);姿态优先提供严格的"保角度"解(0.0000°),位置代价如实上报。
### S1(根因修复)覆盖率完备化,消除缺陷 A
**方案**(二选一或组合):
1. **解曲线 continuation 跟踪**:把 `poseRes(θ4,θ5) = 0` 视为 (θ4,θ5) 平面上的一维解曲线,用预测-校正沿曲线推进,**跨越折叠点时不换支**(对比现状的"变号括号 + 最近 θ1..θ3 匹配",后者在退化处失效);
2. **分支索引对齐的多起点牛顿 + 退化种子**:以 `branch()` 的**支下标**(0..3)而非"θ1..θ3 最近"做同支判据;在 θ3∈{0, ±180}、各轴贴限位处、以及 rowSpans 的段端点额外播种。
同时把 [rowSpans](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L644-L654) 的"单一连续段"假设放宽为多段(探测步 hp 与段数一起记录)。
**改动点**:`solve()` §4.1b([L697-761](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L697-L761))、`poseTools` 的 `poseQAt` 同支匹配([L495-504](file:///g:/Robot_v2/机器人手臂/web/arm-ik-solver.js#L495-L504))、`rowSpans`。
**预期收益**:覆盖漏洞率 0.25%→<0.05%(D1 口径),近伸直构型 3.3%→<0.5%;最大静默残差 1.934°→≤0.05°。
**代价**:**不增加粗扫密度**,只在退化行补一次定向播种/跟踪,预计耗时增量 <10%,仍满足 ≤100 ms。
### S2(关键收益)姿态不可达时不再清空候选池,消除缺陷 B/C
**实现方式(随 S0 落地,比上述"放宽门"更省)**:**不动** `scoreBranch` 的 POSETOL 硬门(扫描路径与耗时逐字不变),
只在门内额外记录「轴夹角最小」的廉价种子;候选池为空时改由 §4.5 从种子出发做最小残差搜索。
这样既拿到"不清空信息"的全部收益,又避免为超门候选逐个算完整滚转(实测那会让粗扫开销翻倍级增长)。
**改动点**:`solve()` §4.5(新增)、`scoreBranch` 的种子记录、选解段 `poolByOerr` 分支。
**预期收益**:`closest6` 被支配的现象**直接消失**(不需要再走兜底),平均劣化 110.3°→≈0(相对真最优,实测不劣于基准)。
**代价**:极小(常态零开销,仅在落选路径多一次局部搜索)。这是本次评估中**性价比最高的一条**。
### S3 兜底路径加速与改造
**方案**:位置也越界时才需要真正"最近位姿"。此时:
1. 用 S2 得到的闭式候选(位置精确解集的最优者)作为 DLS 初值,取代 160 次随机重启;
2. 只在位置完全不可达时保留受限 DLS(重启次数降到 16~32),并对 J1/J3/J6 三个连续轴改用最短角差而非硬夹限。
**预期收益**:兜底耗时 85~125 ms→≤60 ms;位置不可达时结果优于现状(现状 `T=(400,300,200)` 距目标 104.0 mm / 姿态差 172.0°)。
**代价**:需保证"位置越界"判定与现有 `tol` 口径一致,风险低。
### S4 可观测性(防止静默失败再次发生)
**方案**:把 UI 徽标从两态扩为四态,并同步修正 §6.1 提到的错误悬停说明:
- `已到达`(位置精确 + 姿态 ≤0.05°)
- `位置到达·姿态最近 X.XX°`(位置精确、姿态未达标,**明确说明这是"最近解"而非失败**)
- `姿态到达·位置最近 Y.Y mm`(姿态优先模式)
- `均不可达·最近位姿(位置 Y mm / 姿态 X°)`
**改动点**:[renderResult L318-342](file:///g:/Robot_v2/机器人手臂/web/ik-glue.js#L318-L342);[viewer_tpl.html L229](file:///g:/Robot_v2/机器人手臂/src/viewer_tpl.html#L229) 的 `ikStep` 说明文字。
### 策略汇总
| 策略 | 针对缺陷 | 收益 | 成本 | 是否影响 ≤100 ms | 状态 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| S0 双优先级 | 用户诉求(保点/保角度可切换) | 位置优先劣化 →≈0;姿态优先 0.0000° | 中 | 姿态优先 18~100 ms(均值 52 ms),未超预算 | **已实施** |
| S1 覆盖完备化 | A | 漏洞率 →<0.05% | 中 | 增量 <10% | 待实施 |
| S2 不清空候选池 | B / C | 兜底劣化 →≈0,常态零额外开销 | **极低** | 无 | **已随 S0 实施** |
| S3 兜底加速 + 折中模式 | D + 越界质量 | 85~125 ms→≤60 ms;补上 blend 模式 | 低 | 改善 | 待实施 |
| S4 可观测性 | 静默失败 | 漏解/取舍不再被当成"成功" | 低 | 无 | 部分(结果栏已标注模式与"姿态到达·位置最近") |
---
## 8. 验证方案与验收口径
### 8.1 基准数据集(固定种子,命令见附录)
| 集 | 内容 | 规模 | 用途 |
| :--- | :--- | :--- | :--- |
| D1 | 全空间随机可达位形 | 400 | 覆盖率 / 静默残差 / 耗时 |
| D2 | 前伸工作区 (20,210,300)±90mm;默认区 (150,128,260)±60mm | 60 + 60 | 常规工作区回归 |
| D3 | 目标姿态外推 20° | 120 目标 | 兜底劣化 |
| D4b | 近伸直 θ3≈0/±180 | 30 | 退化构型覆盖 |
| D5 | 越界目标(4 个定点) | 4 | 兜底耗时与质量 |
| D6 | 双优先级不可兼得场景 | 12 | 保点/保角度取舍 |
| O2 | 真最优基准(θ6 解析优化) | 12 | 独立真值 |
### 8.2 验收口径(现状 → 目标)
| 指标 | 现状实测 | 目标 | 当前状态 |
| :--- | ---: | ---: | :--- |
| D1 覆盖漏洞率(step 0.25 相对 0.05) | 0.25%(1/400) | ≤0.05% | 待 S1 |
| D4b 近伸直覆盖漏洞率 | 3.3%(1/30) | ≤0.5% | 待 S1 |
| 最大静默姿态残差 | **1.934°** | ≤0.05° | 待 S1 |
| 步长敏感度(同目标 oerr 波动) | **1.9881 − 0.0258 = 1.96°** | ≤0.05° | 待 S1 |
| 兜底相对真最优劣化(12 目标均值) | **110.3°** | ≤2° | **已达成**(S0 位置优先:0/10 劣于基准) |
| 兜底被支配比例 | **12/12** | 0/12 | **已达成**(S0 位置优先:不劣于基准的解总是存在) |
| 位置优先模式姿态差(12 目标均值) | 5.7°(真最优基准) | ≈真最优(劣化 ≤1°) | **已达成** |
| 姿态优先模式姿态差 | **0.0000°**(最大 1.21e-6°) | ≤0.05° | **已达成** |
| 开姿态单次耗时 · 中位 / p95 | 107 ms / 242 ms | ≤100 ms / ≤150 ms | 待 S1/S3(S0 未使常态变慢) |
| 兜底路径耗时 | 85~125 ms | ≤60 ms | 姿态优先降到 18~100 ms;位置越界仍待 S3 |
| 位置残差(姿态未达时) | 3.5e-13 mm(不得牺牲) | 保持 ≤1e-6 mm | **已达成**(S0 位置优先实测 ~1e-13 mm) |
### 8.3 回归门与新增门
- **沿用**:`node scratch/pose_ik_check.js` 7 项必须继续全绿;
- **修正**:[3] 项的"豁免奇异刀尖"应改为"显式区分可达/不可达",[6] 项应增加"最近性"断言,[7] 项计时需先预热并与 `bench_ik_split.js` 对齐(现状"开比关快"不可靠);
- **新增**:把 §8.1 的 D1/D3/D4b/D6 固化为可重复的覆盖率与最优性断言(当前仓库缺少这两类断言,正是缺陷 A/B 长期存活的直接原因)。
---
## 9. 附录:可复现实验命令
统一环境:在项目根目录 `g:\Robot_v2\机器人手臂` 下执行;`ArmIK` 由 `web/arm-ik-solver.js` 加载;关节表/限位取自 `web/mesh_arm.json`、`docs/travel.json`;`tcp=[18.5,128,93]`。
所有随机采样使用固定种子的 **mulberry32**(`s = seed>>>0`,`s=(s+0x6D2B79F5)>>>0; t=s; t=Math.imul(t^(t>>>15),t|1); t^=t+Math.imul(t^(t>>>7),t|61); return ((t^(t>>>14))>>>0)/4294967296;`),结果逐位可复现。
### A1 / A2 — 覆盖率、静默残差、工作区回归(对应 §6.1、§6.4)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;
const mkseed=v=>{let s=v>>>0;return()=>{s=(s+0x6D2B79F5)>>>0;let t=s;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};};
const rnd=mkseed(20260929);const rndQ=()=>O.map(id=>{const t=LIM[id];return t.cont?(rnd()*720-360):(t.lo+rnd()*(t.hi-t.lo));});
console.log("[A1] D1 全空间采样(seed=20260929) step0.25 两阶段");
const N=400;let fails=[],sum=0;
for(let k=0;k<N;k++){const q=rndQ(),Tp=S.fk(q,0),R=S.orient(q);const a=S.solve(Tp,{step:0.25,tol:1e-6,pose:{R:R}});sum+=a.ms;
const va=a.q?a.oerr:999;if(va>0.05)fails.push({T:Tp,R,oa:va});}
console.log(" N="+N+" oerr>0.05 数量="+fails.length+" 比例="+(fails.length/N*100).toFixed(2)+"pct 均耗时="+(sum/N).toFixed(0)+"ms");
let gap=0,maxS=0,maxG=0,tbl=[];
for(const f of fails){const b=S.solve(f.T,{step:0.05,tol:1e-6,pose:{R:f.R}});const vb=b.q?b.oerr:999;
if(f.oa>maxS)maxS=f.oa;const g=f.oa-vb;if(g>0.05){gap++;if(g>maxG)maxG=g;}
tbl.push({T:f.T.map(v=>+v.toFixed(1)),oa:+f.oa.toFixed(3),ob:+vb.toFixed(3),gap:+g.toFixed(3)});}
console.log(" 其中覆盖漏洞(细扫能达标)="+gap+" 真近奇异="+(fails.length-gap)+" 最大静默="+maxS.toFixed(3)+"deg 最大覆盖差="+maxG.toFixed(3)+"deg");
console.log(" 明细="+JSON.stringify(tbl.slice(0,10)));
const r2=mkseed(777001);const rq2=()=>O.map(id=>{const t=LIM[id];return t.cont?(r2()*720-360):(t.lo+r2()*(t.hi-t.lo));});
function zone(name,T0,rad,nmax){const pts=[];for(let k=0;k<3000000&&pts.length<nmax;k++){const q=rq2();const p=S.fk(q,0);if(Math.hypot(p[0]-T0[0],p[1]-T0[1],p[2]-T0[2])<=rad)pts.push({Tp:p,R:S.orient(q)});}
let f2=[],s2=0;for(const p of pts){const a=S.solve(p.Tp,{step:0.25,tol:1e-6,pose:{R:p.R}});s2+=a.ms;const va=a.q?a.oerr:999;if(va>0.05)f2.push({Tp:p.Tp,R:p.R,oa:va});}
let g2=0,mS=0,mG=0;for(const f of f2){const b=S.solve(f.Tp,{step:0.05,tol:1e-6,pose:{R:f.R}});const vb=b.q?b.oerr:999;if(f.oa>mS)mS=f.oa;const g=f.oa-vb;if(g>0.05){g2++;if(g>mG)mG=g;}}
console.log("["+name+"] N="+pts.length+" 失败="+f2.length+" 覆盖漏洞="+g2+" 最大静默="+mS.toFixed(3)+"deg 均耗时="+(s2/pts.length).toFixed(0)+"ms");}
console.log("[A2] D2 工作区采样(seed=777001)");
zone("前伸工作区 (20,210,300)+-90mm",[20,210,300],90,60);'
```
(查看器默认区 `(150,128,260)±60mm` 把最后一行换成 `zone("查看器默认区 (150,128,260)+-60mm",[150,128,260],60,60);` 再跑一次。)
> 实测输出:
> `[A1] N=400 oerr>0.05 数量=2 比例=0.50pct 均耗时=100ms` / `覆盖漏洞=1 真近奇异=1 最大静默=1.934deg 最大覆盖差=1.759deg`
> `明细=[{"T":[152.6,-315.9,456.2],"oa":1.785,"ob":0.026,"gap":1.759},{"T":[403.7,162.3,599.9],"oa":1.934,"ob":1.994,"gap":-0.061}]`
> `[前伸工作区] N=60 失败=0 覆盖漏洞=0 均耗时=98ms` / `[查看器默认区] N=60 失败=0 覆盖漏洞=0 均耗时=98ms`
### A3 / A4 — 兜底劣化与双优先级取舍(对应 §3.3、§6.3)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;const K=S.K;
let s=60606>>>0;const rnd=()=>{s=(s+0x6D2B79F5)>>>0;let t=s;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
const rndQ=()=>O.map(id=>{const t=LIM[id];return t.cont?(rnd()*720-360):(t.lo+rnd()*(t.hi-t.lo));});
const inLim=q=>{for(let i=0;i<5;i++){const t=LIM[O[i]];if(!(q[i]>=t.lo-1e-9&&q[i]<=t.hi+1e-9))return false;}return true;};
const mul3=(X,Y)=>{const C=new Array(9);for(let i=0;i<3;i++)for(let j=0;j<3;j++)C[i*3+j]=X[i*3]*Y[j]+X[i*3+1]*Y[3+j]+X[i*3+2]*Y[6+j];return C;};
const rotAx=(d,a)=>{const x=d[0],y=d[1],z=d[2],c=Math.cos(a),sn=Math.sin(a),C=1-c;return [x*x*C+c,x*y*C-z*sn,x*z*C+y*sn,y*x*C+z*sn,y*y*C+c,y*z*C-x*sn,z*x*C-y*sn,z*y*C+x*sn,z*z*C+c];};
const T3=M=>[M[0],M[3],M[6],M[1],M[4],M[7],M[2],M[5],M[8]];
/* 位置精确解在 (θ4,θ5) 处只定到 θ1..θ5;θ6 仍可自由选取。度量「可达到的最小姿态差」
必须让 θ6 取最优(与求解器 rollClose 同口径:N = M5ᵀ·R_tar 应为 Rz(−θ6))。
初版漏了这一步,用 θ6=0 度量会系统性高估最小姿态差(见文首勘误)。 */
function oerrOpt(q,Rt){const M5=S.orient(q,5);const N=mul3(T3(M5),Rt);
const th6=-Math.atan2(N[3],N[0])*180/Math.PI;
return A.rotErrDeg(mul3(M5,rotAx([0,0,-1],th6*Math.PI/180)),Rt);}
const nrm=v=>{const n=Math.hypot(v[0],v[1],v[2])||1;return [v[0]/n,v[1]/n,v[2]/n];};
function met(q,Tp,Rt){const p=S.fk(q,0);return {pe:Math.hypot(p[0]-Tp[0],p[1]-Tp[1],p[2]-Tp[2]),oe:A.rotErrDeg(S.orient(q),Rt)};}
function posBest(Tp,Rt,st){const Aa=Tp[0]-K.X0,Bq=Tp[1]-K.Y0,Br=Tp[2]-K.Z1;const Rr=Math.hypot(Bq,Br),ph=Math.atan2(Br,Bq);
let be=999;for(let t4=-111;t4<=16;t4+=st)for(let t5=-95;t5<=34;t5+=st){const ss=S.branch(t4,t5,Aa,Rr,ph,0);
for(const s0 of ss){if(!inLim(s0))continue;const p=S.fk(s0,0);if(Math.hypot(p[0]-Tp[0],p[1]-Tp[1],p[2]-Tp[2])>1e-6)continue;const e=oerrOpt(s0,Rt);if(e<be)be=e;}}return be;}
function desc(Tp,Rt,k,starts){let best=null,bc=1e18;
for(let st=0;st<starts;st++){let q=O.map((id,i)=>{const t=LIM[id];return t.lo+rnd()*(t.hi-t.lo);});
let c0=(m=>m.pe*m.pe+k*m.oe*m.oe)(met(q,Tp,Rt));let step=20;
for(let pass=0;pass<7;pass++){let mv=true,g=0;
while(mv&&g++<200){mv=false;
for(let i=0;i<6;i++){const t=LIM[O[i]];
for(const sg of [1,-1]){const old=q[i];const v=old+sg*step;if(v<t.lo||v>t.hi)continue;q[i]=v;
const m=met(q,Tp,Rt);const c1=m.pe*m.pe+k*m.oe*m.oe;if(c1<c0-1e-12){c0=c1;mv=true;}else q[i]=old;}}}
step*=0.3;}
if(c0<bc){bc=c0;best=q.slice();}}
return best;}
console.log("[A3] D3 姿态外推20deg 兜底劣化(seed=60606) 采样120个目标");
let n=0,sd=0,mx=0,mn=1e9;const rows=[];
for(let k=0;k<120;k++){const q=rndQ(),Tp=S.fk(q,0),R0=S.orient(q);const ax=nrm([rnd()-0.5,rnd()-0.5,rnd()-0.5]);const Rt=mul3(rotAx(ax,20*Math.PI/180),R0);
const r=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rt}});
if(r.reachable||r.oerr==null)continue;const ob=posBest(Tp,Rt,0.25);const dg=r.oerr-ob;
n++;sd+=dg;if(dg>mx)mx=dg;if(dg<mn)mn=dg;if(rows.length<8)rows.push({T:Tp.map(v=>+v.toFixed(0)),base:+r.oerr.toFixed(1),best:+ob.toFixed(2),degr:+dg.toFixed(1)});}
console.log(" 不可达样本="+n+" 平均劣化="+(n?sd/n:0).toFixed(1)+"deg 最大="+mx.toFixed(1)+" 最小="+(n?mn:0).toFixed(1));
console.log(" 明细="+JSON.stringify(rows));
console.log("[A4] D6 双优先级取舍(seed=60606) 同批目标");
let n6=0,aBase=0,aPos=0,aOri=0,aOriP=0;
for(let k=0;k<120&&n6<12;k++){const q=rndQ(),Tp=S.fk(q,0),R0=S.orient(q);const ax=nrm([rnd()-0.5,rnd()-0.5,rnd()-0.5]);const Rt=mul3(rotAx(ax,20*Math.PI/180),R0);
const r=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rt}});
if(r.reachable||r.oerr==null||r.oerr<5)continue;
const pb=posBest(Tp,Rt,0.25);const qo=desc(Tp,Rt,1e3,120);const mo=met(qo,Tp,Rt);
n6++;aBase+=r.oerr;aPos+=pb;aOri+=mo.oe;aOriP+=mo.pe;
console.log(" T=("+Tp.map(v=>v.toFixed(0)).join(",")+") closest6="+r.oerr.toFixed(1)+"deg/"+(r.err!=null?r.err.toFixed(1):"--")+"mm | 位置优先="+pb.toFixed(2)+"deg/0.0mm | 姿态优先="+mo.oe.toFixed(2)+"deg/"+mo.pe.toFixed(1)+"mm");}
console.log(" 小结 采样="+n6+" 均值 closest6="+(n6?aBase/n6:0).toFixed(1)+"deg 位置优先="+(n6?aPos/n6:0).toFixed(1)+"deg 姿态优先="+(n6?aOri/n6:0).toFixed(2)+"deg(位置"+(n6?aOriP/n6:0).toFixed(1)+"mm)");'
```
(`posBest` 即"位置优先真最优"的网格枚举(θ6 取最优口径),`desc(Tp,Rt,1e3,120)` 即"姿态优先"的惩罚坐标下降;A4 的分组与 A3 不同是因为 `desc` 额外消耗了随机数。)
> 实测输出(勘误后重跑):
> `[A3] 不可达样本=38 平均劣化=106.3deg 最大=176.7 最小=16.1`
> `明细=[{"T":[364,336,843],"base":114.2,"best":5.98,"degr":108.2},{"T":[288,-16,595],"base":166.8,"best":7.94,"degr":158.9},{"T":[72,376,161],"base":107.2,"best":5.29,"degr":101.9},{"T":[311,10,701],"base":138.2,"best":14.72,"degr":123.4},{"T":[-52,242,897],"base":121.1,"best":3.18,"degr":117.9},{"T":[4,-218,303],"base":156.3,"best":13.02,"degr":143.3},{"T":[279,-155,765],"base":173,"best":3.13,"degr":169.9},{"T":[402,202,425],"base":81.2,"best":16.45,"degr":64.8}]`
> `[A4] 采样=12 均值 closest6=115.3deg 位置优先=5.7deg 姿态优先=0.04deg(位置11.6mm)`
### F — 静默近失案例与步长敏感度(对应 §6.1)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;const K=S.K;
let s=424242>>>0;const rnd=()=>{s=(s+0x6D2B79F5)>>>0;let t=s;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
const rndQ=()=>O.map(id=>{const t=LIM[id];return t.cont?(rnd()*720-360):(t.lo+rnd()*(t.hi-t.lo));});
const inLim=q=>{for(let i=0;i<5;i++){const t=LIM[O[i]];if(!(q[i]>=t.lo-1e-9&&q[i]<=t.hi+1e-9))return false;}return true;};
let F=null;for(let k=0;k<4000&&!F;k++){const q=rndQ(),Tp=S.fk(q,0),R=S.orient(q);const a=S.solve(Tp,{step:0.25,tol:1e-6,pose:{R:R}});if(a.q&&a.oerr>1.0)F={q,T:Tp,R};}
console.log("真解 q="+F.q.map(v=>+v.toFixed(2)).join(",")+" T="+F.T.map(v=>+v.toFixed(3)).join(","));
let out=[];for(const st of [0.5,0.4,0.3,0.25,0.2,0.15,0.1,0.05]){const r=S.solve(F.T,{step:st,tol:1e-6,pose:{R:F.R}});out.push(st+"->"+(r.q?r.oerr.toFixed(4):"null"));}
console.log("步长敏感度 oerr: "+out.join(" "));
const rw=S.solveTo(F.T,{step:0.25,tol:1e-6,pose:{R:F.R},warm:F.q});
console.log("warm=trueQ: oerr="+(rw.oerr!=null?rw.oerr.toExponential(2):"null"));
const Aa=F.T[0]-K.X0,Bq=F.T[1]-K.Y0,Br=F.T[2]-K.Z1;const Rr=Math.hypot(Bq,Br),ph=Math.atan2(Br,Bq);
for(const c of S.branch(F.q[3],F.q[4],Aa,Rr,ph,0)){const p=S.fk(c,0);console.log(" branch posErr="+Math.hypot(p[0]-F.T[0],p[1]-F.T[1],p[2]-F.T[2]).toExponential(1)+"mm θ1..θ5="+c.slice(0,5).map(v=>+v.toFixed(2)).join(",")+" inLim="+inLim(c));}'
```
> 实测输出:
> `真解 q=198.03,-46.76,-180.52,-92.79,33.48,102.36 T=26.386,-3.334,1019.998`
> `步长敏感度 oerr: 0.5->1.9881 0.4->1.9119 0.3->0.0307 0.25->1.9881 0.2->1.9881 0.15->1.9652 0.1->0.0258 0.05->0.0491`
> `warm=trueQ: oerr=1.21e-6`(真解确实存在)
> `branch posErr=1.4e-13mm ... inLim=true`(退化处仍有在限位内的位置精确支)
### O2 — 严格真最优基准(θ6 解析优化,对应 §6.3)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;const K=S.K;
let s=60606>>>0;const rnd=()=>{s=(s+0x6D2B79F5)>>>0;let t=s;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
const rndQ=()=>O.map(id=>{const t=LIM[id];return t.cont?(rnd()*720-360):(t.lo+rnd()*(t.hi-t.lo));});
const inLim=q=>{for(let i=0;i<5;i++){const t=LIM[O[i]];if(!(q[i]>=t.lo-1e-9&&q[i]<=t.hi+1e-9))return false;}return true;};
const mul3=(X,Y)=>{const C=new Array(9);for(let i=0;i<3;i++)for(let j=0;j<3;j++)C[i*3+j]=X[i*3]*Y[j]+X[i*3+1]*Y[3+j]+X[i*3+2]*Y[6+j];return C;};
const rotAx=(d,a)=>{const x=d[0],y=d[1],z=d[2],c=Math.cos(a),sn=Math.sin(a),C=1-c;return [x*x*C+c,x*y*C-z*sn,x*z*C+y*sn,y*x*C+z*sn,y*y*C+c,y*z*C-x*sn,z*x*C-y*sn,z*y*C+x*sn,z*z*C+c];};
const T3=M=>[M[0],M[3],M[6],M[1],M[4],M[7],M[2],M[5],M[8]];
const nrm=v=>{const n=Math.hypot(v[0],v[1],v[2])||1;return [v[0]/n,v[1]/n,v[2]/n];};
/* θ6 取最优口径(同文首勘误与 A3 的 oerrOpt) */
function oerrOpt(q,Rt){const M5=S.orient(q,5);const N=mul3(T3(M5),Rt);
const th6=-Math.atan2(N[3],N[0])*180/Math.PI;
return A.rotErrDeg(mul3(M5,rotAx([0,0,-1],th6*Math.PI/180)),Rt);}
function oracle(Tp,Rt,st){const Aa=Tp[0]-K.X0,Bq=Tp[1]-K.Y0,Br=Tp[2]-K.Z1;const Rr=Math.hypot(Bq,Br),ph=Math.atan2(Br,Bq);
let b1=999;
for(let t4=-111;t4<=16;t4+=st)for(let t5=-95;t5<=34;t5+=st){const ss=S.branch(t4,t5,Aa,Rr,ph,0);
for(const s0 of ss){if(!inLim(s0))continue;const p=S.fk(s0,0);if(Math.hypot(p[0]-Tp[0],p[1]-Tp[1],p[2]-Tp[2])>1e-6)continue;const e=oerrOpt(s0,Rt);if(e<b1)b1=e;}}
return {opt6:b1};}
let n=0,a=0,c=0,dom=0;
for(let k=0;k<120&&n<12;k++){const q=rndQ(),Tp=S.fk(q,0),R0=S.orient(q);const ax=nrm([rnd()-0.5,rnd()-0.5,rnd()-0.5]);const Rt=mul3(rotAx(ax,20*Math.PI/180),R0);
const r=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rt}});
if(r.reachable||r.oerr==null||r.oerr<5)continue;n++;
const o=oracle(Tp,Rt,0.5);
console.log(" ("+Tp.map(v=>v.toFixed(0)).join(",")+") closest6="+r.oerr.toFixed(1)+"deg 真最优="+o.opt6.toFixed(2)+"deg 劣化="+(r.oerr-o.opt6).toFixed(1)+"deg");
a+=r.oerr;c+=o.opt6;if(r.oerr>o.opt6)dom++;}
console.log(" 均值 closest6="+(a/n).toFixed(1)+"deg 真最优="+(c/n).toFixed(1)+"deg 平均劣化="+((a-c)/n).toFixed(1)+"deg 被支配="+dom+"/"+n);'
```
(O2 与 A4 目标集不同:`desc()` 会额外消耗随机数,故两者 close6 均值略有差异。两者均固定种子、均可复现。)
> 实测输出(勘误后重跑):
> `(364,336,843) closest6=114.2deg 真最优=5.98deg 劣化=108.2deg` … `(137,-315,829) closest6=133.5deg 真最优=6.20deg 劣化=127.3deg`
> `均值 closest6=118.9deg 真最优=8.6deg 平均劣化=110.3deg 被支配=12/12`
### D5 — 越界目标兜底(对应 §5.2、§6.2)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;const K=S.K;
const inLim=q=>{for(let i=0;i<5;i++){const t=LIM[O[i]];if(!(q[i]>=t.lo-1e-9&&q[i]<=t.hi+1e-9))return false;}return true;};
const mul3=(X,Y)=>{const C=new Array(9);for(let i=0;i<3;i++)for(let j=0;j<3;j++)C[i*3+j]=X[i*3]*Y[j]+X[i*3+1]*Y[3+j]+X[i*3+2]*Y[6+j];return C;};
const rotAx=(d,a)=>{const x=d[0],y=d[1],z=d[2],c=Math.cos(a),sn=Math.sin(a),C=1-c;return [x*x*C+c,x*y*C-z*sn,x*z*C+y*sn,y*x*C+z*sn,y*y*C+c,y*z*C-x*sn,z*x*C-y*sn,z*y*C+x*sn,z*z*C+c];};
const T3=M=>[M[0],M[3],M[6],M[1],M[4],M[7],M[2],M[5],M[8]];
function oerrOpt(q,Rt){const M5=S.orient(q,5);const N=mul3(T3(M5),Rt);
const th6=-Math.atan2(N[3],N[0])*180/Math.PI;
return A.rotErrDeg(mul3(M5,rotAx([0,0,-1],th6*Math.PI/180)),Rt);}
function posBest(Tp,Rt,st){const Aa=Tp[0]-K.X0,Bq=Tp[1]-K.Y0,Br=Tp[2]-K.Z1;const Rr=Math.hypot(Bq,Br),ph=Math.atan2(Br,Bq);
let be=999,cnt=0;for(let t4=-111;t4<=16;t4+=st)for(let t5=-95;t5<=34;t5+=st){const ss=S.branch(t4,t5,Aa,Rr,ph,0);
for(const s0 of ss){if(!inLim(s0))continue;const p=S.fk(s0,0);if(Math.hypot(p[0]-Tp[0],p[1]-Tp[1],p[2]-Tp[2])>1e-6)continue;cnt++;const e=oerrOpt(s0,Rt);if(e<be)be=e;}}
return {e:be,cnt:cnt};}
const Rs=A.poseMat(25,95,10);
for(const Tp of [[20,400,300],[150,128,600],[400,300,200],[0,0,1200]]){
const o=posBest(Tp,Rs,0.5);const t0=Date.now();const r=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rs}});const ms=Date.now()-t0;
const rp=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rs},priority:"pos"});
const ro=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rs},priority:"ori"});
console.log("T=("+Tp.join(",")+") 位置精确解="+o.cnt+" 真最优姿态差="+(o.cnt?o.e.toFixed(2)+"deg":"n/a"));
console.log(" 默认DLS "+ms+"ms 位置="+(r.err!=null?r.err.toFixed(1):"--")+"mm 姿态差="+(r.oerr!=null?r.oerr.toFixed(1):"--")+"deg");
console.log(" 位置优先 位置="+(rp.err!=null?rp.err.toExponential(1):"--")+"mm 姿态差="+(rp.oerr!=null?rp.oerr.toFixed(2):"--")+"deg");
console.log(" 姿态优先 位置="+(ro.err!=null?ro.err.toFixed(2):"--")+"mm 姿态差="+(ro.oerr!=null?ro.oerr.toFixed(4):"--")+"deg");}'
```
> 实测输出(勘误后重跑):
> `T=(20,400,300) 位置精确解=11920 真最优姿态差=44.20deg`
> ` 默认DLS 位置=0.0mm 姿态差=136.7deg | 位置优先 位置=1.3e-13mm 姿态差=44.19deg | 姿态优先 位置=116.43mm 姿态差=0.0000deg`
> `T=(150,128,600) 位置精确解=2580 真最优姿态差=14.72deg`
> ` 默认DLS 位置=0.0mm 姿态差=173.4deg | 位置优先 位置=0.0e+0mm 姿态差=14.72deg | 姿态优先 位置=37.40mm 姿态差=0.0000deg`
> `T=(400,300,200) 位置精确解=0 | 默认DLS 位置=104.0mm 姿态差=172.0deg | 姿态优先 位置=404.27mm 姿态差=0.0000deg`(位置不可达)
> `T=(0,0,1200) 位置精确解=0 | 默认DLS 位置=87.1mm 姿态差=130.0deg | 姿态优先 位置=205.67mm 姿态差=0.0000deg`(位置不可达)
> **复现说明**:非耗时类数值(残差、姿态差、可达性、候选数)逐位可复现;`ms` 随机器负载有 ±20% 波动。
### V1 — 位置可达性/候选数(对应 §6.2)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;const K=S.K;
const inLim=q=>{for(let i=0;i<5;i++){const t=LIM[O[i]];if(!(q[i]>=t.lo-1e-9&&q[i]<=t.hi+1e-9))return false;}return true;};
const mul3=(X,Y)=>{const C=new Array(9);for(let i=0;i<3;i++)for(let j=0;j<3;j++)C[i*3+j]=X[i*3]*Y[j]+X[i*3+1]*Y[3+j]+X[i*3+2]*Y[6+j];return C;};
const rotAx=(d,a)=>{const x=d[0],y=d[1],z=d[2],c=Math.cos(a),sn=Math.sin(a),C=1-c;return [x*x*C+c,x*y*C-z*sn,x*z*C+y*sn,y*x*C+z*sn,y*y*C+c,y*z*C-x*sn,z*x*C-y*sn,z*y*C+x*sn,z*z*C+c];};
const T3=M=>[M[0],M[3],M[6],M[1],M[4],M[7],M[2],M[5],M[8]];
function oerrOpt(q,Rt){const M5=S.orient(q,5);const N=mul3(T3(M5),Rt);
const th6=-Math.atan2(N[3],N[0])*180/Math.PI;
return A.rotErrDeg(mul3(M5,rotAx([0,0,-1],th6*Math.PI/180)),Rt);}
const Tp=[20,400,300],Rt=A.poseMat(25,95,10);
const Aa=Tp[0]-K.X0,Bq=Tp[1]-K.Y0,Br=Tp[2]-K.Z1;const Rr=Math.hypot(Bq,Br),ph=Math.atan2(Br,Bq);
let cnt=0,be=999;
for(let t4=-111;t4<=16;t4+=0.5)for(let t5=-95;t5<=34;t5+=0.5){const ss=S.branch(t4,t5,Aa,Rr,ph,0);
for(const s0 of ss){if(!inLim(s0))continue;const p=S.fk(s0,0);if(Math.hypot(p[0]-Tp[0],p[1]-Tp[1],p[2]-Tp[2])>1e-6)continue;cnt++;const e=oerrOpt(s0,Rt);if(e<be)be=e;}}
console.log("位置精确解数量="+cnt+" 其中最小姿态差(θ6最优)="+be.toFixed(2)+"deg");
const rp=S.solve(Tp,{step:0.25,tol:1e-6});
console.log("无姿态 solve: ok="+rp.ok+" 位置误差="+(rp.err!=null?rp.err.toExponential(1):"--")+"mm");
const r6=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rt}});
console.log("带姿态 solveTo(默认): reachable="+r6.reachable+" 位置="+(r6.err!=null?r6.err.toFixed(1):"--")+"mm 姿态差="+(r6.oerr!=null?r6.oerr.toFixed(1):"--")+"deg");
const r5=S.solveTo(Tp,{step:0.25,tol:1e-6,pose:{R:Rt},priority:"pos"});
console.log("带姿态 solveTo(位置优先): reachable="+r5.reachable+" 位置="+(r5.err!=null?r5.err.toExponential(1):"--")+"mm 姿态差="+(r5.oerr!=null?r5.oerr.toFixed(2):"--")+"deg");'
```
> 实测输出(勘误后重跑):
> `位置精确解数量=11920 其中最小姿态差(θ6最优)=44.20deg`
> `无姿态 solve: ok=true 位置误差=2.5e-14mm`
> `带姿态 solveTo(默认): reachable=false 位置=0.0mm 姿态差=136.7deg`
> `带姿态 solveTo(位置优先): reachable=true 位置=1.3e-13mm 姿态差=44.19deg`
### D4b — 近伸直构型(对应 §6.1)
```
node -e 'const fs=require("fs");const A=require("./web/arm-ik-solver.js");
const mesh=JSON.parse(fs.readFileSync("./web/mesh_arm.json","utf8"));const tr=JSON.parse(fs.readFileSync("./docs/travel.json","utf8"));
const J={},T={};for(const j of mesh.joints)J[j.id]={rear:j.rear,d:j.d};for(const t of tr)T[t.axis]={lo:t.lo,hi:t.hi,continuous:!!t.continuous};
const S=A.make({joints:J,travel:T,tcp:[18.5,128,93]});const O=A.ORDER;const LIM=S.LIM;
let s2=909090>>>0;const rnd2=()=>{s2=(s2+0x6D2B79F5)>>>0;let t=s2;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
const rndQ2=()=>O.map(id=>{const t=LIM[id];return t.cont?(rnd2()*720-360):(t.lo+rnd2()*(t.hi-t.lo));});
let N=30,nFail=0,nGap=0,maxS=0,maxG=0,worst=[];
for(let k=0;k<N;k++){const q=rndQ2();const m=rnd2();
if(m<0.5)q[2]=rnd2()*6-3; else q[2]=(rnd2()<0.5?180:-180)+(rnd2()*6-3);
const Tp=S.fk(q,0),R=S.orient(q);
const a=S.solve(Tp,{step:0.25,tol:1e-6,pose:{R:R}}),b=S.solve(Tp,{step:0.05,tol:1e-6,pose:{R:R}});
const va=a.q?a.oerr:999,vb=b.q?b.oerr:999;
if(va>0.05)nFail++;if(va>0.05){if(va>maxS)maxS=va;if(va-vb>0.05){nGap++;if(va-vb>maxG)maxG=va-vb;worst.push({T:Tp.map(v=>+v.toFixed(1)),oa:+va.toFixed(3),ob:+vb.toFixed(3)});}}}
console.log("[D4b] N="+N+" 失败="+nFail+" 覆盖漏洞="+nGap+" 最大静默="+maxS.toFixed(3)+"deg 最大覆盖差="+maxG.toFixed(3));
console.log("样本="+JSON.stringify(worst));'
```
> 实测输出:`[D4b] N=30 失败=1 覆盖漏洞=1 最大静默=1.860deg 最大覆盖差=1.822`
> `样本=[{"T":[240.7,45.3,518.6],"oa":1.86,"ob":0.038}]`
### 既有自检与性能基准(对应 §5.1、§6.5)
```
node scratch/pose_ik_check.js
```
> 实测:7 项全部通过(明细见 §6.5)。注意其内部使用 `Math.random()`,为单次运行示例。
```
node -e 'const j=require("./scratch/bench_ik_split.json");console.log("grid",JSON.stringify(j.grid));console.log("summary",JSON.stringify(j.summary,null,1));console.log("n",j.rows.length,"ms max",Math.max(...j.rows.map(r=>r.ms)).toFixed(0));'
```
> 实测:`grid {"g4":509,"g5":517}` · `summary {medMs:107.1, p95Ms:242.5, medPos:73.6, medPose:37.2, medShare:33.3, medOff:73.4, escRate:0.025}` · `n 40 ms max 299`
---
## 10. 术语表
| 术语 | 含义 |
| :--- | :--- |
| TCP | 工具中心点,此处指工具法兰安装面中心(`tcp=[18.5,128,93]`) |
| `oerr` | 姿态残差(度),工具坐标系与目标姿态的旋转夹角 |
| 位置精确解 | 由 `branch()` 闭式得到、位置误差 ~1e-13 mm 的关节解(θ1..θ3 由 (θ4,θ5) 决定) |
| 退化构型 / 奇异刀尖 | 位置雅可比奇异、`branch()` 的 4 支两两重合的构型(本案为 θ3≈0 或 ±180) |
| `POSETOL` | 姿态接受的粗扫阈值,2.0°;超阈候选现被直接丢弃 |
| `closest6` | 6 维阻尼最小二乘"最近位姿"兜底(160 随机重启 × 160 迭代) |
| 覆盖漏洞 | 精确解存在但求解器未找到(静默返回 >0.05° 残差) |
| 被支配(Pareto-dominated) | 存在另一个解在位置与姿态两个指标上都不劣于此解 |
+206
View File
@@ -0,0 +1,206 @@
# 机器人手臂3D查看器 · 性能优化报告
- 目标文件:`机器人手臂3D查看器.html`(15,314,130 → 15,316,745 字节)
- 优化日期:2026-09-29
- 优化前版本标识:`v1_2026-09-29_2151`(原件最后写入时间)
- 备份:`backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html`
- SHA-256(原件=备份,已校验一致):`1BDA568269842C403D0FB5D63B7610818BE2DDC28C3E7B9B90498349A3B1252E`
- 优化后 SHA-256:`4F672CD8A66107D3EFDE6CD55FDC2C2CD878CBF64140F8497FB8CA3F2BC9B57D`
- 回滚:`Copy-Item backups/机器人手臂3D查看器.v1_2026-09-29_2151_优化前.html 机器人手臂3D查看器.html`(原件为只读属性,已按原状恢复)
---
## 一、摘要(实测数据)
同一台机器、同一个浏览器(Edge,160 Hz 显示器)、同一份插桩脚本,改前 / 改后各测 2 次:
| 指标 | 改前 | 改后 | 变化 |
| --- | ---: | ---: | ---: |
| **空闲 5 s 渲染次数** | 800 / 800 | **0 / 0** | **−100%** |
| 空闲渲染 CPU 时长 | 271.4 / 289.9 ms | **0 / 0 ms** | −100% |
| 单次滑块输入后 500 ms 渲染次数 | 82 / 82 | **1 / 1** | −98.8% |
| 进入「运动模拟」后 4 s 渲染次数 | 626 / 617 | **103 / 104** | −83.5% |
| 按钮求解后 4 s 渲染次数 | 626 / 622 | **103 / 102** | −83.6% |
| 30 次 pointermove 的射线拾取次数 | 30 / 30 | **9 / 9** | −70% |
| 30 次 pointermove 的拾取总耗时 | 13.1 / 19.3 ms | **5.2 / 4.4 ms** | −60% ~ −77% |
| 索引缓冲内存 | 4,123,320 B(Uint32×92) | **2,061,660 B(Uint16×92)** | **−2.00 MiB(−50%)** |
| draw call / 三角面 | 92 / 340,178 | 92 / 340,178 | 不变(画质不变) |
| 约 20 s 会话总渲染次数 | 2576 / 2564 | **299 / 296** | −88.4% |
| 约 20 s 会话渲染 CPU 总时长 | 1114 / 1267.9 ms | **143.9 / 205.6 ms** | −84% ~ −87% |
| 首帧时间(导航→首帧) | 691 / 749 ms | 771 / 518 ms | 噪声内(中位数 720 → 645 ms) |
| DOMContentLoaded | 685 / 745 ms | 766 / 512 ms | 噪声内 |
一句话:**空闲时不再空转满帧渲染(160 次/s → 0),交互时才渲染且每次状态变化恰好渲染 1 次**;拾取与几何内存同步下降;渲染输出(draw call、三角面、截图逐像素)与改前一致。
功能验证:改前 / 改后跑同一份 18.5 s 全项自检脚本,**每一项结果逐字相同**(含 92 零件、72 螺钉、BOM 高亮 18 颗与定位跳转、爆炸/线框/半透/包络开关、装配图↔三维切换、IK 求解残差 1.1e-13 / 1.8e-13 mm、姿态控制、右键隐藏与撤销)。截图对比:`scratch/perf/shots/before_0*.png` 与 `after_0*.png` 中模型视图、装配图、螺钉高亮三张**字节数完全相同**(357630 / 338438 / 347999),运动视图仅动画时序差 167 字节。
---
## 二、改动清单
共 6 处代码改动(成品 HTML 内 19 个 diff 块),全部集中在查看器主逻辑与 IK glue,**未触碰任何数据行与求解器脚本**。
### 1. 按需渲染(核心)
- 问题:`loop()` 无条件每次 rAF 都 `resize() + updCam() + rend.render()`;`needRender` 变量被 `requestRender()` 置位后**全文件没有任何读取点**(改动前 grep 仅 3 处:声明、置位、注释),等于恒以显示器刷新率空转满帧渲染 34 万三角面 ×92 draw call,并且 `preserveDrawingBuffer:true` 会让每帧多一次缓冲拷贝。
- 改法:
```js
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
```
- 关键约束:**保留 `preserveDrawingBuffer:true`**(不重绘时靠它保住最后一帧画面);**保留 rAF 循环**(入场动画 `animTo`、实时追踪、拖拽都依赖后续帧渲染);装配图页画布隐藏时**不清标志**,切回三维页由 tab 处理器 `resize()+requestRender()` 补渲染。
- 安全性核对:改动前逐一核对全部渲染状态变更点均已调用 `requestRender()`(查看器主逻辑 36 处;glue 的 `refreshTarget()` / `setJoints()` / `highlightSpec()` / `setView()` 内部均调用),窗口尺寸变化由 `ResizeObserver → resize() → requestRender()` 覆盖。
- 顺带收益:`resize()` 读 `stage.clientWidth/clientHeight` 会造成强制布局,旧版每帧都读,现在只在真正渲染前读一次。
### 2. 悬浮拾取限流 + 零分配
- 问题:射线要对 34 万三角面全量求交(实测 0.5~1.5 ms/次),旧版每个 `pointermove` 都算(rAF 合帧后仍≈刷新率次数);且每次 `meshes.filter(...)` 都新建数组 + `new THREE.Vector2`。
- 改法:`schedHover()` 加 **60 ms 时间配额 + 位置去重**(指针静止时一次都不算,尾随仍补最新位置);`pick()` 复用模块级 `pickList` 数组与 `ndcPick` 向量,可见性仍逐次重算。
- 注意:three r159 的 `Raycaster` **不检查 `object.visible`**(已在 `web/three_inline.js` 源码确认),因此可见性过滤必须保留。
- 点击 / 右键拾取不经过 `schedHover`,仍是即时求交,交互判定不受影响。
### 3. `applyJoint()` 去分配
- 问题:每次调用新建 2 个 `Vector3` + 3 个 `Matrix4`,且用 `JO.find` 线性查找;一次关节写入 = 6 次调用 = 30 个临时对象,动画/实时追踪每帧一批。
- 改法:轴心 `JP` / 轴向 `JD` 预计算一次;矩阵合成改用 3 个共享 `Matrix4` 实例。数学与原式逐位相同(`T(P)·R(D,θ)·T(−P)`)——场景图残差复算结果与改前完全一致(1.1e-13 / 1.8e-13 mm)即为证据。
### 4. 索引缓冲降位(Uint16)
- 问题:92 件零件索引一律 `Uint32Array`。
- 改法:按顶点数选择 `p.v.length <= 65535 ? Uint16Array : Uint32Array`(实测单件最大顶点 47,483,全部走 Uint16),索引内存 4.08 MB → 2.00 MB。
### 5. BOM 高亮只记一行
- 问题:点选 / ESC 时 `for (const tr of tb.children) tr.style.background = ''` 把整张表(13 行零件 + 10 行螺钉)刷一遍。
- 改法:新增 `bomHlRow` / `bomHlClear()`,只清理上一次高亮的那一行,设置高亮时同步记录。可见结果完全一致。
### 6. Worker Blob URL 回收
- 问题:`new Worker(URL.createObjectURL(new Blob([...])))` 不回收,常驻一份 ~86 KB Blob;Worker 构造失败路径同样泄漏。
- 改法:先取 URL,`try { new Worker(url) } finally { URL.revokeObjectURL(url) }`(URL 只在构造时被读取)。
---
## 三、验证方法与证据
### 3.1 静态校验(`scratch/perf/verify_edit.py`)
- 4 个内联 `<script>` 的 SHA-256 对比:
- `[0] three.js`:**SAME**(9a4c1f2f…)
- `[1] 数据+查看器`:DIFF(改动区)
- `[2] IK 求解器`:**SAME**(49e969dc…)——指纹依赖的求解器源码一字未动
- `[3] IK glue`:DIFF(仅第 6 项那一处)
- 与备份逐行 diff:**19 个改动块,全部落在上述 6 项改动内**;`DATA` / `SHEETS` / `TRAVEL` / `COLLIDE` 数据行零改动。
- `node --check`:改后的查看器主脚本(14.1 MB)与 glue 脚本均语法通过;模板补丁后的脚本同样通过。
### 3.2 运行期一致性
| 检查项 | 改前 | 改后 |
| --- | --- | --- |
| 预置自检指纹(`chkSig()` 当前值) | `3e03f22b` | **`3e03f22b`(未变)** |
| IK 残差(闭式 / 场景图) | 1.1e-13 / 1.8e-13 mm | **1.1e-13 / 1.8e-13 mm** |
| 零件 / 螺钉 / draw call / 三角面 | 92 / 72 / 92 / 340,178 | 相同 |
| 关节矩阵元(J2 滑块 25°→夹到 7.867°) | 7.1441 | 7.1441 |
### 3.3 功能对照自检(`scratch/perf/func_check.js`,18.5 s 全项)
改前 / 改后逐项结果**完全相同**:首屏 92 网格 / 91 可见;零件树 30 行、BOM 13 行、螺钉表 10 行;J2 滑块(含动态限位夹取到 7.867°)与画面重绘;爆炸 60% 最大位移 129.6 mm 且「装配视图」回位;线框 91 开 / 0 关;电机半透 0.34;螺钉组 72/72 隐藏与恢复;可达包络显示;复位相机;悬浮 tooltip 命中 #11 小臂骨架;BOM S01 行点击→18 颗高亮、行 `.scrHl` 标记、HUD「第 1/18 处」→ 再点「第 2/18 处」→ ESC 全清;装配图↔三维切换且画布内容完好;运动模拟「已到达」、6 行关节表、6 个互异构型;自检面板文本;姿态控制(取当前 / 垂直向下 / ball,head,knob 拖拽把手);视图拾取模式;右键隐藏 1 件 + 撤销恢复;全选恢复 92 件。
截图:`scratch/perf/shots/`(before/after 各 4 张)。
### 3.3b 交互路径补充探针(`scratch/perf/interact_check.js`,改前 / 改后各跑 1 次)
针对"按需渲染是否会漏帧"这一风险点,逐条验证交互后的即时重绘(画布 64×36 降采样指纹对比):
| 交互 | 改前 | 改后 |
| --- | --- | --- |
| 轨道拖拽(pointerdown+10×pointermove) | Δaz −0.64、Δpo −0.213、画面重绘 ✓ | 完全相同 |
| 滚轮缩放 | Δrd −159.9、画面重绘 ✓ | 完全相同 |
| 爆炸滑块连续拖拽(7 次 input) | 最大偏移 226.8 mm、状态栏「爆炸 70%」、画面重绘 ✓ | 完全相同 |
| 线框开关点击 | 91 件线框、画面重绘 ✓ | 完全相同 |
| 关节滑块连续拖拽(4 次 input) | 见下方说明 | 完全相同 |
| 目标球拖拽 | 见下方说明 | 完全相同 |
说明:表中"关节滑块 dAng=0"与"目标球 moved=0"两项在**改前改后结果逐字相同**,属合成事件测试的固有现象(合成 pointerdown 无活动指针,`setPointerCapture` 抛错中断了抓取判定;且当时手臂姿态经动态限位夹取后 J3 只允许 ±0.06°),**不是回归**——同一探针在改前文件上得到同一结果即为证据。真实鼠标操作不受此影响(`screenshot` 与 func_check 中的姿态控制、拖拽把手、视图拾取等均已通过)。
### 3.3c URL 参数脚本化视图(`?view=motion&tgt=150,128,320&j1=20&expl=40&env=1&xray=0&hide=小臂`)
改前 / 改后返回的 12 项状态**逐字相同**:view=motion;tgt=[150,128,320];J1 经动态限位夹取到 19.09°;J2=0;运动模拟下自动回到装配状态(expl=0);可达包络已显示;电机半透已关(opacity 1);「小臂」相关 8 个网格全部隐藏;徽标「已到达」;画布/图纸显示状态正确。
### 3.4 性能插桩(`scratch/perf/harness.js` + `make_measure.py`)
插桩副本(`measure_before.html` / `measure_after.html`)只多一个尾部 `<script>`,不改变原有 4 个脚本索引,因此指纹逻辑不受影响。包装 `rend.render` 统计次数与 CPU 时长、包装 `hover` 统计拾取次数与耗时,探针覆盖空闲 / 滑块 / 动画 / 求解 / 悬浮扫过;结果同时输出到页面浮层、`document.title` 与 console,便于截图与读取。
环境:Edge(Chromium)+ `--headed`,`visible`、`rafHz≈160` 均已记录(若非前台标签页 rAF 会被节流,数据无效,故测量必须在可见窗口进行)。
---
## 四、经实测否决 / 未做的优化
| 候选 | 实测 | 结论 |
| --- | --- | --- |
| `scene.updateMatrixWorld(true)` 改 6 级关节链更新 | 全量强制刷新仅 **0.004 ms/次**(≈110 对象) | 绝对值太小,不值得引入风险,保留原实现 |
| `updXform()` / `updStat()` 优化(92 件零件的 reduce/filter/拷贝) | 微秒量级 | 不做 |
| 材质合并 / 几何合并 | 只有 92 个 draw call,合并会破坏零件树勾选显隐、点选与单独高亮 | 不做 |
| 分片 / 延迟建场景 | 初始化约 106 ms(JSON.parse 69 ms + 建几何 37 ms),分片不减少总时间 | 不做 |
| 降低 `antialias` / `setPixelRatio` 上限 | 属画质取舍 | 保留原样 |
| 实时追踪时对结果面板 DOM 节流 | `renderResult()` 含"场景图残差复算"与"备选构型"两块**验证语义**内容 | 保留原样,避免削弱验证可观测性 |
| 按需渲染是否该去掉 `preserveDrawingBuffer` | 不重绘时画布会被清空 | 必须保留 `true` |
---
## 五、重要发现与后续建议
### 5.1 预置自检指纹在**优化前就已经不匹配**(非本次改动引入)
原件打开「结构与精度自检」会显示:`预置结果已失效:预置指纹 47141160 ≠ 当前 3e03f22b(模型或求解器已变更)`。
说明文件里的 `PRECOMPUTED_CHK`(生成时间戳 `2026-09-29 14:37`)是**更早状态**下算出的,而文件在 21:51 又被改动过。本次优化前后该值都是 `3e03f22b`,未使情况变化。
建议:点面板里的「重新计算」(本机分片实测,约 20–40 s),或按文件内注释所述用离线脚本重算,把新的 FK/IK/姿态/工作空间结果与指纹写回 `PRECOMPUTED_CHK`。
### 5.2 生成器源文件与成品已漂移(**在完成反向同步前不要重跑构建**)
`src/build_viewer.py` 由 `src/viewer_tpl.html` + `web/ik-glue.js` + `web/arm-ik-solver.js` + `web/mesh_arm.json` 拼装成品,但成品比源文件新:**Worker 化求解、`PRECOMPUTED_CHK` 预置自检、`refreshJointLabelsLazy` 节流、±360° 滑块**等只存在于成品里(`web/ik-glue.js` 仍是同步求解的旧版,776 行 vs 成品 961 行)。
本次处理:按约定**直接优化成品**,并把性能补丁同步回 `src/viewer_tpl.html`(补丁前副本:`backups/viewer_tpl.html.v1_2026-09-29_性能补丁前.bak`)。`web/ik-glue.js` 中不存在本次改动的对应代码(无 Worker、无拾取/渲染循环/几何构建),故无需同步。
**在把成品里的新代码反向同步回模板/glue 之前,重跑 `python src/arm_model.py` 或 `build_viewer.py` 会把成品回退到旧功能(并丢掉本次性能优化)。**
### 5.3 内存与泄漏审计结论
- 几何:`p.v / p.t / p.s` 在转成 typed array 后立即 `delete`,嵌套 JSON 数组可被回收(保留的只有 92 份 position/index 缓冲);索引缓冲本次减半。
- 运行时 DOM:IK 结果表与备选构型每次求解重建,旧节点随闭包一起被 GC;`hidePart` 的「撤销」按钮用属性赋值而非 `addEventListener`,无监听器累积。
- Worker:单例 + Blob URL 已回收;`IKWjob` 每次覆盖,无排队泄漏。
- `performance.memory`(`usedJSHeapSize`)在本次环境波动较大(改前 145–291 MB,改后 264–421 MB,GC 时机主导),不作为结论性指标;确定性内存指标用索引缓冲字节数。
---
## 六、复现方法
```powershell
# 1) 本机静态服务器(把 /v.html /mb.html /ma.html 等别名映射到对应文件)
python scratch/perf/serve.py 8123
# 2) 生成插桩副本(源 HTML → 尾部追加 harness.js)
python scratch/perf/make_measure.py "机器人手臂3D查看器.html" "scratch/perf/measure_after.html" after
# 3) 浏览器打开 http://127.0.0.1:8123/ma.html ,约 25 s 后读取 #perfOut(或 document.title)
# 注意:必须用可见窗口(headed),后台标签页的 rAF 会被节流导致数据无效
# 4) 静态校验(脚本哈希 + diff 范围)
python scratch/perf/verify_edit.py
# 5) 功能对照自检:把 scratch/perf/func_check.js 用 base64 喂给浏览器 eval,
# 约 19 s 后读取 window.__FC
```
测量脚本均在 `scratch/`(已被 `.gitignore` 忽略),成品文件保持干净、无任何插桩代码。
+314 -88
View File
@@ -23,7 +23,7 @@ from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from human_layout import (HUB, PLATE_T, R_MOT, Z_TIP, Z_WR, Z_EI, Z_SH, Z_RL, Z_R1,
Z_R3, Z_HUB3, Z_EL, Z_R6, Z_HUB6,
Z_R4, Z_CAP, Z_M6, Z_CHEST0, Z_CHEST1, X_J1_HUB, X_ARM, L1,
X_FORK_GAP,
X_FORK_GAP, X_BAY_EXT,
Y_AXIS, Y_HUB, Y_REAR, Y_COVER, Y_H, Y_R_IN, Y_R_OUT,
Y_M6, Y_CHEST, X_CHEST, FOOT_H)
@@ -80,12 +80,14 @@ def make_direct_hub(shape, j, r_disc=35.0, z_out=74.0):
# ========== 躯干/测试台架部分 (后置防倾翻高刚性机架) ==========
X_COL = -115.0
Y_COL = Y_AXIS # 128.0
Z_LOCK = 463.0 # 横向防脱锁紧螺钉高度(立柱后壁 → 榫头,M4x16 自攻)
def p_chest():
"""P01_胸腔支撑立柱 (High-Rigidity Box Mast Tower - 后置台架立柱):
1. 66x66mm 封闭中空厚壁箱型柱(壁厚 11mm,内腔 44x44),中心在 (-115.0, 128.0),位于手臂正后方;
2. 顶部接收 P03 48x48x15mm 榫头插槽与 4x M5 安装螺栓;
2. 顶部接收 P03 43.4x43.4x15mm 定位榫头(44x44 R4 内腔)、4x 竖向安装孔 + 1x 横向防脱锁紧孔
(全部 Ø3.9 直孔:M4x16 从外部直接拧入自攻成型,无过孔/无沉孔/无预埋);
3. 底部 50x50x12mm 阶梯榫头插入 P02 榫座:环形台肩(66->50)面承载轴向力,槽壁全周承载剪切;
4. 前向开设两组大圆角走线工艺窗口(Z=60~150mm 与 Z=330~420mm);
5. 外棱角全部采用 R=6mm 圆角。
@@ -115,9 +117,13 @@ def p_chest():
for xx in (X_COL - 20.0, X_COL + 16.0):
for yy in (Y_COL - 18.0, Y_COL + 18.0):
s = s.cut(cyl(2.75, 9.99, z_bot + 15.0, xx, yy))
for xx in (X_COL - 21.0, X_COL + 21.0):
for yy in (Y_COL - 21.0, Y_COL + 21.0):
s = s.cut(cyl(2.75, z_top - 20.0, z_top + 0.01, xx, yy))
# 顶部 4x 安装孔:统一 Ø3.9 直孔(M4x16 直接拧入自攻成型;只打这一个孔,无过孔/无沉孔),深 20mm
# 位于 66x66 顶环 11mm 实体壁内(内腔 22 → 外壁 33),避开 44x44 内腔与 R4 内腔圆角;
for xx in (X_COL - 27.5, X_COL + 27.5):
for yy in (Y_COL - 13.5, Y_COL + 13.5):
s = s.cut(cyl(1.95, z_top - 20.0, z_top + 0.01, xx, yy))
# 横向防脱锁紧孔(后壁 → 榫头):Ø3.9 贯穿 11mm 后壁 + 榫头同径(M4x16 拧入自攻)
s = s.cut(Xcyl(1.95, X_COL - 34.0, X_COL - 20.0, Y_COL, Z_LOCK))
w_bot = (cq.Workplane("YZ", origin=(X_COL + 33.5, Y_COL, 105.0))
.rect(34.0, 70.0)
@@ -134,7 +140,9 @@ def p_chest():
def split_p01_poka_yoke(p01):
"""拆分 P01 为 A/B/C 三段(严格适配 200x200mm 打印机,竖直免支撑打印):
带 16mm 阶梯互锁榫卯(外套筒壁 3.6 + 公榫壁 6.9,内腔收窄 51->44 换取双厚壁,
单边配合间隙 0.5)、非对称 M4 横向对锁穿钉孔(物理防反装防呆)、以及本体 3D 凹刻朝向标识。
单边配合间隙 0.5)、以及统一 Ø3.9 直孔的分段对锁孔(M4x16 从外面直接拧入自攻成型,
母口壁与公榫壁同径,无过孔/沉孔/预埋,前后两面对称各一颗);
孔位相对立柱中心 −16 / +22 非对称(物理防反装防呆);本体 3D 凹刻朝向标识。
"""
z_bot = 22.0
z_s1 = 172.0
@@ -145,6 +153,14 @@ def split_p01_poka_yoke(p01):
# 非对称对锁孔位(相对立柱中心偏心 -16mm 与 +22mm,偏心差 6mm,反装无法穿入螺栓)
xx_bolts = (X_COL - 16.0, X_COL + 22.0)
# 分段对锁孔:统一 Ø3.9 直孔(M4x16 从外面直接拧入自攻成型;母口壁与公榫壁同径,
# 没有过孔/沉孔等其它孔),前后两面对称;杆尖伸过榫壁的一段在内腔侧留 Ø4.5 让位。
def _seam_holes(part, xx, z):
return (part.cut(Ycyl(1.95, Y_COL - 33.6, Y_COL - 21.4, xx, z))
.cut(Ycyl(1.95, Y_COL + 21.4, Y_COL + 33.6, xx, z))
.cut(Ycyl(2.25, Y_COL - 21.7, Y_COL - 15.5, xx, z))
.cut(Ycyl(2.25, Y_COL + 15.5, Y_COL + 21.7, xx, z)))
# --- P01_A (下段) ---
cut_above_s1 = bx(X_COL - 50.0, X_COL + 50.0, Y_COL - 50.0, Y_COL + 50.0, z_s1, z_top + 10.0)
p01_a_base = p01.cut(cut_above_s1)
@@ -158,7 +174,7 @@ def split_p01_poka_yoke(p01):
z_bolt1 = z_s1 + h_lap / 2.0 # 180.0
for xx in xx_bolts:
p01_a = p01_a.cut(Ycyl(2.2, Y_COL - 40.0, Y_COL + 40.0, xx, z_bolt1))
p01_a = _seam_holes(p01_a, xx, z_bolt1) # A-B 缝对锁孔(Ø3.9,前后两面)
# Engrave the front (-Y) flat face, reading VERTICALLY bottom-to-top along
# the column (user request): local +X = world +Z, local +Y = world -X, so
@@ -189,8 +205,8 @@ def split_p01_poka_yoke(p01):
z_bolt2 = z_s2 + h_lap / 2.0 # 329.0
for xx in xx_bolts:
p01_b = p01_b.cut(Ycyl(2.2, Y_COL - 40.0, Y_COL + 40.0, xx, z_bolt1))
p01_b = p01_b.cut(Ycyl(2.2, Y_COL - 40.0, Y_COL + 40.0, xx, z_bolt2))
p01_b = _seam_holes(p01_b, xx, z_bolt1) # A-B 缝对锁孔(Ø3.9,前后两面)
p01_b = _seam_holes(p01_b, xx, z_bolt2) # B-C 缝对锁孔(Ø3.9,前后两面)
front_label_plane_b = cq.Plane(
origin=(X_COL, Y_COL - 33.0, 240.0),
@@ -207,7 +223,7 @@ def split_p01_poka_yoke(p01):
.rect(58.8, 58.8).extrude(h_lap + 0.6).edges("|Z").fillet(3.9)).val()
p01_c = p01_c_base.cut(pocket2)
for xx in xx_bolts:
p01_c = p01_c.cut(Ycyl(2.2, Y_COL - 40.0, Y_COL + 40.0, xx, z_bolt2))
p01_c = _seam_holes(p01_c, xx, z_bolt2) # B-C 缝对锁孔(Ø3.9,前后两面)
front_label_plane_c = cq.Plane(
origin=(X_COL, Y_COL - 33.0, 390.0),
@@ -492,111 +508,308 @@ def p_base_foot():
def p_girdle(j1):
"""P03_肩关节安装悬臂 (高强受力多筋桁架 + 侧开式电机托架与前置止脱锁紧法兰):
"""P03_肩关节安装悬臂 (轻量化板式桁架版 + 侧开式电机托架):
1. 10mm 后端重载法兰盘 (X: -61.0..-51.0, 74x74mm);
2. 双侧大截面三角加强筋 (8mm 厚,Y = Y_ARM ± 28mm);
3. 下腹部纵向大受力龙骨筋 (12mm 厚);
4. 顶部横向锁紧环抱筋 (60x10x10mm);
5. 侧开式电机托架 (+Y侧全开, X: -51.0..8.5),M1 电机可从侧方滑入装配,由后端法兰 4xM3 与前置止脱锁紧法兰 4xM3 双向锁死;
6. 端面 4x Ø3.2mm 沉头通孔直连 M1 定子端面螺纹孔 (BCD=55mm),彻底消除轴向拉脱风险;
7. 两侧大面积散热舷窗 (34x20mm) + 顶部双斜向对流排热槽 (30x6.5mm),确保散热畅通;
8. 4x 直通式螺丝刀安装通道 (Ø8.5mm) 与 Ø26mm 中央走线孔。
2. 主干由 66mm 实心块改为双侧 6mm 桁架竖板 (Y = Y_ARM ± 27..33),外轮廓保持旧版
"主干 ∪ 侧筋"并集不变 —— 只减材料不加体积,零新增干涉风险;实心 728 -> 286 cm³ (-61%);
3. 竖板帐篷形减重窗 (顶/底斜面 ≥52°,自支撑;仅 20mm 平顶可桥接);
4. 底部 9mm 承力鞍座 + 43.4x43.4 定位榫头 (配 P01_C 44x44 内腔,单边 0.3mm 间隙);
鞍座底面 66x66 与立柱顶环带承压面积 2403mm² —— 竖向载荷走面压,螺栓只防上抬/防滑;
5. 4x 安装孔 (X_COL±27.5, Y_COL±13.5):Ø3.9 直孔,M4x16 从上方直接拧入自攻成型
(鞍座与立柱顶环同径,无过孔/沉孔/预埋),可拆装;
6. 下腹中央龙骨筋 (12mm 中位腹板,自鞍座斜拉至法兰下缘) + 顶部横向锁紧环抱筋 (66x9x10mm,
与两侧竖板外面齐平、顶面压在筋板脊线上,仅顶前棱一道 2mm 倒角);
7. 侧开式电机托架 (+Y侧全开, X: -51.0..11.5,前端随 X_BAY_EXT 前延 3mm),
M1 电机可从侧方斜插卡入装配(插槽比电机长 3mm),由后端法兰 4xM3x16 锁死;
电机舱 Ø60.7 从后端直通到前端面(2026-10-02 用户裁定"去掉台阶改成平的":无环形台阶、
无喇叭锥;原前置止脱锁紧法兰 4xM3 随之取消,电机轴向由后端 4xM3x16 约束);
9. 两侧大面积散热舷窗 (34x20mm) + 顶部双斜向对流排热槽 (30x6.5mm),确保散热畅通;
10. Ø26mm 中央走线孔;竖板间为开放腔体(无封闭空腔),M3 螺丝刀通道直接走腔体,拧紧更顺手。
"""
X_MOUNT = Z_R1 # -51.0 (Rear face of M1)
T_FLANGE = 10.0 # 10mm thick high-strength mounting flange
X_BACK = X_MOUNT - T_FLANGE # -61.0
Y_ARM = Y_AXIS # 128.0
Z_ARM = Z_RL # 610.0
Y_P_IN = 27.0 # 桁架竖板内表面 (Y_ARM ± 27)
Y_P_OUT = 33.0 # 桁架竖板外表面 (与旧主干齐平)
T_SADDLE = 9.0 # 底部鞍座厚度
TENON = 43.4 # 定位榫头边长(P01_C 内腔 44x44 R4,单边留 0.3mm)
WIN_R = 4.0 # 竖板减重窗内角圆角(消除内尖角应力集中,Kt 由 ~2.5 降到 ~1.2)
BOLT_DX, BOLT_DY = 27.5, 13.5 # 4x 安装孔相对立柱中心偏移:落在顶环 11mm 实体壁内
# 1. 主安装法兰盘 (74x74mm, 倒圆角 R=8mm)
# 1. 主安装法兰盘 (74x74mm, 四角倒圆 R=8mm)
# 背面那圈 2mm 倒角已去掉:板料只伸进法兰 0.1mm,倒角面与板背面相交会在
# Z≈574.9 挤出一批 2mm 短棱,正是这批短棱让整条交接内棱网络倒不出圆角;
# 同时它跨过 R8 角部圆弧,就是角上那道"卷起来"的怪倒角。
flange = (cq.Workplane("YZ", origin=(X_BACK, Y_ARM, Z_ARM))
.rect(74.0, 74.0)
.extrude(T_FLANGE)
.edges("|X").fillet(8.0)
.edges("<X").chamfer(2.0)).val()
.edges("|X").fillet(8.0)).val()
s = cq.Shape(flange.wrapped)
# 2. 斜挑支撑主干 (Central Arch Backbone)
# 2. 双侧桁架竖板 (6mm):旧"斜挑支撑主干"与"大三角加强筋"轮廓同段挤出 → 自动并集成单块侧板
# 主干的后缘两点取在加强筋斜边的延长线上,使外轮廓顶部成为一整条直线
# (否则后缘 (-148,550) 处有 5° 折点、留下 5mm 台阶)。
# Z_CREST = 侧筋脊线高度,同时是顶筋 2mm 顶前倒角的下沿:倒角从 (X-71,Z_CREST)
# 斜上到 (X-69,Z_CREST+2) = 顶筋顶面,斜坡面与倒角面几乎相切,顶部读起来是一整张面。
Z_CREST = Z_ARM + 33.0 # 643(初版 642;顶筋整体上移 3mm 让出扳手空间后跟着抬)
RIB_SLOPE = (Z_CREST - 575.0) / ((X_BACK - 10.0) - (X_COL - 15.0))
def rib_line_z(x):
return Z_CREST + RIB_SLOPE * (x - (X_BACK - 10.0))
trunk_pts = [
(X_COL - 33.0, Z_CHEST1),
(X_COL + 33.0, Z_CHEST1),
(X_COL + 33.0, 505.0),
(X_BACK + 0.1, 565.0),
(X_BACK + 0.1, Z_ARM - 5.0),
(X_COL - 10.0, 580.0),
(X_COL - 33.0, 550.0),
(X_COL - 10.0, rib_line_z(X_COL - 10.0)),
(X_COL - 33.0, rib_line_z(X_COL - 33.0)),
]
trunk = (cq.Workplane("XZ", origin=(0, Y_ARM, 0))
.polyline(trunk_pts).close()
.extrude(33.0, both=True)).val()
s = s.fuse(cq.Shape(trunk.wrapped))
# 3. 左右两侧主抗弯抗扭大三角加强筋 (厚度 8mm)
for sign in (-1, 1):
y_rib = Y_ARM + sign * 28.0
rib_pts = [
(X_COL - 30.0, Z_CHEST1),
(X_COL + 30.0, Z_CHEST1),
(X_COL - 33.0, Z_CHEST1), # 底边两端与主干/鞍座对齐 (±33):原来收进 3mm,
(X_COL + 33.0, Z_CHEST1), # 会在鞍座顶面上留下 0.7/3mm 短棱,卡住整条底棱圆角
(X_BACK + 0.1, 565.0),
(X_BACK + 0.1, Z_ARM + 32.0),
(X_BACK - 15.0, Z_ARM + 32.0),
(X_BACK + 0.1, Z_CREST),
(X_BACK - 10.0, Z_CREST), # 脊线 (-71,643):正好落在顶筋前面与 2mm 倒角下沿的交点
(X_COL - 15.0, 575.0),
(X_COL - 30.0, 530.0),
]
side_rib = (cq.Workplane("XZ", origin=(0, y_rib, 0))
.polyline(rib_pts).close()
.extrude(5.0, both=True)).val()
s = s.fuse(cq.Shape(side_rib.wrapped))
for sign in (-1, 1):
ya = Y_ARM + sign * Y_P_IN
yb = Y_ARM + sign * Y_P_OUT
y0, y1 = min(ya, yb), max(ya, yb)
for prof in (trunk_pts, rib_pts):
s = s.fuse(cq.Shape(Yplate(prof, y0, y1).wrapped))
# 4. 下腹部纵向抗弯龙骨筋 (厚度 12mm)
keel_pts = [
(X_COL + 33.0, Z_CHEST1),
(X_COL + 33.0, 495.0),
(X_BACK + 0.1, 570.0),
(X_BACK + 0.1, 555.0),
(X_COL + 20.0, 480.0),
(X_COL + 20.0, Z_CHEST1),
# 3. 竖板减重窗 (帐篷形:斜面 ≥52° 自支撑、仅 20mm 平顶可桥接;避开鞍座与 M5 螺母柱)
# 6 个内角全部 R4 圆角:把内尖角应力集中 (Kt≈2.5) 降到 ≈1.2,同时圆角面仍是竖直/自支撑面
win_pts = [
(X_COL - 24.0, 498.0), (X_COL + 23.0, 498.0), (X_COL + 25.0, 522.0),
(X_COL + 10.0, 545.0), (X_COL - 10.0, 545.0), (X_COL - 25.0, 524.0),
]
keel = (cq.Workplane("XZ", origin=(0, Y_ARM, 0))
.polyline(keel_pts).close()
.extrude(12.0, both=True)).val()
s = s.fuse(cq.Shape(keel.wrapped))
for sign in (-1, 1):
yc = Y_ARM + sign * 0.5 * (Y_P_IN + Y_P_OUT)
tool = (cq.Workplane("XZ", origin=(0, yc, 0)).polyline(win_pts).close()
.extrude(Y_P_OUT - Y_P_IN + 1.0, both=True)
.edges("|Y").fillet(WIN_R).val())
s = s.cut(cq.Shape(tool.wrapped))
# 5. 顶部横向锁紧环抱横筋
tie_rib = (cq.Workplane("YZ", origin=(X_BACK - 10.0, Y_ARM, Z_ARM + 28.0))
.rect(60.0, 10.0)
# 4. 底部承力鞍座:坐实立柱顶面,把两竖板、龙骨与榫头连成整体
s = s.fuse(bx(X_COL - 33.0, X_COL + 33.0, Y_ARM - Y_P_OUT, Y_ARM + Y_P_OUT,
Z_CHEST1, Z_CHEST1 + T_SADDLE))
# 5. 下腹部纵向抗弯龙骨筋 (12mm 中位腹板,自鞍座斜拉至法兰背面;交接内棱在融合后做真 3D 圆角)
keel_pts = [
(X_COL + 33.0, Z_CHEST1), # (-82, 470)
(X_COL + 33.0, 495.0), # (-82, 495)
(X_BACK + 0.1, 573.0), # (-60.9, 573) 斜拉至法兰背面
(X_BACK + 0.1, 594.4), # (-60.9, 594.4) 顶端贴法兰
(X_COL + 42.1, 585.0), # (-72.9, 585)
(X_COL + 20.0, Z_CHEST1), # (-95, 470) 背面一路斜到底:原来在 (-95,480) 有个折点,
] # 折点离鞍座顶面只 1mm,R2.5 圆角爬不上去 → 龙骨底棱整组倒不出
s = s.fuse(cq.Shape(Yplate(keel_pts, Y_ARM - 6.0, Y_ARM + 6.0).wrapped))
# 7. 顶部横向锁紧环抱横筋:Y 向与两竖板外面齐平 (Y_ARM±33)、前面与筋板脊线共面 (X=-71)、
# 顶面 Z=645 压住脊线 (Z=643) 并与 2mm 顶前倒角相切 → 顶筋与两侧脊线合成一整块。
# 整体比初版抬高 3mm (底面 633→636):4x M3 后端沉孔 Ø6.5 跨在 Z 627.2..633.7 上,
# 底面 633 会压住孔口上沿 0.7mm、扳手让位只剩负间隙;抬到 636 后退到孔口上方 2.3mm、
# 离螺钉轴线 5.55mm。顶面 645 不越过法兰 R8 角部圆弧在 Y=95 处的 645.9 上界。
tie_rib = (cq.Workplane("YZ", origin=(X_BACK - 10.0, Y_ARM, Z_ARM + 30.5))
.rect(66.0, 9.0)
.extrude(10.0)
.edges("|X").fillet(3.0)).val()
.edges(cq.selectors.BoxSelector((X_BACK - 10.5, Y_ARM - 34.0, Z_ARM + 34.5),
(X_BACK - 9.5, Y_ARM + 34.0, Z_ARM + 35.5)))
.chamfer(2.0)).val()
s = s.fuse(cq.Shape(tie_rib.wrapped))
# 与立柱顶部配合的榫头 (48x48x15mm)
# 6.y 体-体交接凹棱:融合完成后对"凹棱"直接做真 3D 圆角。
# 原先这里是手工沿棱 fuse 的 10 条"正方形−四分之一圆"焊珠小体(_cove),已整体删除:
# 焊珠端面是平的、悬在半空,两条互相垂直的焊珠在角上互不搭接 → 圆角"缺一块",
# 并与相邻体的棱互相穿插留 1~3mm² 碎面。真圆角在面边界自然收口、多棱相交处由 OCC
# 生成角部补片。
from cadquery import selectors as _sel
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.TopAbs import TopAbs_REVERSED
def _outward(f, sh):
"""平面面的外法向:先按 Orientation 修正,再用"面中心外移 1mm 是否仍在实体内"兜方向。"""
ad = BRepAdaptor_Surface(f.wrapped)
if ad.GetType() != GeomAbs_Plane:
return None
ax = ad.Plane().Axis().Direction()
n = cq.Vector(ax.X(), ax.Y(), ax.Z())
if f.wrapped.Orientation() == TopAbs_REVERSED:
n = n * -1.0
c = f.Center()
if cq.Solid(sh.wrapped).isInside(cq.Vector(c.x + n.x, c.y + n.y, c.z + n.z), 1e-6):
n = n * -1.0
return n
def _fillet_reflex(lo, hi, radii, tag, eps=0.9):
"""倒出 lo~hi 区域内的凹棱(两面夹 270° 材料)。判据:从棱中点沿 (n1-n2) 外探 eps,
落在实体内 = 凹、落在外 = 凸。区域内凹棱先整批一次倒(半径逐级降级,且倒完验拓扑:
OCC 会给出"没抛异常但面已坏"的结果),建不出时按相连棱分组逐组倒。"""
nonlocal s
def _pick(sh):
edges = sh.Edges()
idx = {}
for i, e in enumerate(edges):
idx[e.hashCode()] = i
adj = [[] for _ in edges]
for f in sh.Faces():
for ee in f.Edges():
k = idx.get(ee.hashCode())
if k is not None:
adj[k].append(f)
box = _sel.BoxSelector(lo, hi)
out = []
for i, e in enumerate(edges):
if e.Length() <= 6.0 or len(adj[i]) != 2 or not box.filter([e]):
continue
n1 = _outward(adj[i][0], sh)
n2 = _outward(adj[i][1], sh)
if n1 is None or n2 is None:
continue
dx, dy, dz = n1.x - n2.x, n1.y - n2.y, n1.z - n2.z
ln = math.sqrt(dx * dx + dy * dy + dz * dz)
if ln < 1e-6:
continue
p = e.Center()
if cq.Solid(sh.wrapped).isInside(
cq.Vector(p.x + dx / ln * eps, p.y + dy / ln * eps,
p.z + dz / ln * eps), 1e-6):
out.append(e)
return out
es0 = _pick(s)
if not es0:
print(" [P03 真圆角] %-14s 区域内无凹棱,跳过" % tag, flush=True)
return
def _groups(ee):
"""按共端点把凹棱连成链/网络:一组一次倒,OCC 才能在交汇处生成角部补片;
逐条倒会在交汇处留下断口(看着就像圆角倒一半停了)。"""
pts = [e.Vertices() or [e.Center()] for e in ee]
par = list(range(len(ee)))
def find(a):
while par[a] != a:
par[a] = par[par[a]]
a = par[a]
return a
for i in range(len(ee)):
for j in range(i + 1, len(ee)):
if any(pi.distance(pj) < 0.05 for pi in pts[i] for pj in pts[j]):
par[find(i)] = find(j)
g = {}
for i in range(len(ee)):
g.setdefault(find(i), []).append(ee[i])
return list(g.values())
def _do(edges_, rr):
"""倒完立刻验拓扑:OCC 会给出"没抛异常但面已坏"的结果,必须挡下来。"""
try:
ns = cq.Shape(cq.Solid(s.wrapped).fillet(rr, edges_).wrapped)
except Exception:
return None
return ns if cq.Solid(ns.wrapped).isValid() else None
# 先试"区域内全部凹棱一次倒"(探针实测这样能整批成型,且只重建一次,
# 不会像分批那样把同一张面反复重算坏掉);不行再按相连棱分组逐组倒。
for rr in radii:
ns = _do(es0, rr)
if ns is not None:
s = ns
print(" [P03 真圆角] %-14s %d 条凹棱 R%.1f ✓ 整批一次成型"
% (tag, len(es0), rr), flush=True)
return
n_ok, n_grp, left = 0, 0, len(es0)
for _ in range(24):
es = _pick(s)
if not es:
break
got = False
for g in sorted(_groups(es), key=lambda g: -sum(e.Length() for e in g)):
for rr in radii:
ns = _do(g, rr)
if ns is None:
continue
s = ns
n_ok += len(g)
n_grp += 1
print(" [P03 真圆角] %-14s 第%d组 %d 条 R%.1f ✓"
% (tag, n_grp, len(g), rr), flush=True)
got = True
break
if got:
break
if not got:
left = len(es)
break
print(" [P03 真圆角] %-14s 共 %d 组 %d 条凹棱倒圆,剩 %d 条保持尖棱"
% (tag, n_grp, n_ok, left), flush=True)
XR, YC, ZS = X_BACK, Y_ARM, Z_CHEST1 + T_SADDLE # -61, 128, 479
# —— 鞍座顶面 (Z=479) 上的交接内棱:龙骨三条 + 两竖板内侧两条,一次真圆角 ——
# 半径上限 2.0:龙骨侧面 Y=122 与 M5 螺钉头外缘 Y=118.75 只隔 3.25mm,
# 原来的 R3 焊珠会顶到螺帽承压边,R2.0 留 1.25mm。
_fillet_reflex((X_COL - 34.0, Y_ARM - 40.0, ZS - 3.0),
(X_COL + 34.0, Y_ARM + 40.0, ZS + 7.0),
(2.0, 1.5), "鞍座顶面交接")
# —— 法兰背面 (X=-61) 上的交接内棱:只倒竖板/龙骨的竖直棱 ——
# 顶筋底面那条水平棱 (Z=636) 不倒:4x M3 沉孔 Ø6.5 在 Z 627.2..633.7,
# 倒上 R2.5 会把螺钉上方 5.55mm 的扳手让位吃掉 2.5mm。
# 上界取 631:竖板内棱被顶筋底面切成 573..636 / 636..643 两段,只倒下面长的那段
# ——把 636..643 的 7mm 短段并进来,整批就从"一次成型"退化成逐组、且少倒 6 条。
_fillet_reflex((X_BACK - 3.0, Y_ARM - 40.0, 570.0),
(X_BACK + 3.0, Y_ARM + 40.0, 631.0),
(2.5, 2.0, 1.5), "竖板/龙骨↔法兰")
_fillet_reflex((X_BACK - 3.0, Y_ARM - 40.0, Z_ARM + 34.5),
(X_BACK + 3.0, Y_ARM + 40.0, Z_ARM + 35.5),
(1.5, 1.0), "顶筋顶↔法兰")
# 与立柱顶部配合的定位榫头 (43.4x43.4x15mm):配 P01_C 44x44 R4 内腔,单边 0.3mm 滑配
boss = (cq.Workplane("XY", origin=(X_COL, Y_COL, Z_CHEST1 - 15.0))
.rect(48.0, 48.0)
.rect(TENON, TENON)
.extrude(15.0)
.edges("|Z").fillet(4.0)).val()
.edges("|Z").fillet(4.2)).val()
s = s.fuse(cq.Shape(boss.wrapped))
# 4x M5 连接螺栓孔
for xx in (X_COL - 21.0, X_COL + 21.0):
for yy in (Y_COL - 21.0, Y_COL + 21.0):
s = s.cut(cyl(2.75, Z_CHEST1 - 16.0, Z_CHEST1 + 25.0, xx, yy))
# 4x 安装孔:统一 Ø3.9 直孔(M4x16 从腔体正上方拧入自攻成型;鞍座与立柱顶环同径、无沉孔)
# 孔位 (X_COL±27.5, Y_COL±13.5) 同时满足:①在立柱顶环实体壁内(内腔 22 → 外壁 33);
# ②在 P03 两竖板之间、避开中间龙骨,从腔体正上方用扳手可直接拧紧。
for xx in (X_COL - BOLT_DX, X_COL + BOLT_DX):
for yy in (Y_COL - BOLT_DY, Y_COL + BOLT_DY):
s = s.cut(cyl(1.95, Z_CHEST1 - 0.5, Z_CHEST1 + T_SADDLE + 0.5, xx, yy))
# 4x M3 连接螺孔与直通螺丝刀通道
# 1x 横向防脱锁紧:榫头 −X 侧向内 Ø3.9x14 直孔(与立柱后壁 Ø3.9 孔同轴,M4x16 拧入自攻)
s = s.cut(Xcyl(1.95, X_COL - TENON / 2.0 - 1.0, X_COL - TENON / 2.0 + 14.0, Y_COL, Z_LOCK))
# 4x M3 连接螺孔(螺丝刀通道直接走两竖板之间的开放腔体,无需再钻 Ø8.5 直通隧道)
for dy in (-17.55, 17.55):
for dz in (-20.46, 20.46):
py = Y_ARM + dy
pz = Z_ARM + dz
th = Xcyl(1.7, X_BACK - 0.5, X_MOUNT + 4.0, py, pz)
cb = Xcyl(3.25, X_BACK - 0.5, X_BACK + 4.5, py, pz)
access_tunnel = Xcyl(4.25, X_COL - 40.0, X_BACK + 0.1, py, pz)
s = s.cut(th).cut(cb).cut(access_tunnel)
s = s.cut(th).cut(cb)
# 中央走线孔 (Ø26mm)
wire_hole = Xcyl(13.0, X_COL - 40.0, X_MOUNT + 2.0, Y_ARM, Z_ARM)
s = s.cut(wire_hole)
# 6. 前向电机全包托架与前置止脱锁紧法兰 (X: -51.0..8.5)
X_FRONT = 8.5
# 6. 前向电机全包托架 (X: -51.0..8.5+X_BAY_EXT=11.5,Ø60.7 舱孔平直贯通到端面)
# 端面随插槽加长前延 X_BAY_EXT;叉板背面(10.5+X_FORK_GAP=11.5)与端面 0.0 硬贴平
# (用户两次要求"搞成平的";J1 转动摩擦由用户知悉,发涩时可微松 M2)
X_FRONT = 8.5 + X_BAY_EXT
R_POD = 36.5
R_BORE = 30.35
@@ -606,28 +819,38 @@ def p_girdle(j1):
.edges(">X").chamfer(1.5)).val()
s = s.fuse(cq.Shape(cradle_outer.wrapped))
# 电机定子配合内腔 (精密滑配至 X=5.05)
motor_pocket = Xcyl(R_BORE, X_MOUNT - 0.2, 5.05, Y_ARM, Z_ARM)
# 电机定子配合内腔(最终状态,2026-10-02 用户裁定"去掉台阶改成平的+保留固定电机的面"):
# Ø60.7 圆柱孔壁从前端面平直贯通到舱底(无窄颈、无台阶、无喇叭锥);
# 舱底 = 一个完整的平坦环面(X=5.06+X_BAY_EXT=8.06,与插槽末端墙共面,中开 Ø39 凸台过孔)
# ——即电机前限位面。电机轴向由后端法兰 4xM3x16 约束;
# 前端锁紧由 4 个 Ø10 局部凸台上的 M3 沉头螺钉承担(见下方凸台段)。
motor_pocket = Xcyl(R_BORE, X_MOUNT - 0.2, 5.06 + X_BAY_EXT, Y_ARM, Z_ARM)
s = s.cut(motor_pocket)
# 前端中心避让孔 (避让 J1 输出轴旋转凸台)
hub_clearance = Xcyl(19.5, 4.8, X_FRONT + 0.5, Y_ARM, Z_ARM)
s = s.cut(hub_clearance)
# 4x 前置 M3 锁紧螺钉通孔与沉头 (BCD=55mm, R=27.5mm, 角度 17.8°, 107.8°, 197.8°, 287.8°)
# 4x 前置 M3 锁紧螺钉凸台(BCD=55mm,17.8°/107.8°/197.8°/287.8°):
# 平直舱孔的沉头座位由 4 个 Ø10 局部凸台承载——凸台位于舱孔前段 8.06..11.5,
# 电机前端面(5.0)之前 3mm,电机斜插路径不经过;M3x10 沉头从凸台端面旋入
# 电机前端面,咬合 3.5mm,兼作电机前端防转定位。
for deg in (17.8, 107.8, 197.8, 287.8):
rad = math.radians(deg)
fy = Y_ARM + 27.5 * math.cos(rad)
fz = Z_ARM + 27.5 * math.sin(rad)
th = Xcyl(1.6, 4.5, X_FRONT + 0.5, fy, fz)
cb = Xcyl(3.0, 6.5, X_FRONT + 0.5, fy, fz)
s = s.fuse(Xcyl(5.0, 5.06 + X_BAY_EXT, X_FRONT, fy, fz))
th = Xcyl(1.6, 5.06 + X_BAY_EXT, X_FRONT, fy, fz)
cb = Xcyl(3.0, X_FRONT - 2.0, X_FRONT, fy, fz)
s = s.cut(th).cut(cb)
# J1 输出凸台过孔 (Ø39 × 3.44——J1 凸台与 3xM2 输出螺钉的必经孔,非台阶;
# 原 6.7mm 长颈与半圆台肩已随舱底前移一并消除)
hub_clearance = Xcyl(19.5, 4.8, X_FRONT + 0.5, Y_ARM, Z_ARM)
s = s.cut(hub_clearance)
# 8. 电机托架 +Y 侧全开(侧开式半包,替代原封闭全包筒):
# M1 电机可从 +Y 侧方直线滑入托架完成装配——原封闭筒电机物理上无法装入。
# 轴向约束:后端法兰 4xM3(电机尾端面)+ 前置止脱法兰 4xM3(电机前端面)双向锁死;
# 径向约束:Ø60.7 精密滑配托架底 + 重力贴底。开口前端至 5.06 保留完整止脱法兰环。
s = s.cut(bx(X_MOUNT, 5.06, Y_ARM, Y_ARM + 60.0, Z_ARM - 60.0, Z_ARM + 60.0))
# 轴向约束:后端法兰 4xM3(电机尾端面)单向锁死(前置止脱法兰随平直舱孔要求取消);
# 径向约束:Ø60.7 精密滑配托架底 + 重力贴底。开口前端至 5.06+X_BAY_EXT=8.06
# (比电机前端面多 3mm 斜插余量,电机可倾斜卡入),止脱法兰环随之前移、环宽保持 3.44mm。
s = s.cut(bx(X_MOUNT, 5.06 + X_BAY_EXT, Y_ARM, Y_ARM + 60.0, Z_ARM - 60.0, Z_ARM + 60.0))
# 7. 散热系统:双侧大视窗 + 顶部斜向对流排热槽
for sign in (-1, 1):
@@ -654,8 +877,9 @@ def p_girdle(j1):
def p_fork(j1, j2):
"""P04_肩叉_螺旋体30%_墙5底5_竖打 (Shoulder Fork / L-Bracket) - 加长避让顺滑流线版:
1. 头部与身子起点自 X=5.5 前移加长至 X=10.5,与 P03 前置锁紧法兰 (X=8.5) 保持 2.0mm 均匀轴向安全间隙
(X_FORK_GAP=0:不做整体前移,保持 J1 输出端最短悬伸力臂,避免额外弯矩负荷);
1. 头部与身子起点自 X=5.5 前移加长至 X=10.5+X_FORK_GAP=11.5,叉板背面与 P03 托架端面
(X=8.5+X_BAY_EXT=11.5) 0.0 硬贴平(2026-10-02 用户两次要求"搞成平的";
P03 侧插槽独立加长 3mm 便于 M1 电机斜插卡入);
2. J1 从动法兰圆盘已删除(盘面在 column 前缘形成鼓楞):J1 由 boss(Ø35.6)贯通 P03 前盖中心孔直连电机输出盘;column 顶边保持 Z=646.5 与 P03 环齐高,R=18mm 大圆弧顺滑过渡;
3. 头部转接凸台加长贯通 P03 前盖中心孔 (R=19.5mm),直连 J1 旋转法兰盘;
4. M2 保留后端安装板(R=35mm, 带 rear_mount_holes 螺钉孔),前端定位环鼓出圆盘已删除(前端定位由 column 端面 pocket 承担),中间镂空散热;
@@ -744,7 +968,9 @@ def p_fork(j1, j2):
u = AB.M['m2_bcd'] / 2.0 * math.cos(a * AB.D2R)
v = AB.M['m2_bcd'] / 2.0 * math.sin(a * AB.D2R)
th = cyl(1.4, 54.0, 63.0 + X_FORK_GAP, u, v)
cb = cyl(2.8, 63.0 + X_FORK_GAP, 74.0 + 40.0 + X_FORK_GAP, u, v)
# 沉孔台阶固定在 63.0(不随前移):M2x10 旋入电机输出毂的咬合量与改前一致;
# 螺钉头(63..65)相对叉板背面(64.5)外露 0.5mm,位于 2mm 叉缝空隙内,无干涉。
cb = cyl(2.8, 63.0, 74.0 + 40.0 + X_FORK_GAP, u, v)
s = s.cut(j1.L(th)).cut(j1.L(cb))
return s
@@ -1154,11 +1380,11 @@ def build():
parts_to_build = [
("P01_A_立柱下段_三角形30%_墙4底5_竖打", "立柱下段", lambda: p01_a, 0x0284c7,
"200mm适配分段A:高166mm+16mm阶梯公榫+非对称M4防反装通孔+底部50x50x12榫头+P01-A 竖排TOP刻字"),
"200mm适配分段A:高166mm+16mm阶梯公榫+顶端 2x Ø3.9 对锁孔(M4x16 自攻)+底部50x50x12榫头+P01-A 竖排TOP刻字"),
("P01_B_立柱中段_三角形30%_墙4底5_竖打", "立柱中段", lambda: p01_b, 0x2563eb,
"200mm适配分段B:高165mm+双端公母榫+非对称M4防反装通孔+P01-B 竖排TOP刻字"),
"200mm适配分段B:高165mm+双端公母榫+两端各 2x Ø3.9 对锁孔(M4x16 自攻,非对称孔位防反装)+P01-B 竖排TOP刻字"),
("P01_C_立柱上段_三角形30%_墙4底5_竖打", "立柱上段", lambda: p01_c, 0x1d4ed8,
"200mm适配分段C:高149mm+底端母榫+非对称M4防反装通孔+顶面P03安装台+P01-C 竖排TOP刻字"),
"200mm适配分段C:高149mm+底端母榫+底端 2x Ø3.9 对锁孔(非对称孔位防反装)+顶面P03安装台(4x Ø3.9 安装孔 + 1x Ø3.9 横向锁紧孔)+P01-C 竖排TOP刻字"),
("P02_Center_底座中核心_螺旋体30%_墙5底6_平打", "底座核心承力座", lambda: p02_c, 0x475569,
"200mm适配中座:195x190mm核心承力座+48mm套筒+12mm阶梯榫座(轴向面承载)+前后三角加强筋+前后非对称防呆母榫槽+刻字"),
("P02_Front_前向防翻力臂_螺旋体30%_墙4底5_平打", "前向防翻力臂", lambda: p02_f, 0xe11d48,
@@ -1166,9 +1392,9 @@ def build():
("P02_Rear_后向防翻力臂_螺旋体30%_墙4底5_平打", "后向防翻力臂", lambda: p02_r, 0xd97706,
"200mm适配后臂:45x190mm扩展力臂+80mm窄跨距双公榫+底部M5预埋螺母沉槽+P02-REAR刻字"),
("P03_肩吊臂_螺旋体35%_墙5底5_竖打", "肩关节安装悬臂", lambda: p_girdle(j1), 0x4b5563,
"高强多肋机架:10mm后置法兰+双侧8mm大三角加强筋+下腹12mm抗弯龙骨筋+侧开式电机托架(M1从+Y侧滑入装配,前后法兰8xM3双向锁死)+前置止脱锁紧法兰(4xM3止脱螺孔)+大散热窗与对流槽"),
"轻量化板式桁架版:主干由 66mm 实心块改为双侧 6mm 桁架竖板+帐篷减重窗(斜面≥52°自支撑)+R4内角,外轮廓与旧版并集完全一致,实心 728->286cm³;底部 9mm 承力鞍座(Ø3.9 安装孔)+12mm 中位龙骨筋(自鞍座斜拉至法兰下缘)+40~45°外扩斜撑;保留 10mm 后置法兰、侧开式电机托架、Ø60.7 直通电机舱(平孔无台阶)(M1 从+Y滑入,后端 4xM3x16 锁死)、大散热窗与对流槽;无封闭空腔、免支撑"),
("P04_肩叉_螺旋体30%_墙5底5_竖打", "肩叉", lambda: p_fork(j1, j2), 0x2563eb,
"加长避让版:头部与身子前移加长至X=10.5,与P03锁紧法兰保持2.0mm轴向安全间隙(不做前移,保持输出端最短悬伸力臂),J1输出凸台贯通直连电机,J1从动法兰圆盘鼓楞已删(接缝区与P03止脱环齐径齐高R36.5/顶646.5),单实体全角度零干涉"),
"齐平插槽版:叉板背面与P03托架端面(11.5)0.0硬贴平(用户要求外观齐平),P03侧插槽独立加长3mm,M1电机可倾斜卡入,J1输出凸台贯通直连电机,J1从动法兰圆盘鼓楞已删(接缝区与P03止脱环齐径齐高R36.5/顶646.5),单实体全角度零干涉"),
("P05_大臂旋转座_螺旋体30%_墙4底5_竖打", "大臂旋转座", lambda: p_shoulder_roll_base(j2, j3, None), 0x059669,
"V3圆角:Loft外轮廓R=10mm,L型转角内部2mm倒角,后缘凸包两角90°竖棱磨R2圆角(结构/后缘高度不变,打印无悬挂),保证J2全角度0干涉"),
("P06_大臂骨架_螺旋体25%_墙4底5_竖打", "大臂骨架", lambda: p_humerus_bone(j3, j4), 0x0d9488,
+3
View File
@@ -30,6 +30,8 @@ def rebuild_viewer():
sheets = io.open(WEB + "/sheets.jsdata", encoding="utf-8").read()
trv = io.open(DOCS + "/travel.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/travel.json") else "[]"
col = io.open(DOCS + "/collide.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/collide.json") else '{"caps":[],"parts":{}}'
screws = io.open(DOCS + "/screws.json", encoding="utf-8").read() if os.path.exists(
DOCS + "/screws.json") else "[]"
ik_solver = io.open(WEB + "/arm-ik-solver.js", encoding="utf-8").read()
@@ -42,6 +44,7 @@ def rebuild_viewer():
.replace("/*__DATA__*/", data)
.replace("/*__SHEETS__*/", sheets)
.replace("/*__TRAVEL__*/", trv)
.replace("/*__COLLIDE__*/", col)
.replace("/*__SCREWS__*/", screws)
.replace("/*__IKSOLVER__*/", ik_solver)
.replace("/*__IKGLUE__*/", ik_glue))
+6 -2
View File
@@ -48,8 +48,12 @@ SWEEP_R = 92.0 # farthest point of the humerus from the shoulder pit
POD_R = 47.0 # saddle drum outer radius about the roll axis
X_J1_HUB = 14.0 # J1 hub mating plane, forward of the humerus plate's ±44
X_FORK_GAP = 0.0 # P04 前移量(用户裁定=0:前移会加大输出端悬伸弯矩,保持最短力臂)
X_ARM = X_J1_HUB + PLATE_T / 2.0 + X_FORK_GAP # 18.5 the limb's own plane in x
X_BAY_EXT = 3.0 # M1 侧插槽加长量(2026-10-01 用户裁定:插槽开口 5.06+3=8.06,
# 比电机前端面多 3mm 斜插余量;P03 托架前端 8.5+3=11.5)
X_FORK_GAP = 1.0 # P04 肩叉前移量:叉板背面(10.5+1.0=11.5)与 P03 托架端面(11.5)
# 0.0 硬贴平(2026-10-02 用户两次要求"搞成平的",0.5mm 缝仍被判台阶)。
# 注意:J1 相对转动时 M2 预紧会经此面产生摩擦阻力,若转动发涩可微松 M2。
X_ARM = X_J1_HUB + PLATE_T / 2.0 + X_FORK_GAP # 19.5 the limb's own plane in x
Z_R1 = X_J1_HUB - HUB # -51 J1 rear datum, measured in x: M1 lies behind the shoulder
Z_CHEST1 = Z_RL - 140.0 # 470 torso mast top
Z_CHEST0 = FOOT_H
+28 -16
View File
@@ -8,19 +8,23 @@ docs/screws.json 是紧固件清单的唯一事实源(规格 / 长度 / 数量
实例坐标来源(与 arm_model.py 成孔代码一一对应,改模型后按同样规则复核):
头面 = 沉孔/通道的"承接台阶"(螺钉从外侧塞入,一直顶到孔径变小的那一圈台阶面),
因此每种规格的头都落在模型预留的沉孔里,不会露在零件表面之外。
因此每种规格的头都落在模型预留的沉孔里,不会露在零件表面之外(S05 除外:搭接壁太薄不能开沉孔)。
S01 J1~J6 输出毂 3xM2:joint.L() 局部 (r=m2_bcd/2, HUB_M2_CLOCK 相位),
台阶 z=63(J1, p_fork th 54..63) / 58(其余, th 54..58);头深藏在 Ø5.6 螺丝刀通道内
S02 P04~P08 4xM3 :下级电机 rear_mount_holes:沉孔 Ø5.5 从 -t_plate-1 到 -t_plate+4,
台阶 z = -t_plate+4 = -4(t_plate=8)/ -3(t_plate=7),头沉入 1mm
S03 P03 后法兰 4xM3 :J1 局部 (±17.55, ±20.46, -5.5) (沉孔 Ø6.5 台阶 x=-56.5,法兰外端面 -61)
S04 P03 前止脱 4xM3 :J1 局部 (R27.5 @17.8/107.8/197.8/287.8°, 57.5)(沉孔 Ø6.0 台阶 x=6.5)
S05 P01 分段对锁 4xM4:Ø4.4 贯穿柱身 x∈{-131,-93}, z∈{180,329},头在 -Y 立面 y=95(模型未设沉孔)
S04 P03 前置锁紧凸台 4xM3 :J1 局部 (R27.5 @17.8/107.8/197.8/287.8°, 62.5)
(沉孔 Ø6.0 在 Ø10 凸台端面 x=11.5,M3x10 旋入电机前端面咬合 3.5mm)
S05 P01 分段对锁 8xM4x16:Ø3.9 直孔 x∈{-131,-93}, z∈{180,329}(母口壁与公榫壁同径),
前后各拧一颗,头在 y=95 / y=161 外露(搭接壁薄不开沉孔),直接拧入自攻成型
S06 P09 工具法兰 4xM4:R20 @0/90/180/270°,台阶 z=97(沉孔 Ø7.0 深 4mm,法兰底面 z=93)
S07 P02 中核心 4xM5 :x∈{-135,-99}, y∈{110,146},台阶 z=6(Ø10 沉孔深 6mm,头沉入 1mm)
S08 P02 力臂 4xM5 :(-10,76/180) + (-185,88/168),台阶 z=17(Ø10 沉孔深 5mm,头与顶面齐平)
S09 P03 安装台 4xM5 :x∈{-136,-94}, y∈{107,149},台阶 z=455(P03 榫头底面,Ø5.5 孔 454..495),
头藏在 P01 立柱内腔里(孔深即 M5x35:455 -> 490,与模型 454..495 完全吻合)
S09 P03 安装台 4xM4x16:x∈{-142.5,-87.5}, y∈{114.5,141.5},台阶 z=479(P03 鞍座顶面,
从腔体正上方往下拧,Ø3.9 直孔穿过鞍座 9mm 再吃进 P01_C 顶环 7mm,自攻成型)
S11 P03 横向防脱锁紧 1xM4x16:x=-148(立柱 −X 外端面),y=128,z=463,头朝 -X,
沿 +X Ø3.9 直孔穿 11mm 后壁 + 榫头同径,直接拧入自攻(榫头内咬合 4.7mm)
S10 底座腰槽 6xM6 :3 件底座的 6 组 28x7 通腰槽中心(-97.5/120/-215 × 53/203/58/198),
头面 z=22(底座顶面;腰槽为通槽无沉孔,桌面紧固螺栓头必然外露)
@@ -188,21 +192,22 @@ def instances(m2_bcd, m2_clock):
"%s 后端板" % PROX[tag], "孔位(%.2f,%.2f) -> %s 电机尾端面" % (u, v, tag)))
d1 = frame("J1")[1]
for deg in (17.8, 107.8, 197.8, 287.8):
u = 27.5 * math.cos(math.radians(deg))
v = 27.5 * math.sin(math.radians(deg))
out.append(("S04", to_global("J1", u, v, 62.5), d1, "root",
"P03 前置锁紧凸台", "BCD55 @%.1f° -> M1 前端面" % deg))
for u in (-17.55, 17.55):
for v in (-20.46, 20.46):
out.append(("S03", to_global("J1", u, v, -5.5), tuple(-c for c in d1), "root",
"P03 后置法兰", "35.1×40.9 孔距 -> M1 尾端面"))
for deg in (17.8, 107.8, 197.8, 287.8):
u = 27.5 * math.cos(math.radians(deg))
v = 27.5 * math.sin(math.radians(deg))
out.append(("S04", to_global("J1", u, v, 57.5), d1, "root",
"P03 前置止脱法兰", "BCD55 @%.1f° -> M1 前端面" % deg))
for x in (X_COL - 16.0, X_COL + 22.0):
for z in (180.0, 329.0):
out.append(("S05", (x, 95.0, z), (0.0, -1.0, 0.0), "root",
"P01 立柱分段缝", "z=%.0f 榫缝对锁(另配 M4 螺母)" % z))
"P01 立柱分段缝", "z=%.0f 前面 -Y:Ø3.9 直孔(M4x16 直接拧入自攻)" % z))
out.append(("S05", (x, 161.0, z), (0.0, 1.0, 0.0), "root",
"P01 立柱分段缝", "z=%.0f 背面 +Y:Ø3.9 直孔(M4x16 直接拧入自攻)" % z))
for deg in (0.0, 90.0, 180.0, 270.0):
x = HL.X_ARM + 20.0 * math.cos(math.radians(deg))
@@ -219,10 +224,17 @@ def instances(m2_bcd, m2_clock):
out.append(("S08", (x, y, 17.0), (0.0, 0.0, 1.0), "root",
"P02 防翻力臂", "榫头对锁 (%.0f,%.0f)(另配 M5 预埋螺母)" % (x, y)))
for x in (X_COL - 21.0, X_COL + 21.0):
for y in (Y_COL - 21.0, Y_COL + 21.0):
out.append(("S09", (x, y, 455.0), (0.0, 0.0, -1.0), "root",
"P03 肩吊臂安装台", "穿透榫头 Ø5.5 孔(454..495)旋入立柱,头藏于 P01 立柱内腔"))
for x in (X_COL - 27.5, X_COL + 27.5):
for y in (Y_COL - 13.5, Y_COL + 13.5):
# 方向 = 螺帽朝外法向(+Z,扳手从腔体上方进入);拧入方向 = -Z,Ø3.9 直孔自攻
out.append(("S09", (x, y, 479.0), (0.0, 0.0, 1.0), "root",
"P03 肩吊臂安装台",
"Ø3.9 直孔:从腔体正上方拧入,穿鞍座 9mm 吃进立柱顶环 7mm(自攻成型)"))
# 横向防脱锁紧(M4x16):螺帽压在后壁外面 x=-148,沿 +X Ø3.9 直孔自攻(穿 11mm 后壁 + 榫头)
out.append(("S11", (X_COL - 33.0, Y_COL, 463.0), (-1.0, 0.0, 0.0), "root",
"P03 肩吊臂安装台",
"Ø3.9 直孔:从立柱后方拧入,穿 11mm 后壁吃进榫头 4.7mm(自攻成型)"))
for x, y in ((-97.5, 53.0), (-97.5, 203.0), (120.0, 58.0),
(120.0, 198.0), (-215.0, 58.0), (-215.0, 198.0)):
+10
View File
@@ -61,6 +61,16 @@ VIEWS = {
"user_shoulder_solid": "?cam=joint&az=2.1&po=1.35&rd=380&xray=0",
"user_shoulder_xray": "?cam=joint&az=2.1&po=1.35&rd=380",
"user_shoulder_top": "?cam=joint&az=-0.6&po=0.6&rd=360&xray=0",
# P03 轻量化桁架版(孤立视图:只留 P03,隐藏其余零件/电机/螺钉/包络)
"p03_light_iso": "?az=0.62&po=1.15&rd=300&tx=-70&ty=548&tz=-128&xray=0&hide=P01,P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,S0,S1,ENV",
"p03_light_side": "?az=1.5708&po=1.5708&rd=320&tx=-70&ty=548&tz=-128&xray=0&hide=P01,P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,S0,S1,ENV",
"p03_light_rear": "?az=0.0&po=1.45&rd=300&tx=-70&ty=548&tz=-128&xray=0&hide=P01,P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,S0,S1,ENV",
"p03_light_join": "?az=0.62&po=1.15&rd=420&tx=18.5&ty=520&tz=-128&xray=0",
"p03_light_xray": "?az=0.62&po=1.15&rd=300&tx=-70&ty=548&tz=-128&xray=1&hide=P01,P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,S0,S1,ENV",
# P03↔立柱 安装接口(新版 4xM5 从腔体上方拧入):俯视看螺钉落位
"p03_mount_top": "?az=0.9&po=1.30&rd=190&tx=-115&ty=476&tz=-128&xray=0&hide=P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,ENV",
"p03_mount_iso": "?az=2.2&po=0.95&rd=230&tx=-115&ty=486&tz=-128&xray=0&hide=P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,ENV",
"p03_mount_loose": "?az=0.9&po=0.42&rd=260&tx=-115&ty=500&tz=-128&xray=0&expl=60&hide=P02,P04,P05,P06,P07,P08,P09,M1,M2,M3,M4,M5,M6,ENV",
"svg_side": f"file:///{DOCS}/sheet_side.svg",
"svg_joint": f"file:///{DOCS}/sheet_joint.svg",
"svg_exploded": f"file:///{DOCS}/sheet_exploded.svg",
+2
View File
@@ -159,6 +159,8 @@ def arm_masses():
TREE = ["root", "J1", "J2", "J3", "J4", "J5", "J6"]
tot = {}
for p in meta["parts"]:
if not os.path.exists("%s/parts_step/%s.step" % (OUT, p["name"])):
continue # 螺钉实例 / 可达包络网格等非 CAD 构件没有 STEP,跳过
sh = load(p["name"])
v = volume(sh) / 1000.0
g = (v * RHO["PLA"] * 1000.0 * INFILL) if p["printed"] else RHO["motor_g"]
+172 -60
View File
@@ -84,7 +84,8 @@ small{color:var(--dim)}
#bomDock .dk-hd{display:flex;align-items:center;gap:8px;padding:9px 10px;flex:none;
border-bottom:1px solid var(--line)}
#bomDock .dk-hd b{font-size:13px}
#bomDock .dk-hd .q{color:var(--dim);font-size:11px}
#bomDock .dk-hd .q{color:var(--dim);font-size:11px;min-width:0;flex:0 1 auto;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#bomDock .dk-x{margin-left:auto}
#bomDock .dk-bd{overflow:auto;padding:8px 11px 16px;flex:1;min-height:0}
.dk-sec{margin:4px 0 5px;color:var(--ac);font-size:12px;letter-spacing:.4px}
@@ -118,13 +119,19 @@ small{color:var(--dim)}
#ikPose.on{background:#3a2b63;border-color:#a06bff;color:#fff;font-weight:600}
#ikPoseBox{display:none;align-items:center;gap:6px}
#ikPoseBox input[type=number]{width:54px}
#ikMixBox{display:none;align-items:center;gap:6px;flex:none}
#ikMixBox input[type=range]{width:118px;flex:none;accent-color:var(--ac);cursor:pointer}
#ikMixBox .st{min-width:56px}
/* ===================== IK 结果面板(左栏顶部) ===================== */
#ikpanel{margin:0 0 10px;border:1px solid var(--line);border-radius:6px;background:#141821;padding:7px 8px}
#ikpanel .ikh{display:flex;align-items:center;gap:6px;margin-bottom:5px}
#ikpanel .ikh h3{margin:0;font-size:12px;color:var(--ac);letter-spacing:.3px}
.badge{margin-left:auto;font-size:10.5px;padding:1px 7px;border-radius:9px;border:1px solid;flex:none;
white-space:nowrap}
/* 徽标必须可换行:CJK 的最小内容宽度=一个字,若徽标 nowrap+flex:none,
长状态文案会把标题压成竖排一字一行(实测 h3 缩到 12px) */
#ikpanel .ikh{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;margin-bottom:5px}
#ikpanel .ikh h3{margin:0;font-size:12px;color:var(--ac);letter-spacing:.3px;
min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{margin-left:auto;font-size:10.5px;padding:1px 7px;border-radius:9px;border:1px solid;
flex:0 1 auto;max-width:100%;text-align:center;line-height:1.35;text-wrap:balance}
.badge.wait{color:var(--dim);border-color:var(--line)}
.badge.goodb{color:#0c1a12;background:var(--ok);border-color:var(--ok)}
.badge.warnb{color:#241a06;background:var(--warn);border-color:var(--warn)}
@@ -226,14 +233,14 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
<span class="lb">mm</span>
<span class="vsep"></span>
<span class="lb">扫描精度</span>
<select id="ikStep" title="冗余维 (J4,J5) 的扫描步长:越小越接近全局最优,耗时越长">
<select id="ikStep" title="冗余维 (J4,J5) 的粗扫步长:只在可行段内采样;位置任务取全局最优,姿态任务的精确解由孤立根枚举给出、与步长无关">
<option value="1">快速 1°</option>
<option value="0.5">较快 0.5°</option>
<option value="0.25" selected>标准 0.25°</option>
<option value="0.1">精细 0.1°</option>
<option value="0.05">极高 0.05°</option>
<option value="0.05">参考级 0.05°</option>
</select>
<button id="ikSolve" title="求解并把手臂动画移动到该位姿(取 Σ|θ| 最小的构型)">求解并移动</button>
<button id="ikSolve" title="求解并把手臂动画移动到该位姿(取相对当前姿态行程最小的构型)">求解并移动</button>
<button id="ikPose" title="开启姿态控制:除 TCP 位置外,同时指定末端工具姿态(工具轴指向 + 绕轴滚转)">姿态控制</button>
<span id="ikPoseBox">
<span class="lb">方位</span><input type="number" id="ikAz" step="5" value="0" title="工具轴(法兰朝外方向)绕 CAD Z 轴的方位角 / 度">
@@ -241,6 +248,18 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
<span class="lb">滚转</span><input type="number" id="ikRoll" step="5" value="0" title="绕工具轴自身的滚转角 / 度(右手系)">
<button id="ikPoseCur" title="把当前真实工具姿态读回三个输入框">取当前</button>
<button id="ikPoseDown" title="快捷设为工具轴竖直向下(爪取常用姿态)">垂直向下</button>
<span class="lb">优先级</span>
<select id="ikPrio" title="位置与姿态不能同时满足时的取舍:位置优先 = 保 TCP 点位精确、姿态取最接近可达值;姿态优先 = 保指定姿态精确、TCP 取最接近可达点(位置差在结果栏如实给出);混合优先 = 用右侧滑杆自由分配两端比重,0% = 姿态优先、100% = 位置优先,中间值在两端锚点之间按归一化折中代价加权搜索">
<option value="pos" selected>位置优先</option>
<option value="ori">姿态优先</option>
<option value="mix">混合优先</option>
</select>
<span id="ikMixBox" title="混合优先的比重(自由分配位置与姿态):向左 = 姿态优先、向右 = 位置优先;0% / 100% 与两条纯算法逐位一致。折中代价 J = w·(e/e_ref)² + (1−w)·(o/o_ref)²,两端尺度取各自锚点(位置端 = 位置优先解的姿态残差、姿态端 = 姿态优先解的位置差),故读数即「沿折中历程的进度」。滑动即算即动,松手全量折中">
<span class="lb">姿态</span>
<input type="range" id="ikMixW" min="0" max="100" step="1" value="50">
<span class="lb">位置</span>
<span class="st" id="ikMixV">50% 位置</span>
</span>
</span>
</div>
<div class="ikrow">
@@ -277,7 +296,7 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
<div class="doc">
<b>模式</b>:本页「<b>运动模拟</b>」承载全部运动学能力;「三维模型」为纯静态模型视图(关节锁定在设计零位、无目标点与求解控件),「装配图」为二维图纸。运动模拟下左栏只保留 IK 结果与关节角度,零件树/打印清单自动收走。<hr>
<b>目标</b>:使 TCP(末端工具法兰 P09 安装面中心,可加“工具长”)到达给定点,并在一切满足限位的可行构型中取
<b>Σ|θᵢ| 最小</b>者(交互连续求解时叠加参考位姿连续项,见第 6 条)。开启上方“<b>姿态控制</b>”后,
<b>各电机需转过的角度之和最小</b>者(相对当前姿态的行程;连续轴 θ 与 θ±360 是同一物理位形,角差一律取 ±180° 内的短弧,见第 6 条;离线批算不给参考姿态时退化为 Σ|θᵢ| 最小)。开启上方“<b>姿态控制</b>”后,
约束升级为 <b>位置 3 + 姿态 3</b>(工具轴指向 + 绕轴滚转),见第 5 条。
<br><b>姿态操控</b>:姿态开启后目标球处出现方向箭头与粉色侧球——<b>箭头指向即工具朝外方向</b>(法兰面法向朝外,也就是工具伸出的方向)。
<b>拖箭头头部</b>用弧球法(指针射线与以目标为球心、半径=箭长的球求交,方向=交点−球心)连续调整方位/俯仰;
@@ -288,7 +307,7 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
<ol>
<li><b>先消去一维</b>(仅位置任务):TCP 恰好落在 J6 轴线上,绕 J6 转动不改变 TCP 位置 ⇒ 对“到点”任务取
<code>θ6 = 0</code> 即已最优。</li>
<li><b>识别结构</b>:J2 / J4 / J5 三轴严格平行(轴向 Y,过 x = 18.5),J1 轴为 X,J3 轴沿臂长方向 Z。
<li><b>识别结构</b>:J2 / J4 / J5 三轴严格平行(轴向 Y,过 x = 19.5),J1 轴为 X,J3 轴沿臂长方向 Z。
记 <code>A = X−X0</code>、<code>R = |(Y−Y0, Z−Z1)|</code>、<code>φ = atan2(Z−Z1, Y−Y0)</code>,
则 <code>X = X0 + a</code>,而 <code>(Y, Z)</code> 由 <code>(b, c)</code> 绕 J1 轴旋转 φ 得到:<br>
<code>a = Vx·cosθ3·cosθ2 + Vz·sinθ2</code>,<code>b = −Vx·sinθ3</code>,
@@ -303,25 +322,55 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
最后 θ2 与 θ1 各由一个 2×2 线性方程组唯一确定:
<code>cosθ2 = (κA + Vz·n)/(κ² + Vz²)</code>,<code>sinθ2 = (Vz·A − κ·n)/(κ² + Vz²)</code>,
<code>κ = Vx·cosθ3</code>;<code>θ1 = φ − atan2(c, b)</code>。</li>
<li><b>遍历冗余</b>:(θ4, θ5) 就是二维冗余。在 (θ4, θ5) 的限位盒内<b>全域扫描</b>(区间端点必采,含上下限位),
每个网格点闭式求出全部支 ⇒ <b>覆盖整个可行解族</b>;再对最优的若干互异候选做 h/8 局部加密 + 模式搜索,
得到 <b>全局最小 Σ|θ|</b>,误差量级 ≤ 扫描步长。</li>
<li><b>遍历冗余(按行裁剪到可行段)</b>:(θ4, θ5) 就是二维冗余。闭式支只在平面支有解时才存在,
限位盒内只有 <b>7~27%</b> 的网格点能过判据,且实测(5 个目标 × 全部 θ4 行)<b>每行的可行集都是单一连续段</b>
—— 于是先用探测步找出每行的可行段、向两端各放宽一个探测步兜住段界,段内再按步长精扫。
段内密度与全域扫一致,因此<b>解的质量不动</b>,粗扫采样量降约 4.6×(但全量总耗时只降约 1.10×——局部加密窗口是 ±2h 步长 h/8,格数恒为 32×32/种子、与 h 无关,精修才是耗时地板)。
之所以不把粗扫一起放松:根枚举目前不保证完备,漏根时要靠粗扫的窄带候选兜底,
实测把粗扫同步放松会换来上百度级的代价劣化。</li>
<li><b>姿态任务(6 自由度)</b>:工具坐标系姿态 <code>R = R₁·R₂·…·R₆</code>(与场景图 6 级矩阵链一致)。
结构上 <b>J5 轴与 J6 轴在腕心 (18.5, 128, 222) 垂直相交</b>,因此 θ6 只提供绕工具自身轴的滚转:
结构上 <b>J5 轴与 J6 轴在腕心 (19.5, 128, 222) 垂直相交</b>,因此 θ6 只提供绕工具自身轴的滚转:
<u>工具轴指向 = (R₁…R₅)·ẑ,与 θ6 无关,TCP 位置也与 θ6 无关</u>。每条位置闭式支都附带确定的 R₅
(= R₁…R₅),滚转角随之闭式解出:<code>N = R₅ᵀ·R_des</code>(需为 Rz(−θ6))⇒
<code>θ6 = −atan2(N₁₀, N₀₀)</code>;姿态偏差 <code>e = ∠(R₅·Rz(−θ6), R_des)</code> ≤ 2° 的支才收入。</li>
<li><b>姿态达标优先</b>:达标解在 (θ4,θ5) 平面上是一条斜窄谷(奇异邻域灵敏度可达 ~100°/°),
按 Σ|θ| 取种子会整体错过它。达标带(e ≤ 1°)按 e 升序取种子、局部窗口加密、必要时 escalating
(姿态惩罚 ×100 重扫),最后做 <b>二维牛顿收口</b>:对“工具轴在目标轴法平面上的切向偏差”迭代,
每步在新 (θ4,θ5) 上重解闭式位置支(按 θ1..θ3 最近匹配取同支,位置天然保持精确),把 e 压到 ~10⁻³° 量级。
<b>最终只在 e ≤ 0.05° 的达标候选里按含连续项代价选优——不为省几度关节角牺牲姿态精度</b>;
确有 ~5% 的均匀随机姿型落在奇异“刀尖”上(姿态仅在奇异点精确可达),此时按最近位姿返回并如实报告残差。</li>
<li><b>连续优先(防倒摆)</b>:等价多分支(θ3 的 ±σ/±(π−σ)、肘上/肘下)之间只按 Σ|θ| 选优时,目标微动就会在分支间
跳来跳去,某轴大幅倒摆(实测 4mm 步长过可达边界最大跳 81.9°)。交互求解以<b>当前手臂姿态</b>为参考位姿,
代价加入 <code>λ·Σwrap(θᵢ−refᵢ)²</code>(λ=0.02):同族分支被锁定,仅当近支越限或误差超差才换支。
A/B 实测(4 条扫描路径):&gt;10° 跳变 14→5 次,最大跳变 163.5°→16.8°,平均 Σ|θ| 不升反降。</li>
<li><b>代表角</b>:J1 / J3 / J6 为 ±180° 整周轴,取 |θ| 最小的等价角;J2 / J4 / J5 有限位,越限即丢弃。</li>
<li><b>姿态 = 孤立根求解</b>:闭式支把 θ1..θ3 吸收掉之后,姿态任务只剩「工具轴指向 = 目标轴」
这 2 个方程、2 个未知数 (θ4,θ5),解是<b>孤立点</b>(独立密集种子 + 牛顿普查实测只有 1~2 个根),
滚转再由 θ6 闭式补上。所以不再用网格密度去撞那条斜窄谷:逐行对残差两个分量找<b>变号括号</b>、
从括号中点起跑<b>阻尼高斯-牛顿</b>(奇异时加大阻尼而非退出),并对每支的 ‖e‖ 最小节点补种以收
切向相切的根;代价最优的若干条入选候选再各收口一次。于是
<b>姿态残差量级从 ~10⁻²° 变为 10⁻⁶°(工程意义上的精确)</b>,且 Σ|θ| 不劣于原路径。</li>
<li><b>两处判据陷阱</b>(都验过,写在这里防止被自证):其一,切向残差在工具轴<b>反平</b>时同样为零,
同向判据只能在收口时验,当作迭代中止条件会让每支一进来就作废;其二,求根按<b>支下标</b>对齐
prev/cur,不可行的支必须留 null 占位,否则下标错开、括号判据失配,实测会整支漏掉一个真根、
多付 130° 关节角。确有少数奇异「刀尖」构型<b>无精确根</b>,此时牛顿收敛到 ‖e‖ 的真实最小,
报出的残差不再是采样伪影。</li>
<li><b>混合优先(比重可调折中)</b>:优先级下拉的第三种模式——一根滑杆自由分配两端:
<b>0% 即姿态优先、100% 即位置优先</b>(两端直接交给上面两条专用算法,结果逐位一致),
中间值在两端锚点之间最小化<b>归一化折中代价</b>
<code>J = w·(e/e_ref)² + (1−w)·(o/o_ref)²</code>。两端尺度取锚点自身:
<code>e_ref</code> = 姿态优先解的 TCP 位置差、<code>o_ref</code> = 位置优先解的姿态残差,
于是<b>滑杆读数就是「沿折中历程的进度」</b>、与目标的绝对误差量级无关(50% ≈ 两端各自归一后对半)。
之所以不用「1 mm ↔ 1°」的绝对量纲:实测两端误差常在 100 mm / 40° 量级,绝对量纲会把读数整体挤向
位置端(折线中点落到 ~13% 处),滑杆就不好用了。两端锚点复用上面两条算法——位置端 = 位置精确族 +
闭式滚转 + 孤立根收口(位置不可达时退化为最近可达位置),姿态端 = 工具轴闭式对齐 + θ6 补滚转
(姿态精确、位置最近);中间值以两锚点及<b>关节空间插值</b>为种子,对 6 维残差
<code>[√w·(p−t)/e_ref , √(1−w)·RV°/o_ref]</code> 做<b>阻尼高斯-牛顿 + 单轴抛光</b>,取 J 最小者
(J 同量级 2% 带内取行程 — 相对当前姿态的 Σ|wrap(θᵢ−refᵢ)| — 更小者,防等价支横跳)。两端中任一端在另一维也达标
(≤0.05)时折中无意义、直接返回该端——注意位置端不达标而姿态端在位置上达标时返回<b>姿态端</b>
(它两维同时占优:位置优先族收不到的到达解,姿态端收得到)。全程无随机数,同一输入逐位可复现;
拖动走快路(同「实时续解」条哲学,归一化尺度沿用最近一次全量的缓存),松手必走全量。</li>
<li><b>连续优先(最小移动角 / 防倒摆)</b>:等价多分支(θ3 的 ±σ/±(π−σ)、肘上/肘下、J1/J3 镜像族)之间只按 Σ|θ| 选优时,
目标微动就会在分支间跳来跳去、绕远路(实测目标挪 20 mm 就翻族:垂直向下时左右移动单步最大 74.8°、
累计绕出 348°,而目标实际只扫过 160°)。交互求解以<b>当前手臂姿态</b>为参考位姿,选解判据取
<b>所有电机需要转过的角度之和最小</b>——<code>Σ|wrap180(θᵢ−refᵢ)|</code>:连续轴(J1/J3/J6)无机械限位,
θ 与 θ±360 是同一物理位形,故角差一律取短弧;末尾 <code>1e-6·Σ|θ|</code> 只作并列时的次序稳定项。
同一条扫描路径实测:单步最大 5.0°、累计 160.0°,与目标实际扫过的角度一致。
拖动动画同样对连续轴取「离起始角最近的等价角」再插值,避免 +151.9°→−167.0° 反向扫 318.9° 绕一整圈。</li>
<li><b>实时续解(拖动快路)</b>:目标每帧只挪几毫米时,真解在 (θ4,θ5) 平面上连续移动,
没必要为重扫整个冗余维付全价。<code>solveWarm</code> 只在上一帧解附近取<b>同一闭式支</b>、走一小步同支搜索,
姿态任务再收口一次;<b>任一校验不过(换支、越限、位置残差超差、姿态没收口、关节跳太大)就返回 null
自动回落全量粗扫</b>,松手后的求解一定不带 warm、走全量。快路只负责「能快的时候快」,不改变正确性判据。</li>
<li><b>代表角</b>:位姿以 360° 为周期,解角一律取 ±180° 内的等价角(|θ| 最小);J2 / J4 / J5 另受相邻件实测机械区间约束,越限即丢弃。</li>
<li><b>自检</b>:每次求解都用闭式 FK 与场景图真实 6 级矩阵链各算一次 TCP 和工具姿态并与目标比对(见上表残差)。</li>
</ol>
<hr>
@@ -333,23 +382,35 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
<tr><td>逆解分支重构(200 组随机可行位形)</td><td class="good">200 / 200 命中</td></tr>
<tr><td>θ6 不变性(0° → 137°,TCP 位移)</td><td class="good">0.00 mm</td></tr>
<tr><td>姿态参数 ↔ 旋转矩阵往返(2000 组随机)</td><td class="good">2.4 × 10⁻⁶°</td></tr>
<tr><td>姿态重构达标率(200 组随机可行位形,0.5° 步长)</td><td class="good">185–191 / 200(4–7% 奇异刀尖构型残差 ≤ 2°,如实报告)</td></tr>
<tr><td>姿态重构达标率(200 组随机可行位形,step 0.5,A/B 同机)</td><td class="good">中位残差 0.0234° → <b>0.000000°</b>;&gt;0.05° 的例数 2 → <b>0</b>(改造前文档口径 185–191/200)</td></tr>
<tr><td>姿态解 Σ|θ| vs 改造前(60 组,step 0.25 + 连续项)</td><td>中位 +0.03°,最大 +8.4°(该例是新解更贴参考位姿、Σ|θ| 反而更大,属连续项的正常取舍而非漏解)</td></tr>
<tr><td>位置解 vs 0.05° 参考网格(100 目标,step 0.25)</td><td class="good">中位 +0.000°,最大 +0.49°,&gt;1° 例数 0</td></tr>
<tr><td>全量求解耗时(60 组,step 0.25 + 连续项)</td><td>仅位置 70.6 → 63.3 ms;位置+姿态 88.8 → 81.0 ms(约 1.10×;粗扫采样量降 4.6×,但精修才是地板)</td></tr>
<tr><td>实时续解快路(每帧 3 mm + 1.5°,240 帧)</td><td class="good">位置命中 96%、均 0.058 ms;姿态命中 65%、均 0.59 ms —— 对全量 81 ms 约 140~1400×,是交互延迟的主要收益来源</td></tr>
<tr><td>快路结果的独立复核(fkChain / 全 6 轴 orient,非求解器自报)</td><td class="good">位置 5.5×10⁻¹³ mm · 姿态 1.7×10⁻⁶°</td></tr>
<tr><td>姿态模式候选表长度(供互异构型/碰撞改选取样)</td><td>26 → 13(精确根集合本就只有 1~2 个,达标门收紧后可选样变少;动态碰撞改选若依赖宽候选需留意)</td></tr>
<tr><td>滚转角交互 roll 0→90(参考位姿连续)</td><td class="good">Δθ6 = 89.99°,θ1..θ5 变化 &lt; 0.01°</td></tr>
<tr><td>60 个可达目标:失败次数 / 最大残差</td><td class="good">0 次 / 3.5 × 10⁻¹³ mm</td></tr>
<tr><td>相对 0.05° 极细网格参考的最大劣化</td><td class="good">0.02°</td></tr>
<tr><td>vs 4000 次随机多起点数值解</td><td class="good">0 / 20 次劣于数值解</td></tr>
<tr><td>等价分支跳变 · 4 条扫描路径(修复前 → 后)</td><td class="good">&gt;10° 跳变 14 → 5 次;最大单步 163.5° → 16.8°</td></tr>
<tr><td>单次求解耗时(默认 0.25°)</td><td>约 0.1 s(1° ≈ 30 ms,0.05° ≈ 1.1 s;开姿态后同级或更快)</td></tr>
<tr><td>单次求解耗时</td><td>拖动跟随走快路 ~0.1 ms;全量求解默认步长下位置 ~20 ms、姿态 ~100 ms(node 侧本机实测,浏览器同量级)</td></tr>
</tbody>
</table>
<hr>
<b>限位</b>:J2 ∈ [−106, 9],J4 ∈ [−111, 16],J5 ∈ [−95, 34],J1 / J3 / J6 为 ±180°(可整周)。
<br><b>局限</b>:本算法为纯运动学求解,只受各关节行程限位约束,<u>不含跨关节自碰撞校核</u>。
原模型限位来自全对全动态扫描;若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
<b>限位</b>:电机行程各轴 ±360°(=滑块范围);解角取 ±180° 内的等价角——位姿以 360° 为周期,超出 ±180° 不产生新位姿。
相邻打印件的实测机械区间封顶:J2 ∈ [−107, 128],J4 ∈ [−112, 112],J5 ∈ [−96, 96],J1 / J3 / J6 可整周。
<br><b>参考</b>:零位静态扫描 J2 [−106, 9]、J4 [−111, 16]、J5 [−95, 34] 只在其它轴全零时对固定立柱成立,已不再当可行域;
跨组零件对(腕/法兰撞立柱那类)由胶囊碰撞模型按当前姿态实时判定(实测 J1=90° 时 J2 正端从 +8° 放到 +107°)。
<br><b>局限</b>:胶囊模型故意不检相邻组零件对(|Δg|=1),那部分由上面的实测机械区间兜底;换模型须重跑
scratch/probe_adj.py 更新 collide.json 的 adj。若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
<br><b>不可达</b>:若目标超出工作空间,自动求最近可达位姿——阻尼最小二乘<b>从当前姿态锚定起步</b>(抑制退化方向的无用漂移)、固定种子的确定性重启(同一点重复解算逐位一致)、再经零空间抛光取最小总关节角的代表值;报告的距离为抛光后真实值。
<br><b>姿态不可达</b>:姿态任务超可达空间时,阻尼最小二乘误差向量升级为 6 维(位置 mm + 姿态 deg,按 20 mm/rad 折算),返回位置与姿态联合最近的位姿,两个残差分别显示。
<br><b>混合优先不可达</b>:位置超可达空间时,位置端锚点退化为「最近可达位置」、姿态端仍取「姿态精确、位置最近」,
再按同一权重折中,两端各自的残差在结果栏如实列出(与位置优先的历史兜底——6 维联合 DLS——是两个不同的代表值,切换模式可得)。
<br><b>API</b>:控制台可用 <code>IK.solver.solve([x,y,z],{step:0.25,tool:0,pose:{R:ArmIK.poseMat(az,el,roll)}})</code>;
<code>IK.setPose(az,el,roll)</code> 可直接设定目标姿态。
<code>IK.setPose(az,el,roll)</code> 可直接设定目标姿态;<code>IK.setPrio('mix',0.3)</code> 切到混合优先并设比重;
<code>IK.solver.solveTo([x,y,z],{pose:{R:…},priority:'mix',mixW:0.3})</code> 直接取折中解(0 与 1 分别等于姿态优先 / 位置优先)。
</div>
</details>
</div>
@@ -403,6 +464,7 @@ body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
const DATA = /*__DATA__*/;
const SHEETS = /*__SHEETS__*/;
const TRAVEL = /*__TRAVEL__*/;
const COLLIDE = /*__COLLIDE__*/;
const TL = {}; for (const t of TRAVEL) TL[t.axis] = t;
const V3 = THREE.Vector3;
const VC = p => [p[0], p[2], -p[1]]; // CAD (x,y,z) -> viewer (x,z,-y); must precede use
@@ -430,15 +492,20 @@ for (const j of JO) {
const g = new THREE.Object3D(); g.matrixAutoUpdate = false;
JG[j.id] = g; JG[parentOf[j.id]].add(g);
}
/* 轴心 P / 轴向 D 只与 CAD 参数有关:预计算一次;矩阵合成改用 3 个共享实例。
旧版每次调用新建 2 个 Vector3 + 3 个 Matrix4(一次关节写入 = 6 次调用 = 30 个临时对象),
动画与实时追踪高频触发时纯属垃圾。数学与原式逐位相同:T(P)·R(D,θ)·T(−P)。 */
const JP = {}, JD = {};
for (const j of JO) { JP[j.id] = new V3(...VC(j.rear)); JD[j.id] = new V3(...VC(j.d)).normalize(); }
const _jmT = new THREE.Matrix4(), _jmR = new THREE.Matrix4(), _jmTi = new THREE.Matrix4();
function applyJoint(id) {
const j = JO.find(q => q.id === id); if (!j) return;
// geometry vertices were built in the viewer frame (x, z, -y), so the pivot point and the
// axis must go through the same swap - using raw CAD coords rotates about the wrong line
const P = new V3(...VC(j.rear)), D = new V3(...VC(j.d)).normalize();
const P = JP[id], D = JD[id]; if (!P) return;
const a = (ang[id] || 0) * Math.PI / 180;
const m = new THREE.Matrix4().makeTranslation(P.x, P.y, P.z)
.multiply(new THREE.Matrix4().makeRotationAxis(D, a))
.multiply(new THREE.Matrix4().makeTranslation(-P.x, -P.y, -P.z));
const m = _jmT.makeTranslation(P.x, P.y, P.z)
.multiply(_jmR.makeRotationAxis(D, a))
.multiply(_jmTi.makeTranslation(-P.x, -P.y, -P.z));
JG[id].matrix.copy(m);
JG[id].matrixWorldNeedsUpdate = true;
requestRender();
@@ -454,8 +521,11 @@ for (const p of DATA.parts) {
p.triCount = p.t ? p.t.length : 0;
p.vertCount = p.v.length;
const isEnv = p.name.slice(0, 4) === 'ENV_'; // 可达包络:运动学边界,不随关节动
/* 索引按顶点数降位:单件 ≤65535 顶点时 Uint16 合法(92 件实测最大 47483),
索引内存与上传带宽减半;超大件保留 Uint32 兜底。 */
const IDX16 = p.v.length <= 65535;
if (p.kind === 'line') { // 经纬线:LineSegments,无三角面
const sIdx = new Uint32Array(p.s);
const sIdx = IDX16 ? new Uint16Array(p.s) : new Uint32Array(p.s);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setIndex(new THREE.BufferAttribute(sIdx, 1));
geo.computeBoundingSphere(); geo.computeBoundingBox();
@@ -470,7 +540,7 @@ for (const p of DATA.parts) {
delete p.v; delete p.s;
continue;
}
const idx = new Uint32Array(p.t.length * 3);
const idx = IDX16 ? new Uint16Array(p.t.length * 3) : new Uint32Array(p.t.length * 3);
// the vertex map (x,y,z)->(x,z,-y) has determinant +1, so triangle winding must be kept
// as-is; swapping two indices here inverted every face and made the normals point inward
for (let i = 0; i < p.t.length; i++) { idx[i*3] = p.t[i][0]; idx[i*3+1] = p.t[i][1]; idx[i*3+2] = p.t[i][2]; }
@@ -566,16 +636,23 @@ cv.addEventListener('pointerdown', e => {
panDist = 0;
cv.setPointerCapture(e.pointerId);
});
let hoverPending = false, lastHoverEv = null;
/* 悬浮拾取限流:射线要对 34 万三角面全量求交(实测 ≈0.5~1.5 ms/次),
旧版每个 pointermove 都算(rAF 合帧后仍≈显示器刷新率次数);
这里加 60 ms 时间配额 + 位置去重,指针静止时一次都不算,尾随仍会补最新位置,
tooltip 观感不变。点击 / 右键拾取不经过此函数,仍即时求交。 */
let hoverPending = false, lastHoverEv = null, hoverT = -1e9, hoverX = -1, hoverY = -1;
const HOVER_MS = 60;
function schedHover(e) {
lastHoverEv = e;
if (!hoverPending) {
if (e.clientX === hoverX && e.clientY === hoverY) return; // 没移动:结果不可能变
hoverX = e.clientX; hoverY = e.clientY; lastHoverEv = e;
if (hoverPending) return;
hoverPending = true;
requestAnimationFrame(() => {
setTimeout(() => {
hoverPending = false;
if (lastHoverEv && !drag) hover(lastHoverEv);
});
}
if (!lastHoverEv || drag) return;
hoverT = performance.now();
hover(lastHoverEv);
}, Math.max(0, HOVER_MS - (performance.now() - hoverT)));
}
cv.addEventListener('pointermove', e => {
if (!drag) { schedHover(e); return; }
@@ -592,12 +669,23 @@ cv.addEventListener('wheel', e => { e.preventDefault(); rd *= (1 + Math.sign(e.d
/* ---- picking & hiding ---- */
const ray = new THREE.Raycaster(); let sel = null, hov = null;
/* 拾取列表与 NDC 向量复用同一实例:旧版每次 pointermove 都 filter 出一个新数组 +
新建 Vector2(高频下纯垃圾)。可见性逐次重算,语义与 filter 完全一致
——three r159 的 Raycaster 不检查 object.visible,只能靠这里过滤。 */
const pickList = [], ndcPick = new THREE.Vector2();
function pick(e) {
const r = cv.getBoundingClientRect();
ray.setFromCamera(new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1), cam);
const hit = ray.intersectObjects(meshes.filter(m => m.visible), false)[0];
ndcPick.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
ray.setFromCamera(ndcPick, cam);
pickList.length = 0;
for (const m of meshes) if (m.visible) pickList.push(m);
const hit = ray.intersectObjects(pickList, false)[0];
return hit ? hit.object : null;
}
/* BOM 表高亮行只记一行:旧版每次点选/ESC 都要把整张表(13 行零件 + 10 行螺钉)
的 style.background 全刷一遍,纯属多余 DOM 写入。 */
let bomHlRow = null;
function bomHlClear() { if (bomHlRow) { bomHlRow.style.background = ''; bomHlRow = null; } }
function hover(e) {
const o = pick(e); if (o === hov) return; hov = o;
const n = document.getElementById('note');
@@ -650,8 +738,7 @@ window.addEventListener('keydown', e => {
sel = null;
const hud = document.getElementById('hud');
if (hud) hud.textContent = '';
const tb = document.querySelector('#bom tbody');
if (tb) for (const tr of tb.children) tr.style.background = '';
bomHlClear();
requestRender();
}
}
@@ -660,7 +747,7 @@ cv.addEventListener('click', e => {
const o = pick(e);
if (o === sel) {
if (sel) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; sel = null; }
const tb = document.querySelector('#bom tbody'); if (tb) for (const tr of tb.children) tr.style.background = '';
bomHlClear();
clearScrewHl();
const hud = document.getElementById('hud'); if (hud) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
@@ -669,7 +756,8 @@ cv.addEventListener('click', e => {
if (sel && !sel.userData.screw) { sel.material.emissive.setHex(0); sel.material.emissiveIntensity = 0; }
sel = o;
if (sel && !sel.userData.screw) { sel.material.emissive.setHex(0x2f6bff); sel.material.emissiveIntensity = .55; }
const tb = document.querySelector('#bom tbody'); for (const tr of tb.children) tr.style.background = '';
const tb = document.querySelector('#bom tbody');
bomHlClear();
const hud = document.getElementById('hud');
hud.textContent = '';
delete hud.dataset.scrHl;
@@ -680,31 +768,50 @@ cv.addEventListener('click', e => {
' cm3 · ' + (u.printed ? '打印件' : '电机/已有件') +
' <span style="margin-left:8px;color:var(--warn);font-size:11px">[右键单击或按H键隐藏此部件]</span>';
const tr = tb.querySelector('[data-idx="' + u.idx + '"]');
if (tr) { tr.style.background = '#22406b'; tr.scrollIntoView({block:'nearest'}); }
if (tr) { tr.style.background = '#22406b'; bomHlRow = tr; tr.scrollIntoView({block:'nearest'}); }
}
requestRender();
});
/* ---- UI: joint sliders, ranges from the swept hard stops (docs/travel.json) ---- */
/* ---- UI: joint sliders ----
滑块范围 = 电机行程 ±360°(docs/collide.json 的 motor_limit_deg;原 ±106° 作废)。
能拖到哪由几何说话:相邻件实测自由区间(与姿态无关)封顶,跨组零件对由胶囊碰撞
模型按当前姿态实时夹取(见 ik-glue 的 IK.clampAxis);docs/travel.json 的零位静态
扫描值只作 tooltip 参考——它是"其它轴全零"时对固定立柱的一次性结果,随姿态大变。 */
const JNAMES = ['J1','J2','J3','J4','J5','J6'];
const MOTOR = (typeof COLLIDE !== 'undefined' && COLLIDE.motor_limit_deg) || 360;
const jh = document.getElementById('joints');
for (const id of JNAMES) {
const j = JO.find(q => q.id === id); if (!j) continue;
const t = TL[id] || {lo:-180, hi:180, continuous:true, note:'360°旋转'};
const lo = Math.floor(t.lo), hi = Math.ceil(t.hi);
const lo = -MOTOR, hi = MOTOR;
const d = document.createElement('div');
d.className = 'j-box';
d.innerHTML = '<div class="row"><label title="' + t.note + '(双击复位 0°)">' + id + ' ' + j.cn +
(t.continuous ? ' <em class="ok">360°连续</em>' : ' <em>' + lo + '°…' + hi + '°</em>') +
d.innerHTML = '<div class="row"><label title="' + t.note +
'(电机行程 ±' + MOTOR + '°=滑块范围;零位静态扫描 ' + Math.floor(t.lo) + '°…' + Math.ceil(t.hi) +
'°仅作参考,实际边界随姿态由碰撞模型实时夹取。双击复位 0°)">' +
id + ' ' + j.cn +
(t.continuous ? ' <em class="ok">360°连续</em>' : ' <em id="e' + id + '">' + lo + '°…' + hi + '°</em>') +
'</label><span class="j-val" id="v' + id + '" title="点击复位为 0°">0°</span></div>' +
'<input type="range" id="s' + id + '" min="' + lo + '" max="' + hi + '" value="0">';
jh.appendChild(d);
const inp = d.querySelector('input');
inp.oninput = ev => { ang[id] = +ev.target.value;
document.getElementById('v'+id).textContent = ang[id] + '°';
for (const q of JNAMES) applyJoint(q); };
inp.oninput = ev => {
let v = +ev.target.value;
if (window.IK && IK.clampAxis) { // 动态限位夹取(glue 加载后才生效)
const c = IK.clampAxis(id, v);
if (c !== v) { v = c; ev.target.value = c; } // 滑块头停在真实边界上
}
ang[id] = v;
document.getElementById('v'+id).textContent = v + '°';
for (const q of JNAMES) applyJoint(q);
};
d.querySelector('.j-val').onclick = () => { inp.value = 0; inp.dispatchEvent(new Event('input')); };
d.querySelector('label').ondblclick = () => { inp.value = 0; inp.dispatchEvent(new Event('input')); };
inp.onchange = () => { // 松手后异步刷新动态范围标签(拖拽中不刷,避免卡顿)
if (window.IK && IK.refreshJointLabels) setTimeout(() => IK.refreshJointLabels(
['J1','J2','J3','J4','J5','J6'].map(x => (document.getElementById('s'+x) || {}).value || 0)), 0);
};
applyJoint(id);
}
const bRstJ = document.getElementById('resetJoints');
@@ -1011,9 +1118,14 @@ function updStat() {
}
updStat();
/* 按需渲染:needRender 由 requestRender() 置位(所有交互与状态变化都已调用它;
旧版置位后全文件无读取点,等于恒以显示器刷新率空转满帧渲染)。
装配图页画布隐藏时【不清标志】,切回三维页由 tab 处理器 resize()+requestRender() 补渲染。
preserveDrawingBuffer 必须保持 true:不重绘时靠它保住最后一帧画面。 */
function loop() {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
if (cv.style.display !== 'none') {
updCam();
rend.render(scene, cam);
}
+1277 -54
View File
File diff suppressed because it is too large Load Diff
+401 -51
View File
@@ -29,19 +29,47 @@ const TCP0 = [JJ.J6.rear[0], JJ.J6.rear[1], tcpZ];
/* ---------- 1. 建立求解器 ---------- */
const JMap = {}; for (const j of JO) JMap[j.id] = { rear: j.rear, d: j.d };
const TR = {}; for (const t of TRAVEL) TR[t.axis] = { lo: t.lo, hi: t.hi, continuous: !!t.continuous };
const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0 });
const COL = (typeof COLLIDE !== 'undefined' && COLLIDE.caps && COLLIDE.caps.length) ? COLLIDE : null;
/* 可行域不再取 docs/travel.json 的静态扫描值——那是"其它轴全零"时对固定立柱的一次
性结果,随姿态大变(电机行程现为 ±360°,见 collide.motor_limit_deg)。改由两处
真实几何决定:adj = 相邻打印件 1D 自由区间(CAD 实测、与姿态无关,胶囊模型故意
不检相邻对);跨 ≥2 组的零件对由 dynLimit 按当前姿态实时二分。 */
const ADJ = (COL && COL.adj) || {};
const MOTOR = +((COL && COL.motor_limit_deg) || 360); // 电机行程(滑块范围)
const STATIC_TL = {}; for (const t of TRAVEL) STATIC_TL[t.axis] = { lo: t.lo, hi: t.hi };
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 };
}
/* 限位轴丢了 adj 就会退回 travel.json 的零位扫描值(表现为"又被锁回 −106"):
fit_capsules.py 重写 collide.json 时已沿用 adj,若仍缺说明只跑了拟合没跑实测。 */
if (COL && noAdj.length)
console.warn('[IK] collide.json 缺 ' + noAdj.join('/') + ' 的 adj 实测区间,这几轴退回零位扫描限位;' +
'重跑 python scratch/probe_adj.py --workers 10');
const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0, collide: COL });
const LIM = S.LIM;
let lastTool = 0;
let TGT = [150, 128, 260];
/* 参考位姿连续项权重:以当前手臂姿态为参考解算,目标微动时锁定同族分支,
避免某轴在等价分支间大幅倒摆(实测 4mm 步长过边界最多跳 81.9° -> 1.2°) */
const REF_W = 0.02;
/* 选解判据(用户指定):所有电机需要转过的角度之和最小 —— 以当前手臂姿态为参考。
连续轴 θ 与 θ±360 等价,角差取 wrap180;不给 ref 时退化为 Σ|θ| 最小(离线批算)。
(旧实现是 Σ|θ| + λ·ΣΔ² 的二次连续项,已被本判据取代。) */
const curRef = () => ORDER.map(id => ang[id]);
/* ---------- 1b. 姿态控制状态 ---------- */
let poseOn = false;
const POSE = { az: 0, el: 90, roll: 0 }; // 目标姿态:工具轴方位角 / 仰角 + 绕轴滚转(度)
/* 双优先级:位置与姿态在远目标上不可兼得(实测某目标位置 0mm 时姿态差 44°,而姿态 0° 时位置差 116mm)。
'pos' 位置优先 —— 保 TCP 点位精确,姿态取最接近可达值;
'ori' 姿态优先 —— 保指定姿态精确,TCP 取最接近可达点;
'mix' 混合优先 —— 滑杆自由分配两端比重:0% = 姿态优先、100% = 位置优先(两端与纯算法逐位一致),
中间值按两端锚点误差归一的折中代价 J = w·(e/e_ref)² + (1−w)·(o/o_ref)² 搜索。 */
const prioVal = () => { const e = $('ikPrio'); const v = e && e.value; return v === 'ori' || v === 'mix' ? v : 'pos'; };
const mixWVal = () => { const e = $('ikMixW'); return e ? Math.max(0, Math.min(100, +e.value || 0)) / 100 : 0.5; };
const prioTxt = () => prioVal() === 'ori' ? '姿态优先'
: (prioVal() === 'mix' ? '混合优先 ' + Math.round(mixWVal() * 100) + '% 位置' : '位置优先');
const Rdes = () => ArmIK.poseMat(POSE.az, POSE.el, POSE.roll);
const syncPoseInputs = () => {
$('ikAz').value = (+POSE.az).toFixed(1);
@@ -206,6 +234,68 @@ function refreshTarget() {
requestRender();
}
/* ---------- 2b. 滑块动态限位夹取(边界缓存)----------
collideFree 一票 ~5ms(1995 胶囊、零位最坏),不允许每次 input 都二分。
缓存 [轴, 其余轴 1° 量化姿态] → {lo, hi}:拖同一轴时其余轴不动,直接取缓存;
只有拖过缓存边界(或换了姿态)才重算一次二分(~40ms,一次卡顿换正确边界)。 */
const bnd = { key: '', lo: 0, hi: 0 };
function clampAxis(id, v) {
if (!COL) return v;
const others = ORDER.map(x => x === id ? '-' : Math.round(ang[x])).join(',');
const key = id + '|' + others;
if (bnd.key !== key) {
bnd.key = key;
const q = ORDER.map(x => x === id ? ang[x] : Math.round(ang[x]));
if (!S.collideFree(q)) {
/* 当前姿本身已干涉(上次被拖越界后卡在界外):dynLimit 会把"当前值"当边界,
滑块就再也回不来了。此时退回该轴机械区间(相邻件实测自由区间,必然无自碰撞)。 */
bnd.lo = LIM[id].lo; bnd.hi = LIM[id].hi;
} else {
bnd.lo = S.dynLimit(id, q, -1);
bnd.hi = S.dynLimit(id, q, 1);
}
}
return Math.max(bnd.lo, Math.min(bnd.hi, v));
}
/* 刷新滑块标签里的动态范围(异步执行,避免阻塞拖拽) */
/* 节流版:拖动/实时追踪中每次 setJoints 都会触发 3 次 dynLimit(每次 ~9 次 collideFree 二分 ≈ 28 ms),
高频下把 0.1 ms 的快路放大 280×。250 ms 节流 + 尾随补一次;动画结束/松手仍走立即版。 */
let _rjlLast = -1e9, _rjlTimer = 0;
function refreshJointLabelsLazy(q) {
const t = performance.now();
if (t - _rjlLast > 250) { _rjlLast = t; refreshJointLabels(q); return; }
if (!_rjlTimer) _rjlTimer = setTimeout(() => {
_rjlTimer = 0; _rjlLast = performance.now();
refreshJointLabels(ORDER.map(id => ang[id]));
}, 260);
}
function refreshJointLabels(q) {
for (const id of ORDER) {
const el = $('e' + id);
if (!el) continue;
const t = LIM[id];
if (COL && !t.cont) {
/* 边界由几何说话:机械区间(adj 实测)之内,再按当前姿态二分胶囊碰撞。
lo 侧一律收紧;hi 侧只有"硬边"(相邻件自碰撞,配置无关)才受静态值封顶,
可放宽侧(撞固定立柱那类)由模型说话——动态边界本就可以远超静态 hi。 */
const lo = Math.max(t.lo, S.dynLimit(id, q, -1, 1.0));
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 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>' : '');
const st = STATIC_TL[id] || {};
el.title = '机械区间(相邻件实测)' + 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' : '');
} else {
el.textContent = t.lo + '\u00B0\u2026' + t.hi + '\u00B0';
}
}
}
/* ---------- 3. 关节写入(同步滑块与标签)---------- */
let anim = null;
function setJoints(q, doAnim) {
@@ -219,11 +309,24 @@ function setJoints(q, doAnim) {
applyJoint(id);
}
refreshTarget();
if (COL) refreshJointLabelsLazy(ORDER.map(id => ang[id]));
}
function animTo(q, ms) {
if (anim) { anim = null; }
const from = ORDER.map(id => ang[id]);
anim = { from: from, to: q.slice(), t0: performance.now(), ms: ms };
/* 连续轴(J1 肩抬举 / J3 大臂自转 / J6 腕自转)走「短弧」。
三者物理上无机械限位,θ 与 θ±360 是同一物理位形(applyJoint 走 cos/sin);
若按数值直接线性插值,+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]);
}
const end = q.slice();
anim = { from: from, to: to, t0: performance.now(), ms: ms };
const tick = () => {
if (!anim) return;
const k = Math.min(1, (performance.now() - anim.t0) / anim.ms);
@@ -238,7 +341,20 @@ function animTo(q, ms) {
}
refreshTarget();
if (k < 1) requestAnimationFrame(tick);
else { anim = null; for (let i = 0; i < 6; i++) applyJoint(ORDER[i]); requestRender(); }
else {
anim = null;
/* 落回代表角(与插值终点等价,视觉无变化),使 ang 与 res.q 对齐 */
for (let i = 0; i < 6; i++) {
const id = ORDER[i];
ang[id] = end[i];
const sl = $('s' + id), v = $('v' + id);
if (sl) sl.value = end[i];
if (v) v.textContent = end[i].toFixed(2) + '\u00B0';
applyJoint(id);
}
if (COL) refreshJointLabels(ORDER.map(id => ang[id]));
requestRender();
}
};
requestAnimationFrame(tick);
}
@@ -286,7 +402,9 @@ function renderResult(res, src) {
tr.innerHTML = '<td>' + id + '</td><td>' + CN[id] + '</td>' +
'<td class="num">' + q[i].toFixed(2) + '\u00B0</td>' +
'<td class="num dim">' + Math.abs(q[i]).toFixed(1) + '</td>' +
'<td class="dim">' + (t.cont ? '\u00B1180' : t.lo + '..' + t.hi) + '</td>';
'<td class="dim">' + (t.cont ? '\u00B1' + MOTOR
: (res.dynAxis === id ? t.lo + '..' + '<b style="color:var(--warn)">' + res.dynBound.toFixed(1) + '*</b>'
: t.lo + '..' + t.hi)) + '</td>';
if (i === 5 && !res.tool) tr.title = poseOn
? '姿态控制开启:θ6 为绕工具轴的滚转角,由目标姿态(方位/仰角/滚转)解算'
: 'TCP 位于 J6 轴线上,转动 J6 不改变 TCP 位置,故取 0 使总角最小';
@@ -309,7 +427,9 @@ function renderResult(res, src) {
const sum = q.reduce((a, b) => a + b, 0);
const pa = poseOn ? ArmIK.poseAng(Uof(q)) : null;
$('ikSum').innerHTML =
'<i>总关节角 \u03A3|\u03B8|</i><b class="hi">' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
(res.moveSum != null ? '<i>本步行程 \u03A3|\u0394\u03B8|(判据)</i><b class="hi">' +
res.moveSum.toFixed(2) + '\u00B0</b>' : '') +
'<i>总关节角 \u03A3|\u03B8|</i><span>' + S.costOf(q, null).toFixed(2) + '\u00B0</span>' +
'<i>\u03A3\u03B8 代数和</i><span>' + sum.toFixed(1) + '\u00B0</span>' +
'<i>残差 闭式/场景图</i><span class="' + (dcl < 1e-4 && dsc < .05 ? 'good' : 'bad') + '">' +
dcl.toExponential(1) + ' / ' + dsc.toExponential(1) + ' mm</span>' +
@@ -319,6 +439,11 @@ function renderResult(res, src) {
'\u00B0 · ' + pa.roll.toFixed(1) + '\u00B0</span>' : '') +
(poseOn ? '<i>姿态残差 闭式/场景图</i><span class="' + (dori < .05 && doriSc < .1 ? 'good' : 'bad') + '">' +
dori.toFixed(3) + '\u00B0 / ' + doriSc.toFixed(3) + '\u00B0</span>' : '') +
(poseOn && res.priority === 'mix' && res.mixP && res.mixO
? '<i>折中权重</i><span>位置 ' + Math.round((res.mixW == null ? 0.5 : res.mixW) * 100) + '% · 姿态 ' +
(100 - Math.round((res.mixW == null ? 0.5 : res.mixW) * 100)) + '%(按两端锚点误差归一)</span>' +
'<i>两端锚点</i><span>位置端 ' + res.mixP.e.toFixed(2) + ' mm / ' + res.mixP.o.toFixed(2) +
'\u00B0 · 姿态端 ' + res.mixO.e.toFixed(1) + ' mm / ' + res.mixO.o.toFixed(2) + '\u00B0</span>' : '') +
'<i>可行采样点</i><span>' + res.nSol + '</span>' +
'<i>耗时</i><span>' + res.ms.toFixed(0) + ' ms</span>' +
'<i>目标</i><span>(' + res.target.map(v => v.toFixed(1)).join(', ') + ')</span>' +
@@ -328,10 +453,18 @@ function renderResult(res, src) {
const oriOk = !poseOn || (dori < 0.05 && doriSc < 0.1);
/* 以残差判到达:阻尼最小二乘兜底也可能精确到达(扫描漏掉的奇异刀尖解),不能只看 reachable */
const posOk = dsc < .05;
badge.textContent = (posOk && oriOk) ? '已到达'
/* 姿态优先/混合姿态端:姿态精确、TCP 落在最近可达点——与「不可达」语义不同,单独标出并给出距离 */
const oriPrio = poseOn && (res.priority === 'ori' || res.priority === 'mix') && oriOk && !posOk;
/* 混合折中:位置与姿态都有残差(两端都不可兼得时的加权折中点) */
const mixMid = poseOn && res.priority === 'mix' && !posOk && !oriOk;
badge.textContent = res.unsafe
? '位姿与底座/立柱近似干涉(目标可能不可达或位于立柱区内)'
: (posOk && oriOk) ? '已到达'
: oriPrio ? '姿态到达·位置最近 ' + dsc.toFixed(1) + ' mm'
: mixMid ? '混合折中 · 位置差 ' + dsc.toFixed(1) + ' mm · 姿态残差 ' + dori.toFixed(2) + '\u00B0'
: (posOk && poseOn && dori > 0.05 ? '位置到达·姿态残差 ' + dori.toFixed(2) + '\u00B0' : '不可达·最近位姿');
badge.className = 'badge ' + ((posOk && oriOk) ? 'goodb'
: (posOk ? 'warnb' : 'badb'));
badge.className = 'badge ' + (res.unsafe || (!posOk && !oriPrio && !mixMid) ? 'badb'
: (posOk && oriOk) ? 'goodb' : 'warnb');
$('ikSrc').textContent = src;
$('ikBar').innerHTML = '<b class="hi">\u03A3|\u03B8| ' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
'<span class="sep">|</span>\u6B8B\u5DEE ' +
@@ -365,7 +498,67 @@ function renderResult(res, src) {
});
}
/* ---------- 5. 求解 ---------- */
/* ---------- 5. 求解(Worker 化)----------
solveDyn/solveTo 的全量扫描(40~430 ms)挪进后台线程,主线程只收结果渲染,
松手/按钮不再冻结 UI。单文件自包含:ArmIK 源码取自页面内嵌 <script>,
连同关节/限位/碰撞配置一次性注入 Blob Worker;拖动实时追踪同样走 Worker,
全量回落也不再卡顿。Worker 不可用(老浏览器/CSP/file 限制)时自动回落
主线程同步求解,行为与旧版逐位一致。碰撞 dynLimit/clampAxis 仍留主线程
(滑块交互需要同步边界),主线程的 S 实例保留给 FK/渲染/控制台 API。 */
let IKW = null, IKWseq = 0, IKWlast = 0;
/* 每个请求按 id 记账,回包必定派发一次;「是否还是最新一帧」用第二参交给调用方判断。
旧写法只留一个 IKWjob 槽:live 在飞时被 runSolve 插队,那一发的回调就彻底蒸发,
而它是唯一给 liveBusy 复位的地方——一次竞态即让实时追踪永久停摆。 */
const IKWjobs = new Map();
function ikWorker() {
if (IKW) return IKW;
if (IKW === false) return null;
try {
let src = null;
for (const sc of document.querySelectorAll('script')) {
if (sc.textContent.indexOf('六轴手臂逆运动学求解器') >= 0) { src = sc.textContent; break; }
}
if (!src) throw new Error('ArmIK script 未找到');
const boot = '\nconst CFGW = ' + JSON.stringify({ joints: JMap, travel: TR, tcp: TCP0 }) + ';\n' +
'const COLW = ' + JSON.stringify(COL) + ';\n' +
'const SW = ArmIK.make({ joints: CFGW.joints, travel: CFGW.travel, tcp: CFGW.tcp, collide: COLW });\n' +
'onmessage = e => { const d = e.data; let r;\n' +
' try { r = SW[d.op].apply(SW, d.args); } catch (err) { r = { __err: String(err && err.message || err) }; }\n' +
' postMessage({ id: d.id, r: r }); };\n';
/* Blob URL 只在构造 Worker 时被读取,构造后立即回收(失败路径同样不留常驻 Blob) */
const u = URL.createObjectURL(new Blob([src + boot], { type: 'application/javascript' }));
let w;
try { w = new Worker(u); } finally { URL.revokeObjectURL(u); }
w.onmessage = e => {
const job = IKWjobs.get(e.data.id);
if (!job) return; // 已结清过(正常不会发生)
IKWjobs.delete(e.data.id);
job(e.data.r, e.data.id === IKWlast); // 过期请求也派发,只是标为不新鲜
};
w.onerror = () => { // Worker 挂了:把在飞的账全部结清
IKW = false;
const pend = Array.from(IKWjobs.values()); IKWjobs.clear();
for (const j of pend) j(null, false);
};
IKW = w;
} catch (err) { IKW = false; }
return IKW || null;
}
function solveAsync(op, args, job) {
const w = ikWorker();
if (!w) { job(S[op].apply(S, args), true); return; } // 同步兜底(结果结构完全一致)
const id = ++IKWseq; IKWlast = id;
IKWjobs.set(id, (r, fresh) => { // Worker 内异常 → 主线程重算一次
if (r && r.__err) {
if (fresh) { try { r = S[op].apply(S, args); } catch (e2) { r = null; } }
else r = null;
}
job(r, fresh);
});
w.postMessage({ id: id, op: op, args: args });
}
/* ---------- 5a. 求解入口 ---------- */
function runSolve() {
const X = +$('ikX').value, Y = +$('ikY').value, Z = +$('ikZ').value;
if (!isFinite(X) || !isFinite(Y) || !isFinite(Z)) {
@@ -376,37 +569,64 @@ function runSolve() {
refreshTarget();
badge.textContent = '计算中…'; badge.className = 'badge wait';
setTimeout(() => {
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), refW: REF_W };
if (poseOn) opt.pose = { R: Rsolver() };
const res = S.solveTo(TGT, opt);
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef() };
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; // 已被更新的一发取代:交给那一发渲染
renderResult(res, res.reachable
? (poseOn ? '闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖' : '闭式全域扫描 · 冗余维精确覆盖')
? (res.dynAxis ? '动态限位放宽重解 · ' + res.dynAxis + ' 边界 ' + res.dynBound.toFixed(1) + '°'
: (poseOn ? (prioVal() === 'mix'
? prioTxt() + ' · 两端锚点加权折中(位置精确族 + 姿态精确族)'
: prioTxt() + ' · 闭式全域扫描 + 孤立根枚举 · 位置+姿态联合')
: '闭式全域扫描 · 按行裁剪冗余维'))
: (res.note ? res.note
: (poseOn ? '超可达位姿空间 → 阻尼最小二乘最近位姿(位置 mm + 姿态联合)'
: '超工作空间 → 阻尼最小二乘最近位姿'));
: '超工作空间 → 阻尼最小二乘最近位置')));
if (res.q) setJoints(res.q, true);
});
}, 0);
}
/* 实时追踪:拖动过程中同步求解、立即应用(无动画,跟在手后面)。
求解是同步的,同一时刻只跑一个;拖动中的后续 pointermove 只记待处理标志,
当前求解结束后用最新 TGT 再解一次,避免事件排队越拖越卡。 */
/* 实时追踪:拖动过程中即时求解、立即应用(无动画,跟在手后面)。
求解在 Worker 后台跑(全量回落也不冻结 UI);同一时刻只跑一个,
在飞期间后续 pointermove 只记待处理标志,结果回来后用最新 TGT 再解一次。
门闩由「派发」而非「渲染」复位(settle 放在 finally):被 runSolve 插队而作废的
那一发同样必须放行,否则一次竞态就永久停摆。作废时待处理标志一并交回——
插队者用的已是最新 TGT,此时重发只会把它再挤掉、连松手的全量精修一起丢掉。 */
let liveBusy = false, livePending = false;
function runSolveLive() {
if (liveBusy) { livePending = true; return; }
liveBusy = true;
const settle = (fresh) => {
liveBusy = false;
if (!fresh) { livePending = false; return; }
if (livePending) { livePending = false; runSolveLive(); }
};
try {
lastTool = +$('ikTool').value || 0;
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), refW: REF_W };
if (poseOn) opt.pose = { R: Rsolver() };
const res = S.solveTo(TGT, opt);
renderResult(res, res.reachable
? (poseOn ? '实时追踪 · 闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖'
: '实时追踪 · 闭式全域扫描 · 冗余维精确覆盖')
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), warm: curRef() };
if (poseOn) { opt.pose = { R: Rsolver() }; opt.priority = prioVal(); if (opt.priority === 'mix') opt.mixW = mixWVal(); }
solveAsync('solveTo', [TGT.slice(), opt], (res, fresh) => { // Worker 后台:全量回落也不冻结主线程
try {
if (!res || !fresh) return;
renderResult(res, res.warm
? (res.dynAxis ? '实时追踪 · 动态限位 ' + res.dynAxis + '=' + res.dynBound.toFixed(1) + '°'
: (poseOn ? (res.priority === 'mix'
? '实时追踪 · 混合优先沿上一帧同盆地续解(加权折中局部跟随 · 松手全量精修)'
: '实时追踪 · 沿上一帧构型同支续解(位置精确 · 姿态牛顿收口 · 松手全量精修)')
: '实时追踪 · 沿上一帧构型同支续解(冗余维局部跟随 · 松手全量精修)'))
: (res.reachable
? (poseOn ? (prioVal() === 'mix'
? '实时追踪 · ' + prioTxt() + ' · 两端锚点加权折中'
: '实时追踪 · ' + prioTxt() + ' · 闭式全域扫描 + 孤立根枚举 · 位置+姿态联合')
: '实时追踪 · 闭式全域扫描 · 按行裁剪冗余维')
: (res.note ? '实时追踪 · ' + res.note
: (poseOn ? '实时追踪 · 超可达位姿空间(阻尼最小二乘最近位姿)'
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)'));
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)'))));
if (res.q) setJoints(res.q, false);
} finally { liveBusy = false; }
if (livePending) { livePending = false; runSolveLive(); }
} finally { settle(fresh); }
});
} catch (err) { settle(false); }
}
/* ---------- 6. 目标拖动 / 拾取 ---------- */
@@ -521,7 +741,8 @@ cv.addEventListener('pointermove', e => {
if (mode === 'rt' && dragged) runSolveLive(); // 实时追踪:拖动过程中即算即动
});
addEventListener('pointerup', () => { camSnap = null; });
cv.addEventListener('pointerup', e => {
/* 挂 window 而非画布:在画布外松手也要结束抓取,否则 grab 残留 → 光挪鼠标就拖着目标走 */
addEventListener('pointerup', e => {
if (!grab) return;
if (grab.pick) { // 视图拾取:射线打到当前高度的水平面
grab = null; justPicked = true;
@@ -559,6 +780,19 @@ function syncInputs() {
el.addEventListener('change', () => runSolve());
});
$('ikStep').onchange = () => runSolve();
/* 混合优先:比重滑杆(仅混合模式显示)。滑动即算即动(走混合快路,松手全量折中),
与拖动黄球同一套 live 机制;两端 0% / 100% 直接交回姿态优先 / 位置优先。 */
function syncMixUI() {
const box = $('ikMixBox'), v = $('ikMixV');
if (box) box.style.display = prioVal() === 'mix' ? 'flex' : 'none';
if (v) v.textContent = Math.round(mixWVal() * 100) + '% 位置';
}
if ($('ikPrio')) $('ikPrio').onchange = () => { syncMixUI(); if (poseOn) runSolve(); };
if ($('ikMixW')) {
$('ikMixW').addEventListener('input', () => { syncMixUI(); if (poseOn) runSolveLive(); });
$('ikMixW').addEventListener('change', () => { syncMixUI(); if (poseOn) runSolve(); });
}
syncMixUI();
$('ikSolve').onclick = () => runSolve();
$('ikPick').onclick = () => setMode(mode === 'pick' ? 'drag' : 'pick');
$('ikRt').onclick = () => setMode(mode === 'rt' ? 'drag' : 'rt');
@@ -621,12 +855,109 @@ function setView(v, keepPose) {
document.querySelectorAll('.tab[data-view]').forEach(b =>
b.addEventListener('click', () => setView(b.dataset.view)));
/* 预置自检结果(离线用同一份求解器代码算出,样本量比在线默认更大)。
启动时不再重算:原先姿态自检 200 组要 ~14 s(400 组 ~23 s),是本页唯一的启动瓶颈。
指纹 = FNV-1a over(限位表 + K + TCP + 胶囊数 + 行程表 + 求解器块源码),
与当前代码不符时会标红提示重算。
重新生成: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 }
};
/* 紧凑指纹:与离线脚本 compute_precomputed.cjs 里的算法逐字一致
(注意取 script.textContent——HTML 解析会把行尾归一化成 LF) */
function chkSig() {
const p = [];
for (const id of ORDER) { const t = LIM[id]; p.push(id + t.lo + ',' + t.hi + ',' + t.cont); }
p.push(TCP0.join(','));
p.push([S.K.X0, S.K.Y0, S.K.Z1, S.K.Z2, S.K.Z3, S.K.Z4, S.K.Z5].join(','));
p.push(String(typeof COLLIDE !== 'undefined' && COLLIDE.caps ? COLLIDE.caps.length : -1));
p.push(typeof TRAVEL !== 'undefined'
? TRAVEL.map(t => t.axis + t.lo + t.hi + (t.continuous ? 1 : 0)).join('') : '');
let str = p.join('|');
const scr = document.scripts; // 4 个内联脚本:0 three / 1 数据 / 2 求解器 / 3 界面
if (scr.length > 3 && scr[2]) str += '|' + scr[2].textContent;
let hh = 0x811c9dc5;
for (let i = 0; i < str.length; i++) { hh ^= str.charCodeAt(i); hh = Math.imul(hh, 0x01000193) >>> 0; }
return (hh >>> 0).toString(16).padStart(8, '0');
}
/* ---------- 9. 启动:自检 + 工作空间采样 ---------- */
function boot() {
const st = S.selfTest(600, 6);
const ws = (function () {
/* 启动不再做任何重计算:自检数值全部取自预置结果,只用指纹复核是否仍适用。
「结构前提」仍取运行时 WARN(make() 时已算好,零额外代价)。 */
const SIG_NOW = chkSig();
const CHK = {
/* 结构前提是 make() 时定下的常量(求解器内部 WARN),与样本量无关:
用一次 1 样本的 selfTest 取出即可(~0 ms),其余字段一律用预置值。 */
st: { structWarn: S.selfTest(1, 0).structWarn, maxFkErr: PRECOMPUTED_CHK.fk.err,
ikHit: PRECOMPUTED_CHK.ik.hit, ikTried: PRECOMPUTED_CHK.ik.tried, maxIkErr: PRECOMPUTED_CHK.ik.err },
ws: { lo: [PRECOMPUTED_CHK.ws.x[0], PRECOMPUTED_CHK.ws.y[0], PRECOMPUTED_CHK.ws.z[0]],
hi: [PRECOMPUTED_CHK.ws.x[1], PRECOMPUTED_CHK.ws.y[1], PRECOMPUTED_CHK.ws.z[1]],
rmax: PRECOMPUTED_CHK.ws.rmax },
mode: 'pre', // 'pre' 用预置值 | 'live' 本机实测
pose: null, // null 用预置 | {running,done,total} | {hit,tried,err,ms}
sigOk: SIG_NOW === PRECOMPUTED_CHK.sig
};
let reRunning = false;
function poseLine() {
const P = PRECOMPUTED_CHK.pose, p = CHK.pose;
if (!p) {
if (!CHK.sigOk) return '<i>姿态 IK 校验</i><span class="bad">预置结果已失效,请点下方「重新计算」</span>';
return '<i>姿态 IK 校验</i><span class="' + (P.hit >= P.n * 0.9 ? 'good' : 'bad') + '">' +
P.hit + '/' + P.n + ' 命中 · 最大 ' + P.err.toFixed(2) + '° · ' + P.ms.toFixed(0) + ' ms/次</span>';
}
if (p.running) return '<i>姿态 IK 校验</i><span>本机实测中 ' + p.done + '/' + p.total + '…</span>';
return '<i>姿态 IK 校验</i><span class="' + (p.hit >= p.tried * 0.9 ? 'good' : 'bad') + '">' +
p.hit + '/' + p.tried + ' 命中 · 最大 ' + p.err.toFixed(2) + '° · ' + p.ms.toFixed(0) + ' ms/次</span>';
}
function renderChk() {
const st = CHK.st, ws = CHK.ws, P = PRECOMPUTED_CHK;
/* 指纹不符时把"为什么变慢/为什么值是新的"讲清楚:即使已自动转为实测,
也在来源行保留失效提示,避免用户不明所以。 */
const src = (CHK.mode === 'live')
? (CHK.sigOk
? '<span class="good">本机测试(FK ' + P.fk.n + ' · IK ' + P.ik.n + ' · 姿态 ' + P.pose.n + ' · 工作空间 ' + P.ws.n + ' 位形)</span>'
: '<span class="bad">⚠ 预置结果已失效(预置指纹 ' + P.sig + ' ≠ 当前 ' + SIG_NOW +
',模型或求解器已变更)· 以下为本次本机测试</span>')
: (CHK.sigOk
? '<span>预置 · 生成于 ' + P.stamp + '(FK ' + P.fk.n + ' · IK ' + P.ik.n + ' · 姿态 ' + P.pose.n +
' · 工作空间 ' + P.ws.n + ')· 指纹 ' + SIG_NOW + ' 匹配</span>'
: '<span class="bad">预置结果已失效:预置指纹 ' + P.sig + ' ≠ 当前 ' + SIG_NOW + '(模型或求解器已变更)</span>');
$('ikChk').innerHTML =
'<i>数据来源</i>' + src +
'<i>结构前提</i><span class="' + (st.structWarn.length ? 'bad' : 'good') + '">' +
(st.structWarn.length ? st.structWarn.join(';') : '自洽 ✓') + '</span>' +
'<i>FK 校验</i><span class="good">' + st.maxFkErr.toExponential(1) + ' mm</span>' +
'<i>IK 校验</i><span class="good">' + st.ikHit + '/' + st.ikTried + ' · ' + st.maxIkErr.toExponential(1) + ' mm</span>' +
'<i>TCP 法兰面</i><span>(' + TCP0.map(v => v.toFixed(1)).join(', ') + ')</span>' +
'<i>*运动学可达</i><span>限位内随机位形,未计自碰撞</span>' +
'<i>可达 x*</i><span>' + ws.lo[0].toFixed(0) + '…' + ws.hi[0].toFixed(0) +
' mm(前伸 ' + ws.hi[0].toFixed(0) + ')</span>' +
'<i>可达 y*</i><span>' + ws.lo[1].toFixed(0) + '…' + ws.hi[1].toFixed(0) + ' mm</span>' +
'<i>可达 z*</i><span>' + ws.lo[2].toFixed(0) + '…' + ws.hi[2].toFixed(0) + ' mm</span>' +
'<i>离 J1 轴*</i><span class="good">最远 ' + ws.rmax.toFixed(0) + ' mm</span>' +
poseLine() +
'<i>姿态结构</i><span>J5∩J6 垂直相交于腕心;θ6 为绕工具轴滚转,不影响 TCP 位置</span>' +
'<i>操作</i><span><b id="ikChkRe" style="cursor:pointer;text-decoration:underline;color:var(--ac)">' +
(reRunning ? '实测进行中…' : '重新计算(本机测试,姿态 ' + P.pose.n + ' 组分片执行 · 约 20–40 s,期间可继续操作)') +
'</b></span>';
const re = $('ikChkRe');
if (re) re.onclick = () => runRecompute();
}
/* 「重新计算」:便宜部分同步跑(FK/IK ~30 ms、工作空间 ~120 ms),
姿态部分 4 组一片 + 让出主线程,避免一次长任务冻住界面。 */
function runRecompute() {
if (reRunning) return;
reRunning = true;
const P = PRECOMPUTED_CHK;
CHK.st = S.selfTest(P.fk.n, 0);
CHK.ws = (function () {
let lo = [1e9, 1e9, 1e9], hi = [-1e9, -1e9, -1e9], rmax = 0;
for (let k = 0; k < 120000; k++) {
for (let k = 0; k < P.ws.n; k++) {
const q = [];
for (let i = 0; i < 5; i++) { const t = LIM[ORDER[i]]; q.push(t.lo + Math.random() * (t.hi - t.lo)); }
q.push(0);
@@ -636,22 +967,29 @@ function boot() {
}
return { lo: lo, hi: hi, rmax: rmax };
})();
$('ikChk').innerHTML =
'<i>结构前提</i><span class="' + (st.structWarn.length ? 'bad' : 'good') + '">' +
(st.structWarn.length ? st.structWarn.join(';') : '自洽 ✓') + '</span>' +
'<i>FK 校验</i><span class="good">' + st.maxFkErr.toExponential(1) + ' mm</span>' +
'<i>IK 校验</i><span class="good">' + st.ikHit + '/' + st.ikTried + ' · ' + st.maxIkErr.toExponential(1) + ' mm</span>' +
'<i>TCP 法兰面</i><span>(' + TCP0.map(v => v.toFixed(1)).join(', ') + ')</span>' +
'<i>*运动学可达</i><span>限位内 12 万随机位形,未计自碰撞</span>' +
'<i>可达 x*</i><span>' + ws.lo[0].toFixed(0) + '…' + ws.hi[0].toFixed(0) +
' mm(前伸 ' + ws.hi[0].toFixed(0) + ')</span>' +
'<i>可达 y*</i><span>' + ws.lo[1].toFixed(0) + '…' + ws.hi[1].toFixed(0) + ' mm</span>' +
'<i>可达 z*</i><span>' + ws.lo[2].toFixed(0) + '…' + ws.hi[2].toFixed(0) + ' mm</span>' +
'<i>离 J1 轴*</i><span class="good">最远 ' + ws.rmax.toFixed(0) + ' mm</span>' +
'<i>姿态 IK 校验</i><span class="' + (st.poseHit >= st.poseTried * 0.9 ? 'good' : 'bad') + '">' +
st.poseHit + '/' + st.poseTried + ' 命中 · 最大 ' + st.poseErr.toExponential(1) +
'° · ' + st.poseMs.toFixed(0) + ' ms/次</span>' +
'<i>姿态结构</i><span>J5∩J6 垂直相交于腕心;θ6 为绕工具轴滚转,不影响 TCP 位置</span>';
CHK.mode = 'live';
CHK.pose = { running: true, done: 0, total: P.pose.n };
const acc = { hit: 0, tried: 0, err: 0, msSum: 0 };
renderChk();
const step = () => {
const r = S.poseSelfTest(4);
acc.hit += r.poseHit; acc.tried += r.poseTried;
acc.err = Math.max(acc.err, r.poseErr);
acc.msSum += r.poseMs * r.poseTried;
CHK.pose.done += r.poseTried;
if (CHK.pose.done < CHK.pose.total) { renderChk(); setTimeout(step, 16); return; }
CHK.pose = { hit: acc.hit, tried: acc.tried, err: acc.err, ms: acc.msSum / Math.max(1, acc.tried) };
reRunning = false;
renderChk();
};
setTimeout(step, 16);
}
renderChk();
/* 展开自检栏本身不触发计算;只有"预置结果已失效"时才自动补算 */
{
const cb = $('ikChkBox');
if (cb) cb.addEventListener('toggle', () => { if (cb.open && !CHK.sigOk && !CHK.pose) runRecompute(); });
}
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */
const ext = document.querySelectorAll(
'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length;
@@ -671,8 +1009,10 @@ function boot() {
'<i>外部引用</i>' + ok(ext === 0) +
(ext === 0 ? '0 个(script/link/img/iframe 等全类型已查)' : ext + ' 个 —— 已破坏自包含!') + '</span>';
syncInputs(); refreshTarget();
const q = new URLSearchParams(location.search);
const tgt = q.get('tgt'); // tgt=x,y,z:直接以该目标进入运动模拟(复现用)
if (tgt && /^-?[\d.]+,-?[\d.]+,-?[\d.]+$/.test(tgt)) TGT = tgt.split(',').map(Number);
syncInputs(); refreshTarget();
const hasJ = ['j1', 'j2', 'j3', 'j4', 'j5', 'j6'].some(k => q.has(k));
const v0 = q.get('view');
if ((v0 === 'motion' || v0 === 'sheet') && document.querySelector('.tab[data-view="' + v0 + '"]')) {
@@ -682,6 +1022,7 @@ function boot() {
boot();
window.IK = { solver: S, setTarget: p => { TGT = p.slice(); syncInputs(); refreshTarget(); },
clampAxis: clampAxis, refreshJointLabels: refreshJointLabelsLazy,
getTarget: () => TGT.slice(), run: runSolve, TCP0: TCP0, LIM: LIM,
setView: setView, getView: () => VIEW,
setPose: (az, el, roll, solve) => {
@@ -689,6 +1030,15 @@ window.IK = { solver: S, setTarget: p => { TGT = p.slice(); syncInputs(); refres
syncPoseInputs(); refreshTarget(); if (solve !== false) runSolve();
},
getPose: () => ({ on: poseOn, az: POSE.az, el: POSE.el, roll: POSE.roll }),
/* 优先级 / 混合比重(调试与复现用):setPrio('mix', 0.3) */
setPrio: (p, w) => {
const e = $('ikPrio');
if (e && (p === 'pos' || p === 'ori' || p === 'mix')) e.value = p;
if (w != null && $('ikMixW')) $('ikMixW').value = Math.round(Math.max(0, Math.min(1, +w)) * 100);
syncMixUI();
if (poseOn) runSolve();
return { prio: prioVal(), mixW: mixWVal() };
},
/* 可拖控点的屏幕坐标(画布内像素):黄球 / 箭头头 / 滚转侧球 */
pickPts: () => poseOn ? { ball: projTgt(), head: projArrow(HEADZ), knob: knobScreen() } : null,
/* 调试:某视口坐标下的把手拾取结果('axis'/'roll'/null) */
+33872 -181
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -7
View File
@@ -96,16 +96,15 @@ const ROWS = [
{id:'S04', size:'M3x8', spec:'M3 × 8', head:'沉头内六角', qty:4,
pos:'P03 前置止脱锁紧法兰 → M1 电机前端面',
note:'Ø3.2 通孔 + Ø6.0 沉孔,BCD 55', base:false},
{id:'S05', size:'M4x25', spec:'M4 × 25', head:'圆柱头内六角', qty:4,
pos:'P01 立柱分段对锁:A-B 缝 2 颗 + B-C 缝 2 颗',
note:'Ø4.4 通孔,非对称偏心孔位防反装', base:false,
extra:{label:'M4 螺母 ×4', q:'M4 六角螺母 304不锈钢'}},
{id:'S05', size:'M4x16', spec:'M4 × 16', head:'圆柱头内六角', qty:8,
pos:'P01 立柱分段对锁:A-B 缝 4 颗 + B-C 缝 4 颗(每缝前后各 2 颗)',
note:'全部 Ø3.9 直孔(母口壁与公榫壁同径,无过孔/无沉孔):M4×16 从外面直接拧入自攻成型,螺纹吃在母口壁+公榫壁共 10.5mm;非对称孔位防反装', base:false},
{id:'S06', size:'M4x16', spec:'M4 × 16', head:'沉头内六角', qty:4,
pos:'P09 末端工具法兰 → 末端工具/夹爪',
note:'Ø3.7 通孔 + Ø7.0 沉孔(深 4mm),4 孔分布圆 R20', base:false},
{id:'S09', size:'M5x35', spec:'M5 × 35', head:'圆柱头内六角', qty:4,
pos:'P03 肩吊臂安装台 → P01_C 立柱顶面',
note:'Ø5.5 通孔,穿过 15mm 凸台旋入立柱 20mm', base:false},
{id:'S09', size:'M4x16', spec:'M4 × 16', head:'圆柱头内六角', qty:5,
pos:'P03 肩吊臂 → P01_C 立柱顶面(4 颗)+ 立柱后壁 → P03 榫头横向防脱锁紧(1 颗)',
note:'全部 Ø3.9 直孔(鞍座/顶环/后壁/榫头同径,无过孔/无沉孔):M4×16 直接拧入自攻成型;顶部 4 颗穿鞍座 9mm 吃进顶环 7mm,横向 1 颗穿 11mm 后壁吃进榫头 4.7mm', base:false},
/* —— 底座 P02 相关,本次暂不采购 —— */
{id:'S07', size:'M5x25', spec:'M5 × 25', head:'圆柱头内六角', qty:4,
pos:'P02 底座中核心 → P01 立柱底部榫头',