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
+175 -63
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) {
hoverPending = true;
requestAnimationFrame(() => {
hoverPending = false;
if (lastHoverEv && !drag) hover(lastHoverEv);
});
}
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)));
}
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() {
resize();
if (cv.style.display !== 'none') {
if (needRender && cv.style.display !== 'none') {
needRender = false;
resize();
updCam();
rend.render(scene, cam);
}