Files
liyang aed6e20a90 双臂操作沙盒(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
2026-09-01 17:16:41 +08:00

886 lines
40 KiB
HTML
Raw Permalink 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.
<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>