双臂操作沙盒(MuJoCo WASM 浏览器版)首次入库

从 ~/mujoco-web-spike 抽出 manip.html 的依赖闭包:
- manip.html + src/{manip,autograsp,scene-bridge,math}.js
- scenes/so101_pair(桌面并排两台 SO-101,默认)与 scenes/p7_bimanual(P7 双臂 + SO-101 夹爪)
  —— 自包含 MJCF,网格已内联
- serve.py(COOP/COEP + no-store 的静态服务器)、start.sh、README
- tools/export_manip_scene.py 场景生成脚本(仅供参考,跑页面不需要)

快照对应 2026-08-31 的最后一次改动(自动抓取规划 autograsp.js 落地版)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tgfYxR5mWNRKQ67PKga4T
This commit is contained in:
2026-09-01 17:16:41 +08:00
commit aed6e20a90
14 changed files with 4079 additions and 0 deletions
+885
View File
@@ -0,0 +1,885 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>双臂操作沙盒</title>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
<style>
/* ── Mission Control 令牌 ────────────────────────────────────────────
深空藏青底 + 琥珀遥测 + 等宽数字。数据色只表状态,不做装饰;
圆角一律 ≤4px。字体走系统栈而非 Google Fonts —— 这页要能离线跑。 */
:root {
--bg:#0B1120; --surface:#111827; --surface-2:#1A2535; --surface-on:#1E3A5F;
--line:#1E3A5F; --line-soft:#162035;
--data:#FFB800; --data-2:#00D4FF;
--good:#26DE81; --warn:#FF9F43; --err:#FF4757;
--fg:#E8F0FE; --fg-2:#8BA3C7; --fg-3:#4A6080;
--dim:#4A6080; /* pushLog 里内联 var(--dim) 画时间戳,别改名 */
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
--r:3px;
--t:100ms ease-in;
}
* { box-sizing:border-box; }
html,body { height:100%; margin:0; }
/* 三栏控制台:左轨 / 视口 / 右轨。轨道用固定列宽,面板 display:none 时
列依然存在,所以启动时画面不会跳。 */
body { background:var(--bg); color:var(--fg); overflow:hidden;
font:14px/1.5 var(--sans);
display:grid; grid-template-columns:292px 1fr 308px; }
#view { grid-column:2; width:100%; height:100%; min-width:0; min-height:0;
display:block; background:var(--bg); }
.panel { background:var(--surface); overflow-y:auto; overflow-x:hidden;
display:flex; flex-direction:column; padding:0 0 20px; min-height:0; }
#left { grid-column:1; border-right:1px solid var(--line); }
#right { grid-column:3; border-left:1px solid var(--line); }
/* 轨头:标题 + 状态徽章,吸顶 */
.rail-hd { position:sticky; top:0; z-index:2; background:var(--surface);
border-bottom:1px solid var(--line); padding:12px 14px 10px;
display:flex; align-items:center; justify-content:space-between; gap:8px; }
.rail-hd h3 { margin:0; font-size:13px; font-weight:600; letter-spacing:.1em;
text-transform:uppercase; color:var(--fg); }
.badge { font-size:10px; font-weight:600; letter-spacing:.05em; text-transform:uppercase;
padding:2px 8px; border-radius:2px; font-family:var(--mono);
background:rgba(0,212,255,.14); color:var(--data-2);
border:1px solid rgba(0,212,255,.3); }
.badge.right { background:rgba(255,184,0,.14); color:var(--data);
border-color:rgba(255,184,0,.3); }
section { padding:14px 14px 0; }
h4 { margin:0 0 8px; font-size:11px; font-weight:600; letter-spacing:.1em;
text-transform:uppercase; color:var(--fg-2);
display:flex; align-items:center; gap:7px; }
h4::before { content:""; width:2px; height:11px; background:var(--data); flex:none; }
h4 .unit { margin-left:auto; font-family:var(--mono); font-size:10px;
letter-spacing:0; text-transform:none; color:var(--fg-3); }
/* ── 控件 ── */
button { font:inherit; font-size:12px; color:var(--fg-2); background:var(--surface-2);
border:1px solid var(--line); border-radius:var(--r); padding:6px 9px;
cursor:pointer; transition:border-color var(--t), color var(--t),
background var(--t); }
button:hover { border-color:var(--data-2); color:var(--fg); }
button:active { background:var(--surface-on); }
button.on { background:var(--surface-on); color:var(--fg); font-weight:600;
border-color:var(--surface-on); }
button:focus-visible { outline:none; box-shadow:0 0 0 3px rgba(0,212,255,.28); }
/* 分段选择器:1px 缝隙拼成一条,像仪表面板上的连排按键 */
.seg { display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:1px;
background:var(--line); border:1px solid var(--line);
border-radius:var(--r); overflow:hidden; }
.seg button { border:0; border-radius:0; background:var(--surface-2); }
/* 左右臂各带自己的颜色,和场上那两个末端小球一一对应 */
#side-left.on { color:var(--data-2); box-shadow:inset 0 -2px 0 var(--data-2); }
#side-right.on { color:var(--data); box-shadow:inset 0 -2px 0 var(--data); }
input[type=number], input[type=text] {
font:inherit; font-family:var(--mono); font-size:12px; width:100%;
color:var(--fg); background:var(--bg); border:1px solid var(--line);
border-radius:var(--r); padding:5px 6px; }
input[type=number]:focus, input[type=text]:focus {
outline:none; border-color:var(--data-2); box-shadow:0 0 0 3px rgba(0,212,255,.22); }
input[type=range] { width:100%; accent-color:var(--data-2); margin:2px 0 0; }
.row { display:flex; gap:6px; align-items:center; }
.row > * { flex:1; }
.grid3 { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; }
/* ── 坐标读数(Coordinate Display)── */
.coords { border:1px solid var(--line); border-radius:var(--r);
background:var(--bg); overflow:hidden; }
.coord { display:flex; justify-content:space-between; align-items:baseline;
padding:5px 9px; border-bottom:1px solid var(--line-soft); }
.coord:last-child { border-bottom:0; }
.coord > span { font-size:10px; font-weight:600; letter-spacing:.08em;
text-transform:uppercase; color:var(--fg-3); }
.coord > b { font-family:var(--mono); font-size:13px; font-weight:500;
color:var(--data-2); font-variant-numeric:tabular-nums; }
/* ── 数据瓦片(Data Tile)—— IK 残差是这个界面最要紧的一个数 ── */
.tile { margin-top:7px; border:1px solid var(--line); border-radius:var(--r);
background:var(--bg); padding:8px 10px 9px;
display:flex; align-items:baseline; justify-content:space-between; }
.tile-l { font-size:10px; font-weight:600; letter-spacing:.08em;
text-transform:uppercase; color:var(--fg-2); }
/* 注意:refreshHud() 会整个替换 #t-err 的 className,所以字号字族只能挂 id,
.ok/.warn/.bad 只负责上色。而 id 选择器(1-0-0)会压过单类(0-1-0),
所以上色规则必须写成 #t-err.ok 这种同权形式,否则残差永远是默认色、不会报警。 */
#t-err { font-family:var(--mono); font-size:22px; font-weight:700; line-height:1;
color:var(--data); font-variant-numeric:tabular-nums; }
#t-err.ok { color:var(--good); } #t-err.warn { color:var(--warn); }
#t-err.bad { color:var(--err); }
.ok { color:var(--good); } .bad { color:var(--err); } .warn { color:var(--warn); }
/* ── 点动键盘 ── */
.grid3 button { font-family:var(--mono); font-size:12px; padding:8px 4px;
letter-spacing:.02em; }
.slider-hd { display:flex; justify-content:space-between; align-items:baseline;
font-size:10px; font-weight:600; letter-spacing:.08em;
text-transform:uppercase; color:var(--fg-2); margin:9px 0 1px; }
.slider-hd b { font-family:var(--mono); font-size:12px; font-weight:600;
color:var(--data); letter-spacing:0; }
/* ── 物体卡 ── */
.obj { border:1px solid var(--line); border-radius:var(--r);
background:var(--bg); padding:9px; margin-bottom:8px; }
.obj-hd { display:flex; align-items:center; gap:7px; margin-bottom:8px; }
.obj-name { flex:1; font-size:12.5px; font-weight:600; color:var(--fg);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.obj-hd button { flex:none; font-size:11px; padding:3px 8px;
background:transparent; color:var(--fg-3); }
.obj .fields { display:grid; grid-template-columns:1fr 1fr 1.25fr; gap:5px; }
.obj .fields label { display:block; }
.obj .fields label span { display:block; font-size:9.5px; font-weight:600;
letter-spacing:.07em; text-transform:uppercase;
color:var(--fg-3); margin-bottom:2px; }
.obj .apply { width:100%; margin-top:7px; font-size:11.5px; padding:5px; }
.obj-tel { font-family:var(--mono); font-size:10.5px; color:var(--fg-3);
margin-top:6px; font-variant-numeric:tabular-nums; }
/* 状态指示灯:方形而非圆点 —— 和 Status Badge 同一套语言。
className 会被 JS 整个替换成 dot / dot held,样式只能挂这两个类上。 */
.dot { width:7px; height:7px; border-radius:1px; flex:none;
background:var(--fg-3); display:inline-block;
transition:background var(--t), box-shadow var(--t); }
.dot.held { background:var(--good); box-shadow:0 0 7px rgba(38,222,129,.75); }
/* ── 自动抓取判定卡 ── */
.plan { border:1px solid var(--line); border-radius:var(--r); background:var(--bg);
padding:9px 10px; }
.plan-empty { font-size:11px; color:var(--fg-3); line-height:1.5; }
.plan-hd { display:flex; align-items:center; gap:7px; margin-bottom:7px; }
.plan-obj { font-size:12.5px; font-weight:600; color:var(--fg); }
.verdict { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600;
letter-spacing:.05em; text-transform:uppercase; padding:2px 7px;
border-radius:2px; white-space:nowrap; }
.verdict.go { background:rgba(38,222,129,.15); color:var(--good);
border:1px solid rgba(38,222,129,.3); }
.verdict.no { background:rgba(255,71,87,.15); color:var(--err);
border:1px solid rgba(255,71,87,.3); }
.verdict.busy { background:rgba(255,184,0,.15); color:var(--data);
border:1px solid rgba(255,184,0,.3); }
.plan-kv { display:flex; justify-content:space-between; gap:8px; padding:2.5px 0;
font-size:11px; border-bottom:1px solid var(--line-soft); }
.plan-kv:last-of-type { border-bottom:0; }
.plan-kv > span { color:var(--fg-3); }
.plan-kv > b { font-family:var(--mono); font-weight:500; color:var(--fg-2);
font-variant-numeric:tabular-nums; }
.plan-kv > b.hit { color:var(--data-2); }
.plan-why { font-size:11px; line-height:1.5; margin-top:7px;
padding-top:7px; border-top:1px solid var(--line-soft); }
/* 爪口方向小罗盘:一条线表示两爪会从哪个方向合上 */
.rose { width:44px; height:44px; flex:none; }
.rose .ring { fill:none; stroke:var(--line); stroke-width:1; }
.rose .obj { fill:rgba(96,165,250,.18); stroke:var(--fg-3); stroke-width:1; }
.rose .jaw { stroke:var(--data); stroke-width:2.5; stroke-linecap:round; }
button:disabled { opacity:.4; cursor:not-allowed; }
button:disabled:hover { border-color:var(--line); color:var(--fg-2); }
/* ── 计数条 ── */
.counters { display:grid; grid-template-columns:repeat(3,1fr); gap:1px;
background:var(--line); border:1px solid var(--line);
border-radius:var(--r); overflow:hidden; margin-bottom:8px; }
.cnt { background:var(--bg); padding:6px 8px 7px; }
.cnt span { display:block; font-size:9.5px; font-weight:600; letter-spacing:.07em;
text-transform:uppercase; color:var(--fg-3); }
.cnt b { font-family:var(--mono); font-size:17px; font-weight:700; line-height:1.15;
font-variant-numeric:tabular-nums; }
/* ── 事件日志 ── */
#log { max-height:210px; overflow:auto; font-size:11.5px;
font-family:var(--mono); font-variant-numeric:tabular-nums;
border:1px solid var(--line); border-radius:var(--r);
background:var(--bg); padding:2px 8px; }
#log div { padding:4px 0; border-bottom:1px solid var(--line-soft); }
#log div:last-child { border-bottom:0; }
.hint { font-size:11px; color:var(--fg-3); margin-top:9px; line-height:1.5; }
.hint b { color:var(--fg-2); font-weight:600; }
kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2);
background:var(--surface-2); border:1px solid var(--line);
border-radius:2px; padding:0 4px; }
/* ── 启动屏 ── */
#boot { position:fixed; inset:0; z-index:9; background:var(--bg);
display:flex; flex-direction:column; gap:10px;
align-items:center; justify-content:center; text-align:center; }
#boot b { font-size:13px; font-weight:600; letter-spacing:.22em;
text-transform:uppercase; color:var(--fg); }
#boot span { font-family:var(--mono); font-size:12px; color:var(--data); }
#boot i { display:block; width:132px; height:2px; background:var(--line);
position:relative; overflow:hidden; }
#boot i::after { content:""; position:absolute; inset:0; width:42px;
background:var(--data); animation:scan 1.15s ease-in-out infinite; }
@keyframes scan { 0%{transform:translateX(-42px)} 100%{transform:translateX(132px)} }
/* 细滚动条,别抢数据的注意力 */
.panel::-webkit-scrollbar, #log::-webkit-scrollbar { width:8px; }
.panel::-webkit-scrollbar-thumb, #log::-webkit-scrollbar-thumb {
background:var(--line); border-radius:4px; }
.panel::-webkit-scrollbar-track, #log::-webkit-scrollbar-track { background:transparent; }
@media (max-width:1180px) {
body { grid-template-columns:258px 1fr 272px; }
}
@media (prefers-reduced-motion:reduce) {
*, *::before, *::after { animation-duration:.01ms !important;
animation-iteration-count:1 !important; transition-duration:.01ms !important; }
}
</style>
<div class="panel" id="left" style="display:none">
<div class="rail-hd"><h3>机械臂控制台</h3><span class="badge" id="arm-badge">LEFT</span></div>
<section>
<h4>手臂</h4>
<div class="seg">
<button id="side-left" class="on">左臂</button>
<button id="side-right">右臂</button>
</div>
</section>
<section>
<h4>抓取点目标 <span class="unit">世界系 · m</span></h4>
<div class="coords">
<div class="coord"><span>X</span><b id="t-x"></b></div>
<div class="coord"><span>Y</span><b id="t-y"></b></div>
<div class="coord"><span>Z</span><b id="t-z"></b></div>
</div>
<div class="tile"><span class="tile-l">IK 残差</span><b id="t-err"></b></div>
</section>
<section>
<h4>点动</h4>
<div class="grid3">
<button data-jog="-1,0,0">X</button><button data-jog="0,0,1">Z+ 抬</button><button data-jog="1,0,0">X+</button>
<button data-jog="0,1,0">Y+ 左</button><button data-jog="0,0,-1">Z</button><button data-jog="0,-1,0">Y</button>
</div>
<div class="slider-hd"><span>步长</span><b><span id="stepv">2.0</span> cm</b></div>
<input type="range" id="step" min="0.5" max="5" step="0.5" value="2">
</section>
<section>
<h4>爪口朝向</h4>
<div class="seg">
<button class="ap on" data-ap="down">朝下</button>
<button class="ap" data-ap="front">朝前</button>
<button class="ap" data-ap="free">不约束</button>
</div>
</section>
<section>
<h4>夹爪</h4>
<div class="slider-hd"><span>开合</span><b id="gripv">0%</b></div>
<input type="range" id="grip" min="0" max="100" step="1" value="0">
<div class="row" style="margin-top:7px">
<button id="open">张开</button><button id="close">夹紧</button>
</div>
</section>
<section>
<h4>复位</h4>
<div class="row">
<button id="reset-arm">复位手臂</button><button id="reset-all">全部复位</button>
</div>
<div class="hint"><kbd>←→↑↓</kbd> XY · <kbd>PgUp</kbd><kbd>PgDn</kbd> Z ·
<kbd>空格</kbd> 夹紧/张开 · <kbd>Tab</kbd> 换手<br>
爪口朝下时可达高度约台面上方 10 cm 以内,抓取先悬停 6~8 cm 再下降。</div>
</section>
</div>
<canvas id="view"></canvas>
<div class="panel" id="right" style="display:none">
<div class="rail-hd"><h3>载荷与遥测</h3><span class="badge right" id="obj-badge"></span></div>
<section>
<h4>测试物体 <span class="unit">位置 · 质量</span></h4>
<div id="objs"></div>
<div class="hint">画面里可以直接<b>拖动物体</b>换位置;拖<b>末端小球</b>可直接带动机械臂
(按住 <kbd>Shift</kbd> 拖高度)。</div>
</section>
<section>
<h4>自动抓取 <span class="unit">拖动物体后自动判定</span></h4>
<div id="plan" class="plan">
<div class="plan-empty">把物体拖到一个位置,或点「应用」/「归位」,<br>这里会判断哪只手能抓、怎么下爪。</div>
</div>
<div class="row" style="margin-top:7px">
<button id="go-grasp" disabled>自动抓取</button>
<button id="stop-grasp" style="flex:0 0 68px" disabled>中止</button>
</div>
</section>
<section>
<h4>抓放记录</h4>
<div class="counters" id="score">
<div class="cnt"><span>抓取</span><b id="n-pick" class="ok">0</b></div>
<div class="cnt"><span>放置</span><b id="n-place" class="ok">0</b></div>
<div class="cnt"><span>掉落</span><b id="n-drop" class="bad">0</b></div>
</div>
<!-- 占位 div 必须和 #log 同行无空白:pushLog 用 box.firstChild.style 判断它在不在,
一换行 firstChild 就成了空白文本节点,占位符永远清不掉。 -->
<div id="log"><div style="color:var(--dim)">还没有记录</div></div>
<div class="hint">判定:物体离抓取点 &lt;9 cm 且高于原位 5 cm 并保持 0.5 秒 = 抓取成功;
松手后静止落在台面 = 放置成功;掉到台下 = 失败。</div>
</section>
</div>
<div id="boot"><b>双臂操作沙盒</b><i></i><span id="boot-msg">加载中…</span></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { buildGeomMeshes, applyPose } from './src/scene-bridge.js';
import { matToQuat } from './src/math.js';
// 本地模块带时间戳动态导入,强制绕开浏览器的模块缓存。
// ES 模块缓存得很死:改完 src/*.js 普通刷新照样跑旧代码,而且**不报错**,
// 只会在调用新方法时抛 "xxx is not a function" —— 极难往缓存上想。
const V = `?v=${Date.now()}`;
const [manipMod, agMod] = await Promise.all([
import(`./src/manip.js${V}`),
import(`./src/autograsp.js${V}`),
]);
const { ArmController, GraspMonitor, APPROACH } = manipMod;
const { GraspPlanner } = agMod;
const $ = (id) => document.getElementById(id);
const $$ = (s) => document.querySelectorAll(s);
const SCENE = new URLSearchParams(location.search).get('scene') || 'so101_pair';
$('boot-msg').textContent = '载入 MuJoCo WASM…';
const loadMujoco = (await import('https://cdn.jsdelivr.net/npm/@mujoco/mujoco/+esm')).default;
const mujoco = await loadMujoco();
$('boot-msg').textContent = '下载场景…';
const [meta, xml] = await Promise.all([
fetch(`./scenes/${SCENE}/meta.json`).then((r) => r.json()),
fetch(`./scenes/${SCENE}/model.xml`).then((r) => r.text()),
]);
$('boot-msg').textContent = `编译 MJCF(${meta.xmlMB} MB,网格已内联,会花几秒)…`;
await new Promise((r) => setTimeout(r, 30));
const model = mujoco.MjModel.from_xml_string(xml);
const data = new mujoco.MjData(model);
mujoco.mj_resetDataKeyframe(model, data, 0);
mujoco.mj_forward(model, data);
const arms = {
left: new ArmController(mujoco, model, data, meta, 'left'),
right: new ArmController(mujoco, model, data, meta, 'right'),
};
const monitor = new GraspMonitor(model, data, meta, arms);
const planner = new GraspPlanner(model, data, meta, arms, monitor);
let side = 'left';
let running = true;
let curPlan = null; // 最近一次判定结果
let busy = false; // 自动抓取执行中
// ─────────── 渲染 ───────────
function extractDesc(m) {
const meshes = [];
for (let i = 0; i < m.nmesh; i++) {
const va = m.mesh_vertadr[i], vn = m.mesh_vertnum[i];
const fa = m.mesh_faceadr[i], fn = m.mesh_facenum[i];
const vertices = new Float32Array(vn * 3);
for (let k = 0; k < vn * 3; k++) vertices[k] = m.mesh_vert[va * 3 + k];
const indices = new Uint32Array(fn * 3);
for (let k = 0; k < fn * 3; k++) indices[k] = m.mesh_face[fa * 3 + k];
meshes.push({ vertices, indices });
}
const geoms = [];
for (let g = 0; g < m.ngeom; g++) {
const matid = m.geom_matid ? m.geom_matid[g] : -1;
const rgba = matid >= 0 && m.mat_rgba
? [m.mat_rgba[matid * 4], m.mat_rgba[matid * 4 + 1],
m.mat_rgba[matid * 4 + 2], m.mat_rgba[matid * 4 + 3]]
: [m.geom_rgba[g * 4], m.geom_rgba[g * 4 + 1],
m.geom_rgba[g * 4 + 2], m.geom_rgba[g * 4 + 3]];
geoms.push({
type: m.geom_type[g],
size: [m.geom_size[g * 3], m.geom_size[g * 3 + 1], m.geom_size[g * 3 + 2]],
rgba, group: m.geom_group ? m.geom_group[g] : 0,
contype: m.geom_contype ? m.geom_contype[g] : 1,
conaffinity: m.geom_conaffinity ? m.geom_conaffinity[g] : 1,
body: m.geom_bodyid ? m.geom_bodyid[g] : 0,
meshId: m.geom_dataid ? m.geom_dataid[g] : -1,
});
}
return { geoms, meshes };
}
const BG = 0x0b1120; // 和面板底色同一个值,视口才不会像贴上去的
const canvas = $('view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setClearColor(BG);
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(BG, 6, 22);
const camera = new THREE.PerspectiveCamera(42, 1, 0.03, 60);
camera.up.set(0, 0, 1);
camera.position.set(1.55, -1.15, 1.45);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0.40, 0, 0.88);
controls.enableDamping = true; controls.dampingFactor = 0.08;
scene.add(new THREE.AmbientLight(0xffffff, 0.62));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(2.5, -2.5, 4); sun.castShadow = true;
scene.add(sun);
const rig = buildGeomMeshes(extractDesc(model), 0, { filter: 'visual' });
scene.add(rig.group);
const pose = new Float32Array(model.ngeom * 7);
// 目标点的小标记。颜色和左右臂按钮一致:左=青(data-2),右=琥珀(data)。
const MARK_COLOR = { left: 0x00d4ff, right: 0xffb800 };
const marks = {};
for (const s of ['left', 'right']) {
const mk = new THREE.Mesh(
new THREE.SphereGeometry(0.022, 20, 14),
new THREE.MeshBasicMaterial({ color: MARK_COLOR[s],
transparent: true, opacity: 0.6 }));
mk.userData.side = s;
scene.add(mk); marks[s] = mk;
}
// ─────────── 拖拽移动物体 ───────────
// 射线打到物体的视觉网格就开始拖,拖动时把射线和"台面高度"的水平面求交,
// 直接改自由关节的 qpos —— 比在面板里敲坐标直观得多。
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const hitPt = new THREE.Vector3();
const pickMap = new Map(); // THREE.Mesh -> 物体
for (const o of monitor.objs) {
const mesh = rig.meshes[o.geom_id];
if (mesh) { pickMap.set(mesh, o); mesh.userData.obj = o; }
}
const pickables = [...pickMap.keys()];
const handles = ['left', 'right'].map((s) => marks[s]); // 末端目标小球
let dragging = null; // 正在拖的物体
let dragArm = null; // 正在拖的末端小球所属手臂
let lastPtrY = 0; // 上一次指针的屏幕 y,Shift 拖高度用
const tmpV = new THREE.Vector3();
const plane = new THREE.Plane();
/** 屏幕 1 像素在目标处对应多少米(按相机距离换算,拖起来手感和缩放无关)。 */
function metersPerPixel(at) {
const dist = camera.position.distanceTo(tmpV.set(at[0], at[1], at[2]));
return (2 * Math.tan((camera.fov * Math.PI / 180) / 2) * dist) / canvas.clientHeight;
}
function setNdc(e) {
const r = canvas.getBoundingClientRect();
ndc.x = ((e.clientX - r.left) / r.width) * 2 - 1;
ndc.y = -((e.clientY - r.top) / r.height) * 2 + 1;
ray.setFromCamera(ndc, camera);
}
canvas.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
setNdc(e);
// 末端小球优先:它比物体小,挡在前面时应该先抓到它
const hh = ray.intersectObjects(handles, false)[0];
const ho = ray.intersectObjects(pickables, false)[0];
if (hh && (!ho || hh.distance <= ho.distance + 0.02)) {
dragArm = hh.object.userData.side;
setSide(dragArm);
} else if (ho) {
dragging = pickMap.get(ho.object);
} else return;
lastPtrY = e.clientY;
controls.enabled = false; // 拖拽时别转相机
try { canvas.setPointerCapture(e.pointerId); } catch { /* 合成事件没有真实指针 */ }
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging && !dragArm) {
setNdc(e);
const over = ray.intersectObjects(handles, false).length
|| ray.intersectObjects(pickables, false).length;
canvas.style.cursor = over ? 'grab' : '';
return;
}
setNdc(e);
// 拖末端小球 = 直接拖 IK 目标。平着拖改 XY,按住 Shift 改高度。
if (dragArm) {
const t = arms[dragArm].target;
if (e.shiftKey) {
// 竖直方向不用平面求交(相机接近俯视时那个竖直平面几乎和视线平行,
// 交点会飞很远甚至求不出来),直接把屏幕上移多少像素换算成抬高多少米。
arms[dragArm].jog(0, 0, (lastPtrY - e.clientY) * metersPerPixel(t));
} else {
plane.setFromNormalAndCoplanarPoint(new THREE.Vector3(0, 0, 1),
new THREE.Vector3(t[0], t[1], t[2]));
if (ray.ray.intersectPlane(plane, hitPt)) {
arms[dragArm].jog(hitPt.x - t[0], hitPt.y - t[1], 0);
}
}
lastPtrY = e.clientY;
refreshHud();
return;
}
// 注意复用上面的 plane —— 这里再 const 一个同名的会遮蔽它,
// 让前面 dragArm 分支里的引用落进暂时性死区直接抛异常(拖小球没反应就是这个)。
const z = meta.scene.table.top + dragging.half_h + 0.002;
plane.setFromNormalAndCoplanarPoint(new THREE.Vector3(0, 0, 1),
new THREE.Vector3(0, 0, z));
if (!ray.ray.intersectPlane(plane, hitPt)) return;
const T = meta.scene.table, m = 0.04; // 别拖到台面外
const x = Math.min(Math.max(hitPt.x, T.x - T.half[0] + m), T.x + T.half[0] - m);
const y = Math.min(Math.max(hitPt.y, -T.half[1] + m), T.half[1] - m);
placeObj(dragging.name, x, y);
});
for (const ev of ['pointerup', 'pointercancel']) {
canvas.addEventListener(ev, (e) => {
if (!dragging && !dragArm) return;
// 松手立刻出完整判定;再排一次落稳后的复判(物体可能翻倒,姿态会变)。
if (dragging) replan(dragging, { fast: false, settle: 900 });
dragging = null;
dragArm = null;
controls.enabled = true;
try { canvas.releasePointerCapture?.(e.pointerId); } catch { /* 同上 */ }
canvas.style.cursor = '';
});
}
function packPose() {
const xpos = data.geom_xpos, xmat = data.geom_xmat;
for (let g = 0; g < model.ngeom; g++) {
const o = g * 7;
pose[o] = xpos[g * 3]; pose[o + 1] = xpos[g * 3 + 1]; pose[o + 2] = xpos[g * 3 + 2];
matToQuat(xmat, g * 9, pose, o + 3);
}
}
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
const pr = Math.min(devicePixelRatio || 1, 2);
if (canvas.width === Math.floor(w * pr) && canvas.height === Math.floor(h * pr)) return;
renderer.setPixelRatio(pr); renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
}
// ─────────── 主循环 ───────────
const DT = meta.sim.timestep;
const SUB = meta.sim.substeps; // 每个控制步的物理子步
let last = performance.now();
function tick() {
requestAnimationFrame(tick);
const now = performance.now();
const el = Math.min((now - last) / 1000, 0.2); last = now;
if (running) {
const ctrlSteps = Math.max(1, Math.round(el / (DT * SUB)));
for (let c = 0; c < ctrlSteps; c++) {
arms.left.step();
arms.right.step();
for (let i = 0; i < SUB; i++) mujoco.mj_step(model, data);
const ev = monitor.step();
if (ev) pushLog(ev);
}
refreshHud();
}
packPose(); applyPose(rig.meshes, pose);
for (const s of ['left', 'right']) marks[s].position.set(...arms[s].target);
resize(); controls.update();
renderer.render(scene, camera);
}
// ─────────── 界面 ───────────
function refreshHud() {
const a = arms[side];
$('t-x').textContent = a.target[0].toFixed(3);
$('t-y').textContent = a.target[1].toFixed(3);
$('t-z').textContent = a.target[2].toFixed(3);
$('t-err').textContent = `${(a.err * 1000).toFixed(0)} mm`;
$('t-err').className = a.err < 0.01 ? 'ok' : a.err < 0.04 ? 'warn' : 'bad';
let nHeld = 0;
for (const o of monitor.objs) {
const el = document.getElementById(`st-${o.name}`);
if (el) {
el.className = `dot${o.state === 'held' ? ' held' : ''}`;
el.title = o.state === 'held' ? '已抓起' : '在台面';
}
if (o.state === 'held') nHeld++;
const dv = document.getElementById(`d-${o.name}`);
if (dv) dv.textContent = `离手 ${(o.dist ?? 0).toFixed(2)}m 抬升 ${((o.lift ?? 0) * 100).toFixed(1)}cm`;
}
$('obj-badge').textContent = nHeld ? `持有 ${nHeld}` : `${monitor.objs.length} 件在台`;
}
// ─────────── 自动抓取:判定 + 执行 ───────────
const R2D = 180 / Math.PI;
/** 爪口方向小罗盘:外圈是物体的水平轮廓,琥珀线是两爪合上的方向。 */
function rose(pr, theta) {
const S = 22, r = 17;
const pts = pr.widths.map((w) => {
// 半径按该方向的宽度缩放,画出来就是物体的粗细分布
const a = w.deg / R2D, k = (w.w / pr.maxW) * r;
return [S + Math.cos(a) * k, S - Math.sin(a) * k];
});
const poly = [...pts, ...pts.map(([x, y]) => [2 * S - x, 2 * S - y])]
.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
const jx = Math.cos(theta) * r, jy = Math.sin(theta) * r;
return `<svg class="rose" viewBox="0 0 44 44">
<circle class="ring" cx="22" cy="22" r="20"/>
<polygon class="obj" points="${poly}"/>
<line class="jaw" x1="${(S - jx).toFixed(1)}" y1="${(S + jy).toFixed(1)}"
x2="${(S + jx).toFixed(1)}" y2="${(S - jy).toFixed(1)}"/>
</svg>`;
}
function renderPlan(p, phase) {
const box = $('plan');
if (!p) {
box.innerHTML = '<div class="plan-empty">把物体拖到一个位置,或点「应用」/「归位」,'
+ '<br>这里会判断哪只手能抓、怎么下爪。</div>';
$('go-grasp').disabled = true;
return;
}
const nm = (s) => (s === 'left' ? '左臂' : '右臂');
const vc = phase ? 'busy' : p.ok ? 'go' : 'no';
const vt = phase ? phase : p.ok ? nm(p.side) : '不可抓';
const armRows = (p.arms || []).map((c) => {
const hit = p.ok && c.side === p.side;
const bad = !c.fits && c.reach < 0.012;
return `<div class="plan-kv"><span>${nm(c.side)}${hit ? ' ←' : ''}</span>`
+ `<b class="${hit ? 'hit' : ''}">${(c.reach * 1000).toFixed(0)} mm`
+ ` · 夹 <span class="${bad ? 'bad' : ''}">${(c.gripW * 100).toFixed(1)}</span> cm`
+ `${c.rolled ? ' · 转腕' : ''}</b></div>`;
}).join('');
// 罗盘画**实际**下爪方向:可抓时用选中臂真正解出来的爪口角,
// 不可抓时退回物体的最窄方向(至少告诉你"理想是往哪夹")
const shown = p.ok ? (p.arms.find((c) => c.side === p.side)?.yaw ?? p.theta) : p.theta;
box.innerHTML = `
<div class="plan-hd">
${rose(p.pr, shown)}
<div>
<div class="plan-obj">${p.obj.label || p.obj.name}</div>
<div style="font-size:10.5px;color:var(--fg-3)">${p.post.label}
· 各向异性 ${p.aniso.toFixed(2)}</div>
</div>
<span class="verdict ${vc}">${vt}</span>
</div>
<div class="plan-kv"><span>下爪方向</span><b>${
(((shown * R2D) % 180 + 180) % 180).toFixed(0)}°${
p.ok && p.rolled ? ' (转腕)' : p.ok ? ' (IK 自选)' : ''}</b></div>
<div class="plan-kv"><span>该方向宽度</span><b>${p.ok
? `${(p.gripW * 100).toFixed(1)} cm` : '—'}</b></div>
<div class="plan-kv"><span>最窄 / 爪口</span><b>${(p.width * 100).toFixed(1)}
/ ${(p.span * 100).toFixed(1)} cm</b></div>
${armRows}
<div class="plan-why" style="color:var(--${p.ok ? 'good' : 'err'})">${p.reason}</div>`;
$('go-grasp').disabled = !p.ok || busy;
}
/**
* 重新判定。分两档:
* · 拖动中走 fast(少起点、跳过悬停点,约 8 ms)—— 边拖边出结论;
* · 松手/点按钮后跑完整版(约 40 ms),并在物体落稳后再补一次
* —— 刚放下还在弹,这时量到的是半空中的姿态,立着/倒了会判反。
*/
let planTimer = null, settleTimer = null;
function doPlan(obj, fast) {
if (busy) return;
try {
curPlan = planner.plan(obj, { fast });
} catch (e) {
curPlan = { ok: false, obj, reason: `判定出错:${e.message}`,
pr: { widths: [], maxW: 1, minDeg: 0 }, post: { label: '—' },
aniso: 0, width: 0, span: 0, theta: 0, arms: [] };
}
renderPlan(curPlan);
}
function replan(obj, { fast = true, settle = 0 } = {}) {
clearTimeout(planTimer);
// 30 ms 只为把一帧里的多次调用合并掉,不是"等一等" —— 拖动时每帧都会进来。
planTimer = setTimeout(() => doPlan(obj, fast), fast ? 30 : 0);
clearTimeout(settleTimer);
if (settle) settleTimer = setTimeout(() => doPlan(obj, false), settle);
}
async function runGrasp() {
if (!curPlan || !curPlan.ok || busy) return;
busy = true;
$('go-grasp').disabled = true;
$('stop-grasp').disabled = false;
setSide(curPlan.side); // 让面板跟着切到执行的那只手
const r = await planner.run(curPlan, {
phase: (ph) => renderPlan(curPlan, ph),
});
busy = false;
$('stop-grasp').disabled = true;
curPlan = { ...curPlan, ok: r.ok, reason: r.reason };
renderPlan(curPlan);
$('go-grasp').disabled = false;
}
$('go-grasp').onclick = runGrasp;
$('stop-grasp').onclick = () => planner.cancel();
function buildObjPanel() {
$('objs').innerHTML = monitor.objs.map((o) => `
<div class="obj">
<div class="obj-hd">
<span class="dot" id="st-${o.name}"></span>
<span class="obj-name">${o.label || o.name}</span>
<button data-home="${o.name}">归位</button>
</div>
<div class="fields">
<label><span>X m</span><input type="number" step="0.01" id="x-${o.name}" value="${o.home[0].toFixed(2)}"></label>
<label><span>Y m</span><input type="number" step="0.01" id="y-${o.name}" value="${o.home[1].toFixed(2)}"></label>
<label><span>质量 kg</span><input type="number" step="0.05" min="0.01" id="m-${o.name}" value="${o.mass}"></label>
</div>
<button class="apply" data-apply="${o.name}">应用</button>
<div class="obj-tel" id="d-${o.name}"></div>
</div>`).join('');
$$('[data-apply]').forEach((b) => { b.onclick = () => applyObj(b.dataset.apply); });
$$('[data-home]').forEach((b) => { b.onclick = () => placeObj(b.dataset.home); });
}
/** 改物体的位置和质量。质量要连惯量一起按比例缩,否则转起来不对。 */
function applyObj(name) {
const o = monitor.objs.find((x) => x.name === name);
const x = +$(`x-${name}`).value, y = +$(`y-${name}`).value;
const mNew = Math.max(0.01, +$(`m-${name}`).value);
const b = o.body_id;
const scale = mNew / model.body_mass[b];
model.body_mass[b] = mNew;
for (let k = 0; k < 3; k++) model.body_inertia[3 * b + k] *= scale;
o.mass = mNew;
placeObj(name, x, y);
}
function placeObj(name, x, y) {
const o = monitor.objs.find((n) => n.name === name);
const px = x ?? o.home[0], py = y ?? o.home[1];
const q = data.qpos, i = o.qadr;
q[i] = px; q[i + 1] = py; q[i + 2] = meta.scene.table.top + o.half_h + 0.002;
q[i + 3] = 1; q[i + 4] = 0; q[i + 5] = 0; q[i + 6] = 0;
const dof = model.jnt_dofadr[model.body_jntadr[o.body_id]];
for (let k = 0; k < 6; k++) data.qvel[dof + k] = 0;
o.home = [px, py, q[i + 2]];
o.state = 'idle'; o.hold = 0; o.rest = 0; o.peak = 0;
mujoco.mj_forward(model, data);
$(`x-${name}`).value = px.toFixed(2);
$(`y-${name}`).value = py.toFixed(2);
// 位置一变就重新判定。拖动时这函数每帧都会被调,所以走快速档。
replan(o, { fast: true });
}
let nPick = 0, nPlace = 0, nDrop = 0;
function pushLog(ev) {
const t = (ev.t * DT * SUB).toFixed(1);
const obj = (monitor.objs.find((o) => o.name === ev.obj) || {}).label || ev.obj;
const s = ev.side === 'left' ? '左手' : '右手';
let html;
if (ev.kind === 'pick') {
nPick++;
html = `<span class="ok">✅ 抓取成功</span> ${s} · ${obj} · 抬升 ${(ev.lift * 100).toFixed(1)} cm`;
} else if (ev.kind === 'place') {
nPlace++;
html = `<span class="ok">📦 放置成功</span> ${s} · ${obj} · 落点 (${ev.pos[0].toFixed(2)}, ${ev.pos[1].toFixed(2)})`;
} else {
nDrop++;
html = `<span class="bad">❌ 掉落</span> ${s} · ${obj}`;
}
const box = $('log');
if (box.firstChild && box.firstChild.style) box.innerHTML = '';
const div = document.createElement('div');
div.innerHTML = `<span style="color:var(--dim)">${t}s</span> ${html}`;
box.prepend(div);
$('n-pick').textContent = nPick;
$('n-place').textContent = nPlace;
$('n-drop').textContent = nDrop;
}
function setSide(s) {
side = s;
$('side-left').classList.toggle('on', s === 'left');
$('side-right').classList.toggle('on', s === 'right');
$('arm-badge').textContent = s === 'left' ? 'LEFT' : 'RIGHT';
$('arm-badge').classList.toggle('right', s === 'right');
$('grip').value = Math.round(arms[s].grip * 100);
$('gripv').textContent = `${Math.round(arms[s].grip * 100)}%`;
$$('.ap').forEach((b) => b.classList.toggle('on', b.dataset.ap === arms[s].approach));
refreshHud();
}
$('side-left').onclick = () => setSide('left');
$('side-right').onclick = () => setSide('right');
$('step').oninput = (e) => { $('stepv').textContent = (+e.target.value).toFixed(1); };
$$('[data-jog]').forEach((b) => {
b.onclick = () => {
const s = +$('step').value / 100;
const [dx, dy, dz] = b.dataset.jog.split(',').map(Number);
arms[side].jog(dx * s, dy * s, dz * s);
refreshHud();
};
});
$$('.ap').forEach((b) => {
b.onclick = () => {
arms[side].approach = b.dataset.ap;
$$('.ap').forEach((x) => x.classList.toggle('on', x === b));
};
});
$('grip').oninput = (e) => {
arms[side].grip = +e.target.value / 100;
$('gripv').textContent = `${e.target.value}%`;
};
$('open').onclick = () => { $('grip').value = 0; $('grip').oninput({ target: $('grip') }); };
$('close').onclick = () => { $('grip').value = 100; $('grip').oninput({ target: $('grip') }); };
$('reset-arm').onclick = () => { arms[side].reset(); setSide(side); };
$('reset-all').onclick = () => {
mujoco.mj_resetDataKeyframe(model, data, 0);
mujoco.mj_forward(model, data);
arms.left.reset(); arms.right.reset();
for (const o of monitor.objs) placeObj(o.name);
setSide(side);
};
addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
const s = +$('step').value / 100;
const map = { ArrowUp: [s, 0, 0], ArrowDown: [-s, 0, 0], ArrowLeft: [0, s, 0],
ArrowRight: [0, -s, 0], PageUp: [0, 0, s], PageDown: [0, 0, -s] };
if (map[e.key]) { e.preventDefault(); arms[side].jog(...map[e.key]); refreshHud(); }
else if (e.key === ' ') {
e.preventDefault();
const g = arms[side].grip > 0.5 ? 0 : 100;
$('grip').value = g; $('grip').oninput({ target: $('grip') });
} else if (e.key === 'Tab') { e.preventDefault(); setSide(side === 'left' ? 'right' : 'left'); }
});
buildObjPanel();
setSide('left');
$('boot').style.display = 'none';
$('left').style.display = '';
$('right').style.display = '';
window.__dbg = { mujoco, model, data, meta, arms, monitor, camera, canvas, THREE,
marks, handles, pickables, scene, planner,
plan: () => curPlan, replan, runGrasp, placeObj };
tick();
</script>