#!/usr/bin/env bash
# MuJoCo O30i 右手单窗仿真(V2_0819)
# 默认使用 V10 纯 URDF:保留对指锚点,调整新零位,保持线性换算。
# O30_RIGHT_URDF 可覆盖;启动姿态对应第五张照片的零位数组。
# 订阅 /cb_right_hand_control_cmd(20 维 SDK 0~255,顺序见 linkerhand-o30-ros2 JOINT_MAP)
# 发布 /sim/mujoco/o30/right/joint_state
#
# 用法:
# ./hands/O30/scripts/run_o30_right_mujoco_plan.sh
# ./hands/O30/scripts/run_o30_right_mujoco_plan.sh --pd-drive
# O30_RIGHT_PREPARE_ONLY=1 ./hands/O30/scripts/run_o30_right_mujoco_plan.sh # 只生成/校验模型
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT"
PKG_SRC="$ROOT/src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
O30_DIR="$PKG_SRC/urdf/O30/linker_hand_o30_right"
URDF_SRC="${O30_RIGHT_URDF:-$O30_DIR/linkerhand_o30_right_zero_v10_calibrated.urdf}"
MESH_SRC="${O30_RIGHT_MESHDIR:-$O30_DIR/meshes}"
XML="$O30_DIR/linker_hand_o30_right.xml"
URDF_PKG="$O30_DIR/linkerhand_o30_right.urdf"
if [[ ! -f "$URDF_SRC" ]]; then
echo "[error] missing O30 right URDF: $URDF_SRC" >&2
exit 1
fi
if [[ ! -d "$MESH_SRC" ]]; then
echo "[error] missing O30 right meshes: $MESH_SRC" >&2
exit 1
fi
VENV_SITE="$ROOT/venv/lib/python3.12/site-packages"
if [[ -d "$VENV_SITE" ]]; then
export PYTHONPATH="$VENV_SITE${PYTHONPATH:+:$PYTHONPATH}"
fi
export PYTHONPATH="$ROOT/src/linkerhand-sim/linker_hand_mujoco_ros2${PYTHONPATH:+:$PYTHONPATH}"
echo "[o30-right] sync MJCF from $URDF_SRC"
python3 - "$URDF_SRC" "$MESH_SRC" "$O30_DIR" "$XML" "$URDF_PKG" <<'PY'
import re
import shutil
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
import mujoco
urdf_src = Path(sys.argv[1])
mesh_src = Path(sys.argv[2])
out_dir = Path(sys.argv[3])
xml_path = Path(sys.argv[4])
urdf_pkg = Path(sys.argv[5])
out_dir.mkdir(parents=True, exist_ok=True)
mesh_out = out_dir / "meshes"
mesh_out.mkdir(exist_ok=True)
for f in mesh_src.iterdir():
if f.is_file() and f.resolve() != (mesh_out / f.name).resolve():
shutil.copy2(f, mesh_out / f.name)
text = urdf_src.read_text()
text = re.sub(r"package://[^/]+/meshes/", "meshes/", text)
urdf_pkg.write_text(text)
def attrs_of(chunk: str) -> dict[str, str]:
return dict(re.findall(r'(\w+)="([^"]*)"', chunk))
def parse_links(urdf: str) -> dict[str, dict]:
links: dict[str, dict] = {}
for m in re.finditer(r']*>(.*?)', urdf, re.S):
name, body = m.group(1), m.group(2)
inertial = re.search(r"(.*?)", body, re.S)
pos = "0 0 0"
mass = "0.01"
fullinertia = "1e-6 1e-6 1e-6 0 0 0"
if inertial:
ib = inertial.group(1)
origin = re.search(r"", ib)
mass_m = re.search(r"", ib)
inertia = re.search(r"", ib)
if origin:
pos = attrs_of(origin.group(1)).get("xyz", pos)
if mass_m:
mass = attrs_of(mass_m.group(1)).get("value", mass)
if inertia:
ia = attrs_of(inertia.group(1))
fullinertia = " ".join(
ia.get(k, "0") for k in ("ixx", "iyy", "izz", "ixy", "ixz", "iyz")
)
color = "0.7 0.7 0.7 1"
vis = re.search(r"(.*?)", body, re.S)
if vis:
col = re.search(r'rgba="([^"]+)"', vis.group(1))
if col:
color = col.group(1)
mesh = None
mesh_scale = None
mesh_m = re.search(r"]*)/>", body)
if mesh_m:
ma = attrs_of(mesh_m.group(1))
fn = ma.get("filename", "")
mesh = Path(fn).name
mesh_scale = ma.get("scale")
links[name] = {
"pos": pos,
"mass": mass,
"fullinertia": fullinertia,
"color": color,
"mesh": mesh,
"mesh_scale": mesh_scale,
}
return links
def parse_joints(urdf: str) -> list[dict]:
joints = []
for m in re.finditer(r"]*)>(.*?)", urdf, re.S):
attrs, body = m.group(1), m.group(2)
name = re.search(r'name="([^"]+)"', attrs)
jtype = re.search(r'type="([^"]+)"', attrs)
if not name or not jtype or jtype.group(1) != "revolute":
continue
origin = re.search(r"", body)
axis = re.search(r"", body)
lim = re.search(r"", body)
parent = re.search(r']*link="([^"]+)"', body)
child = re.search(r']*link="([^"]+)"', body)
oa = attrs_of(origin.group(1)) if origin else {}
la = attrs_of(lim.group(1)) if lim else {}
joints.append(
{
"name": name.group(1),
"xyz": oa.get("xyz", "0 0 0"),
"rpy": oa.get("rpy", "0 0 0"),
"axis": attrs_of(axis.group(1)).get("xyz", "0 0 1") if axis else "0 0 1",
"lo": float(la.get("lower", "0")),
"hi": float(la.get("upper", "0")),
"parent": parent.group(1) if parent else "",
"child": child.group(1) if child else "",
}
)
return joints
def rpy_to_quat(rpy: str) -> str | None:
import math
r, p, y = [float(x) for x in rpy.split()]
if abs(r) + abs(p) + abs(y) < 1e-12:
return None
cr, sr = math.cos(r / 2), math.sin(r / 2)
cp, sp = math.cos(p / 2), math.sin(p / 2)
cy, sy = math.cos(y / 2), math.sin(y / 2)
w = cr * cp * cy + sr * sp * sy
x = sr * cp * cy - cr * sp * sy
yq = cr * sp * cy + sr * cp * sy
z = cr * cp * sy - sr * sp * cy
return f"{w:.12g} {x:.12g} {yq:.12g} {z:.12g}"
links = parse_links(text)
joints = parse_joints(text)
by_parent: dict[str, list[dict]] = {}
for j in joints:
by_parent.setdefault(j["parent"], []).append(j)
root = "hand_base_link"
# mesh name -> scale (from first link that uses it)
mesh_scale: dict[str, str | None] = {}
for info in links.values():
if info["mesh"] and info["mesh"] not in mesh_scale:
mesh_scale[info["mesh"]] = info["mesh_scale"]
meshes = sorted(mesh_scale.keys())
lines = [
'',
' ',
' ',
' ',
" ",
' ',
' ',
' ',
' ',
" ",
' ',
' ',
" ",
" ",
" ",
]
for mesh in meshes:
stem = Path(mesh).stem
sc = mesh_scale.get(mesh)
if sc:
lines.append(f' ')
else:
lines.append(f' ')
lines.append(" ")
lines.append(" ")
def emit_body(link: str, indent: int, joint: dict | None) -> None:
pad = " " * indent
info = links[link]
if joint is None:
lines.append(f'{pad}')
else:
quat = rpy_to_quat(joint["rpy"])
pose = f'pos="{joint["xyz"]}"'
if quat:
pose += f' quat="{quat}"'
lines.append(f'{pad}')
lines.append(
f'{pad} '
)
if joint is not None:
lines.append(
f'{pad} '
)
mesh = Path(info["mesh"]).stem if info["mesh"] else None
if mesh:
lines.append(
f'{pad} '
)
lines.append(f'{pad} ')
for child_j in by_parent.get(link, []):
emit_body(child_j["child"], indent + 1, child_j)
lines.append(f"{pad}")
emit_body(root, 2, None)
lines.append(" ")
lines.append(" ")
for j in joints:
lines.append(f' ')
for other in (
"thumb_metacarpals_base2",
"index_proximal",
"middle_proximal",
"ring_proximal",
"pinky_proximal",
):
lines.append(f' ')
lines.append(" ")
lines.append(" ")
for j in joints:
lines.append(
f' '
)
lines.append(" ")
sdk_mapping = ET.fromstring(text).find('o30_sdk_mapping')
if sdk_mapping is not None:
if sdk_mapping.get('version') != '1' or sdk_mapping.get('interpolation') != 'linear':
raise ValueError('Unsupported O30 SDK mapping format')
lines.append(' ')
for item in sdk_mapping.findall('map'):
name = item.get('joint', '')
if name not in {j['name'] for j in joints}:
raise ValueError(f'Unknown mapped joint: {name}')
sdk = item.get('sdk', '').split()
radians = item.get('radians', '').split()
if len(sdk) != len(radians):
raise ValueError(f'O30 mapping knot count mismatch: {name}')
data = ' '.join(format(float(v), '.16g') for pair in zip(sdk, radians) for v in pair)
lines.append(f' ')
lines.append(' ')
lines.append("")
xml_path.write_text("\n".join(lines) + "\n")
model = mujoco.MjModel.from_xml_path(str(xml_path))
from linker_hand_mujoco_ros2.utils.o30_right_joints import configure_o30_right_limits
configure_o30_right_limits(xml_path)
print(f"[o30-right] wrote {xml_path} njnt={model.njnt} nbody={model.nbody} ngeom={model.ngeom}")
PY
if [[ "${O30_RIGHT_PREPARE_ONLY:-0}" == "1" ]]; then
exit 0
fi
set +u
# shellcheck disable=SC1091
source /opt/ros/jazzy/setup.bash
if [[ -f "$ROOT/install/setup.bash" ]]; then
# shellcheck disable=SC1091
source "$ROOT/install/setup.bash"
fi
set -u
# Keep this checkout ahead of installed copies after sourcing ROS workspaces.
export PYTHONPATH="$ROOT/src/linkerhand-sim/linker_hand_mujoco_ros2${PYTHONPATH:+:$PYTHONPATH}"
echo "[o30-right] urdf=$URDF_PKG"
echo "[o30-right] xml=$XML"
echo "[o30-right] cmd-topic=/cb_right_hand_control_cmd (20ch SDK 0~255, JOINT_MAP 顺序)"
echo "[o30-right] state-topic=/sim/mujoco/o30/right/joint_state"
echo "[o30-right] order: thumb_roll,yaw, idx/mid/ring/little_yaw, thumb/idx/mid/ring/little_root1, idx/mid/ring/little_root2, thumb/idx/mid/ring/little_tip"
echo "[o30-right] default mode=kinematic; 加 --pd-drive 看接触"
exec python3 -m linker_hand_mujoco_ros2.o30_right_plan_bridge \
--xml "$XML" \
--node-name o30_mujoco_plan_bridge_right \
--window-title "O30i right V10 zero calibrated" \
--cmd-topic /cb_right_hand_control_cmd \
--sim-state-topic /sim/mujoco/o30/right/joint_state \
--mapping g20_urdf \
"$@"