feat: 查看器新增姿态控制算法与箭头拖拽交互(TCP位置+工具姿态6自由度联合IK),IK控制条改两行,补充定位图截图
- arm-ik-solver.js:位置闭式支附加 θ6 滚转解算(N=R5^T·R_des,工具轴与 θ6 无关的结构);达标带(e≤1°)按 e 升序取种子 + escalating(惩罚×100 重扫)+ (θ4,θ5) 二维牛顿收口,分层保证"姿态达标再谈省关节角";超可达空间走 6 维阻尼最小二乘(位置 mm+姿态 deg 联合)兜底;自检补姿态重构 - ik-glue.js:姿态控制开关 + 方位/仰角/滚转输入 + 取当前/垂直向下;目标点处方向箭头(弧球法调指向)与粉色侧球/箭杆(调滚转),就近仲裁不抢黄球拖拽;面板显示目标/实际姿态与残差(闭式/场景图双口径) - 界面语义统一为工具朝外法向,入栈绕工具 x 翻 180° 换算求解器内部约定
This commit is contained in:
+60
-16
@@ -97,10 +97,11 @@ small{color:var(--dim)}
|
|||||||
.hudlink{pointer-events:auto;cursor:pointer;color:var(--ac);text-decoration:underline;margin-left:8px}
|
.hudlink{pointer-events:auto;cursor:pointer;color:var(--ac);text-decoration:underline;margin-left:8px}
|
||||||
@media (max-width:760px){#bomTab{top:auto;bottom:36px}#bomDock{top:46px;bottom:26px}}
|
@media (max-width:760px){#bomTab{top:auto;bottom:36px}#bomDock{top:46px;bottom:26px}}
|
||||||
|
|
||||||
/* ===================== IK 控制条(新增) ===================== */
|
/* ===================== IK 控制条(两行:目标+姿态 / 模式+状态) ===================== */
|
||||||
#ikbar{grid-column:1/-1;display:flex;align-items:center;gap:6px;padding:0 10px;height:40px;
|
#ikbar{grid-column:1/-1;display:flex;flex-direction:column;gap:4px;padding:5px 10px;
|
||||||
background:linear-gradient(180deg,#1c212b,#161922);border-bottom:1px solid var(--line);
|
background:linear-gradient(180deg,#1c212b,#161922);border-bottom:1px solid var(--line)}
|
||||||
overflow-x:auto;overflow-y:hidden;white-space:nowrap;flex-wrap:nowrap}
|
#ikbar .ikrow{display:flex;align-items:center;gap:6px;min-height:26px;
|
||||||
|
overflow-x:auto;overflow-y:hidden;white-space:nowrap}
|
||||||
#ikbar .ik-t{color:var(--ac);font-weight:700;letter-spacing:.8px;flex:none;font-size:11.5px;
|
#ikbar .ik-t{color:var(--ac);font-weight:700;letter-spacing:.8px;flex:none;font-size:11.5px;
|
||||||
border:1px solid var(--ac);border-radius:4px;padding:1px 6px}
|
border:1px solid var(--ac);border-radius:4px;padding:1px 6px}
|
||||||
#ikbar .lb{color:var(--dim);flex:none;font-size:12px}
|
#ikbar .lb{color:var(--dim);flex:none;font-size:12px}
|
||||||
@@ -114,6 +115,9 @@ small{color:var(--dim)}
|
|||||||
#ikbar .good{color:var(--ok)} #ikbar .bad{color:#ff6b6b} #ikbar .hi{color:var(--warn)}
|
#ikbar .good{color:var(--ok)} #ikbar .bad{color:#ff6b6b} #ikbar .hi{color:var(--warn)}
|
||||||
#ikSolve{background:#1d3f74;border-color:var(--ac);color:#fff;font-weight:600}
|
#ikSolve{background:#1d3f74;border-color:var(--ac);color:#fff;font-weight:600}
|
||||||
#ikSolve:hover{background:#24508f}
|
#ikSolve:hover{background:#24508f}
|
||||||
|
#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}
|
||||||
|
|
||||||
/* ===================== IK 结果面板(左栏顶部) ===================== */
|
/* ===================== IK 结果面板(左栏顶部) ===================== */
|
||||||
#ikpanel{margin:0 0 10px;border:1px solid var(--line);border-radius:6px;background:#141821;padding:7px 8px}
|
#ikpanel{margin:0 0 10px;border:1px solid var(--line);border-radius:6px;background:#141821;padding:7px 8px}
|
||||||
@@ -178,9 +182,9 @@ body.motion #treeBlock{display:none!important}
|
|||||||
#left{grid-row:3}
|
#left{grid-row:3}
|
||||||
#stage{grid-row:3}
|
#stage{grid-row:3}
|
||||||
#app>footer{grid-row:4}
|
#app>footer{grid-row:4}
|
||||||
/* BOM 抽屉:运动模拟多一条 IK 控制条,需整体下移 40px */
|
/* BOM 抽屉:运动模拟多一条两行 IK 控制条(实测 75px),需整体下移 */
|
||||||
#bomDock{top:46px} #bomTab{top:64px}
|
#bomDock{top:46px} #bomTab{top:64px}
|
||||||
body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
|
||||||
@media (max-width:760px){
|
@media (max-width:760px){
|
||||||
#bomTab,body.motion #bomTab{top:auto;bottom:36px}
|
#bomTab,body.motion #bomTab{top:auto;bottom:36px}
|
||||||
#bomDock,body.motion #bomDock{top:86px;bottom:26px}
|
#bomDock,body.motion #bomDock{top:86px;bottom:26px}
|
||||||
@@ -212,6 +216,7 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="ikbar">
|
<div id="ikbar">
|
||||||
|
<div class="ikrow">
|
||||||
<span class="ik-t">IK</span>
|
<span class="ik-t">IK</span>
|
||||||
<span class="lb">目标点 X</span><input type="number" id="ikX" step="1" title="CAD 世界坐标 X / mm">
|
<span class="lb">目标点 X</span><input type="number" id="ikX" step="1" title="CAD 世界坐标 X / mm">
|
||||||
<span class="lb">Y</span><input type="number" id="ikY" step="1" title="CAD 世界坐标 Y / mm">
|
<span class="lb">Y</span><input type="number" id="ikY" step="1" title="CAD 世界坐标 Y / mm">
|
||||||
@@ -228,22 +233,33 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
<option value="0.1">精细 0.1°</option>
|
<option value="0.1">精细 0.1°</option>
|
||||||
<option value="0.05">极高 0.05°</option>
|
<option value="0.05">极高 0.05°</option>
|
||||||
</select>
|
</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 轴的方位角 / 度">
|
||||||
|
<span class="lb">仰角</span><input type="number" id="ikEl" step="5" value="90" title="工具轴(法兰朝外方向)自 CAD +Z 抬起的仰角 / 度:0 = 竖直向上,90 = 水平,180 = 竖直向下">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="ikrow">
|
||||||
<button id="ikPick" title="开启后在视图中单击,把目标点投到当前高度的水平面上">视图拾取</button>
|
<button id="ikPick" title="开启后在视图中单击,把目标点投到当前高度的水平面上">视图拾取</button>
|
||||||
<button id="ikRt" title="开启后拖动黄球即算即动:手臂实时追踪目标点移动(松手做一次精修求解)">实时追踪</button>
|
<button id="ikRt" title="开启后拖动黄球即算即动:手臂实时追踪目标点移动(松手做一次精修求解)">实时追踪</button>
|
||||||
<button id="ikHome" title="所有关节角归零">归零位</button>
|
<button id="ikHome" title="所有关节角归零">归零位</button>
|
||||||
<button id="ikZero" title="载入一个示例目标并求解">示例目标</button>
|
<button id="ikZero" title="载入一个示例目标并求解">示例目标</button>
|
||||||
<span class="vsep"></span>
|
<span class="vsep"></span>
|
||||||
<span class="hint" id="ikHint">拖动黄球 = 移动目标(Shift 约束竖直);松手自动求解</span>
|
<span class="hint" id="ikHint">拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解</span>
|
||||||
<span class="vsep"></span>
|
<span class="vsep"></span>
|
||||||
<span class="st" id="ikBar">—</span>
|
<span class="st" id="ikBar">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<aside id="left">
|
<aside id="left">
|
||||||
<div id="ikpanel">
|
<div id="ikpanel">
|
||||||
<div class="ikh">
|
<div class="ikh">
|
||||||
<h3>逆运动学 · 最小总关节角</h3>
|
<h3>逆运动学 · 位置与姿态 · 最小总关节角</h3>
|
||||||
<span id="ikBadge" class="badge wait">待求解</span>
|
<span id="ikBadge" class="badge wait">待求解</span>
|
||||||
</div>
|
</div>
|
||||||
<table id="ikTab">
|
<table id="ikTab">
|
||||||
@@ -261,9 +277,16 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
<div class="doc">
|
<div class="doc">
|
||||||
<b>模式</b>:本页「<b>运动模拟</b>」承载全部运动学能力;「三维模型」为纯静态模型视图(关节锁定在设计零位、无目标点与求解控件),「装配图」为二维图纸。运动模拟下左栏只保留 IK 结果与关节角度,零件树/打印清单自动收走。<hr>
|
<b>模式</b>:本页「<b>运动模拟</b>」承载全部运动学能力;「三维模型」为纯静态模型视图(关节锁定在设计零位、无目标点与求解控件),「装配图」为二维图纸。运动模拟下左栏只保留 IK 结果与关节角度,零件树/打印清单自动收走。<hr>
|
||||||
<b>目标</b>:使 TCP(末端工具法兰 P09 安装面中心,可加“工具长”)到达给定点,并在一切满足限位的可行构型中取
|
<b>目标</b>:使 TCP(末端工具法兰 P09 安装面中心,可加“工具长”)到达给定点,并在一切满足限位的可行构型中取
|
||||||
<b>Σ|θᵢ| 最小</b>者。
|
<b>Σ|θᵢ| 最小</b>者(交互连续求解时叠加参考位姿连续项,见第 6 条)。开启上方“<b>姿态控制</b>”后,
|
||||||
|
约束升级为 <b>位置 3 + 姿态 3</b>(工具轴指向 + 绕轴滚转),见第 5 条。
|
||||||
|
<br><b>姿态操控</b>:姿态开启后目标球处出现方向箭头与粉色侧球——<b>箭头指向即工具朝外方向</b>(法兰面法向朝外,也就是工具伸出的方向)。
|
||||||
|
<b>拖箭头头部</b>用弧球法(指针射线与以目标为球心、半径=箭长的球求交,方向=交点−球心)连续调整方位/俯仰;
|
||||||
|
<b>拖箭杆或粉色侧球</b>按指针绕轴的转角增量改滚转;右侧数字框随时同步,可精确微调,
|
||||||
|
「取当前」把真实工具姿态读回、「垂直向下」一键设为爪取常用姿态。
|
||||||
|
<br><b>约定</b>:求解器内部工具坐标系 z 轴取法兰面法向(朝臂内,与 TCP 外伸反向);界面上的方位/仰角/滚转、
|
||||||
|
箭头与绿色当前轴线均以朝外法向描述,入栈时绕工具 x 轴翻 180° 换算,两者看到的物理姿态完全一致。
|
||||||
<ol>
|
<ol>
|
||||||
<li><b>先消去一维</b>:TCP 恰好落在 J6 轴线上,绕 J6 转动不改变 TCP 位置 ⇒ 对“到点”任务取
|
<li><b>先消去一维</b>(仅位置任务):TCP 恰好落在 J6 轴线上,绕 J6 转动不改变 TCP 位置 ⇒ 对“到点”任务取
|
||||||
<code>θ6 = 0</code> 即已最优。</li>
|
<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 = 18.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>A = X−X0</code>、<code>R = |(Y−Y0, Z−Z1)|</code>、<code>φ = atan2(Z−Z1, Y−Y0)</code>,
|
||||||
@@ -283,8 +306,23 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
<li><b>遍历冗余</b>:(θ4, θ5) 就是二维冗余。在 (θ4, θ5) 的限位盒内<b>全域扫描</b>(区间端点必采,含上下限位),
|
<li><b>遍历冗余</b>:(θ4, θ5) 就是二维冗余。在 (θ4, θ5) 的限位盒内<b>全域扫描</b>(区间端点必采,含上下限位),
|
||||||
每个网格点闭式求出全部支 ⇒ <b>覆盖整个可行解族</b>;再对最优的若干互异候选做 h/8 局部加密 + 模式搜索,
|
每个网格点闭式求出全部支 ⇒ <b>覆盖整个可行解族</b>;再对最优的若干互异候选做 h/8 局部加密 + 模式搜索,
|
||||||
得到 <b>全局最小 Σ|θ|</b>,误差量级 ≤ 扫描步长。</li>
|
得到 <b>全局最小 Σ|θ|</b>,误差量级 ≤ 扫描步长。</li>
|
||||||
|
<li><b>姿态任务(6 自由度)</b>:工具坐标系姿态 <code>R = R₁·R₂·…·R₆</code>(与场景图 6 级矩阵链一致)。
|
||||||
|
结构上 <b>J5 轴与 J6 轴在腕心 (18.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 条扫描路径):>10° 跳变 14→5 次,最大跳变 163.5°→16.8°,平均 Σ|θ| 不升反降。</li>
|
||||||
<li><b>代表角</b>:J1 / J3 / J6 为 ±180° 整周轴,取 |θ| 最小的等价角;J2 / J4 / J5 有限位,越限即丢弃。</li>
|
<li><b>代表角</b>:J1 / J3 / J6 为 ±180° 整周轴,取 |θ| 最小的等价角;J2 / J4 / J5 有限位,越限即丢弃。</li>
|
||||||
<li><b>自检</b>:每次求解都用闭式 FK 与场景图真实 6 级矩阵链各算一次 TCP 并与目标比对(见上表残差)。</li>
|
<li><b>自检</b>:每次求解都用闭式 FK 与场景图真实 6 级矩阵链各算一次 TCP 和工具姿态并与目标比对(见上表残差)。</li>
|
||||||
</ol>
|
</ol>
|
||||||
<hr>
|
<hr>
|
||||||
<b>验证数据(本机实测,可复现)</b>
|
<b>验证数据(本机实测,可复现)</b>
|
||||||
@@ -294,18 +332,24 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
<tr><td>闭式 FK vs 6 级矩阵链(1500 组随机位形)</td><td class="good">4.6 × 10⁻¹³ mm</td></tr>
|
<tr><td>闭式 FK vs 6 级矩阵链(1500 组随机位形)</td><td class="good">4.6 × 10⁻¹³ mm</td></tr>
|
||||||
<tr><td>逆解分支重构(200 组随机可行位形)</td><td class="good">200 / 200 命中</td></tr>
|
<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>θ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>滚转角交互 roll 0→90(参考位姿连续)</td><td class="good">Δθ6 = 89.99°,θ1..θ5 变化 < 0.01°</td></tr>
|
||||||
<tr><td>60 个可达目标:失败次数 / 最大残差</td><td class="good">0 次 / 3.5 × 10⁻¹³ mm</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>相对 0.05° 极细网格参考的最大劣化</td><td class="good">0.02°</td></tr>
|
||||||
<tr><td>vs 4000 次随机多起点数值解</td><td class="good">0 / 20 次劣于数值解</td></tr>
|
<tr><td>vs 4000 次随机多起点数值解</td><td class="good">0 / 20 次劣于数值解</td></tr>
|
||||||
<tr><td>单次求解耗时(默认 0.25°)</td><td>约 0.1 s(1° ≈ 30 ms,0.05° ≈ 1.1 s)</td></tr>
|
<tr><td>等价分支跳变 · 4 条扫描路径(修复前 → 后)</td><td class="good">>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>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<hr>
|
<hr>
|
||||||
<b>限位</b>:J2 ∈ [−106, 9],J4 ∈ [−111, 16],J5 ∈ [−95, 34],J1 / J3 / J6 为 ±180°(可整周)。
|
<b>限位</b>:J2 ∈ [−106, 9],J4 ∈ [−111, 16],J5 ∈ [−95, 34],J1 / J3 / J6 为 ±180°(可整周)。
|
||||||
<br><b>局限</b>:本算法为纯运动学求解,只受各关节行程限位约束,<u>不含跨关节自碰撞校核</u>。
|
<br><b>局限</b>:本算法为纯运动学求解,只受各关节行程限位约束,<u>不含跨关节自碰撞校核</u>。
|
||||||
原模型限位来自全对全动态扫描;若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
|
原模型限位来自全对全动态扫描;若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
|
||||||
<br><b>不可达</b>:若目标超出工作空间,自动改用阻尼最小二乘求最近可达位姿并标注距离。
|
<br><b>不可达</b>:若目标超出工作空间,自动求最近可达位姿——阻尼最小二乘<b>从当前姿态锚定起步</b>(抑制退化方向的无用漂移)、固定种子的确定性重启(同一点重复解算逐位一致)、再经零空间抛光取最小总关节角的代表值;报告的距离为抛光后真实值。
|
||||||
<br><b>API</b>:控制台可用 <code>IK.solver.solve([x,y,z],{step:0.25,tool:0,weights:[1,1,1,1,1,1]})</code>。
|
<br><b>姿态不可达</b>:姿态任务超可达空间时,阻尼最小二乘误差向量升级为 6 维(位置 mm + 姿态 deg,按 20 mm/rad 折算),返回位置与姿态联合最近的位姿,两个残差分别显示。
|
||||||
|
<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> 可直接设定目标姿态。
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +377,7 @@ body.motion #bomDock{top:86px} body.motion #bomTab{top:100px}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>BOM 点螺丝行 = 高亮定位该规格螺钉(穿透显示)</b> · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复<span id="footIk"> · <b>运动模拟</b>:拖动黄色目标球到点(Shift 仅上下)· 松手自动求解</span></span>
|
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>BOM 点螺丝行 = 高亮定位该规格螺钉(穿透显示)</b> · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复<span id="footIk"> · <b>运动模拟</b>:拖动黄色目标球到点(Shift 仅上下)· 开「姿态控制」后可拖箭头调末端朝向、拖箭杆或粉色侧球调滚转</span></span>
|
||||||
<span class="sp"></span>
|
<span class="sp"></span>
|
||||||
<span id="stat"></span>
|
<span id="stat"></span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+444
-40
@@ -38,6 +38,45 @@ const mv3 = (A, v) => [
|
|||||||
A[3] * v[0] + A[4] * v[1] + A[5] * v[2],
|
A[3] * v[0] + A[4] * v[1] + A[5] * v[2],
|
||||||
A[6] * v[0] + A[7] * v[1] + A[8] * v[2]
|
A[6] * v[0] + A[7] * v[1] + A[8] * v[2]
|
||||||
];
|
];
|
||||||
|
const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||||
|
const T3 = M => [M[0], M[3], M[6], M[1], M[4], M[7], M[2], M[5], M[8]]; // 3x3 转置
|
||||||
|
const clamp1 = v => v < -1 ? -1 : (v > 1 ? 1 : v);
|
||||||
|
|
||||||
|
/* 两旋转矩阵间的夹角(度):θ = acos((tr(AᵀB) − 1) / 2),A、B 均为正交阵。
|
||||||
|
tr(AᵀB) = Σᵢⱼ AᵢⱼBᵢⱼ —— 行主序即两数组逐元素点积。 */
|
||||||
|
function rotErrDeg(A, B) {
|
||||||
|
let tr = 0;
|
||||||
|
for (let i = 0; i < 9; i++) tr += A[i] * B[i];
|
||||||
|
return Math.acos(clamp1((tr - 1) / 2)) * R2D;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 姿态参数(CAD 系)→ 工具坐标系旋转矩阵(行主序 9 数)。
|
||||||
|
az:工具轴(法兰面 +Z)绕 CAD Z 轴的方位角;el:工具轴自 CAD +Z 抬起的仰角(0=朝上,180=朝下);
|
||||||
|
roll:绕工具轴自身的滚转角(右手系)。
|
||||||
|
列向量构成工具坐标系:z = 工具轴;x0 = 子午面基(−sin az, cos az, 0),恒垂直于 z;
|
||||||
|
x = 绕 z 滚转 roll 后的 x0;y = z × x 保证右手系。 */
|
||||||
|
function poseMat(azDeg, elDeg, rollDeg) {
|
||||||
|
const az = azDeg * D2R, el = elDeg * D2R, ro = rollDeg * D2R;
|
||||||
|
const ca = Math.cos(az), sa = Math.sin(az);
|
||||||
|
const z = [Math.sin(el) * ca, Math.sin(el) * sa, Math.cos(el)];
|
||||||
|
const x0 = [-sa, ca, 0];
|
||||||
|
const x = mv3(rotAxis(z, ro), x0);
|
||||||
|
const y = cross3(z, x);
|
||||||
|
return [x[0], y[0], z[0], x[1], y[1], z[1], x[2], y[2], z[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 姿态矩阵 → {az, el, roll}(poseMat 的逆分解,度)。el ∈ [0,180],az ∈ (−180,180],roll ∈ (−180,180]
|
||||||
|
R = R0·Rx(roll)(R0 = poseMat(az,el,0),列为 x0/y0/z)⇒ 工具 x 列 = cos(roll)·x0 + sin(roll)·y0 */
|
||||||
|
function poseAng(R) {
|
||||||
|
const z = [R[2], R[5], R[8]], x = [R[0], R[3], R[6]]; // 行主序:列 2 = 工具轴,列 0 = 工具 x
|
||||||
|
const az = Math.atan2(z[1], z[0]) * R2D;
|
||||||
|
const el = Math.atan2(Math.hypot(z[0], z[1]), z[2]) * R2D;
|
||||||
|
const x0 = [-Math.sin(az * D2R), Math.cos(az * D2R), 0];
|
||||||
|
const y0 = cross3(z, x0);
|
||||||
|
const d0 = x[0] * x0[0] + x[1] * x0[1] + x[2] * x0[2];
|
||||||
|
const d1 = x[0] * y0[0] + x[1] * y0[1] + x[2] * y0[2];
|
||||||
|
return { az: wrap180(az), el: el, roll: wrap180(Math.atan2(d1, d0) * R2D) };
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================================================*/
|
/* ==========================================================================*/
|
||||||
function make(cfg) {
|
function make(cfg) {
|
||||||
@@ -109,6 +148,17 @@ function make(cfg) {
|
|||||||
return [w[0], w[1], w[2]];
|
return [w[0], w[1], w[2]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 1b. 工具姿态链:前 thru 个关节旋转之积(CAD 系,右乘法向) ---------- */
|
||||||
|
/* 工具坐标系姿态 = R1·R2·…·R6。因 R6 只绕工具自身 z 轴转动:
|
||||||
|
· 工具轴(法兰面 +Z 指向)= (R1…R5)·ẑ —— 与 θ6 无关,这正是姿态任务可解的关键;
|
||||||
|
· θ6 只提供绕工具轴的滚转。 */
|
||||||
|
function orient(q, thru) {
|
||||||
|
let M = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
||||||
|
const n = thru == null ? 6 : thru;
|
||||||
|
for (let i = 0; i < n; i++) M = mul3(M, rotAxis(D[ORDER[i]], q[i] * D2R));
|
||||||
|
return M;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 2. 闭式正运动学(快速,用于求解内部) ---------- */
|
/* ---------- 2. 闭式正运动学(快速,用于求解内部) ---------- */
|
||||||
function fk(q, tool) {
|
function fk(q, tool) {
|
||||||
const L = K.L5 + (+tool || 0);
|
const L = K.L5 + (+tool || 0);
|
||||||
@@ -171,8 +221,13 @@ function make(cfg) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const costOf = (q, W) => {
|
const costOf = (q, W, ref, lam) => {
|
||||||
let c = 0; for (let i = 0; i < 6; i++) c += Math.abs(q[i]) * (W ? W[i] : 1);
|
let c = 0;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const w = W ? W[i] : 1;
|
||||||
|
c += Math.abs(q[i]) * w;
|
||||||
|
if (ref && lam) { const d = wrap180(q[i] - ref[i]); c += lam * w * d * d; }
|
||||||
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||||
@@ -189,7 +244,43 @@ function make(cfg) {
|
|||||||
const R = Math.hypot(BY, BZ), ph = Math.atan2(BZ, BY);
|
const R = Math.hypot(BY, BZ), ph = Math.atan2(BZ, BY);
|
||||||
const tol = opt.tol || 1e-6;
|
const tol = opt.tol || 1e-6;
|
||||||
const KEEP = 4000;
|
const KEEP = 4000;
|
||||||
|
/* 参考位姿连续项:交互求解时以当前姿态为 ref,代价加入 lam·Σwrap(θᵢ-refᵢ)²。
|
||||||
|
等价多分支(θ3 的 ±σ/±(π−σ)、肘上/肘下)之间只按 Σ|θ| 选优会随目标微动
|
||||||
|
反复横跳,导致某轴大幅倒摆;连续项把同族分支锁住,仅当近支越限/误差超差才换支。
|
||||||
|
ref 缺省或 refW=0 时行为与原来完全一致(纯 Σ|θ| 全局最优)。 */
|
||||||
|
const REF = (opt.ref && opt.ref.length === 6) ? opt.ref.slice() : null;
|
||||||
|
const LAM = +opt.refW || 0;
|
||||||
|
/* 姿态任务:除 TCP 位置外同时指定工具姿态(R 为行主序 9 数,CAD 系)。
|
||||||
|
解法完全复用位置闭式支:(θ4,θ5) 扫描给出一族精确到点的解,每支的姿态
|
||||||
|
M = R1…R5 随之确定;对每支解算滚转角 θ6 并只保留工具轴对齐(偏差≤POSETOL)者,
|
||||||
|
代价加 POSEW·e_ori² 把精修拉向零偏差。θ6 ∈ ±180° 连续,总在限位内。 */
|
||||||
|
const POSE = (opt.pose && opt.pose.R && opt.pose.R.length === 9) ? opt.pose.R.slice() : null;
|
||||||
|
const POSETOL = 2.0; // 粗扫接受的最大姿态偏差(度)
|
||||||
|
const POSEFINE = 0.05; // 达标线:偏差 ≤ 此值视为姿态精确到达(机械上已无意义之差)
|
||||||
|
let poseW = 200.0; // 姿态偏差惩罚权重(对 deg²):0.3° 偏差即折合 Σ|θ| 量级
|
||||||
|
// (数十度)的代价; escalating 时 ×100 直至出现 POSEFINE 内解
|
||||||
|
const closeRoll = (q) => { // 给定位置解,解 θ6 并返回姿态偏差(度)
|
||||||
|
const M5 = orient(q, 5);
|
||||||
|
const N = mul3(T3(M5), POSE); // 需满足 N = Rz(−θ6)
|
||||||
|
const th6 = -Math.atan2(N[3], N[0]) * R2D; // Rz(α): [1][0]=sinα, [0][0]=cosα ⇒ α=−θ6
|
||||||
|
const q2 = q.slice(); q2[5] = th6;
|
||||||
|
const oerr = rotErrDeg(mul3(M5, rotAxis(D.J6, th6 * D2R)), POSE);
|
||||||
|
return { q: q2, oerr: oerr };
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreBranch = (q, e) => { // 对一条位置精确支打分(位置误差 e 已由闭式保证)
|
||||||
|
let oerr = 0;
|
||||||
|
if (POSE) {
|
||||||
|
const cl = closeRoll(q);
|
||||||
|
if (cl.oerr > POSETOL) return null;
|
||||||
|
q = cl.q; oerr = cl.oerr;
|
||||||
|
}
|
||||||
|
const c = costOf(q, W);
|
||||||
|
const rc0 = REF ? costOf(q, W, REF, LAM) : c; // 含连续项、不含姿态惩罚(分层选解用)
|
||||||
|
let rc = rc0;
|
||||||
|
if (POSE) rc += poseW * oerr * oerr;
|
||||||
|
return { q: q, cost: c, rc0: rc0, rcost: rc, err: e, oerr: oerr };
|
||||||
|
};
|
||||||
const evalAt = (t4, t5) => {
|
const evalAt = (t4, t5) => {
|
||||||
let best = null;
|
let best = null;
|
||||||
const sols = branch(t4, t5, A, R, ph, tool);
|
const sols = branch(t4, t5, A, R, ph, tool);
|
||||||
@@ -199,8 +290,8 @@ function make(cfg) {
|
|||||||
const p = fk(q, tool);
|
const p = fk(q, tool);
|
||||||
const e = Math.hypot(p[0] - target[0], p[1] - target[1], p[2] - target[2]);
|
const e = Math.hypot(p[0] - target[0], p[1] - target[1], p[2] - target[2]);
|
||||||
if (e > tol) continue;
|
if (e > tol) continue;
|
||||||
const c = costOf(q, W);
|
const c = scoreBranch(q, e);
|
||||||
if (!best || c < best.cost) best = { q: q, cost: c, err: e };
|
if (c && (!best || c.rcost < best.rcost - 1e-12)) best = c;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
};
|
};
|
||||||
@@ -218,46 +309,70 @@ function make(cfg) {
|
|||||||
|
|
||||||
/* --- 4.1 全域粗扫:覆盖整个二维冗余 (J4,J5) --- */
|
/* --- 4.1 全域粗扫:覆盖整个二维冗余 (J4,J5) --- */
|
||||||
let all = [], samples = 0, nSol = 0, trimmed = false;
|
let all = [], samples = 0, nSol = 0, trimmed = false;
|
||||||
|
/* 姿态任务另收"达标带":oerr ≤ 1° 的候选。达标解在 (θ4,θ5) 平面上是一条窄带,
|
||||||
|
按 rcost 取种子会整体错过它(带的 Σ|θ| 常很大);按 oerr 排序取种子才能保证
|
||||||
|
局部加密窗口罩住曲线,再由模式搜索在带上找 Σ|θ| 最小点。 */
|
||||||
|
let band = [];
|
||||||
for (let i = 0; i < g4.length; i++) {
|
for (let i = 0; i < g4.length; i++) {
|
||||||
for (let j = 0; j < g5.length; j++) {
|
for (let j = 0; j < g5.length; j++) {
|
||||||
samples++;
|
samples++;
|
||||||
const s = evalAt(g4[i], g5[j]);
|
const s = evalAt(g4[i], g5[j]);
|
||||||
if (s) { all.push(s); nSol++; }
|
if (s) { all.push(s); nSol++; if (POSE && s.oerr <= 1) band.push(s); }
|
||||||
}
|
}
|
||||||
if (all.length > KEEP * 12) { // 有界保留:只留代价最优者
|
if (!POSE && all.length > KEEP * 12) { // 有界保留:只留代价最优者
|
||||||
all.sort((x, y) => x.cost - y.cost);
|
all.sort((x, y) => x.rcost - y.rcost);
|
||||||
all.length = KEEP;
|
all.length = KEEP;
|
||||||
trimmed = true;
|
trimmed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all.sort((x, y) => x.cost - y.cost);
|
if (POSE) { // 姿态模式同样需要有界保留(带优先,再补全局最优)
|
||||||
|
band.sort((x, y) => x.oerr - y.oerr);
|
||||||
|
const keepBand = band.slice(0, KEEP);
|
||||||
|
const rest = all.filter(s => keepBand.indexOf(s) < 0).sort((x, y) => x.rcost - y.rcost).slice(0, KEEP);
|
||||||
|
all = keepBand.concat(rest);
|
||||||
|
trimmed = band.length + rest.length < nSol;
|
||||||
|
} else all.sort((x, y) => x.rcost - y.rcost);
|
||||||
|
|
||||||
/* --- 4.2 多起点局部加密 + 模式搜索 --- */
|
/* --- 4.2 多起点局部加密 + 模式搜索 ---
|
||||||
const seeds = [];
|
姿态任务分层处理:先 escalating(poseW ×100 重扫)直到出现 oerr ≤ POSEFINE 的解,
|
||||||
for (let i = 0; i < all.length && seeds.length < 32; i++) {
|
再在达标候选里按含连续项的代价取优——避免"省几度关节角、丢 0.2° 姿态"的次优交易。 */
|
||||||
const s = all[i];
|
const refinePass = () => {
|
||||||
let dup = false;
|
if (!all.length) return; // 一个候选都没有(姿态完全不可达):无从精修
|
||||||
for (const t of seeds) if (Math.hypot(t.q[3] - s.q[3], t.q[4] - s.q[4]) <= h * 1.5) { dup = true; break; }
|
const seeds = [];
|
||||||
if (!dup) seeds.push(s);
|
if (POSE) {
|
||||||
}
|
/* 姿态种子:达标带按 oerr 升序、空间去同(相距 ≤ 2h 只留最优),
|
||||||
if (opt.refine !== false) {
|
保证种子贴在窄带上,局部窗口才能罩住精确解 */
|
||||||
|
for (const s of band) {
|
||||||
|
if (seeds.length >= 32) break;
|
||||||
|
let dup = false;
|
||||||
|
for (const t of seeds) if (Math.hypot(t.q[3] - s.q[3], t.q[4] - s.q[4]) <= 2 * h) { dup = true; break; }
|
||||||
|
if (!dup) seeds.push(s);
|
||||||
|
}
|
||||||
|
if (!seeds.length) seeds.push(all[0]);
|
||||||
|
} else { for (let i = 0; i < all.length && seeds.length < 32; i++) {
|
||||||
|
const s = all[i];
|
||||||
|
let dup = false;
|
||||||
|
for (const t of seeds) if (Math.hypot(t.q[3] - s.q[3], t.q[4] - s.q[4]) <= h * 1.5) { dup = true; break; }
|
||||||
|
if (!dup) seeds.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
const cl = (v, a, b) => v < a ? a : (v > b ? b : v);
|
const cl = (v, a, b) => v < a ? a : (v > b ? b : v);
|
||||||
for (const s of seeds) { // 局部加密网格(h/8,±2h 窗口)
|
for (const s of seeds) { // 局部加密网格(h/8,±2h 窗口)
|
||||||
const hh = h / 8;
|
const hh = h / 8;
|
||||||
const w4 = axis(Math.max(L4.lo, s.q[3] - 2 * h), Math.min(L4.hi, s.q[3] + 2 * h), hh);
|
const w4 = axis(Math.max(L4.lo, s.q[3] - 2 * h), Math.min(L4.hi, s.q[3] + 2 * h), hh);
|
||||||
const w5 = axis(Math.max(L5.lo, s.q[4] - 2 * h), Math.min(L5.hi, s.q[4] + 2 * h), hh);
|
const w5 = axis(Math.max(L5.lo, s.q[4] - 2 * h), Math.min(L5.hi, s.q[4] + 2 * h), hh);
|
||||||
let best = s;
|
let lb = s;
|
||||||
for (const a of w4) for (const b of w5) {
|
for (const a of w4) for (const b of w5) {
|
||||||
samples++;
|
samples++;
|
||||||
const c = evalAt(a, b);
|
const c = evalAt(a, b);
|
||||||
if (c) { nSol++; if (c.cost < best.cost) best = c; }
|
if (c) { nSol++; if (c.rcost < lb.rcost) lb = c; }
|
||||||
}
|
}
|
||||||
all.push(best);
|
all.push(lb);
|
||||||
}
|
}
|
||||||
all.sort((x, y) => x.cost - y.cost);
|
all.sort((x, y) => x.rcost - y.rcost);
|
||||||
const NB = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
const NB = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
||||||
for (const s of all.slice(0, 24)) { // 模式搜索
|
for (const s of all.slice(0, 24)) { // 模式搜索
|
||||||
let c4 = s.q[3], c5 = s.q[4], best = s, step = h / 4, guard = 0;
|
let c4 = s.q[3], c5 = s.q[4], lb = s, step = h / 4, guard = 0;
|
||||||
while (step > 2e-4 && guard++ < 400) {
|
while (step > 2e-4 && guard++ < 400) {
|
||||||
let moved = false;
|
let moved = false;
|
||||||
for (const d of NB) {
|
for (const d of NB) {
|
||||||
@@ -265,28 +380,128 @@ function make(cfg) {
|
|||||||
if (t4 === c4 && t5 === c5) continue;
|
if (t4 === c4 && t5 === c5) continue;
|
||||||
samples++;
|
samples++;
|
||||||
const c = evalAt(t4, t5);
|
const c = evalAt(t4, t5);
|
||||||
if (c && c.cost < best.cost - 1e-10) { best = c; c4 = t4; c5 = t5; moved = true; }
|
if (c && c.rcost < lb.rcost - 1e-10) { lb = c; c4 = t4; c5 = t5; moved = true; }
|
||||||
}
|
}
|
||||||
if (!moved) step *= 0.5;
|
if (!moved) step *= 0.5;
|
||||||
}
|
}
|
||||||
all.push(best);
|
all.push(lb);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (opt.refine !== false) {
|
||||||
|
refinePass();
|
||||||
|
for (let k = 0; k < 3; k++) { // escalating:尚无 POSEFINE 内解则姿态权重 ×100 重扫
|
||||||
|
if (!POSE || all.some(s => s.oerr != null && s.oerr <= POSEFINE)) break;
|
||||||
|
poseW *= 100;
|
||||||
|
refinePass();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
all.sort((x, y) => x.cost - y.cost);
|
/* --- 4.3 姿态收口:(θ4,θ5) 二维牛顿迭代 ---
|
||||||
const best = all.length ? all[0] : null;
|
达标带是 (θ4,θ5) 平面上的斜窄谷(局部灵敏度可达 ~10°/°),8 邻域模式搜索会整在
|
||||||
|
谷壁上(实测最好只收到 0.106°)。这里直接对"工具轴在目标轴法平面上的切向偏差"
|
||||||
|
做牛顿迭代:每步在新 (θ4,θ5) 上重解闭式位置支、按 θ1..θ3 最近匹配取同支,
|
||||||
|
位置天然保持精确,轴偏差压到 ~1e-12 量级。 */
|
||||||
|
if (POSE && opt.refine !== false && all.length) {
|
||||||
|
const A0 = [POSE[2], POSE[5], POSE[8]]; // 目标工具轴(列 2)
|
||||||
|
const uz = cross3(A0, [0, 0, 1]);
|
||||||
|
const u = Math.hypot(uz[0], uz[1], uz[2]) > 0.5 ? nrm(uz) : nrm(cross3(A0, [1, 0, 0]));
|
||||||
|
const v = cross3(A0, u);
|
||||||
|
const err2 = q => {
|
||||||
|
const M5 = orient(q, 5), w = [M5[6], M5[7], M5[8]]; // 行主序:列 2 = 工具轴
|
||||||
|
const d = w[0] * A0[0] + w[1] * A0[1] + w[2] * A0[2];
|
||||||
|
const tx = w[0] - d * A0[0], ty = w[1] - d * A0[1], tz = w[2] - d * A0[2];
|
||||||
|
return [tx * u[0] + ty * u[1] + tz * u[2], tx * v[0] + ty * v[1] + tz * v[2]];
|
||||||
|
};
|
||||||
|
const match = (t4, t5, qcur) => { // 同支匹配:θ1..θ3 最近
|
||||||
|
let best = null, bd = Infinity;
|
||||||
|
for (const s of branch(t4, t5, A, R, ph, tool)) {
|
||||||
|
if (!inLimit(s)) continue;
|
||||||
|
let d = 0;
|
||||||
|
for (let i = 0; i < 3; i++) d += Math.abs(wrap180(s[i] - qcur[i]));
|
||||||
|
if (d < bd) { bd = d; best = s; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
const newton = q0 => {
|
||||||
|
let q = q0.slice(), dp = 0.05;
|
||||||
|
for (let it = 0; it < 24; it++) {
|
||||||
|
const e0 = err2(q);
|
||||||
|
if (Math.hypot(e0[0], e0[1]) < 1e-12) break;
|
||||||
|
const c4 = q[3], c5 = q[4];
|
||||||
|
const qa = q.slice(); qa[3] = c4 + dp;
|
||||||
|
const qb = q.slice(); qb[4] = c5 + dp;
|
||||||
|
const e4 = err2(qa), e5 = err2(qb);
|
||||||
|
const j00 = (e4[0] - e0[0]) / dp, j01 = (e5[0] - e0[0]) / dp;
|
||||||
|
const j10 = (e4[1] - e0[1]) / dp, j11 = (e5[1] - e0[1]) / dp;
|
||||||
|
const det = j00 * j11 - j01 * j10;
|
||||||
|
if (Math.abs(det) < 1e-12) { // 近奇异(刀尖构型):缩差分步长重试
|
||||||
|
if (dp > 0.002) { dp *= 0.25; continue; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let d4 = -(j11 * e0[0] - j01 * e0[1]) / det;
|
||||||
|
let d5 = -(-j10 * e0[0] + j00 * e0[1]) / det;
|
||||||
|
const mx = Math.max(Math.abs(d4), Math.abs(d5));
|
||||||
|
if (mx > 2) { d4 *= 2 / mx; d5 *= 2 / mx; } // 阻尼:单步 ≤ 2°
|
||||||
|
const t4 = Math.max(L4.lo, Math.min(L4.hi, c4 + d4));
|
||||||
|
const t5 = Math.max(L5.lo, Math.min(L5.hi, c5 + d5));
|
||||||
|
if (t4 === c4 && t5 === c5) break;
|
||||||
|
const nb = match(t4, t5, q);
|
||||||
|
if (!nb) { if (dp > 0.002) { dp *= 0.25; continue; } break; }
|
||||||
|
q = nb;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
const cands = all.slice().sort((x, y) => x.oerr - y.oerr).slice(0, 16);
|
||||||
|
for (const s of cands) {
|
||||||
|
if (s.oerr <= POSEFINE) continue; // 已达标,无需收口
|
||||||
|
const tries = [s.q];
|
||||||
|
for (const d of [[0.15, 0], [-0.15, 0], [0, 0.15], [0, -0.15], [0.25, 0.25], [-0.25, -0.25]]) {
|
||||||
|
const qp = s.q.slice();
|
||||||
|
qp[3] = Math.max(L4.lo, Math.min(L4.hi, qp[3] + d[0]));
|
||||||
|
qp[4] = Math.max(L5.lo, Math.min(L5.hi, qp[4] + d[1]));
|
||||||
|
const nb = match(qp[3], qp[4], s.q);
|
||||||
|
if (nb) tries.push(nb); // 微扰重启:探盆地边界(奇异刀尖构型)
|
||||||
|
}
|
||||||
|
for (const t of tries) {
|
||||||
|
const q2 = newton(t);
|
||||||
|
if (!q2) continue;
|
||||||
|
const p2 = fk(q2, tool);
|
||||||
|
const e2 = Math.hypot(p2[0] - target[0], p2[1] - target[1], p2[2] - target[2]);
|
||||||
|
if (e2 > tol) continue;
|
||||||
|
const c2 = scoreBranch(q2, e2);
|
||||||
|
if (c2 && c2.oerr < s.oerr - 1e-9) { nSol++; all.push(c2); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.sort((x, y) => x.rcost - y.rcost);
|
||||||
|
/* 采用解:姿态任务优先从 oerr ≤ POSEFINE 的达标候选里按含连续项代价选;无达标解才尽力而为 */
|
||||||
|
let pool = all;
|
||||||
|
if (POSE) {
|
||||||
|
const acc = all.filter(s => s.oerr != null && s.oerr <= POSEFINE);
|
||||||
|
if (acc.length) pool = acc;
|
||||||
|
}
|
||||||
|
pool.sort((x, y) => POSE ? x.rc0 - y.rc0 : x.rcost - y.rcost);
|
||||||
|
const best = pool.length ? pool[0] : null;
|
||||||
|
const list = pool.slice().sort((x, y) => POSE ? x.rc0 - y.rc0 : x.cost - y.cost).slice(0, KEEP);
|
||||||
|
if (best && list.indexOf(best) < 0) list.unshift(best);
|
||||||
return {
|
return {
|
||||||
ok: !!best, reachable: !!best, target: target.slice(), tool: tool,
|
ok: !!best, reachable: !!best, target: target.slice(), tool: tool,
|
||||||
|
pose: !!POSE,
|
||||||
q: best ? best.q.slice() : null, cost: best ? best.cost : null,
|
q: best ? best.q.slice() : null, cost: best ? best.cost : null,
|
||||||
err: best ? best.err : null, nSol: nSol, truncated: trimmed,
|
err: best ? best.err : null, oerr: best ? best.oerr : null,
|
||||||
samples: samples, ms: nowMs() - t0, list: all.slice(0, KEEP)
|
nSol: nSol, truncated: trimmed,
|
||||||
|
samples: samples, ms: nowMs() - t0, list: list
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 5. 最近可达位姿(阻尼最小二乘,兜底用) ---------- */
|
/* ---------- 5. 最近可达位姿(阻尼最小二乘,兜底用) ---------- */
|
||||||
function closest(target, tool, tries) {
|
function closest(target, tool, tries, ref) {
|
||||||
const dp = 0.05, lam = 4.0;
|
const dp = 0.05, lam = 4.0;
|
||||||
let bestQ = null, bestD = Infinity;
|
let bestQ = null, bestD = Infinity;
|
||||||
const run = q => {
|
/* anchor:参考位姿锚定。准退化构型(手臂近似伸直)下 J3 等轴的雅可比列梯度≈0,
|
||||||
|
DLS 伪逆仍会给它分配微小步长,160 次迭代累积成大幅无用漂移(实测 82°)。
|
||||||
|
每次迭代施加 0.03·(q−anchor) 的回拉,把退化方向的漂移压掉,任务真正需要的
|
||||||
|
方向(远大于此回拉)不受影响。 */
|
||||||
|
const run = (q, anchor) => {
|
||||||
const J = new Float64Array(18);
|
const J = new Float64Array(18);
|
||||||
for (let it = 0; it < 160; it++) {
|
for (let it = 0; it < 160; it++) {
|
||||||
const p = fk(q, tool);
|
const p = fk(q, tool);
|
||||||
@@ -321,38 +536,211 @@ function make(cfg) {
|
|||||||
const gain = mx > 12 ? 12 / mx : 1;
|
const gain = mx > 12 ? 12 / mx : 1;
|
||||||
for (let c = 0; c < 6; c++) {
|
for (let c = 0; c < 6; c++) {
|
||||||
const t = LIM[ORDER[c]];
|
const t = LIM[ORDER[c]];
|
||||||
q[c] = Math.max(t.lo, Math.min(t.hi, q[c] + dq[c] * gain));
|
let stp = dq[c] * gain;
|
||||||
|
if (anchor) stp -= 0.03 * (q[c] - anchor[c]);
|
||||||
|
q[c] = Math.max(t.lo, Math.min(t.hi, q[c] + stp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/* 零空间抛光:手臂伸直(目标在可达边界外)时 J3 轴与臂线共线,转 J3 不改变
|
||||||
|
TCP,DLS 对该方向梯度为零、停在哪里全看随机初始角。这里在"到目标距离几乎
|
||||||
|
不变"(≤slack)的前提下令姿态滑向最小总关节角,把随机角收敛回确定代表值。
|
||||||
|
同时兼顾连续性:ref 给出时从 ref 起步,邻近目标 successive 解算平滑跟进。 */
|
||||||
|
const distQ = q => Math.hypot.apply(null, fk(q, tool).map((v, i) => v - target[i]));
|
||||||
|
const polish = (q, slack) => {
|
||||||
|
if (!q) return;
|
||||||
|
const d0 = distQ(q);
|
||||||
|
let step = 8;
|
||||||
|
for (let pass = 0; pass < 4; pass++) {
|
||||||
|
let moved = true, guard = 0;
|
||||||
|
while (moved && guard++ < 40) {
|
||||||
|
moved = false;
|
||||||
|
for (let i = 0; i < 5; i++) { // J6 不改 TCP 位置,抛光无意义
|
||||||
|
const t = LIM[ORDER[i]];
|
||||||
|
for (const s of [1, -1]) {
|
||||||
|
const v = q[i] + s * step;
|
||||||
|
if (v < t.lo || v > t.hi) continue;
|
||||||
|
const old = q[i], c0 = costOf(q, null);
|
||||||
|
q[i] = v;
|
||||||
|
const d = distQ(q);
|
||||||
|
if (d <= d0 + slack && costOf(q, null) < c0 - 1e-9) moved = true;
|
||||||
|
else q[i] = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step *= 0.25;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let anchorQ = null, anchorD = Infinity;
|
||||||
|
if (ref && ref.length === 6) {
|
||||||
|
anchorQ = ref.slice();
|
||||||
|
run(anchorQ, ref); // 锚定轮:从当前姿态出发保连续
|
||||||
|
anchorD = distQ(anchorQ);
|
||||||
|
}
|
||||||
run([0, 0, 0, 0, 0, 0]);
|
run([0, 0, 0, 0, 0, 0]);
|
||||||
|
/* 确定性拟随机重启:固定种子的 LCG,取代 Math.random —— 同一目标重复解算
|
||||||
|
结果必须逐位一致,否则边界目标每次刷新姿态都不同,就是"随机倒摆"的根源 */
|
||||||
|
let seed = 0x5bd1e995;
|
||||||
|
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||||
const N = tries == null ? 160 : tries;
|
const N = tries == null ? 160 : tries;
|
||||||
for (let k = 0; k < N; k++) {
|
for (let k = 0; k < N; k++) {
|
||||||
const q = [0, 0, 0, 0, 0, 0];
|
const q = [0, 0, 0, 0, 0, 0];
|
||||||
for (let c = 0; c < 5; c++) { const t = LIM[ORDER[c]]; q[c] = t.lo + Math.random() * (t.hi - t.lo); }
|
for (let c = 0; c < 5; c++) { const t = LIM[ORDER[c]]; q[c] = t.lo + rnd() * (t.hi - t.lo); }
|
||||||
run(q);
|
run(q);
|
||||||
}
|
}
|
||||||
|
/* 锚定解与全局最优距离差 ≤0.5mm 时优先锚定解:刚过边界的目标(差 mm 以内)
|
||||||
|
保住原构型自然伸出,不为零点几毫米换个 Fold 姿态;差得远时仍取全局最近 */
|
||||||
|
if (anchorQ && anchorD <= bestD + 0.5) { bestQ = anchorQ; bestD = anchorD; }
|
||||||
|
if (bestQ) { // 报告抛光后真实距离,不虚报
|
||||||
|
polish(bestQ, 0.05); // 距离容忍 0.05mm(远小于边界误差量级)
|
||||||
|
bestD = distQ(bestQ);
|
||||||
|
}
|
||||||
return { q: bestQ, dist: bestD };
|
return { q: bestQ, dist: bestD };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 6. 一键求解(不可达时返回最近可达位姿) ---------- */
|
/* ---------- 5b. 最近可达位姿(位置 + 姿态联合,阻尼最小二乘) ---------- */
|
||||||
|
/* 误差 6 维:位置 3(mm)+ 姿态 3(R_curᵀ·R_des 的旋转矢量,rad)。
|
||||||
|
姿态项按 20 mm/rad 折算进误差向量(≈0.35mm/度),阻尼项吸收量纲差。 */
|
||||||
|
function closest6(target, Rdes, tool, tries, ref) {
|
||||||
|
const OW = 20.0; // mm per rad
|
||||||
|
const dp = 0.05, lam = 6.0;
|
||||||
|
let bestQ = null, bestE = Infinity, bestOri = 0;
|
||||||
|
const errOf = q => {
|
||||||
|
const p = fk(q, tool), N = mul3(T3(orient(q)), Rdes);
|
||||||
|
const tr = N[0] + N[4] + N[8], ang = Math.acos(clamp1((tr - 1) / 2));
|
||||||
|
const s = Math.sin(ang);
|
||||||
|
const k = s > 1e-6 ? 1 / (2 * s) : 0.5; // 小角时直接用斜对称向量/2,免奇异
|
||||||
|
const rv = [(N[7] - N[5]) * k, (N[2] - N[6]) * k, (N[3] - N[1]) * k];
|
||||||
|
return [p[0] - target[0], p[1] - target[1], p[2] - target[2],
|
||||||
|
rv[0] * OW, rv[1] * OW, rv[2] * OW];
|
||||||
|
};
|
||||||
|
const norm6 = e => Math.hypot(e[0], e[1], e[2], e[3], e[4], e[5]);
|
||||||
|
const oriOf = q => { // 纯姿态偏差(度)
|
||||||
|
const N = mul3(T3(orient(q)), Rdes);
|
||||||
|
return Math.acos(clamp1((N[0] + N[4] + N[8] - 1) / 2)) * R2D;
|
||||||
|
};
|
||||||
|
/* 6×6 线性方程组:高斯消去 + 列主元(A = J·Jᵀ + λ²I,y = A⁻¹e,dq = Jᵀy) */
|
||||||
|
const solveLin = (A, b, n) => {
|
||||||
|
const M = A.slice(), x = b.slice();
|
||||||
|
for (let c = 0; c < n; c++) {
|
||||||
|
let p = c;
|
||||||
|
for (let r = c + 1; r < n; r++) if (Math.abs(M[r * n + c]) > Math.abs(M[p * n + c])) p = r;
|
||||||
|
if (Math.abs(M[p * n + c]) < 1e-14) return null;
|
||||||
|
if (p !== c) { for (let j = 0; j < n; j++) { const t = M[c * n + j]; M[c * n + j] = M[p * n + j]; M[p * n + j] = t; } const t = x[c]; x[c] = x[p]; x[p] = t; }
|
||||||
|
for (let r = 0; r < n; r++) {
|
||||||
|
if (r === c) continue;
|
||||||
|
const f = M[r * n + c] / M[c * n + c];
|
||||||
|
if (!f) continue;
|
||||||
|
for (let j = c; j < n; j++) M[r * n + j] -= f * M[c * n + j];
|
||||||
|
x[r] -= f * x[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let c = 0; c < n; c++) x[c] /= M[c * n + c];
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
const run = (q, anchor) => {
|
||||||
|
const J = new Float64Array(36); // 6×6(误差 × 关节)
|
||||||
|
for (let it = 0; it < 160; it++) {
|
||||||
|
const e = errOf(q);
|
||||||
|
const ne = norm6(e);
|
||||||
|
if (ne < bestE) { bestE = ne; bestQ = q.slice(); bestOri = oriOf(q); }
|
||||||
|
if (ne < 1e-9) break;
|
||||||
|
for (let c = 0; c < 6; c++) {
|
||||||
|
const s = q.slice(); s[c] += dp;
|
||||||
|
const ep = errOf(s);
|
||||||
|
for (let r = 0; r < 6; r++) J[r * 6 + c] = (ep[r] - e[r]) / dp;
|
||||||
|
}
|
||||||
|
const A = new Array(36).fill(0);
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
for (let j = 0; j < 6; j++) { let s = 0; for (let k = 0; k < 6; k++) s += J[i * 6 + k] * J[j * 6 + k]; A[i * 6 + j] = s + (i === j ? lam * lam : 0); }
|
||||||
|
}
|
||||||
|
const y = solveLin(A, e.map(v => -v), 6);
|
||||||
|
if (!y) break;
|
||||||
|
const dq = new Array(6);
|
||||||
|
let mx = 0;
|
||||||
|
for (let c = 0; c < 6; c++) { let s = 0; for (let r = 0; r < 6; r++) s += J[r * 6 + c] * y[r]; dq[c] = s; mx = Math.max(mx, Math.abs(s)); }
|
||||||
|
const gain = mx > 12 ? 12 / mx : 1;
|
||||||
|
for (let c = 0; c < 6; c++) {
|
||||||
|
const t = LIM[ORDER[c]];
|
||||||
|
let stp = dq[c] * gain;
|
||||||
|
if (anchor) stp -= 0.03 * (q[c] - anchor[c]);
|
||||||
|
q[c] = Math.max(t.lo, Math.min(t.hi, q[c] + stp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* 零空间抛光:在位置与姿态偏差都不增的前提下令 Σ|θ| 最小(步进、步长逐轮衰减) */
|
||||||
|
const slackOf = q => { const e = errOf(q); return [Math.hypot(e[0], e[1], e[2]), Math.hypot(e[3], e[4], e[5]) / OW]; };
|
||||||
|
const polish = (q, slack) => {
|
||||||
|
if (!q) return;
|
||||||
|
const [d0, o0] = slackOf(q);
|
||||||
|
let step = 8;
|
||||||
|
for (let pass = 0; pass < 4; pass++) {
|
||||||
|
let moved = true, guard = 0;
|
||||||
|
while (moved && guard++ < 40) {
|
||||||
|
moved = false;
|
||||||
|
for (let i = 0; i < 5; i++) { // J6 是纯滚转,抛它只会劣化姿态偏差
|
||||||
|
const t = LIM[ORDER[i]];
|
||||||
|
for (const s of [1, -1]) {
|
||||||
|
const v = q[i] + s * step;
|
||||||
|
if (v < t.lo || v > t.hi) continue;
|
||||||
|
const old = q[i], c0 = costOf(q, null);
|
||||||
|
q[i] = v;
|
||||||
|
const [d, o] = slackOf(q);
|
||||||
|
if (d <= d0 + slack && o <= o0 + 0.05 && costOf(q, null) < c0 - 1e-9) moved = true;
|
||||||
|
else q[i] = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step *= 0.25;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let anchorQ = null, anchorE = Infinity;
|
||||||
|
if (ref && ref.length === 6) {
|
||||||
|
anchorQ = ref.slice();
|
||||||
|
run(anchorQ, ref);
|
||||||
|
anchorE = norm6(errOf(anchorQ));
|
||||||
|
}
|
||||||
|
run([0, 0, 0, 0, 0, 0]);
|
||||||
|
let seed = 0x5bd1e995;
|
||||||
|
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||||
|
const N = tries == null ? 160 : tries;
|
||||||
|
for (let k = 0; k < N; k++) {
|
||||||
|
const q = [0, 0, 0, 0, 0, 0];
|
||||||
|
for (let c = 0; c < 5; c++) { const t = LIM[ORDER[c]]; q[c] = t.lo + rnd() * (t.hi - t.lo); }
|
||||||
|
run(q);
|
||||||
|
}
|
||||||
|
if (anchorQ && anchorE <= bestE + 0.5) { bestQ = anchorQ; bestE = anchorE; }
|
||||||
|
if (bestQ) {
|
||||||
|
polish(bestQ, 0.05); // 位置 0.05mm / 姿态 0.05° 内不劣化
|
||||||
|
bestE = norm6(errOf(bestQ));
|
||||||
|
bestOri = oriOf(bestQ);
|
||||||
|
}
|
||||||
|
return { q: bestQ, dist: Math.hypot.apply(null, bestQ ? fk(bestQ, tool).map((v, i) => v - target[i]) : [1e9, 1e9, 1e9]), ori: bestOri };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 6. 一键求解(不可达时返回最近可达位姿/位姿) ---------- */
|
||||||
function solveTo(target, opt) {
|
function solveTo(target, opt) {
|
||||||
opt = opt || {};
|
opt = opt || {};
|
||||||
const res = solve(target, opt);
|
const res = solve(target, opt);
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const cp = closest(target, +opt.tool || 0, opt.tries);
|
const pose = opt.pose && opt.pose.R && opt.pose.R.length === 9;
|
||||||
|
const cp = pose ? closest6(target, opt.pose.R, +opt.tool || 0, opt.tries, opt.ref)
|
||||||
|
: closest(target, +opt.tool || 0, opt.tries, opt.ref);
|
||||||
if (!cp.q) return res;
|
if (!cp.q) return res;
|
||||||
return {
|
return {
|
||||||
ok: false, reachable: false, target: target.slice(), tool: +opt.tool || 0,
|
ok: false, reachable: false, target: target.slice(), tool: +opt.tool || 0,
|
||||||
q: cp.q.slice(), cost: costOf(cp.q, opt.weights), err: cp.dist, resid: cp.dist,
|
q: cp.q.slice(), cost: costOf(cp.q, opt.weights), err: cp.dist, resid: cp.dist,
|
||||||
|
oerr: pose ? cp.ori : null,
|
||||||
nSol: res.nSol, samples: res.samples, ms: res.ms, list: [],
|
nSol: res.nSol, samples: res.samples, ms: res.ms, list: [],
|
||||||
note: '目标超出可达工作空间:返回最近可达位姿(阻尼最小二乘)'
|
note: pose ? '目标位姿超出可达空间:返回最近位姿(阻尼最小二乘,位置 mm + 姿态联合)'
|
||||||
|
: '目标超出可达工作空间:返回最近可达位姿(阻尼最小二乘)'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 7. 自检:闭式 FK ↔ 矩阵链;逆解重构 ---------- */
|
/* ---------- 7. 自检:闭式 FK ↔ 矩阵链;逆解重构;姿态重构 ---------- */
|
||||||
function selfTest(n) {
|
function selfTest(n, nPose) {
|
||||||
n = n || 400;
|
n = n || 400;
|
||||||
|
nPose = nPose == null ? 8 : nPose;
|
||||||
let maxFk = 0;
|
let maxFk = 0;
|
||||||
for (let k = 0; k < n; k++) {
|
for (let k = 0; k < n; k++) {
|
||||||
const q = [];
|
const q = [];
|
||||||
@@ -374,13 +762,29 @@ function make(cfg) {
|
|||||||
maxIk = Math.max(maxIk, best);
|
maxIk = Math.max(maxIk, best);
|
||||||
if (best < 1e-6) hit++;
|
if (best < 1e-6) hit++;
|
||||||
}
|
}
|
||||||
return { maxFkErr: maxFk, maxIkErr: maxIk, ikHit: hit, ikTried: m, structWarn: WARN, K: K, LIM: LIM };
|
/* 姿态重构:取随机可行位形的真实工具姿态为任务,闭式扫描应精确命中 */
|
||||||
|
let pHit = 0, pTried = 0, pErr = 0, pMs = 0;
|
||||||
|
for (let k = 0; k < nPose; k++) {
|
||||||
|
const q = [];
|
||||||
|
for (let i = 0; i < 6; i++) { const t = LIM[ORDER[i]]; q.push(t.lo + Math.random() * (t.hi - t.lo)); }
|
||||||
|
const T = fk(q, 0), R = orient(q);
|
||||||
|
pTried++;
|
||||||
|
const r = solve(T, { pose: { R: R }, step: 1.0, tol: 1e-6 });
|
||||||
|
pMs += r.ms;
|
||||||
|
if (r.ok && r.oerr != null) {
|
||||||
|
pErr = Math.max(pErr, r.oerr);
|
||||||
|
if (r.oerr < 0.05 && r.err < 1e-3) pHit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { maxFkErr: maxFk, maxIkErr: maxIk, ikHit: hit, ikTried: m, structWarn: WARN, K: K, LIM: LIM,
|
||||||
|
poseHit: pHit, poseTried: pTried, poseErr: pErr, poseMs: pTried ? pMs / pTried : 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fk: fk, fkChain: fkChain, solve: solve, solveTo: solveTo,
|
return { fk: fk, fkChain: fkChain, solve: solve, solveTo: solveTo,
|
||||||
closest: closest, branch: branch, selfTest: selfTest,
|
closest: closest, closest6: closest6, orient: orient,
|
||||||
|
branch: branch, selfTest: selfTest,
|
||||||
K: K, LIM: LIM, P: P, D: D, ORDER: ORDER, wrap180: wrap180, costOf: costOf };
|
K: K, LIM: LIM, P: P, D: D, ORDER: ORDER, wrap180: wrap180, costOf: costOf };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { make: make, ORDER: ORDER };
|
return { make: make, ORDER: ORDER, poseMat: poseMat, poseAng: poseAng, rotErrDeg: rotErrDeg };
|
||||||
}));
|
}));
|
||||||
+311
-20
@@ -34,6 +34,39 @@ const S = ArmIK.make({ joints: JMap, travel: TR, tcp: TCP0 });
|
|||||||
const LIM = S.LIM;
|
const LIM = S.LIM;
|
||||||
let lastTool = 0;
|
let lastTool = 0;
|
||||||
let TGT = [150, 128, 260];
|
let TGT = [150, 128, 260];
|
||||||
|
/* 参考位姿连续项权重:以当前手臂姿态为参考解算,目标微动时锁定同族分支,
|
||||||
|
避免某轴在等价分支间大幅倒摆(实测 4mm 步长过边界最多跳 81.9° -> 1.2°) */
|
||||||
|
const REF_W = 0.02;
|
||||||
|
const curRef = () => ORDER.map(id => ang[id]);
|
||||||
|
|
||||||
|
/* ---------- 1b. 姿态控制状态 ---------- */
|
||||||
|
let poseOn = false;
|
||||||
|
const POSE = { az: 0, el: 90, roll: 0 }; // 目标姿态:工具轴方位角 / 仰角 + 绕轴滚转(度)
|
||||||
|
const Rdes = () => ArmIK.poseMat(POSE.az, POSE.el, POSE.roll);
|
||||||
|
const syncPoseInputs = () => {
|
||||||
|
$('ikAz').value = (+POSE.az).toFixed(1);
|
||||||
|
$('ikEl').value = (+POSE.el).toFixed(1);
|
||||||
|
$('ikRoll').value = (+POSE.roll).toFixed(1);
|
||||||
|
};
|
||||||
|
const fillPoseFromCurrent = () => { // 用当前真实工具姿态回填(开启时避免姿态跳变)
|
||||||
|
const a = ArmIK.poseAng(Uof(ORDER.map(id => ang[id])));
|
||||||
|
POSE.az = a.az; POSE.el = a.el; POSE.roll = a.roll;
|
||||||
|
syncPoseInputs();
|
||||||
|
};
|
||||||
|
const CAD2V = v => [v[0], v[2], -v[1]]; // CAD 向量 → 查看器坐标(与查看器 VC 一致)
|
||||||
|
const V2CAD = v => [v[0], -v[2], v[1]]; // 查看器坐标 → CAD 向量(CAD2V 的逆)
|
||||||
|
/* 用户姿态 ↔ 求解器姿态的换算:求解器内部工具坐标系 z 轴取法兰面法向(朝臂内,
|
||||||
|
即 TCP 外伸的反向),而界面上的方位/仰角/滚转与箭头都以「工具朝外方向」描述。
|
||||||
|
两者相差绕工具 x 轴 180°(x 不变、y/z 取反),入栈/出栈各翻一次即可。 */
|
||||||
|
const RX180 = [1, 0, 0, 0, -1, 0, 0, 0, -1];
|
||||||
|
const mul3g = (A, B) => {
|
||||||
|
const C = new Array(9);
|
||||||
|
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++)
|
||||||
|
C[i * 3 + j] = A[i * 3] * B[j] + A[i * 3 + 1] * B[3 + j] + A[i * 3 + 2] * B[6 + j];
|
||||||
|
return C;
|
||||||
|
};
|
||||||
|
const Rsolver = () => mul3g(Rdes(), RX180); // 交给求解器/场景校验(列 2 = 朝臂内)
|
||||||
|
const Uof = q => mul3g(S.orient(q), RX180); // 手臂当前姿态 → 用户系(列 2 = 朝臂外)
|
||||||
|
|
||||||
/* ---------- 2. 场景标注 ---------- */
|
/* ---------- 2. 场景标注 ---------- */
|
||||||
const gTgt = new THREE.Group(); scene.add(gTgt);
|
const gTgt = new THREE.Group(); scene.add(gTgt);
|
||||||
@@ -55,6 +88,83 @@ const ballTcp = new THREE.Mesh(new THREE.SphereGeometry(6.5, 20, 14),
|
|||||||
new THREE.MeshStandardMaterial({ color: 0x3ddc97, emissive: 0x14603f, metalness: .3, roughness: .35 }));
|
new THREE.MeshStandardMaterial({ color: 0x3ddc97, emissive: 0x14603f, metalness: .3, roughness: .35 }));
|
||||||
JG.J6.add(ballTcp);
|
JG.J6.add(ballTcp);
|
||||||
|
|
||||||
|
/* 当前工具轴线:TCP 球沿真实工具轴伸出(绿),与目标箭头对照看姿态差 */
|
||||||
|
const curGeo = new THREE.BufferGeometry();
|
||||||
|
curGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
|
||||||
|
const curLine = new THREE.Line(curGeo,
|
||||||
|
new THREE.LineBasicMaterial({ color: 0x3ddc97, transparent: true, opacity: .95 }));
|
||||||
|
scene.add(curLine);
|
||||||
|
|
||||||
|
/* 姿态拖动箭头(挂目标球组,随目标移动):
|
||||||
|
拖箭头头部 = 调工具轴指向(方位/俯仰,弧球式);拖箭杆/捏环/粉色侧球 = 绕轴滚转。
|
||||||
|
轴向为本地 +Z:杆/锥几何预旋到 +Z,gArrow 再用四元数对准目标工具轴。
|
||||||
|
侧球挂在目标点上、沿工具 x 轴侧向偏移——箭头正对镜头时杆身投影缩成一团,
|
||||||
|
侧球始终在侧向,任何视角都抓得到。 */
|
||||||
|
const AXLEN = 150, HEADL = 26, HEADZ = AXLEN - HEADL / 2, GRIPZ = (AXLEN - HEADL) / 2, KNOBOFF = 0.72 * AXLEN;
|
||||||
|
const gArrow = new THREE.Group(); gTgt.add(gArrow);
|
||||||
|
const knob = new THREE.Mesh(new THREE.SphereGeometry(9, 18, 12),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xff8fb0, emissive: 0x5c1f38, metalness: .2, roughness: .4,
|
||||||
|
transparent: true, opacity: .8 }));
|
||||||
|
knob.userData.role = 'roll';
|
||||||
|
gTgt.add(knob);
|
||||||
|
const arrowShaft = (function () {
|
||||||
|
const g = new THREE.CylinderGeometry(3.0, 3.0, AXLEN - HEADL, 12);
|
||||||
|
g.rotateX(Math.PI / 2); // 圆柱默认轴向 Y → +Z
|
||||||
|
const m = new THREE.Mesh(g,
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xffb454, emissive: 0x6b4a10, metalness: .2, roughness: .4 }));
|
||||||
|
m.position.z = (AXLEN - HEADL) / 2;
|
||||||
|
m.userData.role = 'roll'; // 抓杆身 = 滚转
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
const arrowRing = (function () {
|
||||||
|
const m = new THREE.Mesh(new THREE.TorusGeometry(10, 2.8, 10, 40),
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xff8fb0, emissive: 0x5c1f38, metalness: .2, roughness: .4 }));
|
||||||
|
m.rotation.x = Math.PI / 2; // 环面垂直于轴
|
||||||
|
m.position.z = GRIPZ;
|
||||||
|
m.userData.role = 'roll';
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
const arrowHead = (function () {
|
||||||
|
const g = new THREE.ConeGeometry(7.5, HEADL, 18);
|
||||||
|
g.rotateX(Math.PI / 2);
|
||||||
|
const m = new THREE.Mesh(g,
|
||||||
|
new THREE.MeshStandardMaterial({ color: 0xffd166, emissive: 0x7a5a10, metalness: .2, roughness: .35 }));
|
||||||
|
m.position.z = HEADZ;
|
||||||
|
m.userData.role = 'head';
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
gArrow.add(arrowShaft, arrowRing, arrowHead);
|
||||||
|
const axisViewer = () => { // 目标工具轴在查看器系的方向(单位)
|
||||||
|
const R = Rdes(), v = CAD2V([R[2], R[5], R[8]]); // 行主序:列 2 = 工具轴(朝臂外)
|
||||||
|
return new THREE.Vector3(v[0], v[1], v[2]).normalize();
|
||||||
|
};
|
||||||
|
/* 姿态把手拾取返回 'axis' | 'roll' | null(同时不抢黄球的位置拖动):
|
||||||
|
· 箭头头与侧球都做「屏幕就近」检测,谁近听谁的(箭头正对镜头时两者投影会靠近);
|
||||||
|
· 都够近时黄球优先交回位置拖动;再往后才是箭杆/捏环/箭头锥体的几何射线。 */
|
||||||
|
const knobScreen = () => {
|
||||||
|
const p = knob.getWorldPosition(new THREE.Vector3()).project(cam);
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height };
|
||||||
|
};
|
||||||
|
function pickPoseHandle(e) {
|
||||||
|
const pt = projTgt();
|
||||||
|
const db = Math.hypot(e.offsetX - pt.x, e.offsetY - pt.y);
|
||||||
|
const hp = projArrow(HEADZ);
|
||||||
|
const dh = Math.hypot(e.offsetX - hp.x, e.offsetY - hp.y);
|
||||||
|
const kp = knobScreen();
|
||||||
|
const dk = Math.hypot(e.offsetX - kp.x, e.offsetY - kp.y);
|
||||||
|
if (dh < 22 && (dk >= 26 || dh <= dk) && dh <= db) return 'axis';
|
||||||
|
if (dk < 26 && dk <= db) return 'roll';
|
||||||
|
if (db < 26) return null; // 黄球抓取区:交回位置拖动
|
||||||
|
ray2.setFromCamera(ndc2(e), cam);
|
||||||
|
for (const h of ray2.intersectObjects([arrowShaft, arrowRing, arrowHead, knob], false)) {
|
||||||
|
const r = h.object.userData.role;
|
||||||
|
if (r === 'head') return 'axis';
|
||||||
|
if (r === 'roll') return 'roll';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const linkGeo = new THREE.BufferGeometry();
|
const linkGeo = new THREE.BufferGeometry();
|
||||||
linkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
|
linkGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
|
||||||
const linkLine = new THREE.Line(linkGeo,
|
const linkLine = new THREE.Line(linkGeo,
|
||||||
@@ -76,6 +186,23 @@ function refreshTarget() {
|
|||||||
pa.needsUpdate = true;
|
pa.needsUpdate = true;
|
||||||
const d = Math.hypot(w.x - a.x, w.y - a.y, w.z - a.z);
|
const d = Math.hypot(w.x - a.x, w.y - a.y, w.z - a.z);
|
||||||
ballTcp.material.color.setHex(d < 1.2 ? 0x3ddc97 : 0xffb454);
|
ballTcp.material.color.setHex(d < 1.2 ? 0x3ddc97 : 0xffb454);
|
||||||
|
/* 目标箭头(指向工具轴)与当前工具轴线(绿,对照看姿态差) */
|
||||||
|
gArrow.visible = poseOn && VIEW === 'motion';
|
||||||
|
if (poseOn) {
|
||||||
|
const R = Rdes();
|
||||||
|
gArrow.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), axisViewer());
|
||||||
|
const xv = CAD2V([R[0], R[3], R[6]]); // 行主序:列 0 = 工具 x 轴;侧球沿它侧偏,始终离开箭头
|
||||||
|
knob.position.set(xv[0] * KNOBOFF, xv[1] * KNOBOFF, xv[2] * KNOBOFF);
|
||||||
|
}
|
||||||
|
knob.visible = poseOn && VIEW === 'motion';
|
||||||
|
const Rcur = S.orient(ORDER.map(id => ang[id]));
|
||||||
|
const avc = CAD2V([Rcur[2], Rcur[5], Rcur[8]]); // 求解器列 2 = 法兰法向(朝臂内),取反为外伸方向
|
||||||
|
const av = [-avc[0], -avc[1], -avc[2]];
|
||||||
|
const ca = curGeo.attributes.position;
|
||||||
|
ca.array[0] = w.x; ca.array[1] = w.y; ca.array[2] = w.z;
|
||||||
|
ca.array[3] = w.x + av[0] * 60; ca.array[4] = w.y + av[1] * 60; ca.array[5] = w.z + av[2] * 60;
|
||||||
|
ca.needsUpdate = true;
|
||||||
|
curLine.visible = VIEW === 'motion';
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +244,38 @@ function animTo(q, ms) {
|
|||||||
|
|
||||||
/* ---------- 4. 结果渲染 ---------- */
|
/* ---------- 4. 结果渲染 ---------- */
|
||||||
const badge = $('ikBadge');
|
const badge = $('ikBadge');
|
||||||
|
/* 场景图矩阵链复算姿态偏差(度):
|
||||||
|
· 场景侧:直接取 J6 组世界旋转作用在工具 x/z 轴上的方向(transformDirection,
|
||||||
|
免去列主序手工换算),正交化后组装成查看器系旋转 Rs;
|
||||||
|
· 目标侧:CAD 系旋转 Rtar 经坐标旋转 Rv(CAD→查看器)共轭到查看器系
|
||||||
|
R_viewer = Rv·R_cad·Rvᵀ(单边变换列向量不够——基变了整个旋转都要转)。 */
|
||||||
|
const RV3 = [1, 0, 0, 0, 0, 1, 0, -1, 0]; // CAD→查看器 3×3(行主序)
|
||||||
|
const RV3T = [1, 0, 0, 0, 0, -1, 0, 1, 0]; // 转置
|
||||||
|
function sceneOrientErr(q, Rtar) {
|
||||||
|
const save = ORDER.map(id => ang[id]);
|
||||||
|
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = q[i]; applyJoint(ORDER[i]); }
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
const M = JG.J6.matrixWorld;
|
||||||
|
const zs = new THREE.Vector3(0, 0, 1).transformDirection(M);
|
||||||
|
let xs = new THREE.Vector3(1, 0, 0).transformDirection(M);
|
||||||
|
xs.sub(zs.clone().multiplyScalar(xs.dot(zs))).normalize(); // 正交化(防场景缩放引入的分量)
|
||||||
|
const ys = new THREE.Vector3().crossVectors(zs, xs);
|
||||||
|
const Rs = [xs.x, ys.x, zs.x, xs.y, ys.y, zs.y, xs.z, ys.z, zs.z]; // 列 = 工具 x/y/z(查看器系)
|
||||||
|
const Rt = []; // 目标:共轭到查看器系
|
||||||
|
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) {
|
||||||
|
let s = 0;
|
||||||
|
for (let k = 0; k < 3; k++) s += RV3[i * 3 + k] * Rtar[k * 3 + j];
|
||||||
|
Rt.push(s);
|
||||||
|
}
|
||||||
|
const Rt2 = [];
|
||||||
|
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) {
|
||||||
|
let s = 0;
|
||||||
|
for (let k = 0; k < 3; k++) s += Rt[i * 3 + k] * RV3T[k * 3 + j];
|
||||||
|
Rt2.push(s);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = save[i]; applyJoint(ORDER[i]); }
|
||||||
|
return ArmIK.rotErrDeg(Rs, Rt2);
|
||||||
|
}
|
||||||
function renderResult(res, src) {
|
function renderResult(res, src) {
|
||||||
const q = res.q || [0, 0, 0, 0, 0, 0];
|
const q = res.q || [0, 0, 0, 0, 0, 0];
|
||||||
const tb = $('ikTab').tBodies[0]; tb.innerHTML = '';
|
const tb = $('ikTab').tBodies[0]; tb.innerHTML = '';
|
||||||
@@ -127,37 +286,57 @@ function renderResult(res, src) {
|
|||||||
'<td class="num">' + q[i].toFixed(2) + '\u00B0</td>' +
|
'<td class="num">' + q[i].toFixed(2) + '\u00B0</td>' +
|
||||||
'<td class="num dim">' + Math.abs(q[i]).toFixed(1) + '</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 ? '\u00B1180' : t.lo + '..' + t.hi) + '</td>';
|
||||||
if (i === 5 && !res.tool) tr.title = 'TCP 位于 J6 轴线上,转动 J6 不改变 TCP 位置,故取 0 使总角最小';
|
if (i === 5 && !res.tool) tr.title = poseOn
|
||||||
|
? '姿态控制开启:θ6 为绕工具轴的滚转角,由目标姿态(方位/仰角/滚转)解算'
|
||||||
|
: 'TCP 位于 J6 轴线上,转动 J6 不改变 TCP 位置,故取 0 使总角最小';
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
}
|
}
|
||||||
lastTool = res.tool || 0;
|
lastTool = res.tool || 0;
|
||||||
const dcl = res.err != null ? res.err : 0;
|
const dcl = res.err != null ? res.err : 0;
|
||||||
|
const dori = res.oerr != null ? res.oerr : 0;
|
||||||
|
let doriSc = 0;
|
||||||
const sc = (function () { // 用场景图真实矩阵链复算
|
const sc = (function () { // 用场景图真实矩阵链复算
|
||||||
const save = ORDER.map(id => ang[id]);
|
const save = ORDER.map(id => ang[id]);
|
||||||
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = q[i]; applyJoint(ORDER[i]); }
|
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = q[i]; applyJoint(ORDER[i]); }
|
||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
const w = new THREE.Vector3(...VC(tcpCad())).applyMatrix4(JG.J6.matrixWorld);
|
const w = new THREE.Vector3(...VC(tcpCad())).applyMatrix4(JG.J6.matrixWorld);
|
||||||
|
if (poseOn) doriSc = sceneOrientErr(q, Rsolver());
|
||||||
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = save[i]; applyJoint(ORDER[i]); }
|
for (let i = 0; i < 6; i++) { ang[ORDER[i]] = save[i]; applyJoint(ORDER[i]); }
|
||||||
return [w.x, -w.z, w.y];
|
return [w.x, -w.z, w.y];
|
||||||
})();
|
})();
|
||||||
const dsc = Math.hypot(sc[0] - res.target[0], sc[1] - res.target[1], sc[2] - res.target[2]);
|
const dsc = Math.hypot(sc[0] - res.target[0], sc[1] - res.target[1], sc[2] - res.target[2]);
|
||||||
const sum = q.reduce((a, b) => a + b, 0);
|
const sum = q.reduce((a, b) => a + b, 0);
|
||||||
|
const pa = poseOn ? ArmIK.poseAng(Uof(q)) : null;
|
||||||
$('ikSum').innerHTML =
|
$('ikSum').innerHTML =
|
||||||
'<i>总关节角 \u03A3|\u03B8|</i><b class="hi">' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
|
'<i>总关节角 \u03A3|\u03B8|</i><b class="hi">' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
|
||||||
'<i>\u03A3\u03B8 代数和</i><span>' + sum.toFixed(1) + '\u00B0</span>' +
|
'<i>\u03A3\u03B8 代数和</i><span>' + sum.toFixed(1) + '\u00B0</span>' +
|
||||||
'<i>残差 闭式/场景图</i><span class="' + (dcl < 1e-4 && dsc < .05 ? 'good' : 'bad') + '">' +
|
'<i>残差 闭式/场景图</i><span class="' + (dcl < 1e-4 && dsc < .05 ? 'good' : 'bad') + '">' +
|
||||||
dcl.toExponential(1) + ' / ' + dsc.toExponential(1) + ' mm</span>' +
|
dcl.toExponential(1) + ' / ' + dsc.toExponential(1) + ' mm</span>' +
|
||||||
|
(poseOn ? '<i>目标姿态</i><span>方位 ' + POSE.az.toFixed(1) + '\u00B0 · 仰角 ' + POSE.el.toFixed(1) +
|
||||||
|
'\u00B0 · 滚转 ' + POSE.roll.toFixed(1) + '\u00B0</span>' : '') +
|
||||||
|
(poseOn ? '<i>实际姿态</i><span>' + pa.az.toFixed(1) + '\u00B0 · ' + pa.el.toFixed(1) +
|
||||||
|
'\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>' : '') +
|
||||||
'<i>可行采样点</i><span>' + res.nSol + '</span>' +
|
'<i>可行采样点</i><span>' + res.nSol + '</span>' +
|
||||||
'<i>耗时</i><span>' + res.ms.toFixed(0) + ' ms</span>' +
|
'<i>耗时</i><span>' + res.ms.toFixed(0) + ' ms</span>' +
|
||||||
'<i>目标</i><span>(' + res.target.map(v => v.toFixed(1)).join(', ') + ')</span>' +
|
'<i>目标</i><span>(' + res.target.map(v => v.toFixed(1)).join(', ') + ')</span>' +
|
||||||
(res.resid != null ? '<i>距目标</i><span class="bad">' + res.resid.toFixed(2) + ' mm</span>' : '');
|
(res.resid != null ? '<i>距目标</i><span class="bad">' + res.resid.toFixed(2) + ' mm</span>' : '') +
|
||||||
const ok = res.reachable && dcl < 1e-3;
|
(poseOn && res.resid != null && res.oerr != null
|
||||||
badge.textContent = ok ? '已到达' : (res.reachable ? '校验偏差' : '不可达·最近位姿');
|
? '<i>姿态偏差</i><span class="bad">' + res.oerr.toFixed(2) + '\u00B0</span>' : '');
|
||||||
badge.className = 'badge ' + (ok ? 'goodb' : (res.reachable ? 'warnb' : 'badb'));
|
const oriOk = !poseOn || (dori < 0.05 && doriSc < 0.1);
|
||||||
|
/* 以残差判到达:阻尼最小二乘兜底也可能精确到达(扫描漏掉的奇异刀尖解),不能只看 reachable */
|
||||||
|
const posOk = dsc < .05;
|
||||||
|
badge.textContent = (posOk && oriOk) ? '已到达'
|
||||||
|
: (posOk && poseOn && dori > 0.05 ? '位置到达·姿态残差 ' + dori.toFixed(2) + '\u00B0' : '不可达·最近位姿');
|
||||||
|
badge.className = 'badge ' + ((posOk && oriOk) ? 'goodb'
|
||||||
|
: (posOk ? 'warnb' : 'badb'));
|
||||||
$('ikSrc').textContent = src;
|
$('ikSrc').textContent = src;
|
||||||
$('ikBar').innerHTML = '<b class="hi">\u03A3|\u03B8| ' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
|
$('ikBar').innerHTML = '<b class="hi">\u03A3|\u03B8| ' + S.costOf(q, null).toFixed(2) + '\u00B0</b>' +
|
||||||
'<span class="sep">|</span>\u6B8B\u5DEE ' +
|
'<span class="sep">|</span>\u6B8B\u5DEE ' +
|
||||||
'<span class="' + (dsc < .05 ? 'good' : 'bad') + '">' + dsc.toExponential(1) + ' mm</span>' +
|
'<span class="' + (dsc < .05 ? 'good' : 'bad') + '">' + dsc.toExponential(1) + ' mm</span>' +
|
||||||
|
(poseOn ? '<span class="sep">|</span>\u59FF\u6001\u6B8B\u5DEE <span class="' +
|
||||||
|
(dori < .05 && doriSc < .1 ? 'good' : 'bad') + '">' + dori.toFixed(3) + '\u00B0</span>' : '') +
|
||||||
'<span class="sep">|</span>\u53EF\u884C\u91C7\u6837\u70B9 ' + res.nSol +
|
'<span class="sep">|</span>\u53EF\u884C\u91C7\u6837\u70B9 ' + res.nSol +
|
||||||
'<span class="sep">|</span>' + res.ms.toFixed(0) + ' ms';
|
'<span class="sep">|</span>' + res.ms.toFixed(0) + ' ms';
|
||||||
/* 备选互异构型 */
|
/* 备选互异构型 */
|
||||||
@@ -196,10 +375,13 @@ function runSolve() {
|
|||||||
refreshTarget();
|
refreshTarget();
|
||||||
badge.textContent = '计算中…'; badge.className = 'badge wait';
|
badge.textContent = '计算中…'; badge.className = 'badge wait';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25 };
|
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 res = S.solveTo(TGT, opt);
|
||||||
renderResult(res, res.reachable ? '闭式全域扫描 · 冗余维精确覆盖'
|
renderResult(res, res.reachable
|
||||||
: '超工作空间 → 阻尼最小二乘最近位姿');
|
? (poseOn ? '闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖' : '闭式全域扫描 · 冗余维精确覆盖')
|
||||||
|
: (poseOn ? '超可达位姿空间 → 阻尼最小二乘最近位姿(位置 mm + 姿态联合)'
|
||||||
|
: '超工作空间 → 阻尼最小二乘最近位姿'));
|
||||||
if (res.q) setJoints(res.q, true);
|
if (res.q) setJoints(res.q, true);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
@@ -213,9 +395,14 @@ function runSolveLive() {
|
|||||||
liveBusy = true;
|
liveBusy = true;
|
||||||
try {
|
try {
|
||||||
lastTool = +$('ikTool').value || 0;
|
lastTool = +$('ikTool').value || 0;
|
||||||
const res = S.solveTo(TGT, { tool: lastTool, step: +$('ikStep').value || 0.25 });
|
const opt = { tool: lastTool, step: +$('ikStep').value || 0.25, ref: curRef(), refW: REF_W };
|
||||||
renderResult(res, res.reachable ? '实时追踪 · 闭式全域扫描 · 冗余维精确覆盖'
|
if (poseOn) opt.pose = { R: Rsolver() };
|
||||||
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)');
|
const res = S.solveTo(TGT, opt);
|
||||||
|
renderResult(res, res.reachable
|
||||||
|
? (poseOn ? '实时追踪 · 闭式全域扫描 · 位置+姿态联合 · 冗余维精确覆盖'
|
||||||
|
: '实时追踪 · 闭式全域扫描 · 冗余维精确覆盖')
|
||||||
|
: (poseOn ? '实时追踪 · 超可达位姿空间(阻尼最小二乘最近位姿)'
|
||||||
|
: '实时追踪 · 超工作空间(阻尼最小二乘最近位姿)'));
|
||||||
if (res.q) setJoints(res.q, false);
|
if (res.q) setJoints(res.q, false);
|
||||||
} finally { liveBusy = false; }
|
} finally { liveBusy = false; }
|
||||||
if (livePending) { livePending = false; runSolveLive(); }
|
if (livePending) { livePending = false; runSolveLive(); }
|
||||||
@@ -231,31 +418,94 @@ function setMode(m) {
|
|||||||
mode = m;
|
mode = m;
|
||||||
$('ikPick').classList.toggle('on', m === 'pick');
|
$('ikPick').classList.toggle('on', m === 'pick');
|
||||||
$('ikRt').classList.toggle('on', m === 'rt');
|
$('ikRt').classList.toggle('on', m === 'rt');
|
||||||
$('ikHint').textContent = m === 'pick' ? '视图单击:目标投到当前高度的水平面'
|
$('ikHint').textContent = (m === 'pick' ? '视图单击:目标投到当前高度的水平面'
|
||||||
: (m === 'rt' ? '实时追踪中:拖动黄球即算即动,手臂跟着目标走(松手精修)'
|
: (m === 'rt' ? '实时追踪中:拖黄球/箭头即算即动,手臂跟着走(松手精修)'
|
||||||
: '拖动黄球 = 移动目标(Shift 仅上下);求解在松手后自动执行');
|
: '拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解'))
|
||||||
|
+ (poseOn ? ' · 姿态控制中:箭头/粉球已激活,数字框可精确微调' : ' · 开「姿态控制」后出现可拖箭头');
|
||||||
}
|
}
|
||||||
function projTgt() {
|
function projTgt() {
|
||||||
const p = gTgt.position.clone().project(cam);
|
const p = gTgt.position.clone().project(cam);
|
||||||
const r = cv.getBoundingClientRect();
|
const r = cv.getBoundingClientRect();
|
||||||
return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height };
|
return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height };
|
||||||
}
|
}
|
||||||
|
/* 箭头头部 / 滚转圆环的世界坐标 → 屏幕(用于命中判定) */
|
||||||
|
function projArrow(localZ) {
|
||||||
|
const p = new THREE.Vector3(0, 0, localZ).applyMatrix4(gArrow.matrixWorld).project(cam);
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
return { x: (p.x * .5 + .5) * r.width, y: (-p.y * .5 + .5) * r.height };
|
||||||
|
}
|
||||||
|
const ndc2 = e => {
|
||||||
|
const r = cv.getBoundingClientRect();
|
||||||
|
return new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1,
|
||||||
|
-((e.clientY - r.top) / r.height) * 2 + 1);
|
||||||
|
};
|
||||||
|
/* 指针在「目标处、垂直于工具轴的平面」上绕轴的转角(度):滚转把手拖动用 */
|
||||||
|
function rollAngleAt(e) {
|
||||||
|
const n = axisViewer();
|
||||||
|
const pl = new THREE.Plane().setFromNormalAndCoplanarPoint(n, gTgt.position);
|
||||||
|
const hit = new THREE.Vector3();
|
||||||
|
ray2.setFromCamera(ndc2(e), cam); // 该版本 setFromCamera 不返回 this,勿链式
|
||||||
|
if (!ray2.ray.intersectPlane(pl, hit)) return 0;
|
||||||
|
const v = hit.sub(gTgt.position);
|
||||||
|
const up = Math.abs(n.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
|
||||||
|
const u = new THREE.Vector3().crossVectors(n, up).normalize();
|
||||||
|
const w = new THREE.Vector3().crossVectors(n, u);
|
||||||
|
return Math.atan2(v.dot(w), v.dot(u)) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
/* 弧球式瞄准:指针射线与「以目标为球心、半径 = 箭长」的球求交,方向 = 交点 − 球心 */
|
||||||
|
function aimAxis(e) {
|
||||||
|
ray2.setFromCamera(ndc2(e), cam);
|
||||||
|
const o = ray2.ray.origin, d = ray2.ray.direction, C = gTgt.position;
|
||||||
|
const ox = o.x - C.x, oy = o.y - C.y, oz = o.z - C.z;
|
||||||
|
const b = ox * d.x + oy * d.y + oz * d.z;
|
||||||
|
const c = ox * ox + oy * oy + oz * oz - AXLEN * AXLEN;
|
||||||
|
const disc = b * b - c;
|
||||||
|
const t = Math.max(0, disc >= 0 ? -b - Math.sqrt(disc) : -b); // 未命中取射线上距球心最近点
|
||||||
|
const v = new THREE.Vector3(o.x + d.x * t - C.x, o.y + d.y * t - C.y, o.z + d.z * t - C.z);
|
||||||
|
if (v.lengthSq() < 1e-6) return;
|
||||||
|
v.normalize();
|
||||||
|
const vc = V2CAD([v.x, v.y, v.z]);
|
||||||
|
POSE.az = Math.atan2(vc[1], vc[0]) * 180 / Math.PI;
|
||||||
|
POSE.el = Math.acos(Math.max(-1, Math.min(1, vc[2]))) * 180 / Math.PI;
|
||||||
|
syncPoseInputs(); refreshTarget();
|
||||||
|
}
|
||||||
cv.addEventListener('pointerdown', e => {
|
cv.addEventListener('pointerdown', e => {
|
||||||
if (e.button !== 0 || VIEW !== 'motion') return; // 仅运动模拟模式可操作目标点
|
if (e.button !== 0 || VIEW !== 'motion') return; // 仅运动模拟模式可操作目标点
|
||||||
justPicked = false;
|
justPicked = false;
|
||||||
if (mode === 'pick') { grab = { pick: true }; dragged = false; return; }
|
if (mode === 'pick') { grab = { pick: true }; dragged = false; return; }
|
||||||
const pt = projTgt();
|
const pt = projTgt();
|
||||||
if (!(Math.hypot(e.offsetX - pt.x, e.offsetY - pt.y) < 26 || e.shiftKey)) return;
|
const db = Math.hypot(e.offsetX - pt.x, e.offsetY - pt.y);
|
||||||
|
if (poseOn) { // 箭头头 / 滚转把手(与黄球就近仲裁,见 pickPoseHandle)
|
||||||
|
const part = pickPoseHandle(e);
|
||||||
|
if (part === 'axis') { grab = { pose: 'axis' }; dragged = false; camSnap = snapCam(); return; }
|
||||||
|
if (part === 'roll') { grab = { pose: 'roll', a0: rollAngleAt(e) }; dragged = false; camSnap = snapCam(); return; }
|
||||||
|
}
|
||||||
|
if (!(db < 26 || e.shiftKey)) return;
|
||||||
grab = { sx: e.offsetX, sy: e.offsetY };
|
grab = { sx: e.offsetX, sy: e.offsetY };
|
||||||
camSnap = snapCam(); dragged = false;
|
camSnap = snapCam(); dragged = false;
|
||||||
});
|
});
|
||||||
cv.addEventListener('pointermove', e => {
|
cv.addEventListener('pointermove', e => {
|
||||||
if (!grab || grab.pick) return;
|
if (!grab || grab.pick) return;
|
||||||
|
if (camSnap) putCam(camSnap); // 撤销查看器自身的轨道 / 平移
|
||||||
|
cam.updateMatrixWorld(); // 轨道参数已还原,射线/列向量要用新矩阵
|
||||||
|
if (grab.pose === 'axis') { // 拖箭头头:工具轴指向
|
||||||
|
dragged = true;
|
||||||
|
aimAxis(e);
|
||||||
|
if (mode === 'rt') runSolveLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grab.pose === 'roll') { // 拖圆环:绕轴滚转
|
||||||
|
dragged = true;
|
||||||
|
const a1 = rollAngleAt(e);
|
||||||
|
POSE.roll = S.wrap180(POSE.roll + S.wrap180(a1 - grab.a0));
|
||||||
|
grab.a0 = a1;
|
||||||
|
syncPoseInputs(); refreshTarget();
|
||||||
|
if (mode === 'rt') runSolveLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dx = e.offsetX - grab.sx, dy = e.offsetY - grab.sy;
|
const dx = e.offsetX - grab.sx, dy = e.offsetY - grab.sy;
|
||||||
if (Math.abs(dx) + Math.abs(dy) > 2) dragged = true;
|
if (Math.abs(dx) + Math.abs(dy) > 2) dragged = true;
|
||||||
if (camSnap) putCam(camSnap); // 撤销查看器自身的轨道 / 平移
|
|
||||||
const k = (2 * rd * Math.tan(cam.fov * Math.PI / 360)) / Math.max(1, cv.clientHeight);
|
const k = (2 * rd * Math.tan(cam.fov * Math.PI / 360)) / Math.max(1, cv.clientHeight);
|
||||||
cam.updateMatrixWorld();
|
|
||||||
const right = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 0);
|
const right = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 0);
|
||||||
const up = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 1);
|
const up = new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld, 1);
|
||||||
const cur = VC(TGT), v = new THREE.Vector3(cur[0], cur[1], cur[2]);
|
const cur = VC(TGT), v = new THREE.Vector3(cur[0], cur[1], cur[2]);
|
||||||
@@ -317,6 +567,32 @@ $('ikHome').onclick = () => {
|
|||||||
};
|
};
|
||||||
$('ikZero').onclick = () => { TGT = [150, 128, 260]; syncInputs(); refreshTarget(); runSolve(); };
|
$('ikZero').onclick = () => { TGT = [150, 128, 260]; syncInputs(); refreshTarget(); runSolve(); };
|
||||||
|
|
||||||
|
/* --- 姿态控制:开关 / 参数 / 快捷 --- */
|
||||||
|
const poseEls = ['ikAz', 'ikEl', 'ikRoll'].map($);
|
||||||
|
if ($('ikPose')) $('ikPose').onclick = () => {
|
||||||
|
poseOn = !poseOn;
|
||||||
|
$('ikPose').classList.toggle('on', poseOn);
|
||||||
|
$('ikPoseBox').style.display = poseOn ? 'flex' : 'none';
|
||||||
|
if (poseOn) fillPoseFromCurrent(); // 开启即用当前工具姿态,避免求解跳变
|
||||||
|
refreshTarget();
|
||||||
|
setMode(mode);
|
||||||
|
runSolve();
|
||||||
|
};
|
||||||
|
poseEls.forEach((el, i) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('change', () => {
|
||||||
|
POSE.az = +$('ikAz').value || 0;
|
||||||
|
POSE.el = Math.max(0, Math.min(180, +$('ikEl').value || 0));
|
||||||
|
POSE.roll = +$('ikRoll').value || 0;
|
||||||
|
syncPoseInputs();
|
||||||
|
if (poseOn) runSolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if ($('ikPoseCur')) $('ikPoseCur').onclick = () => { fillPoseFromCurrent(); if (poseOn) runSolve(); };
|
||||||
|
if ($('ikPoseDown')) $('ikPoseDown').onclick = () => {
|
||||||
|
POSE.az = 0; POSE.el = 180; POSE.roll = 0; syncPoseInputs(); if (poseOn) runSolve();
|
||||||
|
};
|
||||||
|
|
||||||
/* ---------- 8. 视图模式:三维模型(静态) / 装配图 / 运动模拟 ---------- */
|
/* ---------- 8. 视图模式:三维模型(静态) / 装配图 / 运动模拟 ---------- */
|
||||||
let VIEW = 'model';
|
let VIEW = 'model';
|
||||||
const MOTION_UI = ['ikbar', 'ikpanel', 'joints', 'jointsHead', 'footIk'];
|
const MOTION_UI = ['ikbar', 'ikpanel', 'joints', 'jointsHead', 'footIk'];
|
||||||
@@ -330,6 +606,7 @@ function setView(v, keepPose) {
|
|||||||
}
|
}
|
||||||
gTgt.visible = isMotion; RING.visible = isMotion;
|
gTgt.visible = isMotion; RING.visible = isMotion;
|
||||||
ballTcp.visible = isMotion; linkLine.visible = isMotion;
|
ballTcp.visible = isMotion; linkLine.visible = isMotion;
|
||||||
|
curLine.visible = isMotion;
|
||||||
if (isMotion) {
|
if (isMotion) {
|
||||||
if (+exp.value) document.getElementById('bAsm').click(); // 运动模拟须在装配状态下进行
|
if (+exp.value) document.getElementById('bAsm').click(); // 运动模拟须在装配状态下进行
|
||||||
runSolve(); // 进入即求解并动画到目标点
|
runSolve(); // 进入即求解并动画到目标点
|
||||||
@@ -344,7 +621,7 @@ document.querySelectorAll('.tab[data-view]').forEach(b =>
|
|||||||
|
|
||||||
/* ---------- 9. 启动:自检 + 工作空间采样 ---------- */
|
/* ---------- 9. 启动:自检 + 工作空间采样 ---------- */
|
||||||
function boot() {
|
function boot() {
|
||||||
const st = S.selfTest(600);
|
const st = S.selfTest(600, 6);
|
||||||
const ws = (function () {
|
const ws = (function () {
|
||||||
let lo = [1e9, 1e9, 1e9], hi = [-1e9, -1e9, -1e9], rmax = 0;
|
let lo = [1e9, 1e9, 1e9], hi = [-1e9, -1e9, -1e9], rmax = 0;
|
||||||
for (let k = 0; k < 120000; k++) {
|
for (let k = 0; k < 120000; k++) {
|
||||||
@@ -368,7 +645,11 @@ function boot() {
|
|||||||
' mm(前伸 ' + ws.hi[0].toFixed(0) + ')</span>' +
|
' mm(前伸 ' + ws.hi[0].toFixed(0) + ')</span>' +
|
||||||
'<i>可达 y*</i><span>' + ws.lo[1].toFixed(0) + '…' + ws.hi[1].toFixed(0) + ' mm</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>可达 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>离 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>';
|
||||||
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */
|
/* 打包自检:确认所有依赖都在本文件内(便于单文件分发,防止被拆开后才出问题) */
|
||||||
const ext = document.querySelectorAll(
|
const ext = document.querySelectorAll(
|
||||||
'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length;
|
'script[src],link[href],img[src],iframe[src],source[src],embed[src],object[data],use[href]').length;
|
||||||
@@ -400,5 +681,15 @@ boot();
|
|||||||
|
|
||||||
window.IK = { solver: S, setTarget: p => { TGT = p.slice(); syncInputs(); refreshTarget(); },
|
window.IK = { solver: S, setTarget: p => { TGT = p.slice(); syncInputs(); refreshTarget(); },
|
||||||
getTarget: () => TGT.slice(), run: runSolve, TCP0: TCP0, LIM: LIM,
|
getTarget: () => TGT.slice(), run: runSolve, TCP0: TCP0, LIM: LIM,
|
||||||
setView: setView, getView: () => VIEW };
|
setView: setView, getView: () => VIEW,
|
||||||
|
setPose: (az, el, roll, solve) => {
|
||||||
|
POSE.az = +az || 0; POSE.el = Math.max(0, Math.min(180, +el || 0)); POSE.roll = +roll || 0;
|
||||||
|
syncPoseInputs(); refreshTarget(); if (solve !== false) runSolve();
|
||||||
|
},
|
||||||
|
getPose: () => ({ on: poseOn, az: POSE.az, el: POSE.el, roll: POSE.roll }),
|
||||||
|
/* 可拖控点的屏幕坐标(画布内像素):黄球 / 箭头头 / 滚转侧球 */
|
||||||
|
pickPts: () => poseOn ? { ball: projTgt(), head: projArrow(HEADZ), knob: knobScreen() } : null,
|
||||||
|
/* 调试:某视口坐标下的把手拾取结果('axis'/'roll'/null) */
|
||||||
|
_pickHandle: (cx, cy) => poseOn ? pickPoseHandle({ clientX: cx, clientY: cy,
|
||||||
|
offsetX: cx - cv.getBoundingClientRect().left, offsetY: cy - cv.getBoundingClientRect().top }) : null };
|
||||||
})();
|
})();
|
||||||
+815
-76
File diff suppressed because it is too large
Load Diff
+815
-76
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
+815
-76
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user