diff --git a/.cargo/config.toml b/.cargo/config.toml index 3b5738184..0b4dc4bb6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,15 @@ [target.wasm32-unknown-unknown] runner = "wasm-server-runner" # Needed for getrandom/uuid: https://github.com/uuid-rs/uuid/issues/792 -rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] \ No newline at end of file +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] + +# macOS: PyO3 cdylibs built with the `extension-module` feature don't link +# against libpython (Python provides those symbols at runtime). `cargo build` +# doesn't know that, so we tell the linker not to fail on the unresolved +# Python symbols. (Maturin handles this for us; this only affects bare +# `cargo build` invocations.) +[target.'cfg(target_os = "macos")'] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] \ No newline at end of file diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml new file mode 100644 index 000000000..1f60036b6 --- /dev/null +++ b/.github/workflows/python-bindings.yml @@ -0,0 +1,81 @@ +name: Python bindings + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + +# The four engine packages live under python/rapier-py-{2,3}d{,-f64}; the +# pure-Python testbed under python/rapier-testbed; the shared parity test +# suite under python/tests (imports all four engine packages at once). +jobs: + lint-rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-lint- + ${{ runner.os }}-cargo- + - name: Cargo fmt + run: cargo fmt --check + - name: Cargo clippy (Python bindings) + run: | + cargo clippy --no-deps \ + -p rapier-py-core \ + -p rapier-py-2d -p rapier-py-2d-f64 \ + -p rapier-py-3d -p rapier-py-3d-f64 \ + -- -D warnings + + test: + needs: lint-rust + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-py-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-py- + ${{ runner.os }}-cargo- + - name: Install tooling + run: | + python -m pip install --upgrade pip + python -m pip install maturin pytest pytest-timeout hypothesis numpy matplotlib + # Build + install all four engine packages into the runner's environment + # so the cross-flavor parity suite can import every variant. + - name: Build & install engine packages + shell: bash + run: | + for crate in rapier-py-2d rapier-py-2d-f64 rapier-py-3d rapier-py-3d-f64; do + python -m pip install --no-build-isolation "./python/$crate" + done + - name: Install testbed (no deps; engine packages already present) + shell: bash + run: python -m pip install --no-deps ./python/rapier-testbed + - name: Smoke (import surface) + run: python -m pytest python/tests/test_smoke.py python/tests/test_math.py python/tests/test_math_2d.py -v + - name: Full parity test suite + run: python -m pytest python/tests/ -q --timeout=120 diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml new file mode 100644 index 000000000..b2eb04a8c --- /dev/null +++ b/.github/workflows/python-wheels.yml @@ -0,0 +1,83 @@ +name: Python wheels + +# Build abi3 wheels for the four engine packages across every supported +# platform and (on a `python-v*` tag) publish them to PyPI via trusted +# publishing. +# +# abi3-py39 means ONE wheel per (package, platform, arch) covers Python 3.9+, +# so there is deliberately no Python-version axis. +# +# NOTE: no sdist is published. The rapier2d/3d engine crates share their +# source via the repo-root `src/` tree (`[lib] path = "../../src/lib.rs"`), +# which lives outside any vendored crate directory, and maturin's sdist +# packer cannot reach parent paths (`..` is rejected in `include`). A source +# build would therefore be incomplete. The wheel matrix below covers all +# mainstream platforms; source builds use a full git checkout of the repo. +# Restoring an sdist is tracked as a follow-up (needs an upstream layout +# change or registry-based engine deps). +on: + push: + branches: [master] + tags: ["python-v*"] + pull_request: + branches: [master] + paths: + - "python/**" + - ".github/workflows/python-wheels.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: wheel ${{ matrix.package.name }} ${{ matrix.platform.target }} + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + package: + - { dir: rapier-py-3d, name: rapier3d } + - { dir: rapier-py-3d-f64, name: rapier3d-f64 } + - { dir: rapier-py-2d, name: rapier2d } + - { dir: rapier-py-2d-f64, name: rapier2d-f64 } + platform: + - { runner: ubuntu-latest, target: x86_64-unknown-linux-gnu, manylinux: auto } + - { runner: ubuntu-latest, target: aarch64-unknown-linux-gnu, manylinux: auto } + - { runner: ubuntu-latest, target: x86_64-unknown-linux-musl, manylinux: musllinux_1_2 } + - { runner: ubuntu-latest, target: aarch64-unknown-linux-musl, manylinux: musllinux_1_2 } + - { runner: macos-14, target: aarch64-apple-darwin } + - { runner: windows-latest, target: x86_64-pc-windows-msvc } + steps: + - uses: actions/checkout@v4 + - name: Build wheel + uses: PyO3/maturin-action@v1 + with: + command: build + target: ${{ matrix.platform.target }} + manylinux: ${{ matrix.platform.manylinux }} + args: --release --out dist -m python/${{ matrix.package.dir }}/Cargo.toml + sccache: "true" + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.package.name }}-${{ matrix.platform.target }} + path: dist + + publish: + name: Publish to PyPI + needs: [build] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/python-v') + environment: pypi + permissions: + id-token: write # OIDC for trusted publishing + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + pattern: "wheels-*" + merge-multiple: true + - name: Publish all four packages + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist diff --git a/.gitignore b/.gitignore index 64e4fb7e8..d3617f5f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,22 @@ package-lock.json .history .vscode/ *.autosave.json -.claude \ No newline at end of file +.claude + +# Python bindings: compiled extension modules that `maturin develop` stages +# next to each package's Python sources (e.g. `_rapier3d.abi3.so`) and the +# Sphinx build output. +python/rapier-py-*/python/**/_rapier*.so +python/rapier-py-*/python/**/_rapier*.pyd +python/rapier-py-*/python/**/_rapier*.dylib +python/docs/_build/ +**/__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ diff --git a/.typos.toml b/.typos.toml index 31898acfc..1b1ec41a9 100644 --- a/.typos.toml +++ b/.typos.toml @@ -22,6 +22,13 @@ iit_softfoot = "iit_softfoot" FoV = "FoV" # Shepperd's method: named after S. W. Shepperd (quaternion extraction). Shepperd = "Shepperd" +# Short local identifiers in the Python bindings/tests: `ba` (body a, paired +# with `bb`) and `typ` (a "type" value — `type` is reserved in Rust/Python). +ba = "ba" +typ = "typ" +# Template mesh data in the testbed instancer: `tpos`/`tnrm`/`tidx` +# (template positions/normals/indices). Only `tpos` collides with a word. +tpos = "tpos" # Case insensitive, matches inside word. [default.extend-words] diff --git a/Cargo.toml b/Cargo.toml index e0ee6515b..385166b55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,33 @@ members = [ "crates/rapier3d-meshloader", "crates/mjcf-rs", "crates/rapier3d-mjcf", + "python/rapier-py-core", + "python/rapier-py-2d", + "python/rapier-py-2d-f64", + "python/rapier-py-3d", + "python/rapier-py-3d-f64", +] +# Bare `cargo build` / `cargo test` / `cargo clippy` only touches the +# pure-Rust crates. The Python-binding crates need a Python build env +# (pyo3 `extension-module`) and are exercised by the dedicated +# `.github/workflows/python-bindings.yml` workflow via explicit +# `-p rapier-py-*` invocations. +default-members = [ + "crates/rapier2d", + "crates/rapier2d-f64", + "crates/rapier_testbed2d", + "crates/rapier_testbed2d-f64", + "examples2d", + "crates/rapier3d", + "crates/rapier3d-f64", + "crates/rapier_testbed3d", + "crates/rapier_testbed3d-f64", + "examples3d", + "examples3d-f64", + "crates/rapier3d-urdf", + "crates/rapier3d-meshloader", + "crates/mjcf-rs", + "crates/rapier3d-mjcf", ] resolver = "2" diff --git a/README.md b/README.md index 983955f11..492625054 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,18 @@ The easiest way to get started with Rapier is to: Their source code are available on the `examples2d/` and `examples3d/` directory. 3. Don't hesitate to ask for help on [Discord](https://discord.gg/vt9DJSW), or by opening an issue on GitHub. +## Python bindings + +Python bindings are under development. They ship as four PyPI packages — +`rapier2d`, `rapier3d`, `rapier2d-f64`, `rapier3d-f64` — one per +(dimension, scalar) flavor. See [`python/README.md`](python/README.md) for how +to build the bindings, the docs, and the testbed from a checkout, and +[`python/docs/`](python/docs/) for the API documentation. + ## AI coding disclaimer and policy -AI coding is extensively used for the implementation and maintenance of the following crates: `mjcf-rs`, `rapier3d-mjcf`. +AI coding is extensively used for the implementation and maintenance of the following crates: `mjcf-rs`, +`rapier3d-mjcf`, as well as the Python bindings (`python/rapier-py*`), including their tests, examples, and docs. We actively use AI assistance (with human reviews) for the following tasks: - Documentation generation. diff --git a/crates/mjcf-rs/src/lib.rs b/crates/mjcf-rs/src/lib.rs index 3f58e780f..3d1cf9d94 100644 --- a/crates/mjcf-rs/src/lib.rs +++ b/crates/mjcf-rs/src/lib.rs @@ -32,7 +32,7 @@ //! //! - Simulate anything. Pair this with `rapier3d-mjcf` for that. //! - Cover MJCF features that are out of scope for `rapier3d-mjcf`. See the -//! `rapier3d-mjcf` planning docs for the complete list. +//! `rapier3d-mjcf` README for the complete list. #![warn(missing_docs)] diff --git a/crates/rapier3d-mjcf/README.md b/crates/rapier3d-mjcf/README.md index ff8e25358..3da17d864 100644 --- a/crates/rapier3d-mjcf/README.md +++ b/crates/rapier3d-mjcf/README.md @@ -42,8 +42,7 @@ robot.insert_using_impulse_joints(&mut bodies, &mut colliders, &mut impulse_join ## Feature matrix This crate is rolled out in phases. The table tracks what each phase -delivers and the current implementation status. See [`docs/`](docs/) for -the planning rationale. +delivers and the current implementation status. Legend: ✅ supported · ⚠️ partial / approximated · 📦 preserved as metadata · ❌ out of scope. @@ -172,8 +171,6 @@ convention. (Tracked for a future polish pass.) - `` (deprecated MJCF feature). - MJCF write-back (the parser is read-only). -See [`docs/04-out-of-scope.md`](docs/04-out-of-scope.md) for rationale. - ## Limitations The mappings noted with ⚠️ above are deliberate trade-offs. In particular: diff --git a/crates/rapier3d-mjcf/docs/01-overview.md b/crates/rapier3d-mjcf/docs/01-overview.md deleted file mode 100644 index 40195b9e6..000000000 --- a/crates/rapier3d-mjcf/docs/01-overview.md +++ /dev/null @@ -1,122 +0,0 @@ -# 01 — Overview - -## Goals - -1. Let users load a MuJoCo XML model and turn it into a set of rapier - `RigidBody`, `Collider`, and `GenericJoint`/multibody-joint instances. -2. Match the API shape of `rapier3d-urdf` (`Robot::from_file`, `from_str`, - `from_robot`, `insert_using_impulse_joints`, `insert_using_multibody_joints`) - so users moving between the two crates feel at home. -3. Preserve the original MJCF AST alongside the converted rapier objects, so - downstream code can inspect MJCF-only metadata (sites, actuators, sensors, - names, classes, …) that rapier doesn't simulate. -4. Pure Rust. No `libmujoco`, no C bindings. - -## Non-goals - -- We do **not** aim to **simulate** MJCF — only to load the kinematic / dynamic - description into rapier. Anything MJCF-specific that rapier cannot represent - is either (a) approximated, (b) preserved as metadata for the caller, or - (c) explicitly skipped. See [`04-out-of-scope.md`](04-out-of-scope.md). -- We do not aim to round-trip MJCF (load + edit + write). The parser may grow a - serializer later, but it isn't on the roadmap. - -## Two-crate split - -Mirrors the URDF stack: - -``` -crates/ -├── mjcf-rs/ ← XML → typed MJCF AST (no rapier deps) -└── rapier3d-mjcf/ ← MJCF AST → rapier rigid bodies / colliders / joints -``` - -Why split: - -- The parser has zero rapier dependency, so it can be reused (e.g. by other - Dimforge crates, by users who want to read MJCF without using rapier). -- It keeps the rapier-side conversion logic small and focused. -- It mirrors `urdf-rs` / `rapier3d-urdf`, so the codebase stays consistent. - -## Why MJCF differs structurally from URDF - -The two formats look superficially similar but model the world differently: - -| Concept | URDF | MJCF | -| ---------------------- | ---------------------------------------- | ----------------------------------------------------- | -| Topology | Flat list of `` + `` (joint names parent + child by name) | Nested `` tree (joint sits **inside** the child body and connects it to its parent) | -| Default angle units | radians | **degrees** (configurable via ``) | -| Default geom type | n/a (each shape has its own tag) | `sphere` (with `size` interpretation depending on `type`) | -| Joints per link | exactly 1 | **0..N** (multiple joints in one body = serial DoFs) | -| Default class system | none | `` with nested classes and `class` / `childclass` attributes | -| File composition | flat, single file | `` recursively inlines other files | -| Geom origin | shape-local | shape-local, but the body's **inertial frame may differ from the body frame** | -| Coordinate convention | right-handed, X-forward | right-handed, **Z-up** | -| Mesh formats | STL/OBJ/Collada via robot description | STL / OBJ / `.msh` (custom MuJoCo binary) | - -Concrete consequences for the loader: - -- **Multiple joints per body**: rapier's multibody / impulse-joint model - permits at most one joint per body pair. The loader has to insert massless - intermediate rigid-bodies for the joints "between" the parent and the - declared body. (Phase 1 covers the single-joint case; Phase 1.5 / Phase 2 - handles multi-joint bodies.) -- **``**: the body's parent is the world and there is no joint — - emit a free dynamic rigid-body. No constraint to insert. -- **0 joints, parent = world**: the body is welded to the world; force-fixed - rigid-body, just like the URDF "empty-fixed-link → squeeze" fix-up. -- **0 joints, non-world parent**: the body is welded to its parent → emit a - fixed joint between the two. -- **`compiler/angle="degree"`** is the default, so we must convert to - radians before handing values to rapier (URDF doesn't have this trap). -- **`compiler/eulerseq`** changes the convention used for `<… euler="…">` - attributes (default `"xyz"`). The default differs from `glamx`'s default - parser convention, so we handle each case explicitly. - -## API shape (target) - -```rust -pub struct MjcfLoaderOptions { - pub create_colliders_from_collision_shapes: bool, // geom group/contype-aware (see Phase 3) - pub create_colliders_from_visual_shapes: bool, // geoms with contype=conaffinity=0 - pub apply_imported_mass_props: bool, - pub enable_joint_collisions: bool, - pub make_roots_fixed: bool, - pub trimesh_flags: TriMeshFlags, - pub mesh_converter: Option, - pub shift: Pose, - pub scale: Real, - pub collider_blueprint: ColliderBuilder, - pub rigid_body_blueprint: RigidBodyBuilder, - pub squeeze_empty_fixed_bodies: bool, // analogue of squeeze_empty_fixed_links -} - -pub struct MjcfRobot { - pub bodies: Vec, // one entry per , plus implicit world at index 0 - pub joints: Vec, // one entry per joint reachable in the kinematic tree - pub equality: Vec, // mapped to extra impulse joints -} - -impl MjcfRobot { - pub fn from_file(path, options, asset_dir) -> anyhow::Result<(Self, mjcf_rs::Model)>; - pub fn from_str(str, options, asset_dir) -> anyhow::Result<(Self, mjcf_rs::Model)>; - pub fn from_model(model, options, asset_dir) -> Self; - pub fn insert_using_impulse_joints(self, ...) -> MjcfRobotHandles; - pub fn insert_using_multibody_joints(self, ..., MjcfMultibodyOptions) -> MjcfRobotHandles>; - pub fn append_transform(&mut self, transform: &Pose); -} -``` - -Same `MjcfMultibodyOptions::JOINTS_ARE_KINEMATIC | DISABLE_SELF_CONTACTS` -flags as the URDF loader. - -## Cargo features - -Same shape as `rapier3d-urdf`: - -- `stl` — load `.stl` meshes via `rapier3d-meshloader` -- `wavefront` — load `.obj` meshes via `rapier3d-meshloader` -- `msh` — parse MuJoCo's custom `.msh` mesh format (pure Rust, written - in `mjcf-rs` since the format is MJCF-specific). See Phase 2. - -(Collada is intentionally absent — MJCF doesn't use it.) diff --git a/crates/rapier3d-mjcf/docs/02-coverage-and-mapping.md b/crates/rapier3d-mjcf/docs/02-coverage-and-mapping.md deleted file mode 100644 index 242b7bd95..000000000 --- a/crates/rapier3d-mjcf/docs/02-coverage-and-mapping.md +++ /dev/null @@ -1,402 +0,0 @@ -# 02 — Full MJCF Coverage and Rapier Mapping - -This document enumerates every MJCF top-level element, its children and -attributes, and how each maps (or doesn't) to a rapier construct. It is the -authoritative reference for what the parser and loader must support. - -Legend for the **Status** column: - -| Symbol | Meaning | -| ------ | ---------------------------------------------------------------- | -| ✅ | Fully mapped to a rapier feature. | -| ⚠️ | Partially mapped or approximated (lossy). | -| 📦 | Preserved in the AST as metadata; no runtime effect in rapier. | -| ❌ | Out of scope. See [`04-out-of-scope.md`](04-out-of-scope.md). | - -Each row also notes the **Phase** in which it lands. - ---- - -## Top-level structure - -```xml - - ← simulation-input parsing knobs - -``` - -| Element | Status | Phase | Notes | -| ------------- | ------ | ----- | ----- | -| `` | ✅ | 1 | Root. `model` attribute → `MjcfRobot::name`. | -| `` | ⚠️ | 1/2 | See dedicated table below. | -| `