Skip to content

feat: add PyRoKI differentiable IK backend - #484

Open
gonzalocasas wants to merge 2 commits into
docs/migrate-contributing-to-mkdocsfrom
feature/pyroki-backend
Open

feat: add PyRoKI differentiable IK backend#484
gonzalocasas wants to merge 2 commits into
docs/migrate-contributing-to-mkdocsfrom
feature/pyroki-backend

Conversation

@gonzalocasas

Copy link
Copy Markdown
Member

This is a bit experimental, but so far, seems to work very well.

Added a new backend for differentiable IK using PyRoki

The experimental part comes mostly from the fact that PyRoki hasn't published a proper pypi release yet (hopefully, we'll solve that soon)

What type of change is this?

  • Bug fix in a backwards-compatible manner.
  • New feature in a backwards-compatible manner.
  • Breaking change: bug fix or new feature that involve incompatible API changes.
  • Other (e.g. doc update, configuration, etc)

Checklist

Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.

  • I added a line to the CHANGELOG.md file in the Unreleased section under the most fitting heading (e.g. Added, Changed, Removed).
  • I ran all tests on my computer and it's all green (i.e. invoke test).
  • I ran lint on my computer and there are no errors (i.e. invoke lint).
  • I added new functions/classes and made them available on a second-level import, e.g. compas_fab.robots.CollisionMesh.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added necessary documentation (if appropriate)

Add collision-aware FrameTarget and PointAxisTarget IK with direct COMPAS model conversion, planning-group support, and warm problem caching. Package the pinned optional dependency, exercise it in Python 3.12 CI, add integration coverage and backend examples, and migrate the contributor guide into MkDocs.
@gonzalocasas

gonzalocasas commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Servoing a Panda robot model with the keyboard and streaming IK configs to Rhino/GH using compas_eve:

Screenshare.-.2026-08-27.3_24_05.PM.mp4

@gonzalocasas

Copy link
Copy Markdown
Member Author

A substantially more complex robot model:

diff-ik-chase-plane-rfl.mp4

@gonzalocasas
gonzalocasas requested a review from yijiangh August 28, 2026 06:43
@gonzalocasas

Copy link
Copy Markdown
Member Author

/cc @jf---

@yijiangh

Copy link
Copy Markdown
Contributor

A substantially more complex robot model:

diff-ik-chase-plane-rfl.mp4

Woah! Are you also streaming the gradient descent steps before converging, which explains the "motion" between the current conf and the target? Or it's just the IK is taking time to solve and lag behind?

Will review after I come back from ROBARCH!

@gonzalocasas

Copy link
Copy Markdown
Member Author

Woah! Are you also streaming the gradient descent steps before converging, which explains the "motion" between the current conf and the target? Or it's just the IK is taking time to solve and lag behind?

Will review after I come back from ROBARCH!

It's proper velocity chase, not IK lagging behind, IK for the full group (9DOF) is about 12ms on my machine, so a little background thread is constantly recomputing new solutions to approach/converge to the target. All in-process in Rhino 9 because PyRoki needs python >= 3.10

@jf---

jf--- commented Aug 28, 2026

Copy link
Copy Markdown

amazing how external axises and the manipulator work perfectly in tandem -- stellar, congrats @gonzalocasas

@yijiangh yijiangh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice work — I read through the whole backend and the collision compiler, and I'm in favour of merging this. The direct RobotModelpyroki.Robot bridge with no URDF round-trip is the part I like most, and keeping CFAB's disabled_collisions / touch_links / touch_bodies semantics intact means it drops into an existing cell without surprises.
I checked the tool and workpiece attachment chain in collision.py against pybullet_set_robot_cell_state.py and it matches.

For context on the comments below: I run a mobile dual-arm UR5e scaffolding-assembly pipeline on compas_fab (Rhino design front-end → offline TAMP → ROS 2 execution). A typical cell has ~110 rigid bodies, mostly 2 m bars at arbitrary orientations. I evaluated this backend as a possible replacement for our PyBullet one, so the notes come from that angle rather than from a fresh-install perspective.

Five inline comments below. Two broader questions that don't attach to a line:

1. An analogue for max_results?

iter_inverse_kinematics yields exactly once. Would a multi-solution option be welcome in this backend — say options={"max_results": K}, vmap'd over K perturbed seeds and yielded ranked by distance to the seed?

Related question: today the single solution is verified by _assert_collision_free before it is yielded. If several were returned, would each carry that same guarantee, or would callers be expected to filter them?

2. Halfspace / Heightmap world primitives

compile_collision_scene emits only Capsule and Box. pyroki also ships Halfspace and Heightmap (the latter with project_points, used in its humanoid retargeting example). Both map onto things FAB users currently model awkwardly: we represent our floor as a 40 × 40 m slab rigid body purely so it has volume for PyBullet, and a Halfspace would express that exactly and for nothing. A heightmap would matter for anyone doing mobile or legged work on uneven ground.

Is exposing those in scope here, or better as a follow-up once the pinned pyroki revision settles? Happy to open an issue instead if that's cleaner.

— Claude Opus 5, on behalf of Yijiang

for mesh in body_model.collision_meshes_in_meters:
destination.append(
_GeometryRecord(
geometry=(_capsule_from_mesh(mesh, transformation) if destination is robot_records else _box_from_mesh(mesh, transformation)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one thing that blocks the backend for our scenes, so it's worth spelling out.

_box_from_mesh (L103-112) builds an axis-aligned Box.from_extent with no rotation, in robot-base coordinates. For a rotated elongated mesh the inflation is severe: one of our 2 m bars at 45° in the base frame becomes roughly a 1.4 × 1.4 × 0.05 m box instead of a Ø0.05 m cylinder — about three orders of magnitude of volume. With ~110 such bodies in a partially built structure, essentially every IK query reports a collision, and there's no margin setting that recovers it.

The robot side already goes through _capsule_from_mesh, which fits those same bars almost exactly. Would using capsules for the world side too be reasonable? _stack_geometries (L115) needs a homogeneous type, so it would be capsules everywhere rather than a mix.

If a box is preferable for genuinely boxy static geometry, carrying the fitted rotation into Box.pose would already remove most of the error — though _box_distance (L278-283) assumes axis-alignment and would need to change with it.

— Claude Opus 5, on behalf of Yijiang

raise ValueError("RobotCellState.robot_configuration is required for collision checking.")

self.set_robot_cell_state(robot_cell_state)
scene = compile_collision_scene(robot_cell, robot_cell_state)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_collision calls compile_collision_scene() directly, while the IK path goes through PyRokiInverseKinematics._collision_scene() and the LRU in PyRokiProblemCache. So a standalone check_collision re-fits every capsule and box in the cell on each call, even when the scene key is unchanged and the compiled scene is already cached.

For us that's re-transforming and re-fitting ~110 meshes per call, and we call check_collision in a loop during branch selection.

Is that deliberate — avoiding a stale scene — or an oversight? If the latter, going through the same collision_scene_key lookup should be a couple of lines; it might be worth hoisting _collision_scene() off the IK mixin so both features share one path.

— Claude Opus 5, on behalf of Yijiang


def set_robot_cell_state(self, robot_cell_state, options=None):
"""Store the current FAB cell state by reference."""
self.client._robot_cell_state = robot_cell_state

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyBulletSetRobotCellState does robot_cell_state = robot_cell_state.copy() before storing; this one keeps the caller's object. Callers that mutate a state in place after handing it over will silently change the client's stored state — we do exactly that between two per-arm solves (state.robot_configuration = conf_left, then solve the other arm from it).

It also interacts with the cache: collision_scene_key() is derived from the state, so an in-place mutation after set_robot_cell_state can leave a cache entry describing geometry that no longer matches.

The docstring says "by reference" so I assume it's intentional — is the idea to let a servo loop avoid the copy? If so it may be worth calling out in the backend docs, since it differs from the PyBullet backend in a way that's invisible at the call site.

— Claude Opus 5, on behalf of Yijiang

Comment on lines +190 to +195
bounds_by_name = {}
velocity_by_name = {}
for name in joint_names:
joint = robot_model.get_joint_by_name(name)
bounds_by_name[name] = _joint_bounds(joint, bounds_by_name)
velocity_by_name[name] = _joint_velocity_limit(joint, velocity_by_name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: this pass looks fully overwritten by the topological loop just below (L200-203), which recomputes bounds_by_name and velocity_by_name for every joint, these included. The values actually read at L210-212 come from that second pass.

Unless it's seeding something for the mimic branch of _joint_bounds that I've missed, it looks removable.

Also _topological_joint_order(...) is computed here at L200 and again at L242 — could be hoisted into a local.

— Claude Opus 5, on behalf of Yijiang

if not options.get("full_report", False):
collision_pairs = collision_pairs[:1]
message = "PyRoKI capsule collision detected: {}".format(", ".join("'{}' with '{}'".format(name_a, name_b) for name_a, name_b in collision_pairs))
raise CollisionCheckError(message, collision_pairs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CollisionCheckError.collision_pairs carries (str, str) name tuples here. I cross-checked main (2.0.1) and the PyBullet backend still puts the compas model objects in that slot — robot_model.get_link_by_name(...), robot_cell.tool_models[...] and robot_cell.rigid_body_models[...] at pybullet_check_collision.py L140 / L172 / L203 — so the two backends now diverge on what that attribute contains.

CollisionCheckError's own docstring says only "List of pairs of objects that are in collision", so neither is strictly wrong. But code consuming collision_pairs across backends has to branch on the type.

For what it's worth I prefer the strings: we currently reverse-map the PyBullet objects back to cell keys by object identity, which names would let us delete outright.

Might be worth pinning down what the attribute promises, or at least noting the difference in the docs. Not blocking.

— Claude Opus 5, on behalf of Yijiang

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants