Files
cadSet/text-to-cad/models/robots/lyra/lyra_parts/chain.py
T
2026-07-22 19:38:36 +08:00

376 lines
12 KiB
Python

"""lyra kinematic chain spec shared by the CAD assembly and URDF/SRDF.
Stdlib-only (no build123d) so robot-description generators can import it
cheaply. `lyra.py` consumes the same offsets and CAD pose angles, keeping
the STEP assembly, the URDF tree, and the viewer animation sidecar in
lockstep.
Design ledger:
- Units: chain offsets are millimeters in each parent part's local frame;
URDF emission converts to meters/radians. RIGHT hand frame: origin at the
wrist-flange mount face center, +Z distal (fingers up), +Y palmar (the
palm faces +Y), +X radial (thumb side). The palm link is the root; every
child link frame sits at its joint center with axes parallel to the
parent at zero joint angle (joints are pure translations).
- Fingers (index/middle/ring/pinky): 3 flexion joints each (MCP, PIP, DIP)
about -X, so positive angles curl toward the palm (+Y). Phalanges extend
+Z local. MCP abduction/spread is intentionally omitted: the tendon
routing budget of the compact palm is spent on flexion; documented
design trade, not an oversight.
- Thumb: 4 joints. `thumb_cmc_yaw` about +Z swings the column from radial
(+X, the zero pose) across the palm front toward opposition;
`thumb_cmc_flex`, `thumb_mp`, `thumb_ip` about -Y curl the column toward
distal (+Z). Thumb segments extend +X local with the pad facing +Z, so a
yawed-and-curled thumb meets the curled fingertips pad-to-pad.
- Joint limits, efforts, and velocities are ASSUMED plausible values for a
tendon-driven research-hand concept (documented here, not measured); the
CAD does not model hard stops.
"""
from __future__ import annotations
import math
X_AXIS = (1.0, 0.0, 0.0)
Y_AXIS = (0.0, 1.0, 0.0)
Z_AXIS = (0.0, 0.0, 1.0)
NEG_X_AXIS = (-1.0, 0.0, 0.0)
NEG_Y_AXIS = (0.0, -1.0, 0.0)
ROBOT_NAME = "lyra"
FINGERS = ("index", "middle", "ring", "pinky")
# MCP joint centers in the palm frame (mm): a gentle knuckle arch, the
# middle finger proudest, the pinky lower and slightly palmar.
MCP_ORIGIN_MM = {
"index": (28.5, 0.0, 99.0),
"middle": (9.5, 0.0, 103.0),
"ring": (-9.5, 0.0, 99.0),
"pinky": (-28.5, 0.0, 90.0),
}
# Segment lengths (mm): proximal (MCP->PIP), middle (PIP->DIP), and the
# distal tip length (DIP->fingertip surface) used for pose design.
SEGMENT_MM = {
"index": (44.0, 26.0, 23.0),
"middle": (48.0, 29.0, 24.5),
"ring": (44.0, 27.0, 23.5),
"pinky": (35.0, 21.0, 20.5),
}
# Thumb column offsets (mm).
THUMB_CMC_ORIGIN_MM = (31.0, 4.0, 44.0) # turret center on the radial palm corner
THUMB_BASE_LEN_MM = 13.0 # cmc_yaw -> cmc_flex along the column
THUMB_METACARPAL_LEN_MM = 46.0 # cmc_flex -> MP
THUMB_PROXIMAL_LEN_MM = 30.0 # MP -> IP
THUMB_TIP_LEN_MM = 24.0 # IP -> thumb-pad surface (pose design)
# ---------------------------------------------------------------- limits
# ASSUMED tendon-driven actuator budget for a research-hand concept.
FINGER_MCP_EFFORT_NM = 3.0
FINGER_PIP_EFFORT_NM = 2.0
FINGER_DIP_EFFORT_NM = 1.5
THUMB_CMC_EFFORT_NM = 4.0
THUMB_MP_EFFORT_NM = 2.5
THUMB_IP_EFFORT_NM = 2.0
FINGER_VELOCITY_RAD_S = 8.0
THUMB_VELOCITY_RAD_S = 6.0
# ASSUMED joint travel (degrees); positive = flexion (curl toward palm).
MCP_RANGE_DEG = (-15.0, 95.0)
PIP_RANGE_DEG = (-5.0, 105.0)
DIP_RANGE_DEG = (-5.0, 95.0)
THUMB_CMC_YAW_RANGE_DEG = (-10.0, 100.0) # positive sweeps toward opposition
THUMB_CMC_FLEX_RANGE_DEG = (-10.0, 55.0) # positive lifts the column distal
THUMB_MP_RANGE_DEG = (-10.0, 75.0)
THUMB_IP_RANGE_DEG = (-15.0, 85.0)
def finger_joints(finger: str) -> list[dict]:
prox, mid, _tip = SEGMENT_MM[finger]
return [
{
"name": f"{finger}_mcp",
"parent": "palm",
"child": f"{finger}_proximal",
"origin_mm": MCP_ORIGIN_MM[finger],
"axis": NEG_X_AXIS,
"range_deg": MCP_RANGE_DEG,
"effort_nm": FINGER_MCP_EFFORT_NM,
"velocity_rad_s": FINGER_VELOCITY_RAD_S,
},
{
"name": f"{finger}_pip",
"parent": f"{finger}_proximal",
"child": f"{finger}_middle",
"origin_mm": (0.0, 0.0, prox),
"axis": NEG_X_AXIS,
"range_deg": PIP_RANGE_DEG,
"effort_nm": FINGER_PIP_EFFORT_NM,
"velocity_rad_s": FINGER_VELOCITY_RAD_S,
},
{
"name": f"{finger}_dip",
"parent": f"{finger}_middle",
"child": f"{finger}_distal",
"origin_mm": (0.0, 0.0, mid),
"axis": NEG_X_AXIS,
"range_deg": DIP_RANGE_DEG,
"effort_nm": FINGER_DIP_EFFORT_NM,
"velocity_rad_s": FINGER_VELOCITY_RAD_S,
},
]
def thumb_joints() -> list[dict]:
return [
{
"name": "thumb_cmc_yaw",
"parent": "palm",
"child": "thumb_base",
"origin_mm": THUMB_CMC_ORIGIN_MM,
"axis": Z_AXIS,
"range_deg": THUMB_CMC_YAW_RANGE_DEG,
"effort_nm": THUMB_CMC_EFFORT_NM,
"velocity_rad_s": THUMB_VELOCITY_RAD_S,
},
{
"name": "thumb_cmc_flex",
"parent": "thumb_base",
"child": "thumb_metacarpal",
"origin_mm": (THUMB_BASE_LEN_MM, 0.0, 0.0),
"axis": NEG_Y_AXIS,
"range_deg": THUMB_CMC_FLEX_RANGE_DEG,
"effort_nm": THUMB_CMC_EFFORT_NM,
"velocity_rad_s": THUMB_VELOCITY_RAD_S,
},
{
"name": "thumb_mp",
"parent": "thumb_metacarpal",
"child": "thumb_proximal",
"origin_mm": (THUMB_METACARPAL_LEN_MM, 0.0, 0.0),
"axis": NEG_Y_AXIS,
"range_deg": THUMB_MP_RANGE_DEG,
"effort_nm": THUMB_MP_EFFORT_NM,
"velocity_rad_s": THUMB_VELOCITY_RAD_S,
},
{
"name": "thumb_ip",
"parent": "thumb_proximal",
"child": "thumb_distal",
"origin_mm": (THUMB_PROXIMAL_LEN_MM, 0.0, 0.0),
"axis": NEG_Y_AXIS,
"range_deg": THUMB_IP_RANGE_DEG,
"effort_nm": THUMB_IP_EFFORT_NM,
"velocity_rad_s": THUMB_VELOCITY_RAD_S,
},
]
def all_joints() -> list[dict]:
"""All 16 movable joints in root-to-leaf order (fingers, then thumb)."""
joints: list[dict] = []
for finger in FINGERS:
joints.extend(finger_joints(finger))
joints.extend(thumb_joints())
return joints
def all_links() -> list[str]:
"""All 17 link names, root first, in joint emission order."""
links = ["palm"]
for joint in all_joints():
links.append(joint["child"])
return links
# -------------------------------------------------------------------- FK
def _rot_axis(axis, deg: float):
"""3x3 rotation about a principal-ish axis (unit vector), row-major."""
ax, ay, az = axis
rad = math.radians(deg)
c, s = math.cos(rad), math.sin(rad)
t = 1.0 - c
return [
[c + ax * ax * t, ax * ay * t - az * s, ax * az * t + ay * s],
[ay * ax * t + az * s, c + ay * ay * t, ay * az * t - ax * s],
[az * ax * t - ay * s, az * ay * t + ax * s, c + az * az * t],
]
def _mat_mul(a, b):
return [
[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)]
for i in range(3)
]
def _mat_vec(a, v):
return tuple(sum(a[i][k] * v[k] for k in range(3)) for i in range(3))
def fk_frames(pose_deg: dict[str, float]) -> dict[str, tuple]:
"""Link frames {link: (R 3x3, p mm)} in the palm frame for a pose."""
identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
frames = {"palm": (identity, (0.0, 0.0, 0.0))}
for joint in all_joints():
rot_parent, pos_parent = frames[joint["parent"]]
offset = _mat_vec(rot_parent, joint["origin_mm"])
pos = tuple(pos_parent[i] + offset[i] for i in range(3))
rot = _mat_mul(
rot_parent, _rot_axis(joint["axis"], pose_deg.get(joint["name"], 0.0))
)
frames[joint["child"]] = (rot, pos)
return frames
def tip_world_mm(pose_deg: dict[str, float], digit: str) -> tuple:
"""Pad-surface point of a digit tip in the palm frame (pose design aid)."""
frames = fk_frames(pose_deg)
if digit == "thumb":
rot, pos = frames["thumb_distal"]
local = (THUMB_TIP_LEN_MM, 0.0, 0.0)
else:
rot, pos = frames[f"{digit}_distal"]
local = (0.0, 0.0, SEGMENT_MM[digit][2])
offset = _mat_vec(rot, local)
return tuple(pos[i] + offset[i] for i in range(3))
# ----------------------------------------------------------------- poses
def _zero_deg() -> dict[str, float]:
return {joint["name"]: 0.0 for joint in all_joints()}
def _relaxed_deg() -> dict[str, float]:
"""Gentle natural curl; this is the pose baked into lyra.step."""
pose = _zero_deg()
curl = {
"index": (10.0, 14.0, 8.0),
"middle": (12.0, 16.0, 9.0),
"ring": (14.0, 18.0, 10.0),
"pinky": (16.0, 20.0, 12.0),
}
for finger, (mcp, pip_deg, dip) in curl.items():
pose[f"{finger}_mcp"] = mcp
pose[f"{finger}_pip"] = pip_deg
pose[f"{finger}_dip"] = dip
pose.update(
{
"thumb_cmc_yaw": 34.0,
"thumb_cmc_flex": 26.0,
"thumb_mp": 14.0,
"thumb_ip": 12.0,
}
)
return pose
def _fist_deg() -> dict[str, float]:
pose = _zero_deg()
for finger in FINGERS:
pose[f"{finger}_mcp"] = 78.0
pose[f"{finger}_pip"] = 100.0
pose[f"{finger}_dip"] = 60.0
# Capsule-tuned wrap: the thumb pad rests on the index middle phalanx
# with ~0.8 mm surface clearance (no interpenetration at any digit).
pose.update(
{
"thumb_cmc_yaw": 92.0,
"thumb_cmc_flex": 13.0,
"thumb_mp": 58.0,
"thumb_ip": 74.0,
}
)
return pose
def _precision_pinch_deg() -> dict[str, float]:
"""Thumb-index pad opposition, capsule-tuned so the tip pads kiss with
~0.8 mm surface clearance (no tip-sphere interpenetration)."""
pose = _zero_deg()
pose.update({"index_mcp": 40.0, "index_pip": 48.0, "index_dip": 30.0})
for finger in ("middle", "ring", "pinky"):
pose[f"{finger}_mcp"] = 66.0
pose[f"{finger}_pip"] = 92.0
pose[f"{finger}_dip"] = 55.0
pose.update(
{
"thumb_cmc_yaw": 91.0,
"thumb_cmc_flex": 34.0,
"thumb_mp": 29.0,
"thumb_ip": 8.0,
}
)
return pose
def _tripod_pinch_deg() -> dict[str, float]:
"""Thumb opposed between index and middle pads, chuck-gripping a
virtual object (capsule-tuned: ~12 mm pad-surface gap to both)."""
pose = _precision_pinch_deg()
pose.update({"index_mcp": 44.0, "index_pip": 52.0, "index_dip": 32.0})
pose.update({"middle_mcp": 42.0, "middle_pip": 50.0, "middle_dip": 30.0})
pose.update(
{
"thumb_cmc_yaw": 100.0,
"thumb_cmc_flex": 39.0,
"thumb_mp": 0.0,
"thumb_ip": 14.0,
}
)
return pose
def _point_deg() -> dict[str, float]:
pose = _zero_deg()
pose.update({"index_mcp": -6.0, "index_pip": 0.0, "index_dip": 0.0})
for finger in ("middle", "ring", "pinky"):
pose[f"{finger}_mcp"] = 80.0
pose[f"{finger}_pip"] = 102.0
pose[f"{finger}_dip"] = 62.0
pose.update(
{
"thumb_cmc_yaw": 62.0,
"thumb_cmc_flex": 30.0,
"thumb_mp": 42.0,
"thumb_ip": 45.0,
}
)
return pose
def _ok_sign_deg() -> dict[str, float]:
"""Index-thumb ring closed, remaining fingers extended with a stagger."""
pose = _zero_deg()
pose.update({"index_mcp": 42.0, "index_pip": 52.0, "index_dip": 32.0})
pose.update({"middle_mcp": 6.0, "middle_pip": 8.0, "middle_dip": 4.0})
pose.update({"ring_mcp": 10.0, "ring_pip": 12.0, "ring_dip": 6.0})
pose.update({"pinky_mcp": 14.0, "pinky_pip": 16.0, "pinky_dip": 8.0})
pose.update(
{
"thumb_cmc_yaw": 91.0,
"thumb_cmc_flex": 26.0,
"thumb_mp": 35.0,
"thumb_ip": 20.0,
}
)
return pose
BAKED_POSE_NAME = "relaxed"
def named_poses_deg() -> dict[str, dict[str, float]]:
"""SRDF group-state poses (degrees) for the full hand."""
return {
"zero": _zero_deg(),
"relaxed": _relaxed_deg(),
"fist": _fist_deg(),
"precision_pinch": _precision_pinch_deg(),
"tripod_pinch": _tripod_pinch_deg(),
"point": _point_deg(),
"ok_sign": _ok_sign_deg(),
}