Skip to content

COMPAS FAB 2.0 release#458

Open
gonzalocasas wants to merge 427 commits into
mainfrom
prep-release
Open

COMPAS FAB 2.0 release#458
gonzalocasas wants to merge 427 commits into
mainfrom
prep-release

Conversation

@gonzalocasas
Copy link
Copy Markdown
Member

@gonzalocasas gonzalocasas commented May 19, 2026

image image

This PR superceedes #456 and contains the entire Project Theseus + ROS 2 / MoveIt 2 support.

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)

gonzalocasas and others added 24 commits May 18, 2026 01:31
ROS 2 reshapes a handful of message fields that the existing MoveIt 1
backend was hard-coded to. The biggest one is std_msgs/Header: ROS 2
strips the `seq` field, and Time switches from `secs`/`nsecs` to
`sec`/`nanosec`. Because every CollisionObject, JointTrajectory,
JointState, MotionPlanRequest, etc. carries a Header, that one
difference cascades through almost every service call.

Approach: each message that owns a Header (or owns a message that
owns one) implements `filter_fields_for_distro(ros_distro)` which
rewrites the header into the right shape for the active distro.
`ServiceDescription.call` invokes that hook on the request before
sending it. The header rewrite goes through roslibpy's own
`Header`/`ros2.Header` so we stay in sync with how roslibpy
serializes the wire format. Stamps coming back as either layout are
normalised in `Time.from_msg`.

Also extends `MoveItErrorCodes` with the `message` and `source`
fields that MoveIt 2 added.

The pre-existing per-distro `pose` removal for KINETIC/MELODIC is
preserved; `filter_fields_for_distro` now also handles the case
where nested objects came back from `from_msg` as raw dicts
(notably `AttachedCollisionObject.object`, which the reset-scene
path already treats as a dict).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`RosClient(Ros, ClientInterface)` only calls `Ros.__init__` via
super, and `Ros` does not chain to `super().__init__()`, so
`ClientInterface.__init__` was never running. As a result
`_robot_cell` / `_robot_cell_state` were not set, and
`client.robot_cell` raised `AttributeError` before `load_robot_cell`
was called. Initialise the ClientInterface bookkeeping explicitly
in `RosClient.__init__`.

Also brings in `HttpFileServerLoader` so `_get_robot_cell_loader`
(added in the prior commit) can dispatch ROS 2 loads to the HTTP
file server instead of `RosFileServerLoader`'s rosparam path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The compose stack's ur-sim and ur-driver were already configured
for UR5 (ROBOT_MODEL=UR5 / ur_type:=ur5), but moveit-demo was
launching ur_moveit.launch.py with ur_type:=ur5e. The integration
tests target UR5; aligning moveit-demo to ur_type:=ur5 removes the
URDF/SRDF inconsistency between the driver and the planning side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an opt-in integration suite (gated on
COMPAS_FAB_RUN_ROS_INTEGRATION_TESTS=1) that runs the docs examples
end-to-end against a live ROS 2 Jazzy MoveIt 2 stack on rosbridge.
Covers connection, robot-cell loading, FK, IK (single + iter +
full-config + collision-allow + unreachable), plan_motion (frame /
configuration / tolerance / with-obstacle), plan_cartesian_motion
(single + step-size + partial), and rosbridge pub/sub.

The suite reflects what actually differs in ROS 2 Jazzy's UR
description:
- `world` root link (vs `base_link` on Noetic).
- `ur_manipulator` planning group (vs `manipulator`).
- `compute_cartesian_path` returns a fixed-count trajectory
  regardless of `max_step`; the step-size test only asserts both
  variants succeed and reach fraction == 1.
- `plan_motion_with_obstacle` retries 5x with 5s budget each to
  absorb OMPL RRTConnect's stochastic failure rate.
- `cartesian_motion_target_mode` is skipped when the library cell's
  SRDF group name doesn't match what MoveIt has loaded
  (`manipulator` vs `ur_manipulator`).

Also adds unit tests for the message-level ROS 2 adaptations:
header serialisation per distro, Time stamp keys (`secs`/`nsecs` vs
`sec`/`nanosec`), MoveItErrorCodes shape, and nested headers
through cartesian / motion-plan / planning-scene requests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`follow_joint_trajectory` and `execute_joint_trajectory` were both
pinned to `roslibpy.ros1.actionlib.ActionClient` (topic-based ROS 1
actionlib), so calling them against a ROS 2 / MoveIt 2 stack would
fail to wire up the goal/feedback/result topics. Add a distro
dispatch in both methods:

- ROS 1 distros: unchanged (Goal + topic-based actionlib).
- ROS 2 distros: route through `roslibpy.ActionClient` (the
  rosbridge `send_action_goal` op) with action types in the
  ROS 2 long form (`control_msgs/action/FollowJointTrajectory`,
  `moveit_msgs/action/ExecuteTrajectory`).

A thin `_Ros2GoalHandle` adapter exposes the Goal-like
`is_finished` / `cancel()` surface that `CancellableRosActionResult`
relies on, so the public execution API stays the same regardless
of distro.

Adds three unit tests that mock the ROS 1 and ROS 2 ActionClient
classes to confirm:
- `follow_joint_trajectory` routes to the ROS 2 client for ROS 2
  distros and never instantiates the ROS 1 client.
- `execute_joint_trajectory` does the same with the right action
  type string.
- `_Ros2GoalHandle` flips `is_finished` on result delivery and
  forwards cancel calls.

End-to-end verification with a real robot still has to happen
against a properly-configured ROS 2 stack — the demo compose here
does not bring up `FollowJointTrajectory`/`ExecuteTrajectory`
servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Existing docstrings pointed at Kinetic / Melodic / Fuerte / unspecified-
distro docs.ros.org pages. Update all single-distro URLs to Noetic (the
final ROS 1 LTS) and add the ROS 2 (Jazzy) equivalent on a second line
when the message exists in ROS 2.

ROS-1-only types keep just the Noetic URL:
  - actionlib_msgs/* (ROS 2 uses action_msgs / unique_identifier_msgs)
  - control_msgs/FollowJointTrajectory{Goal,Feedback,Result} and the
    matching *Action{Goal,Feedback,Result} expansions (ROS 2 has the
    consolidated control_msgs/action/FollowJointTrajectory instead)
  - moveit_msgs/ExecuteTrajectory{Goal,Feedback,Result} (same pattern;
    ROS 2 has moveit_msgs/action/ExecuteTrajectory)
  - object_recognition_msgs/ObjectType (no current ROS 2 home)

std_msgs/Time is rehomed in ROS 2: the ROS 2 link points to
builtin_interfaces/msg/Time.

Sweep done with /tmp/update_docs_urls.py (regex-based) for the 70+
single-line URL docstrings; the 3 multi-line docstrings in geometry_msgs
(Wrench, WrenchStamped, Inertia) were edited by hand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ROS 2 testing

The ROS 1 test stack and the ROS 2 demo stack both used to publish
rosbridge on host port 9090, so only one could run at a time. Move
the ROS 2 stack onto adjacent ports (9091 for rosbridge, 9092 for the
HTTP asset server) so the two can coexist and the integration suite
can be exercised against both without re-cycling Docker.

All published ports are env-driven via `${VAR:-DEFAULT}` so a host
that already holds the defaults can override:

| Var                | Stack | Default | Container |
|--------------------|-------|---------|-----------|
| ROS1_BRIDGE_PORT   | ROS 1 | 9090    | 9090      |
| ROS1_CORE_PORT     | ROS 1 | 11311   | 11311     |
| ROS2_BRIDGE_PORT   | ROS 2 | 9091    | 9090      |
| ROS2_HTTP_PORT     | ROS 2 | 9092    | 9091      |

The container-internal ports stay unchanged (rosbridge on 9090, the
file server on 9091), so service-to-service references inside the
docker network (`file-server:9091`, etc.) keep working.

Updates the test fixture skip message to point at both stacks and
adds `tests/integration_setup/README.md` documenting the
single-stack and parallel-run patterns.

Per-robot demo stacks under `docs/installation/docker_files/*-demo/`
are unchanged — they're standalone, not part of the test
infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…s2_client

The integration fixture used to be a single `ros_client` that read
`COMPAS_FAB_ROS_PORT` and targeted whichever stack happened to be there.
With both stacks now able to run in parallel (different host ports), tests
should be explicit about which one they target.

`tests/backends/ros/conftest.py` adds two module-scoped fixtures:

  - `ros1_client` — reads `COMPAS_FAB_ROS1_PORT` (default 9090).
  - `ros2_client` — reads `COMPAS_FAB_ROS2_PORT` (default 9091).

Each fixture skips independently if either:
  - `COMPAS_FAB_RUN_ROS_INTEGRATION_TESTS != "1"` (the opt-in switch); or
  - rosbridge isn't reachable at the configured host:port.

So a suite can run with only one stack up — the other half just skips.

`COMPAS_FAB_ROS_PORT` is honoured as a legacy alias for the ROS 1 port
to keep older invocations working.

The existing integration tests in `test_doc_examples_integration.py`
assert ROS-2-specific URDF/SRDF shapes (`world` root link,
`ur_manipulator` planning group), so they switch to `ros2_client`. Future
ROS-1-only tests can add a parallel file that uses `ros1_client`.

The shared module imports `RosClient` *lazily* inside the fixture body.
The repo's top-level `conftest.py` installs Twisted's `selectreactor` in
`pytest_configure`, but pytest imports every `conftest.py` in the tree
before dispatching that hook. Importing `RosClient` at module load would
pull in roslibpy → `twisted.internet.reactor` → the default reactor, and
the subsequent `selectreactor.install()` would then raise
`ReactorAlreadyInstalledError`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two compose files now share the same Dockerfile and image:

  tests/integration_setup/docker-compose-ros2.yml  — lightweight
    test stack. Just enough MoveIt 2 + rosbridge + HTTP file server to
    answer service calls. No URSim, no robot driver, no GUI. Lives next
    to the ROS 1 test stack for symmetry.

  docs/installation/docker_files/ros2-ur10e-demo/docker-compose.yml —
    full demo. Adds URSim, the UR ROS 2 driver, and a noVNC RViz
    viewport on top of the test stack. Restored to its full-featured
    form after being temporarily trimmed for testing.

Container names and image (`compas-fab/ros-jazzy-moveit2`) are
identical between the two stacks, so a single build serves both and
they don't run simultaneously (which is fine — you're either testing
or demoing).

The test stack's `build:` directive points at the demo directory
(`../../docs/installation/docker_files/ros2-ur10e-demo`) so a fresh
checkout can bring up just the test stack without first running the
demo.

References updated:
  - tests/backends/ros/conftest.py: skip message points at the new
    test-stack path.
  - tests/integration_setup/README.md: documents both stacks and
    where each lives.
  - docs/backends/ros2.md: presents the two stacks side-by-side so
    users can pick the right one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit a3317ba (Aug 2025) replaced

    if "pybullet" not in sys.modules:
        pybullet = LazyLoader("pybullet", globals(), "pybullet")

with

    try:
        import pybullet
    except ImportError:
        pybullet = LazyLoader("pybullet", globals(), "pybullet")

defeating the lazy pattern when pybullet is installed. Because
`compas_fab.backends.__init__` pulls in `compas_fab.backends.pybullet`
to re-export `PyBulletClient` and friends, just importing `RosClient`
was loading the pybullet native extension (~8s on macOS).

Reverts to the `sys.modules` guard so the `LazyLoader` proxy is
installed unconditionally unless something else in the process has
already imported the real `pybullet`. Attribute access on the proxy
loads the real module on first touch, so PyBullet code paths are
unaffected — only the cold startup cost moves from `import` time to
first use.

Verified:
- `from compas_fab.backends import RosClient, MoveItPlanner` —
  ~1.3s wall clock (was ~9s); no `pybullet` line in `-X importtime`.
- `PyBulletClient(connection_type='direct')` still connects (which
  proves the LazyLoader proxy still resolves correctly).
- pytest tests/backends/{ros,pybullet}: 58 passed, 21 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tests

build.yml — widen pre-merge coverage:
  - matrix now {ubuntu, macos, windows} × {3.9, 3.13} instead of
    ubuntu × 3.11. 3.9 and 3.13 are the bookend versions declared in
    pyproject.toml; the release workflow continues to exercise the
    full 3.9-3.13 range at tag time.
  - drops stale `wip_process` branch trigger.
  - adds `fail-fast: false` so one OS / Python failure doesn't mask
    others.

integration.yml — actually run the integration tests:
  - bumps `actions/checkout` v2 → v5 and `actions/setup-python`
    v2 → v5.
  - brings up both the ROS 1 and the new ROS 2 test stacks (default
    ports 9090 / 9091, set by their compose files).
  - sets `COMPAS_FAB_RUN_ROS_INTEGRATION_TESTS=1` so the
    `ros1_client` / `ros2_client` fixtures actually connect instead of
    silently skipping the suite — without this, CI was reporting green
    while running zero integration assertions.
  - replaces `pytest --doctest-modules` + `pytest docs` with just
    `pytest --doctest-modules`; the second invocation was a leftover
    from the Sphinx era and produces nothing useful against the new
    Markdown docs tree.
  - tears down both stacks unconditionally (`if: always()`).
  - drops stale `wip_process` branch trigger.

docs.yml — wire the action up for MkDocs:
  - bumps `compas-dev/compas-actions.docs` v3 → v5; the v5 release
    added a `generator` input so the same action can deploy MkDocs
    sites in addition to the original Sphinx flow.
  - passes `generator: mkdocs` so it picks the right code path for
    our `invoke docs` build.

pr-checks.yml — bump `actions/checkout` v1 → v5.

release.yml and publish_yak.yml are unchanged (already current).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ools

CI was running `pip install -r requirements-dev.txt`, which installs the
dev tooling but NOT the package itself and NOT its runtime dependencies.
The top-level `conftest.py` then crashed at `pytest_configure` time with

  ModuleNotFoundError: No module named 'twisted'

because `twisted` only arrives transitively via `roslibpy` (a runtime
dependency of compas_fab, pulled in by installing the package).

`pyproject.toml` already wires both lists up:

  [tool.setuptools.dynamic]
  dependencies = { file = "requirements.txt" }
  optional-dependencies = { dev = { file = "requirements-dev.txt" } }

so `pip install -e ".[dev]"` covers everything in one call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Default pytest import mode (`prepend`) inserts every collected
module's parent directory into sys.path. When pytest walks
`src/compas_fab/backends/pybullet/`, that puts
`src/compas_fab/backends/` on sys.path, after which a plain
`import pybullet` resolves to our own subpackage
(`compas_fab.backends.pybullet`) instead of the PyPI library:

    >>> import pybullet  # with src/compas_fab/backends/ on sys.path
    >>> pybullet.__file__
    'src/compas_fab/backends/pybullet/__init__.py'
    >>> pybullet.connect
    AttributeError: module 'pybullet' has no attribute 'connect'

Locally that's hidden because the real pybullet is in sys.modules
from a prior import. On a fresh CI runner without pybullet
installed, the subpackage wins, and every `PyBulletClient`-using
test fails with the AttributeError above (seen in the build job
on `compas-actions.build@v5`).

`--import-mode=importlib` uses importlib's own loader, which does
not manipulate sys.path. No more name collision between
`compas_fab.backends.pybullet` and the top-level `pybullet`.

Verified locally: 58 passed, 21 skipped (same suite shape as
before). `import pybullet` no longer leaks into `sys.modules`
when `compas_fab.backends.PyBulletClient` is imported alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jf---
Copy link
Copy Markdown

jf--- commented May 19, 2026

holy mackerel! congrats @yck011522 & @gonzalocasas.
epic!

gonzalocasas and others added 5 commits May 25, 2026 14:50
…o CI

Migration to Rhino 8 CPython-only Grasshopper components. The IronPython
components folder is removed entirely, the bumpversion glob and ruff exclusion
follow the cpython folder, and the rhino/ghpython install hooks (no longer
needed under the Rhino 8 yak deployment model) are removed.

build.yml gains a CPython component build job that uploads the resulting
.ghuser files as an artifact for testing without needing a local build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…g (Phase 1)

The old components targeted the pre-stateless Robot.plan_motion(...) API and
no longer match the current data model (RobotCell + RobotCellState +
Planner.plan_motion(target, start_state)). This commit lands the foundation
needed for an offline analytical-IK demo path that requires no ROS.

New components:
  - Cf_LoadRobotCellFromLibrary  (Robot Cell)
  - Cf_DefaultCellState          (Cell State)
  - Cf_AnalyticalKinematicsPlanner (Backends)
  - Cf_FrameTarget               (Targets, replaces old Cf_FrameTargetFromPlane)
  - Cf_PointAxisTarget           (Targets, ported from IronPython)
  - Cf_ConfigurationTarget       (Targets, ported from IronPython)
  - Cf_InverseKinematics         (Planning, rewritten against the stateless API)
  - Cf_ForwardKinematics         (Planning, rewritten with target_mode + Plane output)
  - Cf_VisualizeRobotCell        (Display, uses cached RobotCellObject)

Removed (depended on Robot.* or pre-RobotCell APIs):
  Cf_AttachTool, Cf_AttachedCollisionMesh, Cf_CollisionMesh, Cf_ConfigMerge,
  Cf_ConfigZero, Cf_ConstraintsFromConfiguration, Cf_ConstraintsFromPlane,
  Cf_PlanCartesianMotion, Cf_PlanMotion, Cf_PlanningScene, Cf_RosRobot,
  Cf_VisualizeRobot, Cf_VisualizeTrajectory.

Placeholder icons are recycled from removed components; proper artwork to
follow. Subsequent phases will add the ROS path, cell-construction builders,
PyBullet planners, and trajectory playback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the ROS path so users can plan against a live MoveIt backend from
Grasshopper. The RosClient handles both ROS 1 and ROS 2 transparently.

New components:
  - Cf_RosClient            (Backends, replaces Cf_RosConnect; exposes ros_distro)
  - Cf_MoveItPlanner        (Backends)
  - Cf_LoadRobotCellFromRos (Robot Cell)
  - Cf_PlanMotion           (Planning, planner-agnostic)
  - Cf_PlanCartesianMotion  (Planning, planner-agnostic)

Cf_RosConnect is removed in favor of Cf_RosClient; behavior is the same
plus the auto-detected ROS distro on output.

Planning components cache results in sticky and gate execution on a `compute`
toggle so the canvas does not re-plan on every refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

5 participants