Files
Robot_Arm/src/viewer_tpl.html
T
cbb a70724e8a9 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° 换算求解器内部约定
2026-09-28 22:09:09 +08:00

1063 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>六电机人形机器人手臂 · 装配与打印模型</title>
<style>
:root{--bg:#0f1115;--pan:#171a21;--pan2:#1f242e;--tx:#e6e8ee;--dim:#8b93a7;--ac:#4c9aff;--ok:#3ddc97;--warn:#ffb454;--line:#2b323e}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--tx);
font:13px/1.55 "Microsoft YaHei","Segoe UI",system-ui,sans-serif;overflow:hidden;
-webkit-user-select:none;user-select:none}
#app{display:grid;grid-template-columns:322px minmax(430px,1fr);grid-template-rows:46px auto 1fr 26px;
height:100%;min-width:0;overflow-x:auto}
header{grid-column:1/-1;display:flex;align-items:center;gap:8px;padding:0 12px;flex-wrap:nowrap;
overflow-x:auto;overflow-y:hidden;background:var(--pan);border-bottom:1px solid var(--line)}
header .q{white-space:nowrap;flex:none;color:var(--dim);font-size:12px}
header b{font-size:14px;white-space:nowrap;flex:none}
header .sp{flex:1 1 auto;min-width:6px}
button,select{background:var(--pan2);color:var(--tx);border:1px solid var(--line);
border-radius:5px;padding:4px 9px;font:inherit;cursor:pointer;white-space:nowrap;flex:none}
button:hover{border-color:var(--ac);color:#fff}
button.on{background:#22406b;border-color:var(--ac)}
aside{background:var(--pan);overflow:auto;padding:8px;min-width:0}
#left{border-right:1px solid var(--line)}
#app.l{grid-template-columns:322px minmax(430px,1fr)}
#stage{position:relative;overflow:hidden;min-width:0;
background:radial-gradient(120% 120% at 50% 0%,#1b2028 0%,#0d0f13 70%)}
canvas{display:block;width:100%;height:100%}
footer{grid-column:1/-1;background:var(--pan);border-top:1px solid var(--line);
display:flex;align-items:center;gap:14px;padding:0 12px;color:var(--dim);font-size:12px;
white-space:nowrap;overflow:hidden}
@media (max-width:1150px){
#app{grid-template-columns:300px minmax(400px,1fr)}
}
@media (max-width:760px){
#app{grid-template-columns:minmax(0,1fr)}
#left{display:none}
#app.l #left{display:block}
}
h3{font-size:12px;margin:10px 0 6px;color:var(--ac);letter-spacing:.4px;-webkit-user-select:none;user-select:none}
.row{display:flex;align-items:center;gap:6px;margin:3px 0}
.row label{flex:1;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;-webkit-user-select:none;user-select:none}
.row .q{color:var(--dim);font-size:11px;-webkit-user-select:none;user-select:none}
.chip{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;flex:none}
.pr{color:var(--ok)}.ex{color:var(--warn)}.mo{color:#9aa4b8}
table{width:100%;border-collapse:collapse;font-size:11.5px;-webkit-user-select:text;user-select:text}
th,td{border-bottom:1px solid var(--line);padding:3px 4px;text-align:left;vertical-align:top}
th{color:var(--dim);font-weight:400;position:sticky;top:0;background:var(--pan);-webkit-user-select:none;user-select:none}
.j-box{margin-bottom:7px;padding:3px 4px 1px;border-radius:4px;transition:background .15s}
.j-box:hover{background:rgba(255,255,255,.03)}
.j-box label{cursor:default;font-weight:500}
.j-val{font-size:11px;color:var(--ac);cursor:pointer;padding:1px 5px;border-radius:3px;
background:rgba(76,154,255,.1);font-variant-numeric:tabular-nums;transition:all .15s;
-webkit-user-select:none;user-select:none}
.j-val:hover{background:var(--ac);color:#fff}
input[type=range]{width:100%;height:18px;accent-color:var(--ac);cursor:pointer;display:block;margin:1px 0}
label em{font-style:normal;color:var(--mu);font-size:10.5px;margin-left:4px}
label em.ok{color:#5ddc90}
.gr{font-size:11px;color:var(--dim);margin:2px 0 4px;-webkit-user-select:none;user-select:none}
#hud{position:absolute;left:10px;top:8px;pointer-events:none;font-size:11.5px;color:var(--dim);
background:rgba(15,17,21,.72);border:1px solid var(--line);border-radius:6px;padding:6px 9px;max-width:60%}
#hud b{color:var(--tx)}
#hud:empty{display:none}
#note{position:absolute;right:10px;bottom:10px;max-width:330px;background:rgba(15,17,21,.88);
border:1px solid var(--ac);border-radius:7px;padding:8px 10px;font-size:12px;display:none;
-webkit-user-select:text;user-select:text}
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 8px;font-size:11.5px;-webkit-user-select:text;user-select:text}
.kv i{color:var(--dim);font-style:normal;-webkit-user-select:none;user-select:none}
.tab{padding:4px 9px;-webkit-user-select:none;user-select:none}
.bad{color:#ff6b6b}.good{color:var(--ok)}
svg{display:block;width:100%;height:auto;background:#fff;border-radius:6px}
#sheet{display:none;padding:10px;overflow:auto;height:100%}
small{color:var(--dim)}
/* ---- edge BOM dock (collapsible drawer at the right edge) ---- */
#bomTab{position:fixed;right:0;top:64px;z-index:30;writing-mode:vertical-rl;text-orientation:mixed;
background:var(--pan2);color:var(--tx);border:1px solid var(--line);border-right:none;
border-radius:6px 0 0 6px;padding:12px 6px;font:inherit;cursor:pointer;letter-spacing:3px}
#bomTab:hover{border-color:var(--ac);color:#fff}
#bomTab b{color:var(--ac)}
#bomDock{position:fixed;right:0;top:46px;bottom:26px;width:min(430px,94vw);z-index:29;
transform:translateX(103%);transition:transform .22s ease;display:flex;flex-direction:column;
background:var(--pan);border-left:1px solid var(--ac);box-shadow:-10px 0 28px rgba(0,0,0,.5)}
#bomDock.open{transform:none}
#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-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}
.dk-note{color:var(--dim);font-size:11px;margin:1px 0 6px}
#screws td{white-space:normal}
#screws td:first-child{white-space:nowrap}
#screws tbody tr{cursor:pointer;transition:background .12s}
#screws tbody tr:hover{background:rgba(76,154,255,.14)}
#screws tbody tr.scrHl{background:#1d3f74;box-shadow:inset 3px 0 0 var(--warn)}
.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}}
/* ===================== IK 控制条(两行:目标+姿态 / 模式+状态) ===================== */
#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)}
#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;
border:1px solid var(--ac);border-radius:4px;padding:1px 6px}
#ikbar .lb{color:var(--dim);flex:none;font-size:12px}
#ikbar input[type=number]{width:68px;background:#0d1015;color:var(--tx);border:1px solid var(--line);
border-radius:4px;padding:3px 5px;font:inherit;font-variant-numeric:tabular-nums;flex:none}
#ikbar input[type=number]:focus{outline:none;border-color:var(--ac)}
#ikbar .vsep{width:1px;height:20px;background:var(--line);flex:none;margin:0 3px}
#ikbar .st{color:var(--dim);flex:none;font-size:11.5px;font-variant-numeric:tabular-nums}
#ikbar .sep{color:#3b4453;margin:0 5px}
#ikbar .hint{color:var(--dim);font-size:11px;flex:none}
#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: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 结果面板(左栏顶部) ===================== */
#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}
.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)}
.badge.badb{color:#2a0a0a;background:#ff6b6b;border-color:#ff6b6b}
#ikTab{font-size:11px}
#ikTab td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
#ikTab td.dim{color:var(--dim);white-space:nowrap}
#ikpanel .kv{margin-top:6px}
#ikpanel .kv i{white-space:nowrap}
#ikpanel .kv span,#ikpanel .kv b{overflow-wrap:anywhere;word-break:break-word;min-width:0}
#ikpanel .good{color:var(--ok)} #ikpanel .bad{color:#ff6b6b}
#ikpanel .kv .hi{color:var(--warn);font-variant-numeric:tabular-nums}
#ikAltN{font-size:10.5px;color:var(--dim);margin:7px 0 3px;line-height:1.45;white-space:normal}
.altrow{display:flex;align-items:center;gap:5px;padding:2px 4px;border-radius:4px;cursor:pointer;
font-size:11px;font-variant-numeric:tabular-nums;color:var(--dim)}
.altrow:hover{background:rgba(76,154,255,.16)}
.altrow .ai{color:#5b6478;width:10px;flex:none;text-align:right}
.altrow b{color:var(--ac);width:54px;flex:none;text-align:right;font-weight:500}
.altrow .aq{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#ikpanel details{margin-top:8px;border-top:1px solid var(--line);padding-top:6px}
#ikpanel details>summary{cursor:pointer;color:var(--ac);font-size:11.5px;outline:none}
#ikpanel details .doc{margin-top:5px;font-size:11px;color:var(--dim);line-height:1.7;white-space:normal;
-webkit-user-select:text;user-select:text}
#ikpanel details .doc b{color:var(--tx)}
#ikpanel details .doc code{color:var(--warn);font-family:Consolas,"Courier New",monospace;font-size:10.5px}
#ikpanel details .doc ol{margin:3px 0 3px 16px;padding:0}
#ikpanel details .doc li{margin:3px 0}
#ikpanel details .doc hr{border:none;border-top:1px solid var(--line);margin:6px 0}
#ikpanel .src{font-size:10.5px;color:var(--dim);margin-top:5px;white-space:normal;line-height:1.5}
#ikChkBox{margin-top:7px}
#ikChkBox summary{margin-bottom:2px}
/* ============ 视图模式:三维模型(静态) / 装配图 / 运动模拟 ============ */
/* 三维模型 与 装配图 下,全部运动学 UI 一律隐藏 */
body:not(.motion) #ikbar,
body:not(.motion) #ikpanel,
body:not(.motion) #joints,
body:not(.motion) #jointsHead,
body:not(.motion) #footIk{display:none!important}
/* 运动模拟只看运动:左栏去掉零件树与打印清单(其它模式保留) */
body.motion #treeBlock{display:none!important}
/* 运动模拟左栏紧凑化:目标一屏放下、不下拉(实测原需下拉 127px) */
#ikpanel{margin:0 0 6px;padding:5px 8px}
#ikpanel .ikh{margin-bottom:3px}
#ikpanel #ikTab th,#ikpanel #ikTab td{padding:1px 4px}
#ikAltN{margin:5px 0 2px}
.altrow{padding:1px 4px}
#joints .j-box{margin-bottom:4px}
#joints input[type=range]{height:15px}
/* 显式行定位:IK 控制条在「三维模型/装配图」下被移除后,
若不固定行号,网格自动排布会把页脚挤到 1fr 行并压扁(已实测) */
#app>header{grid-row:1}
#ikbar{grid-row:2}
#left{grid-row:3}
#stage{grid-row:3}
#app>footer{grid-row:4}
/* BOM 抽屉:运动模拟多一条两行 IK 控制条(实测 75px),需整体下移 */
#bomDock{top:46px} #bomTab{top:64px}
body.motion #bomDock{top:124px} body.motion #bomTab{top:140px}
@media (max-width:760px){
#bomTab,body.motion #bomTab{top:auto;bottom:36px}
#bomDock,body.motion #bomDock{top:86px;bottom:26px}
}
</style>
</head>
<body>
<div id="app">
<header>
<b id="ttl">人形机器人手臂</b>
<span class="q" id="ver"></span>
<button class="tab on" data-view="model">三维模型</button>
<button class="tab" data-view="sheet">装配图</button><button class="tab" data-view="motion">运动模拟</button>
<span class="sp"></span>
<button id="bAsm" class="on">装配视图</button>
<button id="bExp">爆炸视图</button>
<input id="exp" type="range" min="0" max="100" value="0" style="width:96px">
<select id="cam">
<option value="iso">等测</option><option value="front">前视</option>
<option value="side">侧视</option><option value="top">顶视</option>
<option value="wrist">腕部特写</option><option value="joint">关节特写</option><option value="elbow">肘部特写</option><option value="hand">手部特写</option>
</select>
<button id="bWire">线框</button>
<button id="bXRay">电机半透</button>
<button id="bScr" class="on" title="显示/隐藏全部螺钉(S01~S10);点右侧 BOM 螺丝行可在模型中高亮定位">螺钉</button>
<button id="bEnv" title="显示/隐藏可达外包络(运动学边界)">可达包络</button>
<button id="bFit">复位</button>
<button id="bL" title="显示/隐藏左栏">左栏</button>
</header>
<div id="ikbar">
<div class="ikrow">
<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">Y</span><input type="number" id="ikY" step="1" title="CAD 世界坐标 Y / mm">
<span class="lb">Z</span><input type="number" id="ikZ" step="1" title="CAD 世界坐标 Z / mm(桌面 z=0,向上为正)">
<span class="vsep"></span>
<span class="lb">工具长</span><input type="number" id="ikTool" value="0" step="5" title="在工具法兰面之外、沿 J6 轴线再伸出的工具长度 / mm">
<span class="lb">mm</span>
<span class="vsep"></span>
<span class="lb">扫描精度</span>
<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>
</select>
<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="ikRt" title="开启后拖动黄球即算即动:手臂实时追踪目标点移动(松手做一次精修求解)">实时追踪</button>
<button id="ikHome" title="所有关节角归零">归零位</button>
<button id="ikZero" title="载入一个示例目标并求解">示例目标</button>
<span class="vsep"></span>
<span class="hint" id="ikHint">拖黄球 = 移动目标(Shift 仅上下);拖箭头 = 调工具轴指向;拖箭杆或粉球 = 滚转;松手自动求解</span>
<span class="vsep"></span>
<span class="st" id="ikBar">—</span>
</div>
</div>
<aside id="left">
<div id="ikpanel">
<div class="ikh">
<h3>逆运动学 · 位置与姿态 · 最小总关节角</h3>
<span id="ikBadge" class="badge wait">待求解</span>
</div>
<table id="ikTab">
<thead><tr><th>轴</th><th>关节</th><th>角度</th><th>|θ|</th><th>限位</th></tr></thead>
<tbody></tbody>
</table>
<div class="kv" id="ikSum"></div>
<div class="src" id="ikSrc"></div>
<div id="ikAltN"></div>
<div id="ikAlts"></div>
<details id="ikChkBox"><summary>结构与精度自检 / 工作空间</summary><div class="kv" id="ikChk"></div>
<div class="kv" id="ikDep"></div></details>
<details id="ikDoc">
<summary>算法说明与公式推导</summary>
<div class="doc">
<b>模式</b>:本页「<b>运动模拟</b>」承载全部运动学能力;「三维模型」为纯静态模型视图(关节锁定在设计零位、无目标点与求解控件),「装配图」为二维图纸。运动模拟下左栏只保留 IK 结果与关节角度,零件树/打印清单自动收走。<hr>
<b>目标</b>:使 TCP(末端工具法兰 P09 安装面中心,可加“工具长”)到达给定点,并在一切满足限位的可行构型中取
<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>
<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。
记 <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>,
<code>c = C0 + (−Vx·cosθ3·sinθ2 + Vz·cosθ2)</code>,
其中 <code>Vx, Vz</code> 只是 (θ4, θ5) 的函数。</li>
<li><b>两式联立消元(关键一步)</b>:由 <code>a = A</code> 得恒等式
<code>A² + n² = Vx² + Vz²</code>(<code>n = −Vx·cosθ3·sinθ2 + Vz·cosθ2</code>);
代入 <code>b² + c² = R²</code> 后 θ3 的项<u>全部抵消</u>,于是<br>
<code>n = (R² + A² − Vx² − Vz² − C0²) / (2·C0)</code> —— <b>唯一确定</b>;<br>
再由 <code>n² + A² = Vx²·cos²θ3 + Vz²</code> 得
<code>cos²θ3 = (n² + A² − Vz²) / Vx²</code> ⇒ θ3 共四支 <code>±σ, ±(π−σ)</code>;<br>
最后 θ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>姿态任务(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 条扫描路径):&gt;10° 跳变 14→5 次,最大跳变 163.5°→16.8°,平均 Σ|θ| 不升反降。</li>
<li><b>代表角</b>:J1 / J3 / J6 为 ±180° 整周轴,取 |θ| 最小的等价角;J2 / J4 / J5 有限位,越限即丢弃。</li>
<li><b>自检</b>:每次求解都用闭式 FK 与场景图真实 6 级矩阵链各算一次 TCP 和工具姿态并与目标比对(见上表残差)。</li>
</ol>
<hr>
<b>验证数据(本机实测,可复现)</b>
<table>
<thead><tr><th>检查项</th><th>结果</th></tr></thead>
<tbody>
<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>θ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 变化 &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>
</tbody>
</table>
<hr>
<b>限位</b>:J2 ∈ [−106, 9],J4 ∈ [−111, 16],J5 ∈ [−95, 34],J1 / J3 / J6 为 ±180°(可整周)。
<br><b>局限</b>:本算法为纯运动学求解,只受各关节行程限位约束,<u>不含跨关节自碰撞校核</u>。
原模型限位来自全对全动态扫描;若需要更舒展的姿态,可从上方“互异构型”列表改选次优解。
<br><b>不可达</b>:若目标超出工作空间,自动求最近可达位姿——阻尼最小二乘<b>从当前姿态锚定起步</b>(抑制退化方向的无用漂移)、固定种子的确定性重启(同一点重复解算逐位一致)、再经零空间抛光取最小总关节角的代表值;报告的距离为抛光后真实值。
<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>
</details>
</div>
<div class="row" id="jointsHead" style="justify-content:space-between;margin:8px 0 4px">
<h3 style="margin:0">关节角度(实时联动)</h3>
<button id="resetJoints" class="tab" style="padding:1px 7px;font-size:11px" title="将所有关节角度复位为 0°">全部归零</button>
</div>
<div id="joints"></div>
<div id="treeBlock">
<h3>零件树</h3>
<div class="row"><button id="allOn" class="tab">全选</button><button id="allOff" class="tab">全不选</button>
<button id="onlyPrint" class="tab">只看打印件</button></div>
<div id="tree"></div>
<h3>打印清单(PLA)</h3>
<div class="kv" id="sum"></div>
</div>
</aside>
<div id="stage">
<canvas id="cv"></canvas>
<div id="hud"></div>
<div id="note"></div>
<div id="sheet"></div>
</div>
<footer>
<span>左键旋转 · 滚轮缩放 · 右键平移(拖动) · 单击选中高亮 · <b>BOM 点螺丝行 = 高亮定位该规格螺钉(穿透显示)</b> · <b>右键单击隐藏部件(或按H键)</b> · 左侧全选/勾选恢复<span id="footIk"> · <b>运动模拟</b>:拖动黄色目标球到点(Shift 仅上下)· 开「姿态控制」后可拖箭头调末端朝向、拖箭杆或粉色侧球调滚转</span></span>
<span class="sp"></span>
<span id="stat"></span>
</footer>
</div>
<button id="bomTab" title="展开 / 收起 物料清单 BOM">物料清单 <b>BOM</b></button>
<aside id="bomDock">
<div class="dk-hd">
<b>物料清单 BOM</b><span class="q" id="bomStat"></span>
<button id="bomHide" class="dk-x" title="收起">收起 ▶</button>
</div>
<div class="dk-bd">
<div class="dk-sec">紧固件 / 螺丝清单(全部紧固点)</div>
<div class="dk-note" id="scrSum"></div>
<table id="screws"><thead><tr><th>规格</th><th>长 mm</th><th>数量</th><th>位置 / 用途</th></tr></thead><tbody></tbody></table>
<div class="dk-sec">打印件 BOM(原明细,PLA)</div>
<table id="bom"><thead><tr><th>#</th><th>件号</th><th>cm3</th><th>g</th></tr></thead><tbody></tbody></table>
</div>
</aside>
<script>/*__THREE__*/</script>
<script>
const DATA = /*__DATA__*/;
const SHEETS = /*__SHEETS__*/;
const TRAVEL = /*__TRAVEL__*/;
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
const cv = document.getElementById('cv');
const rend = new THREE.WebGLRenderer({canvas:cv, antialias:true, preserveDrawingBuffer:true});
rend.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0f13);
const cam = new THREE.PerspectiveCamera(38, 1, 1, 6000);
scene.add(new THREE.HemisphereLight(0xcfe4ff, 0x22262e, 1.15));
const key = new THREE.DirectionalLight(0xffffff, 1.5); key.position.set(420, 620, 520); scene.add(key);
const key2 = new THREE.DirectionalLight(0x9dc4ff, .7); key2.position.set(-520, 300, -420); scene.add(key2);
const grid = new THREE.GridHelper(600, 30, 0x2b323e, 0x1b2028); grid.position.y = -0.5; scene.add(grid);
let needRender = true;
function requestRender() { needRender = true; }
/* ---- kinematic groups: nested pivot matrices, serial chain ---- */
const ROOT = new THREE.Object3D(); scene.add(ROOT);
const JG = {root: ROOT};
const JO = DATA.joints;
const parentOf = {J1:'root', J2:'J1', J3:'J2', J4:'J3', J5:'J4', J6:'J5'};
const ang = {J1:0, J2:0, J3:0, J4:0, J5:0, J6:0};
for (const j of JO) {
const g = new THREE.Object3D(); g.matrixAutoUpdate = false;
JG[j.id] = g; JG[parentOf[j.id]].add(g);
}
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 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));
JG[id].matrix.copy(m);
JG[id].matrixWorldNeedsUpdate = true;
requestRender();
}
/* ---- meshes ---- */
const meshes = [], byName = {};
let expl = 0;
for (const p of DATA.parts) {
const geo = new THREE.BufferGeometry();
const pos = new Float32Array(p.v.length * 3);
for (let i = 0; i < p.v.length; i++) { pos[i*3] = p.v[i][0]; pos[i*3+1] = p.v[i][2]; pos[i*3+2] = -p.v[i][1]; }
p.triCount = p.t ? p.t.length : 0;
p.vertCount = p.v.length;
const isEnv = p.name.slice(0, 4) === 'ENV_'; // 可达包络:运动学边界,不随关节动
if (p.kind === 'line') { // 经纬线:LineSegments,无三角面
const sIdx = new Uint32Array(p.s);
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setIndex(new THREE.BufferAttribute(sIdx, 1));
geo.computeBoundingSphere(); geo.computeBoundingBox();
const lm = new THREE.LineBasicMaterial({color: p.color, transparent: true, opacity: 0.8});
const ls = new THREE.LineSegments(geo, lm);
ls.userData = p;
ls.position.set(p.ex[0], p.ex[2], -p.ex[1]);
ls.base = ls.position.clone();
ls.visible = !isEnv; // 包络默认隐藏,走开关显示
JG[p.grp || 'root'].add(ls);
meshes.push(ls); byName[p.name] = ls;
delete p.v; delete p.s;
continue;
}
const idx = 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]; }
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setIndex(new THREE.BufferAttribute(idx, 1));
geo.computeVertexNormals();
geo.computeBoundingSphere();
geo.computeBoundingBox();
delete p.v;
delete p.t;
const isMo = !p.printed && p.name[0] === 'M';
const isScr = !!p.screw; // 紧固件(螺钉):金属亮色,可被 BOM 行高亮
const mat = new THREE.MeshStandardMaterial({
color: p.color, metalness: isScr ? .78 : (isMo ? .55 : .05),
roughness: isScr ? .30 : (isMo ? .42 : .78),
transparent: isEnv, opacity: isEnv ? .06 : 1,
depthWrite: !isEnv,
flatShading: false, side: isEnv ? THREE.DoubleSide : THREE.FrontSide});
const ms = new THREE.Mesh(geo, mat);
ms.userData = p;
ms.position.set(p.ex[0], p.ex[2], -p.ex[1]);
ms.base = ms.position.clone();
ms.zero = [0,0,0];
if (isEnv) ms.visible = false; // 包络默认隐藏,走开关显示
JG[p.grp || 'root'].add(ms);
meshes.push(ms); byName[p.name] = ms;
}
const J6j = JO.find(j => j.id === 'J6');
// the jaws slide along the gripper motor's own local X, so that axis must come from J6
const JAWD = J6j ? new V3(...VC(J6j.xdir)).normalize() : new V3(0, 0, 1);
const PIN_R = 14.0;
const JMAX = TL.J6 ? PIN_R * (1 - Math.cos(TL.J6.hi * Math.PI / 180)) : 9.0;
const JAW_MAX = Math.min(9.0, Math.max(2.0, JMAX));
let jawMM = 0;
function updXform() {
for (const m of meshes) {
m.position.copy(m.base).multiplyScalar(expl);
if (m.userData.jaw) m.position.addScaledVector(JAWD, m.userData.jaw * jawMM);
}
requestRender();
}
function setJaw(t) {
jawMM = Math.max(0, Math.min(JAW_MAX, t));
// the crank pins sit at radius 14 along the jaw axis as authored, so a rotation of phi
// puts the pin at x = 14*cos(phi) and each jaw rides out by exactly that much
const phi = Math.acos(Math.max(-1, Math.min(1, (PIN_R - jawMM) / PIN_R)));
ang.J6 = -phi * 180 / Math.PI;
applyJoint('J6');
updXform();
const e = document.getElementById('vJ6'); if (e) e.textContent = (2 * jawMM).toFixed(1) + ' mm';
}
function setExpl(k) {
expl = k;
updXform();
if (typeof updStat === 'function') updStat();
requestRender();
}
setExpl(0); // start assembled, not exploded
/* ---- camera orbit ---- */
let az = -0.9, po = 1.15, rd = 620, tx = 120, ty = 190, tz = -30;
// lookAt with the world up-axis degenerate-rolls the image when looking near-vertically,
// so top view pins the screen-up to -Z: model X runs right, depth runs down-screen
const UP_Y = new V3(0, 1, 0), UP_TOP = new V3(0, 0, -1);
let camUp = UP_Y.clone();
function fitTo(b) {
const c = [(b[0][0]+b[1][0])/2, (b[0][1]+b[1][1])/2, (b[0][2]+b[1][2])/2];
tx = c[0]; ty = c[1]; tz = c[2];
const s = Math.max(b[1][0]-b[0][0], b[1][1]-b[0][1], b[1][2]-b[0][2]);
rd = s * 1.78;
requestRender();
}
let BB = [[1e9,1e9,1e9],[-1e9,-1e9,-1e9]];
/* robust global bounds in the viewer frame (x, z, -y) */
(function(){
let lo=[1e9,1e9,1e9], hi=[-1e9,-1e9,-1e9];
for (const m of meshes) { if (m.userData.name.slice(0,4) === 'ENV_') continue; // 取景只按手臂本体,包络不入包围盒
const b=m.geometry.boundingBox;
const a=[b.min.x+m.base.x,b.min.y+m.base.y,b.min.z+m.base.z];
const c=[b.max.x+m.base.x,b.max.y+m.base.y,b.max.z+m.base.z];
for(let i=0;i<3;i++){lo[i]=Math.min(lo[i],a[i]);hi[i]=Math.max(hi[i],c[i]);} }
BB=[lo,hi];
})();
fitTo(BB);
function updCam() {
cam.up.copy(camUp);
cam.position.set(tx + rd*Math.sin(po)*Math.cos(az), ty + rd*Math.cos(po), tz + rd*Math.sin(po)*Math.sin(az));
cam.lookAt(tx, ty, tz);
}
let drag = null, panDist = 0;
cv.addEventListener('pointerdown', e => {
drag = {x:e.offsetX, y:e.offsetY, b:e.button};
panDist = 0;
cv.setPointerCapture(e.pointerId);
});
let hoverPending = false, lastHoverEv = null;
function schedHover(e) {
lastHoverEv = e;
if (!hoverPending) {
hoverPending = true;
requestAnimationFrame(() => {
hoverPending = false;
if (lastHoverEv && !drag) hover(lastHoverEv);
});
}
}
cv.addEventListener('pointermove', e => {
if (!drag) { schedHover(e); return; }
const dx = e.offsetX - drag.x, dy = e.offsetY - drag.y;
panDist += Math.abs(dx) + Math.abs(dy);
drag = {x:e.offsetX, y:e.offsetY, b:drag.b};
if (drag.b === 0) { az -= dx*0.008; po = Math.min(3.04, Math.max(.05, po - dy*0.008)); }
else { const r = rd*0.0016; const f = new V3(Math.sin(az),0,Math.cos(az)), ri = new V3().crossVectors(new V3(0,1,0), f).normalize();
tx -= ri.x*dx*r + f.x*dy*r*0.0; tz -= ri.z*dx*r + f.z*dy*r*0.0; ty += dy*r; }
requestRender();
});
addEventListener('pointerup', () => drag = null);
cv.addEventListener('wheel', e => { e.preventDefault(); rd *= (1 + Math.sign(e.deltaY)*0.09); requestRender(); }, {passive:false});
/* ---- picking & hiding ---- */
const ray = new THREE.Raycaster(); let sel = null, hov = null;
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];
return hit ? hit.object : null;
}
function hover(e) {
const o = pick(e); if (o === hov) return; hov = o;
const n = document.getElementById('note');
if (!o) { n.style.display = 'none'; return; }
const p = o.userData;
n.style.display = 'block';
n.innerHTML = '<b>#' + p.idx + ' ' + p.cn + '</b><br><small>' + p.name +
' · ' + (p.printed ? '3D打印件' : '电机/已有件') + '<br>' + p.note + '</small>';
}
function hidePart(m) {
if (!m) return;
const p = m.userData;
if (m === sel) {
m.material.emissive.setHex(0);
m.material.emissiveIntensity = 0;
sel = null;
}
m.visible = false;
if (m.chk) m.chk.checked = false;
const n = document.getElementById('note');
if (n) n.style.display = 'none';
const hud = document.getElementById('hud');
if (hud) {
hud.innerHTML = '已隐藏:<b>#' + p.idx + ' ' + p.cn + '</b> <span id="unhideBtn" style="color:var(--ac);cursor:pointer;text-decoration:underline;margin-left:6px">撤销</span> <small style="color:var(--dim)">(可在左侧零件树勾选或点击“全选”恢复)</small>';
const ub = document.getElementById('unhideBtn');
if (ub) ub.onclick = () => {
m.visible = true;
if (m.chk) m.chk.checked = true;
hud.textContent = '';
requestRender();
};
}
requestRender();
}
cv.addEventListener('contextmenu', e => {
e.preventDefault();
if (panDist > 6) return; // ignore drag panning
let target = sel;
if (!target) target = pick(e);
if (target) hidePart(target);
});
window.addEventListener('keydown', e => {
if (e.key === 'h' || e.key === 'H' || e.key === 'Delete') {
if (sel) hidePart(sel);
} else if (e.key === 'Escape') {
clearScrewHl();
if (sel) {
sel.material.emissive.setHex(0);
sel.material.emissiveIntensity = 0;
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 = '';
requestRender();
}
}
});
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 = '';
clearScrewHl();
const hud = document.getElementById('hud'); if (hud) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
return;
}
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 hud = document.getElementById('hud');
hud.textContent = '';
delete hud.dataset.scrHl;
if (sel) {
const u = sel.userData;
if (u.screw) { highlightSpec(u.spec, false); requestRender(); return; } // 点中螺钉 -> 反查 BOM 行并定位
hud.innerHTML = '<b>#' + u.idx + ' ' + u.cn + '</b> · ' + (u.vol / 1000).toFixed(1) +
' 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'}); }
}
requestRender();
});
/* ---- UI: joint sliders, ranges from the swept hard stops (docs/travel.json) ---- */
const JNAMES = ['J1','J2','J3','J4','J5','J6'];
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 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>') +
'</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); };
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')); };
applyJoint(id);
}
const bRstJ = document.getElementById('resetJoints');
if (bRstJ) bRstJ.onclick = () => {
for (const id of JNAMES) {
const el = document.getElementById('s' + id);
if (el) { el.value = 0; el.dispatchEvent(new Event('input')); }
}
};
/* ---- UI: part tree ---- */
const th = document.getElementById('tree');
const groups = [['可达包络(运动学)', m => m.userData.name.slice(0,4) === 'ENV_'],
['打印件(新建)', m => m.userData.printed],
['电机模组(复用)', m => !m.userData.printed && m.userData.name[0]==='M'],
['端面齿联轴器/抱箍(已有)', m => !m.userData.printed && !m.userData.screw && m.userData.name[0]!=='M' && m.userData.name.slice(0,4) !== 'ENV_']];
for (const [gt, test] of groups) {
const ms = meshes.filter(test); if (!ms.length) continue;
const h = document.createElement('div'); h.className = 'gr'; h.textContent = gt + ' · ' + ms.length;
th.appendChild(h);
for (const m of ms) {
const p = m.userData;
const r = document.createElement('div'); r.className = 'row';
r.innerHTML = '<input type="checkbox" checked><span class="chip" style="background:#' +
p.color.toString(16).padStart(6,'0') + '"></span><label title="' + p.note + '">' +
'<span class="' + (p.printed?'pr':(p.name[0]==='M'?'mo':'ex')) + '">' + p.cn + '</span></label>' +
'<span class="q">#' + p.idx + '</span>';
const chk = r.querySelector('input');
m.chk = chk;
chk.onchange = e => {
m.visible = e.target.checked;
if (!m.visible && m === sel) { m.material.emissive.setHex(0); sel = null; }
requestRender();
};
r.querySelector('label').onclick = () => {
m.visible = !m.visible;
chk.checked = m.visible;
if (!m.visible && m === sel) { m.material.emissive.setHex(0); sel = null; }
requestRender();
};
th.appendChild(r);
}
}
/* ---- 紧固件(螺钉)分组:按规格聚合,一行 = 一种规格,勾选/点击作用于该规格全部实例 ---- */
const scrAll = meshes.filter(m => m.userData.screw);
const bySpec = {}, specChk = {};
for (const m of scrAll) (bySpec[m.userData.spec] = bySpec[m.userData.spec] || []).push(m);
if (scrAll.length) {
const h = document.createElement('div'); h.className = 'gr';
h.textContent = '紧固件 / 螺钉(按规格聚合) · ' + Object.keys(bySpec).length + ' 种 ' +
scrAll.length + ' 颗(点击名称定位)';
th.appendChild(h);
for (const sid of Object.keys(bySpec).sort()) {
const ms = bySpec[sid], p0 = ms[0].userData;
const r = document.createElement('div'); r.className = 'row';
r.innerHTML = '<input type="checkbox" checked><span class="chip" style="background:#' +
p0.color.toString(16).padStart(6,'0') + '"></span><label title="' + p0.note + '">' +
'<span class="ex">' + sid + ' ' + p0.cn.split(' · ')[0] + '</span></label>' +
'<span class="q">' + ms.length + ' 颗</span>';
const chk = r.querySelector('input');
specChk[sid] = chk;
chk.onchange = e => {
for (const m of ms) {
m.visible = e.target.checked;
if (!m.visible && m === sel) { sel.material.emissive.setHex(0); sel = null; }
}
requestRender();
};
r.querySelector('label').onclick = () => highlightSpec(sid);
for (const m of ms) m.chk = chk;
th.appendChild(r);
}
}
document.getElementById('allOn').onclick = () => {
meshes.forEach(m => {
m.visible = true;
if (m.chk) m.chk.checked = true;
});
bEnv.classList.toggle('on', meshes.some(m => m.userData.name.slice(0,4) === 'ENV_' && m.visible));
requestRender();
};
document.getElementById('allOff').onclick = () => {
meshes.forEach(m => {
m.visible = false;
if (m.chk) m.chk.checked = false;
if (sel) { sel.material.emissive.setHex(0); sel = null; }
});
bEnv.classList.remove('on');
bScr.classList.remove('on');
clearScrewHl();
requestRender();
};
document.getElementById('onlyPrint').onclick = () => {
meshes.forEach(m => {
m.visible = !!m.userData.printed;
if (m.chk) m.chk.checked = m.visible;
if (!m.visible && m === sel) { sel.material.emissive.setHex(0); sel = null; }
});
bEnv.classList.remove('on');
bScr.classList.remove('on');
clearScrewHl();
requestRender();
};
/* ---- BOM ---- */
const NMOT = DATA.parts.filter(p => p.name[0] === 'M').length; // count motors upfront
const tb = document.querySelector('#bom tbody');
let vTot = 0, gTot = 0;
for (const p of DATA.parts) {
if (!p.printed) continue;
vTot += p.vol; gTot += p.vol * 1.24e-3;
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + p.idx + '</td><td>' + p.cn + '</td><td>' + (p.vol/1000).toFixed(1) + '</td><td>' +
(p.vol*1.24e-3).toFixed(0) + '</td>';
tr.dataset.idx = p.idx;
tr.title = p.note + ' / ' + p.name;
tb.appendChild(tr);
}
document.getElementById('sum').innerHTML =
'<i>打印件数</i><span>' + DATA.parts.filter(p=>p.printed).length + ' 种</span>' +
'<i>总体积</i><span>' + (vTot/1000).toFixed(1) + ' cm3</span>' +
'<i>总质量(PLA)</i><span>' + gTot.toFixed(0) + ' g</span>' +
'<i>电机数量</i><span>' + NMOT + ' 台(同款 电机.STEP)</span>' +
'<i>工作空间</i><span>约 ' + Math.round(BB[1][2]) + ' x ' + Math.round(BB[1][0]) + ' mm</span>';
/* ---- edge BOM dock: fasteners + merged parts BOM ---- */
const SCREWS = /*__SCREWS__*/;
const skb = document.querySelector('#screws tbody');
const bySize = {};
let nTot = 0;
for (const s of SCREWS) {
nTot += s.数量;
bySize[s.螺纹] = (bySize[s.螺纹] || 0) + s.数量;
const tr = document.createElement('tr');
tr.innerHTML = '<td><b>' + s.id + '</b> ' + s.规格 + '<br><small>' + s.头型 + '</small></td>' +
'<td>' + s.长度mm + '</td><td>' + s.数量 + '</td>' +
'<td>' + s.位置 + '<br><small>' + s.备注 + '</small></td>';
tr.dataset.spec = s.id;
tr.title = '点击:在三维模型中高亮该规格全部 ' + s.数量 + ' 颗并定位(再次点击切换到下一处)';
tr.onclick = () => highlightSpec(s.id);
skb.appendChild(tr);
}
const tally = Object.keys(bySize).sort().map(k => k + 'x' + bySize[k]).join(' / ');
document.getElementById('scrSum').textContent =
'共 ' + SCREWS.length + ' 种 ' + nTot + ' 颗(' + tally + ');另需 M4 螺母 4、M5 螺母 4、M6 螺母+平垫圈各 6,见各行备注' +
' —— 点击任一行可在模型中高亮定位该行螺钉(再次点击切换下一处,Esc 清除)';
document.getElementById('bomStat').textContent =
'紧固件 ' + nTot + ' 颗 / 打印件 ' + DATA.parts.filter(p=>p.printed).length + ' 种';
/* ---- 紧固件高亮定位(BOM 螺丝行 / 零件树 / 三维点选 三处共用)----
螺钉多数埋在沉孔或螺丝刀通道里,因此高亮时同时关闭深度测试做"穿透"显示,
让位置直接透过零件亮出来;相机再对该孔组自动对焦。 */
let hlSpec = null, hlIdx = 0;
function specMeshes(sid) { return meshes.filter(m => m.userData.screw && m.userData.spec === sid); }
function clearScrewHl() {
if (!hlSpec) return;
for (const m of specMeshes(hlSpec)) {
const mt = m.material;
mt.emissive.setHex(0); mt.emissiveIntensity = 0;
mt.transparent = false; mt.opacity = 1;
mt.depthTest = true; mt.depthWrite = true;
mt.needsUpdate = true; m.renderOrder = 0;
}
hlSpec = null;
for (const tr of document.querySelectorAll('#screws tbody tr')) tr.classList.remove('scrHl');
const hud = document.getElementById('hud');
if (hud && hud.dataset.scrHl) { hud.textContent = ''; delete hud.dataset.scrHl; }
requestRender();
}
function highlightSpec(sid, step) {
if (hlSpec !== sid) { clearScrewHl(); hlSpec = sid; hlIdx = 0; }
else if (step !== false) hlIdx++; // 再次点击同一行 -> 切换到下一处
const ms = specMeshes(sid);
if (!ms.length) return;
const inf = SCREWS.find(s => s.id === sid) || {规格: sid, 头型: '', 数量: ms.length, 位置: '', 备注: ''};
for (const m of ms) {
const mt = m.material;
if (!m.visible) { m.visible = true; if (m.chk) m.chk.checked = true; }
mt.emissive.setHex(0xff8a1e); mt.emissiveIntensity = 1.0;
mt.transparent = true; mt.opacity = .96;
mt.depthTest = false; mt.depthWrite = false; mt.needsUpdate = true;
m.renderOrder = 999;
}
scene.updateMatrixWorld(true);
const pts = ms.map(m => new V3(...m.userData.hl).applyMatrix4(m.parent.matrixWorld));
hlIdx = ((hlIdx % pts.length) + pts.length) % pts.length;
const c0 = pts[hlIdx];
const cl = pts.filter(p => p.distanceTo(c0) < 40.0); // 同一孔组(如某关节 3 颗)一同入框
const lo = [1e9,1e9,1e9], hi = [-1e9,-1e9,-1e9];
for (const p of cl) for (let i = 0; i < 3; i++) {
lo[i] = Math.min(lo[i], p.getComponent(i)); hi[i] = Math.max(hi[i], p.getComponent(i));
}
tx = (lo[0]+hi[0])/2; ty = (lo[1]+hi[1])/2; tz = (lo[2]+hi[2])/2;
const sp = Math.max(hi[0]-lo[0], hi[1]-lo[1], hi[2]-lo[2]);
rd = Math.max(150, sp * 2.6 + 130);
for (const tr of document.querySelectorAll('#screws tbody tr'))
tr.classList.toggle('scrHl', tr.dataset.spec === sid);
const tr0 = document.querySelector('#screws tbody tr[data-spec="' + sid + '"]');
if (tr0) tr0.scrollIntoView({block: 'nearest'});
const hud = document.getElementById('hud');
hud.dataset.scrHl = '1';
hud.innerHTML = '<b>' + sid + ' ' + inf.规格 + ' ' + inf.头型 + '</b> · ' + inf.数量 +
' 颗 —— 第 ' + (hlIdx + 1) + '/' + pts.length + ' 处:' + ms[hlIdx].userData.cn.split(' · ')[1] +
'<span class="hudlink" id="scrClr">清除高亮</span><br><small>' + inf.位置 + ';' + inf.备注 + '</small>';
const scl = document.getElementById('scrClr');
if (scl) scl.onclick = () => clearScrewHl();
requestRender();
}
const dock = document.getElementById('bomDock'), bTab = document.getElementById('bomTab');
function dockSet(open) {
dock.classList.toggle('open', open);
bTab.style.display = open ? 'none' : ''; // 展开时拉手藏掉,否则压住抽屉右缘
bTab.innerHTML = open ? '收起 <b>清单</b>' : '物料清单 <b>BOM</b>';
}
bTab.onclick = () => dockSet(!dock.classList.contains('open'));
document.getElementById('bomHide').onclick = () => dockSet(false);
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !hlSpec) dockSet(false); });
/* ---- toolbar ---- */
const bAsm = document.getElementById('bAsm');
const bExp = document.getElementById('bExp');
const bWire = document.getElementById('bWire');
const bXRay = document.getElementById('bXRay');
const bScr = document.getElementById('bScr');
const bEnv = document.getElementById('bEnv');
const bL = document.getElementById('bL');
const sheet = document.getElementById('sheet');
const stage = document.getElementById('stage');
const on = (id, f) => document.getElementById(id).onclick = f;
on('bAsm', () => { setExpl(0); exp.value = 0; tog(true, false); });
on('bExp', () => { setExpl(+exp.value/100 || .6); exp.value = exp.value || 60; tog(false, true); });
function tog(a, b) { bAsm.classList.toggle('on', a); bExp.classList.toggle('on', b); }
const exp = document.getElementById('exp');
exp.oninput = () => setExpl(exp.value/100);
on('bWire', () => { bWire.classList.toggle('on'); const w = bWire.classList.contains('on');
meshes.forEach(m => { if (m.userData.name.slice(0,4) !== 'ENV_') m.material.wireframe = w; }); requestRender(); });
on('bXRay', () => { bXRay.classList.toggle('on'); const o = bXRay.classList.contains('on');
meshes.forEach(m => { if (!m.userData.printed && m.userData.name[0]==='M') { m.material.opacity = o ? .34 : 1; m.material.transparent = o; } }); requestRender(); });
on('bEnv', () => { bEnv.classList.toggle('on'); const o = bEnv.classList.contains('on');
meshes.forEach(m => { if (m.userData.name.slice(0,4) === 'ENV_') { m.visible = o; if (m.chk) m.chk.checked = o; } });
requestRender(); });
on('bScr', () => { bScr.classList.toggle('on'); const o = bScr.classList.contains('on');
meshes.forEach(m => { if (m.userData.screw) { m.visible = o; if (m.chk) m.chk.checked = o; } });
if (!o) clearScrewHl();
requestRender(); });
on('bFit', () => { fitTo(BB); az = -0.9; po = 1.15; camUp.copy(UP_Y); requestRender(); });
const app = document.getElementById('app');
on('bL', () => { app.classList.toggle('l'); bL.classList.toggle('on'); });
bL.classList.add('on');
document.getElementById('cam').onchange = e => {
const k = e.target.value;
camUp.copy(UP_Y); // upright roll for every view unless pinned below
if (k==='iso'){az=-0.9;po=1.15;} if(k==='front'){az=0;po=1.57;}
if(k==='side'){az=1.5708;po=1.57;}
// true plan view: pin the roll (near-vertical lookAt otherwise rolls the image) and
// frame by the plan extent - the height dimension is irrelevant looking down
if(k==='top'){az=-0.9;po=0.02;camUp.copy(UP_TOP);
rd=Math.max(BB[1][0]-BB[0][0],BB[1][2]-BB[0][2])*1.2+180;}
const hub = id => { const j = JO.find(q => q.id === id); return j ? VC(j.hub) : [tx, ty, tz]; };
if (k === 'wrist') { const a = hub('J5'), b = hub('J6'); // frame the whole hand,
tx = (a[0]+b[0])/2; ty = (a[1]+b[1])/2; tz = (a[2]+b[2])/2; rd = 340; az = -0.6; po = 1.25; }
// shoulder close-up has to hold BOTH the roll drum (J1) and the pitch fork (J2) - aiming at
// one hub alone filled the frame with a single Ø88 cylinder and showed nothing
if (k === 'joint') { const a = hub('J1'), h = hub('J2');
tx = (a[0]+h[0])/2; ty = (a[1]+h[1])/2; tz = (a[2]+h[2])/2; rd = 470; az = -0.6; po = 1.25; }
if (k === 'elbow') { const h = hub('J4');
tx = h[0]; ty = h[1] - 35; tz = h[2]; rd = 280; az = -0.6; po = 1.25; }
if (k === 'hand') { const h = hub('J6'); tx = h[0]; ty = h[1]; tz = h[2] - 45; rd = 215; az = -0.6; po = 1.25; }
requestRender();
};
document.querySelectorAll('.tab[data-view]').forEach(b => b.onclick = () => {
document.querySelectorAll('.tab[data-view]').forEach(x => x.classList.remove('on'));
b.classList.add('on');
const s = b.dataset.view === 'sheet';
sheet.style.display = s ? 'block' : 'none'; cv.style.display = s ? 'none' : 'block';
if (s && !sheet.dataset.done) { sheet.dataset.done = 1; sheet.innerHTML = SHEETS; }
if (!s) { resize(); requestRender(); }
});
/* ---- loop & on-demand rendering ---- */
let curW = 0, curH = 0;
function resize() {
if (cv.style.display === 'none') return;
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
if (w !== curW || h !== curH) {
curW = w; curH = h;
rend.setSize(w, h, false);
cam.aspect = w / h;
cam.updateProjectionMatrix();
requestRender();
}
}
if (window.ResizeObserver) {
new ResizeObserver(() => resize()).observe(stage);
} else {
window.addEventListener('resize', resize);
}
document.getElementById('ttl').textContent = NMOT + ' 电机人形机器人手臂';
document.getElementById('ver').textContent = DATA.layout.ver + ' · 迭代 ' + DATA.layout.it +
' · 上臂' + DATA.layout.L1 + ' 前臂' + DATA.layout.L2 + ' mm';
function updStat() {
const el = document.getElementById('stat');
const tri = (typeof meshes !== 'undefined' && meshes) ? meshes.reduce((a, m) => a + (m.userData.triCount || 0), 0) : 0;
const nScr = (typeof meshes !== 'undefined' && meshes) ? meshes.filter(m => m.userData.screw).length : 0;
const nTot = (typeof meshes !== 'undefined' && meshes) ? meshes.length : 0;
if (el) el.textContent = '零件 ' + (nTot - nScr) + (nScr ? ' + 螺钉 ' + nScr : '') +
' · 三角面 ' + (tri/1000).toFixed(0) + 'k · ' + '爆炸 ' + Math.round(expl*100) + '%';
}
updStat();
function loop() {
resize();
if (cv.style.display !== 'none') {
updCam();
rend.render(scene, cam);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
/* ---- url params so views can be scripted / shared: ?expl=60&j1=20&cam=iso&xray=0 ---- */
(function(){
const q = new URLSearchParams(location.search);
if (q.has('expl')) { exp.value = q.get('expl'); setExpl(+exp.value/100);
if (+exp.value) tog(false, true); }
for (const id of JNAMES) {
const k = id.toLowerCase();
if (q.has(k)) { const el = document.getElementById('s'+id);
el.value = q.get(k); el.dispatchEvent(new Event('input')); }
}
if (q.has('cam')) { const s = document.getElementById('cam'); s.value = q.get('cam'); s.dispatchEvent(new Event('change')); }
if (q.has('az')) az = +q.get('az');
if (q.has('po')) po = +q.get('po');
if (q.has('rd')) rd = +q.get('rd');
if (q.has('tx')) tx = +q.get('tx');
if (q.has('ty')) ty = +q.get('ty');
if (q.has('tz')) tz = +q.get('tz');
if (q.get('env') === '1') { const b = document.getElementById('bEnv');
if (b && !b.classList.contains('on')) b.onclick(); }
if (q.get('xray') === '0') { const b = document.getElementById('bXRay'); if (b.classList.contains('on')) b.onclick(); }
if (q.has('j6')) { const el = document.getElementById('sJ6');
if (el) { el.value = q.get('j6'); el.dispatchEvent(new Event('input')); } }
if (q.has('hide')) {
const h = q.get('hide').split(',');
meshes.forEach(m => {
const nm = (m.userData && m.userData.name) || '';
const cn = (m.userData && m.userData.cn) || '';
if (h.some(k => nm.includes(k) || cn.includes(k))) {
m.visible = false;
if (m.chk) m.chk.checked = false;
}
});
}
requestRender();
})();
</script>
<script>/*__IKSOLVER__*/</script>
<script>/*__IKGLUE__*/</script>
</body>
</html>