diff --git a/.agents/skills/isaaclab-auditing-an-issue b/.agents/skills/isaaclab-auditing-an-issue new file mode 120000 index 000000000000..0c26b7a577cc --- /dev/null +++ b/.agents/skills/isaaclab-auditing-an-issue @@ -0,0 +1 @@ +../../skills/developer/issue-audit \ No newline at end of file diff --git a/.agents/skills/isaaclab-triaging-issue-backlog b/.agents/skills/isaaclab-triaging-issue-backlog new file mode 120000 index 000000000000..97114ccf6e82 --- /dev/null +++ b/.agents/skills/isaaclab-triaging-issue-backlog @@ -0,0 +1 @@ +../../skills/developer/issue-backlog-triage \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 1c382038d7f3..4ad738005085 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,6 +21,8 @@ docker/.container.cfg recordings/ # ignore __pycache__ **/__pycache__/ +**/.pytest_cache/ +**/.ruff_cache/ **/*.egg-info/ # ignore isaac sim symlink _isaac_sim diff --git a/.github/actions/ecr-build-push-pull/README.md b/.github/actions/ecr-build-push-pull/README.md index e26db875f288..da3e90d25503 100644 --- a/.github/actions/ecr-build-push-pull/README.md +++ b/.github/actions/ecr-build-push-pull/README.md @@ -16,6 +16,19 @@ ECR is also used as the BuildKit layer cache. ecr-url: (optional, complete url for ECR storage) ``` +## Verifying a freshly built image + +Pass `verify-test-path` to assert against the image before it is published: + +```yaml + verify-test-path: docker/test/test_image_invariants.py +``` + +The tests run only on a full build, with `IMAGE_TAG` set, so the caller's job needs `uv` +(`astral-sh/setup-uv`). A failure fails the action with nothing pushed, so the next run +rebuilds rather than serving the bad image from the deps cache. Exact-tag and deps-cache hits skip +them: that image passed when it was built. + ## ECR URL resolution order 1. `ecr-url` input diff --git a/.github/actions/ecr-build-push-pull/action.yml b/.github/actions/ecr-build-push-pull/action.yml index fa54513e2e3f..6c6b7eb6f069 100644 --- a/.github/actions/ecr-build-push-pull/action.yml +++ b/.github/actions/ecr-build-push-pull/action.yml @@ -37,6 +37,16 @@ inputs: description: Tag used for the ECR layer cache image (e.g. "cache-base", "cache-curobo"). required: false default: 'cache' + verify-test-path: + description: > + Path to a test file or directory asserted against a freshly built image, before it is + tagged or pushed. Tests run with IMAGE_TAG set; a failure fails the action with nothing + published, so the next run rebuilds instead of inheriting the bad image from the cache. + + Not run on an exact-tag or deps-cache hit: those serve an image that already passed when it + was built. + required: false + default: '' pull-on-deps-hit: description: > Pull the image locally after a deps-cache hit. Needed by jobs that run @@ -244,6 +254,23 @@ runs: cache-to: ${{ steps.resolve-ecr.outputs.available == 'true' && format('type=registry,ref={0},mode=max', env.CACHE_IMAGE) || '' }} deps-hash: ${{ steps.deps-hash.outputs.hash }} + # Assert against the image while it is only local: the push steps below publish under both + # the commit tag and the deps tag, and a deps-cache hit later serves that image without + # rebuilding it, so anything published unverified stays unverified. + - name: Verify freshly built image + if: > + inputs.verify-test-path != '' && + steps.pull-exact.outputs.hit != 'true' && + steps.deps-cache.outputs.deps-cache-hit != 'true' + shell: bash + env: + IMAGE_TAG: ${{ inputs.image-tag }} + TEST_PATH: ${{ inputs.verify-test-path }} + run: | + set -euo pipefail + uv run --no-project --with pytest \ + python -m pytest -q "${TEST_PATH}" + - name: Tag built image with ECR-prefixed name if: > steps.resolve-ecr.outputs.available == 'true' && diff --git a/.github/actions/run-tests/run_tests.sh b/.github/actions/run-tests/run_tests.sh index ec1cf423665c..655f3a3919f3 100755 --- a/.github/actions/run-tests/run_tests.sh +++ b/.github/actions/run-tests/run_tests.sh @@ -413,13 +413,19 @@ run_tests() { fi if [ -n \"\${TEST_EXTRA_UV_PACKAGES:-}\" ]; then echo \"Installing extra packages with uv: \${TEST_EXTRA_UV_PACKAGES}\" - # isaaclab.sh prints an informational line before command output, and pip - # installs scripts into the user base when Isaac Sim site-packages is read-only. - isaac_python=\"\$(./isaaclab.sh -p -c 'import sys; print(sys.executable)' | tail -n 1)\" - isaac_user_site=\"\$(./isaaclab.sh -p -c 'import site; print(site.getusersitepackages())' | tail -n 1)\" - uv_executable=\"\$(./isaaclab.sh -p -c 'import pathlib, site; print(pathlib.Path(site.getuserbase()) / \"bin\" / \"uv\")' | tail -n 1)\" - if [ ! -x \"\${uv_executable}\" ]; then - bash /with-python-package-retries.sh ./isaaclab.sh -p -m pip install uv + # isaaclab.sh prints an [INFO] banner on stdout around the command output, so the + # banner is filtered rather than positionally skipped. + isaac_python=\"\$(./isaaclab.sh -p -c 'import sys; print(sys.executable)' | grep -v '^\[INFO\]' | tail -n 1)\" + isaac_user_site=\"\$(./isaaclab.sh -p -c 'import site; print(site.getusersitepackages())' | grep -v '^\[INFO\]' | tail -n 1)\" + # The image ships uv on PATH. Fall back to the user base only for images that do + # not: pip installs into the venv, not the user base, when the interpreter is a venv, + # so the user-base path is never created there. + uv_executable=\"\$(command -v uv || true)\" + if [ -z \"\${uv_executable}\" ]; then + uv_executable=\"\$(./isaaclab.sh -p -c 'import pathlib, site; print(pathlib.Path(site.getuserbase()) / \"bin\" / \"uv\")' | grep -v '^\[INFO\]' | tail -n 1)\" + if [ ! -x \"\${uv_executable}\" ]; then + bash /with-python-package-retries.sh ./isaaclab.sh -p -m pip install uv + fi fi bash /with-python-package-retries.sh \"\${uv_executable}\" pip install --python \"\${isaac_python}\" --target \"\${isaac_user_site}\" \${TEST_EXTRA_UV_PACKAGES} # Isaac Sim puts bundled packages ahead of the user site. Overlay only diff --git a/.github/test-subsets/postmerge-rendering.toml b/.github/test-subsets/postmerge-rendering.toml index d7d57a8989f7..b11939b6b192 100644 --- a/.github/test-subsets/postmerge-rendering.toml +++ b/.github/test-subsets/postmerge-rendering.toml @@ -23,3 +23,17 @@ rendering-correctness-kitless-ovstage = [ "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovstage-newton-ovrtx_renderer-static]", "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovstage-ovphysx-ovrtx_renderer-static]", ] + +# Nodes the shader-cache warmer runs: the RTX-renderer subset of the lists above, +# which are the only ones that compile PSO blobs. isaacsim_rtx fills kit/ and ovrtx +# fills kitless/; the newton_renderer nodes drive the Warp rasterizer and populate +# neither tree. Exact node IDs rather than a -k expression so a parametrize-ID +# rename fails collection instead of silently selecting nothing. +ovrtx-cache-warm = [ + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[newton-isaacsim_rtx_renderer-static]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole.py::test_rendering_cartpole[physx-isaacsim_rtx_renderer-static]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[legacy-newton-ovrtx_renderer-static]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[legacy-ovphysx-ovrtx_renderer-static]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovstage-newton-ovrtx_renderer-static]", + "source/isaaclab_tasks/test/core/test_rendering_cartpole_kitless.py::test_rendering_cartpole_kitless[ovstage-ovphysx-ovrtx_renderer-static]", +] diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3eded6f78b21..71b2825f107e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -46,7 +46,7 @@ # -e TEST_FILTER_PATTERN="isaaclab_physx" \ # -e TEST_INCLUDE_FILES="test_rigid_object_collection.py" \ # -v "$PWD":/workspace/isaaclab isaac-lab-base:latest \ -# -c 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest tools -v' +# -c 'cd /workspace/isaaclab && ./isaaclab.sh -p -m pytest tools -v' # # Remember to REVERT all temporary changes before merging! # ============================================================================= @@ -223,6 +223,7 @@ jobs: isaacsim-version: ${{ needs.config.outputs.isaacsim_image_tag }} dockerfile-path: docker/Dockerfile.base cache-tag: cache-base + verify-test-path: docker/test/test_image_invariants.py build-curobo: name: Build cuRobo Docker Image @@ -697,16 +698,6 @@ jobs: # Folded from the former standalone verify-base-non-root job: reuses the # base image already pulled by run-package-tests to keep the regression # check without burning a separate runner. - - name: Run Dockerfile non-root regression test - shell: bash - run: | - set -euo pipefail - docker run --rm \ - -v "$PWD":/workspace/isaaclab \ - --entrypoint bash \ - "${{ needs.config.outputs.ci_image_tag }}" \ - -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' - - name: Verify Base runtime user is non-root shell: bash run: | @@ -747,16 +738,6 @@ jobs: # Folded from the former standalone verify-curobo-non-root job: reuses # the curobo image already pulled by run-package-tests to keep the # regression check without burning a separate runner. - - name: Run Dockerfile non-root regression test - shell: bash - run: | - set -euo pipefail - docker run --rm \ - -v "$PWD":/workspace/isaaclab \ - --entrypoint bash \ - "${{ needs.config.outputs.ci_image_tag }}-curobo" \ - -lc 'cd /workspace/isaaclab && /isaac-sim/python.sh -m pytest docker/test/test_dockerfile_nonroot.py -q' - - name: Verify cuRobo runtime user is non-root shell: bash run: | @@ -1044,9 +1025,11 @@ jobs: test_rendering_cartpole_kitless.py # Only the two RTX paths compile PSO blobs: isaacsim_rtx fills kit/ and # ovrtx fills kitless/. newton_warp would exercise the Warp rasterizer, - # which populates neither tree. - test-k-expr: >- - (isaacsim_rtx or ovrtx) and (rgb or depth) + # which populates neither tree. The nodes are pinned by exact ID rather + # than a -k expression, so a parametrize-ID rename fails collection here + # instead of quietly leaving the warmer with nothing to run. + test-node-ids-file: .github/test-subsets/postmerge-rendering.toml + test-node-ids-key: ovrtx-cache-warm ovrtx-shader-cache: ${{ env.PUBLISHES_OVRTX_CACHE == 'true' && 'save' || 'restore' }} container-name: isaac-lab-ovrtx-cache-warm omni-github-test-type: ovrtx-cache-warm diff --git a/.github/workflows/config.yaml b/.github/workflows/config.yaml index 2aab86d10775..6d170ef41dbb 100644 --- a/.github/workflows/config.yaml +++ b/.github/workflows/config.yaml @@ -9,7 +9,7 @@ # (frozen at 6.1.0-alpha.2). Both images use Isaac Sim's NGC org, which is current and # which the CI credential can reach. isaacsim_image_name: nvcr.io/0947644777160149/internal/isaac-sim -# Test the release/3.0.0 branch against the latest Isaac Sim 6.1 release image. -isaacsim_image_tag: latest-release-6-1 +# Isaac Sim 6.1.0-alpha.50 (b86cf6ce) includes Kit 110.3.0-360924's fix for NVBug 6566677. +isaacsim_image_tag: latest-develop@sha256:223adbb0a6f1897ed78d40597dac2efc740843e8051779ffdcbb021a9edc1217 isaaclab_image_name: nvcr.io/0947644777160149/internal/isaac-lab ovphysx_wheelhouse_image: "" diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index ee364b747ee6..09e355ee513f 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -66,7 +66,7 @@ jobs: - name: Install docs dependencies run: | - bash "$GITHUB_WORKSPACE/.github/actions/_lib/with-python-package-retries.sh" uv sync --extra test + bash "$GITHUB_WORKSPACE/.github/actions/_lib/with-python-package-retries.sh" uv sync --extra dev echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - name: Build current version docs @@ -98,7 +98,7 @@ jobs: - name: Install docs dependencies run: | - bash "$GITHUB_WORKSPACE/.github/actions/_lib/with-python-package-retries.sh" uv sync --extra test + bash "$GITHUB_WORKSPACE/.github/actions/_lib/with-python-package-retries.sh" uv sync --extra dev echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - name: Generate multi-version docs diff --git a/.github/workflows/license-check.yaml b/.github/workflows/license-check.yaml index cb3a05125f51..9964bc72a8a4 100644 --- a/.github/workflows/license-check.yaml +++ b/.github/workflows/license-check.yaml @@ -62,7 +62,7 @@ jobs: ISAACSIM_ACCEPT_EULA: YES run: | bash "$GITHUB_WORKSPACE/.github/actions/_lib/with-python-package-retries.sh" \ - uv sync --extra all --extra isaacsim --extra test --extra rlinf --extra mimic + uv sync --extra all --extra isaacsim --extra dev --extra rlinf --extra mimic # ``[tool.uv.pip] prerelease = "allow"`` lets unpinned tools float onto # prereleases. pip-licenses 6.0.0a1 reports an empty License where 5.x reports # ``UNKNOWN``, which license-exceptions.json keys on, and joins multi-license diff --git a/.github/workflows/nightly-changelog.yml b/.github/workflows/nightly-changelog.yml index 2b8499183f39..df3eb5b661f0 100644 --- a/.github/workflows/nightly-changelog.yml +++ b/.github/workflows/nightly-changelog.yml @@ -13,10 +13,10 @@ # Scheduled workflow — must live on the default branch (``main``) for the # cron to register. See ``.github/workflows/README.md``. # -# Adding a branch to the nightly set is a one-line edit to ``env.CRON_BRANCHES`` -# below. Each target branch uses its own ``tools/changelog/cli.py`` (the -# same copy the PR gate already runs), so the nightly compile honours the -# same rules that validated the fragments. +# Scheduled runs combine ``env.CRON_BRANCHES`` with every remote +# ``release/*`` branch that carries ``tools/changelog/cli.py``. This picks up +# new compiler-enabled release branches automatically while leaving historical +# branches that predate the fragment system untouched. # # The push uses a short-lived GitHub App installation token minted from # ``CHANGELOG_APP_ID`` + ``CHANGELOG_APP_PRIVATE_KEY`` (repo secrets). The @@ -30,13 +30,11 @@ name: Nightly Changelog Compilation -# Branches the nightly cron compiles. Single source of truth — append a -# ref here to extend the nightly set (the active release branch belongs -# here). Each branch must carry ``tools/changelog/cli.py`` and the -# isaaclab-bot App must be in its branch-ruleset bypass list. -# Surrounding whitespace per entry is stripped by the resolver below. +# Static branches the nightly cron compiles. The resolver also discovers every +# remote ``release/*`` branch that carries ``tools/changelog/cli.py``. Every +# resolved branch must allow the isaaclab-bot App to bypass its ruleset. env: - CRON_BRANCHES: develop,release/3.0.0-beta2 + CRON_BRANCHES: develop on: schedule: @@ -51,7 +49,7 @@ on: # develop) without needing to update the workflow. A branch # that lacks ``tools/changelog/cli.py`` fails the verify step # below with a clear error, which is the desired failure mode. - description: 'Branch to compile (e.g. develop, release/3.0.0-beta2). Must carry tools/changelog/cli.py.' + description: 'Branch to compile (e.g. develop, release/3.0.0). Must carry tools/changelog/cli.py.' required: true type: string dry_run: @@ -67,10 +65,8 @@ permissions: jobs: resolve-branches: - # CSV → JSON array bridge. ``workflow_dispatch`` inputs can only be - # string / bool / choice, so the branch list arrives as a comma- - # separated string; the matrix below needs a JSON list to fan out. - # Mirrors the ``setup-versions`` job in ``daily-compatibility.yml``. + # Static/manual branches plus supported ``release/*`` refs → JSON array. + # ``workflow_dispatch`` remains exactly one explicitly requested branch. name: Resolve branch list runs-on: ubuntu-latest timeout-minutes: 1 @@ -79,18 +75,39 @@ jobs: steps: - id: b env: - # Schedule → the CRON_BRANCHES list. Manual → the single branch - # the maintainer entered. The two paths are intentionally - # asymmetric: cron is the configured set, manual is exactly one - # branch (required input). + # Schedule → static branches plus supported remote ``release/*`` + # refs. Manual → exactly the branch the maintainer entered. BRANCHES: ${{ github.event_name == 'schedule' && env.CRON_BRANCHES || inputs.branch }} # ``EVENT_NAME`` mirrors ``github.event_name`` so the guard below # branches on it without re-interpolating into the shell. EVENT_NAME: ${{ github.event_name }} + REPOSITORY: ${{ github.repository }} + API_TOKEN: ${{ github.token }} run: | - # CSV → JSON array, trimming surrounding whitespace per entry. - # Manual produces a 1-element array; cron produces N elements. - arr=$(echo "$BRANCHES" | tr ',' '\n' | xargs -n1 | jq -R . | jq -s -c .) + branches=$(echo "$BRANCHES" | tr ',' '\n' | xargs -n1) + if [ "$EVENT_NAME" = "schedule" ]; then + release_branches=$(git ls-remote --heads "https://github.com/$REPOSITORY.git" 'refs/heads/release/*' \ + | awk '{sub(/^refs\/heads\//, "", $2); print $2}') + while IFS= read -r branch; do + [ -z "$branch" ] && continue + encoded_branch=$(jq -rn --arg branch "$branch" '$branch | @uri') + http_status=$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $API_TOKEN" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "https://api.github.com/repos/$REPOSITORY/contents/tools/changelog/cli.py?ref=$encoded_branch") + case "$http_status" in + 200) branches=$(printf '%s\n%s\n' "$branches" "$branch") ;; + 404) echo "::notice::Skipping $branch because it predates the changelog compiler." ;; + *) + echo "::error::GitHub Contents API returned HTTP $http_status for $branch." + exit 1 + ;; + esac + done <<< "$release_branches" + fi + # Trim, de-duplicate, and convert the resolved branches to a JSON array. + arr=$(printf '%s\n' "$branches" | sed '/^$/d' | sort -u | jq -R . | jq -s -c .) # Manual trigger contract: exactly one branch. A maintainer who # pastes a comma-separated list into the dispatch form should # see a clear error, not a silent multi-branch fan-out. @@ -109,9 +126,8 @@ jobs: strategy: # Independent branches: one failing shouldn't cancel the others. # Each matrix entry shows up as a separate job tile in the Actions - # UI, so ``release/3.0.0-beta2`` failing doesn't hide ``develop``'s - # success (and ``gh run rerun --failed`` re-runs only the failed - # entry). Mirrors ``daily-compatibility.yml``'s matrix style. + # UI, so one release ref failing doesn't hide another ref's success + # (and ``gh run rerun --failed`` re-runs only the failed entry). fail-fast: false matrix: branch: ${{ fromJson(needs.resolve-branches.outputs.branches) }} diff --git a/.github/workflows/nightly-isaacsim-image.yml b/.github/workflows/nightly-isaacsim-image.yml index 42f59a3cc5de..5a3d97546cdc 100644 --- a/.github/workflows/nightly-isaacsim-image.yml +++ b/.github/workflows/nightly-isaacsim-image.yml @@ -179,6 +179,7 @@ jobs: CURRENT_PIN: ${{ steps.pin.outputs.current }} CANDIDATE_PIN: ${{ steps.pin.outputs.candidate }} DIGEST: ${{ steps.pin.outputs.digest }} + BRANCH_CHANGED: ${{ steps.pin.outputs.branch_changed }} run: | set -euo pipefail @@ -187,6 +188,8 @@ jobs: title="[CI] Bump Isaac Sim image to $short_digest" body_file="$RUNNER_TEMP/isaacsim-image-update.md" { + echo "# Description" + echo echo "This automated draft updates CI to the current Isaac Sim nightly image." echo echo "| Field | Value |" @@ -199,6 +202,30 @@ jobs: echo "Source: https://registry.ngc.nvidia.com/orgs/0947644777160149/teams/internal/containers/isaac-sim/tags" echo echo "New PRs are opened as drafts so maintainers can merge after the CI results are acceptable." + echo + echo "## Type of change" + echo + echo "- Infrastructure update" + echo + echo "## Release backport" + echo + echo '- [ ] Backport this pull request to the active release branch after it merges into `develop`' + echo + echo "## Screenshots" + echo + echo "Not applicable." + echo + echo "## Checklist" + echo + echo "Docker and GPU tests run on demand. This workflow comments \`run-ci\` after opening the pull request or updating its commit." + echo + echo '- [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html)' + echo '- [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format`' + echo '- [ ] I have made corresponding changes to the documentation' + echo '- [ ] My changes generate no new warnings' + echo '- [ ] I have added tests that prove my fix is effective or that my feature works' + echo '- [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that)' + echo '- [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there' } > "$body_file" repository_owner=${REPOSITORY%%/*} @@ -208,6 +235,7 @@ jobs: -f head="$repository_owner:$UPDATE_BRANCH" \ --jq '.[0].number // empty') + pr_created=false if [ -n "$pr_number" ]; then pr_url=$(gh api --method PATCH "repos/$REPOSITORY/pulls/$pr_number" \ -f title="$title" \ @@ -223,10 +251,18 @@ jobs: -F body=@"$body_file" \ -F draft=true \ --jq '.html_url') + pr_number=${pr_url##*/} + pr_created=true echo "Opened draft PR: $pr_url" echo "Draft PR: $pr_url" >> "$GITHUB_STEP_SUMMARY" fi + if [ "$BRANCH_CHANGED" = "true" ] || [ "$pr_created" = "true" ]; then + gh api --method POST "repos/$REPOSITORY/issues/$pr_number/comments" \ + -f body='run-ci' >/dev/null + echo "Triggered CI on PR #$pr_number." + fi + - name: Report no-op or dry run if: ${{ steps.pin.outputs.changed != 'true' || inputs.dry_run }} env: diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1811ca6fd5eb..17e5dcd01062 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -123,6 +123,7 @@ Guidelines for modifications: * Jiwen Cai * Johnson Sun * Juana Du +* Kai Pei * Kaixi Bao * Kourosh Darvish * Kousheek Chakraborty diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 54631a359217..0136558f551e 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -9,6 +9,10 @@ # Base image ARG ISAACSIM_BASE_IMAGE_ARG=nvcr.io/nvidia/isaac-sim ARG ISAACSIM_VERSION_ARG=6.1.0 + +# uv, pinned by digest. Declared after the ARGs so those stay global for the base FROM. +FROM ghcr.io/astral-sh/uv:0.12.9@sha256:8b940d3a9d65bed080436972241af2e21c84b5e8c9193f7014ed71479ee795ff AS uv + FROM ${ISAACSIM_BASE_IMAGE_ARG}:${ISAACSIM_VERSION_ARG} AS base ARG ISAACSIM_VERSION_ARG ENV ISAACSIM_VERSION=${ISAACSIM_VERSION_ARG} @@ -32,10 +36,19 @@ ARG DOCKER_USER_HOME_ARG ENV DOCKER_USER_HOME=${DOCKER_USER_HOME_ARG} ENV HOME=${DOCKER_USER_HOME} -# Set environment variables +# Locale and apt behaviour. ENV LANG=C.UTF-8 ENV DEBIAN_FRONTEND=noninteractive -ENV PIP_RETRIES=12 + +# uv. Dependencies resolve from uv.lock into this venv instead of Isaac Sim's own +# site-packages; isaaclab.sh sources setup_python_env.sh so ``isaacsim`` stays importable. +# uv is pinned to Kit's interpreter: ``python-preference = "only-managed"`` would otherwise +# download a CPython that cannot load Kit's extension modules. +ARG VENV_PATH_ARG=/opt/isaaclab-venv +ENV VIRTUAL_ENV=${VENV_PATH_ARG} +ENV UV_PROJECT_ENVIRONMENT=${VENV_PATH_ARG} +ENV UV_PYTHON=${ISAACSIM_ROOT_PATH_ARG}/kit/python/bin/python3 +ENV UV_PYTHON_PREFERENCE=only-system ENV UV_HTTP_RETRIES=12 # Base image may end with a non-root user; switch to root for system-level @@ -74,27 +87,24 @@ RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ # copy files necessary for installing Isaac Lab and its dependencies # this way we minimize the chance of cache invalidation on file edits -COPY ../isaaclab.* ../environment.yml ../pyproject.toml ${ISAACLAB_PATH}/ +COPY --from=uv /uv /uvx /usr/local/bin/ +COPY ../isaaclab.* ../environment.yml ../pyproject.toml ../uv.lock ${ISAACLAB_PATH}/ COPY ../tools/ ${ISAACLAB_PATH}/tools/ -COPY ../source/ ${ISAACLAB_PATH}/source/ - -# Fix the line endings for the shell scripts (Windows git may add \r) -RUN find ${ISAACLAB_PATH} -type f -name "*.sh" -exec sed -i 's/\r$//' {} + - -# Ensure isaaclab.sh has execute permissions -RUN chmod +x ${ISAACLAB_PATH}/isaaclab.sh # Set up a symbolic link between the installed Isaac Sim root folder and _isaac_sim in the Isaac Lab directory RUN ln -sf ${ISAACSIM_ROOT_PATH} ${ISAACLAB_PATH}/_isaac_sim -# Install toml (used by tools/install_deps.py) and pytest (needed to run -# in-container tests; moved to optional [test] extra in pyproject.toml) -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install toml pytest +# Created before the venv so every install layer can set ownership as it writes, instead of +# a recursive chown afterwards: on overlayfs a chown copies up every file it touches, which +# costs both build time and a duplicate of the venv in the image. +# The uid/gid 1000 match GitHub runner bind mounts used by Docker tests. +# --non-unique is required because some base image revisions already carry +# another user or group at uid/gid 1000. +RUN groupadd --non-unique --gid 1000 isaaclab \ + && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab -# Install apt dependencies for extensions that declare them in their extension.toml, -RUN ${ISAACLAB_PATH}/isaaclab.sh -p ${ISAACLAB_PATH}/tools/install_deps.py apt ${ISAACLAB_PATH}/source && \ - apt-get -y autoremove && apt-get clean && \ - rm -rf /var/lib/apt/lists/* +# Created up front so the install steps below resolve into it. +RUN uv venv --seed "${VIRTUAL_ENV}" # for singularity usage, have to create the directories that will binded RUN mkdir -p ${ISAACSIM_ROOT_PATH}/kit/cache && \ @@ -116,41 +126,79 @@ RUN touch /bin/nvidia-smi && \ mkdir -p /var/run/nvidia-persistenced && \ touch /var/run/nvidia-persistenced/socket -# robomimic declares imageio-ffmpeg for optional video helpers. Keep its -# bundled FFmpeg binary out of the distributed image and let users who -# enable video recording install MoviePy and its video backend explicitly. -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install && \ - ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y imageio-ffmpeg +# Workspace manifests only: ``uv sync --no-install-workspace`` needs every member's +# pyproject.toml to build an installation plan, but not their sources. Resolving the ~240 +# third-party packages here means a source edit re-runs only the workspace install below. +COPY --parents ../source/*/pyproject.toml ${ISAACLAB_PATH}/ + +# The image's package set. Kit ships in the base image, so the teleop stack comes without the +# ``isaacsim`` wheel; ``teleop`` itself is left exactly as tuned. +ARG IMAGE_EXTRAS="--extra sb3 --extra skrl --extra rl-games --extra rsl-rl --extra viser --extra rerun --extra mimic --extra teleop --extra test" -# HACK: Remove install of quadprog dependency -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog + +RUN --mount=type=cache,target=/root/.cache/uv \ + cd ${ISAACLAB_PATH} && \ + uv sync --frozen --inexact ${IMAGE_EXTRAS} \ + --no-install-workspace --no-install-package imageio-ffmpeg && \ + chown -R isaaclab:isaaclab ${VIRTUAL_ENV} && \ + chmod -R a+rwX ${VIRTUAL_ENV}/lib ${VIRTUAL_ENV}/bin + +COPY ../source/ ${ISAACLAB_PATH}/source/ + +# Fix the line endings for the shell scripts (Windows git may add \r), then restore the +# launcher's mode; one layer because both walk the tree the COPY above just wrote. ``sed -i`` +# rewrites the file under the build umask, so an explicit 755 keeps it runnable by a +# container started with a uid other than the image's own. +RUN find ${ISAACLAB_PATH} -type f -name "*.sh" -exec sed -i 's/\r$//' {} + \ + && chmod 755 ${ISAACLAB_PATH}/isaaclab.sh + +# Install apt dependencies for extensions that declare them in their extension.toml, +RUN ${ISAACLAB_PATH}/isaaclab.sh -p ${ISAACLAB_PATH}/tools/install_deps.py apt ${ISAACLAB_PATH}/source && \ + apt-get -y autoremove && apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# robomimic and moviepy declare imageio-ffmpeg for optional video helpers. Its bundled +# FFmpeg binary stays out of the distributed image; users who enable video recording install +# MoviePy and its video backend explicitly. +RUN --mount=type=cache,target=/root/.cache/uv \ + cd ${ISAACLAB_PATH} && \ + uv sync --frozen --inexact ${IMAGE_EXTRAS} \ + --no-install-package imageio-ffmpeg && \ + find ${VIRTUAL_ENV} \! -user isaaclab \ + -exec chown isaaclab:isaaclab {} + -exec chmod a+rwX {} + + +# Isaac Sim's prebundled packages sit on PYTHONPATH ahead of the venv for launch paths that +# never import isaaclab (``runheadless.sh``, ``isaac-sim.streaming.sh``), so its torch would +# shadow the venv's and load a libtorch_cuda.so needing an NCCL symbol the bundled NCCL does +# not export (nvbugs 6343978). Repoint them at the venv; the pip install path did this itself. +RUN ${ISAACLAB_PATH}/isaaclab.sh -p -c \ + "from isaaclab.cli.commands.install import _repoint_prebundle_packages; _repoint_prebundle_packages()" + +# uv venv/uv sync above needed Kit's interpreter as the venv base; from here on ``uv pip`` +# should target the venv, including the interactive ``pip`` alias below. +ENV UV_PYTHON=${VENV_PATH_ARG}/bin/python # aliasing isaaclab.sh and python for convenience RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python='${ISAACLAB_PATH}/isaaclab.sh -p'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python3='${ISAACLAB_PATH}/isaaclab.sh -p'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip='uv pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip3='uv pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias tensorboard='${VIRTUAL_ENV}/bin/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "export TZ=$(date +%Z)" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "shopt -s histappend" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "PROMPT_COMMAND='history -a'" >> ${DOCKER_USER_HOME}/.bashrc -# Create the non-root runtime user after root-only image setup is complete. -# The uid/gid 1000 match GitHub runner bind mounts used by Docker tests. -# --non-unique is required because some base image revisions already carry -# another user or group at uid/gid 1000. -RUN groupadd --non-unique --gid 1000 isaaclab \ - && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab - # The Isaac Sim image sets HUB__ARGS__DETECT_ONLY=true, which forbids OmniHub from starting. # omni.client still asks it to launch, so every Kit startup retries ~39 times before giving # up. Letting OmniHub start also needs its cache at /var/cache/hub, owned by the isaac-sim # user (uid 1234) upstream, to be writable by the uid 1000 runtime user. ENV HUB__ARGS__DETECT_ONLY=false +# CI installs optional extras into the venv at test time (e.g. ``pytetwild`` for the +# tetrahedralization feature) and a volume-mounted run executes as the runner's uid rather +# than the image's, so the paths pip writes must be writable by a uid the image cannot know. RUN chown -R isaaclab:isaaclab \ ${ISAACLAB_PATH} \ ${DOCKER_USER_HOME} \ diff --git a/docker/Dockerfile.curobo b/docker/Dockerfile.curobo index 00a038874af6..1ad472f8d88b 100644 --- a/docker/Dockerfile.curobo +++ b/docker/Dockerfile.curobo @@ -9,6 +9,9 @@ # Base image ARG ISAACSIM_BASE_IMAGE_ARG=nvcr.io/nvidia/isaac-sim ARG ISAACSIM_VERSION_ARG=6.1.0 +# uv, pinned by digest. Declared after the ARGs so those stay global for the base FROM. +FROM ghcr.io/astral-sh/uv:0.12.9@sha256:8b940d3a9d65bed080436972241af2e21c84b5e8c9193f7014ed71479ee795ff AS uv + FROM ${ISAACSIM_BASE_IMAGE_ARG}:${ISAACSIM_VERSION_ARG} AS base ARG ISAACSIM_VERSION_ARG ENV ISAACSIM_VERSION=${ISAACSIM_VERSION_ARG} @@ -35,9 +38,16 @@ ENV HOME=${DOCKER_USER_HOME} # Set environment variables ENV LANG=C.UTF-8 ENV DEBIAN_FRONTEND=noninteractive -ENV PIP_RETRIES=12 ENV UV_HTTP_RETRIES=12 +# uv. Dependencies resolve from uv.lock into this venv instead of Isaac Sim's own +# site-packages; isaaclab.sh sources setup_python_env.sh so ``isaacsim`` stays importable. +ARG VENV_PATH_ARG=/opt/isaaclab-venv +ENV VIRTUAL_ENV=${VENV_PATH_ARG} +ENV UV_PROJECT_ENVIRONMENT=${VENV_PATH_ARG} +ENV UV_PYTHON=${ISAACSIM_ROOT_PATH_ARG}/kit/python/bin/python3 +ENV UV_PYTHON_PREFERENCE=only-system + # Base image may end with a non-root user; switch to root for system-level # setup and package installation. USER root @@ -105,30 +115,6 @@ ARG LD_LIBRARY_PATH ENV LD_LIBRARY_PATH=${CUDA_HOME}/lib64:${LD_LIBRARY_PATH} ENV TORCH_CUDA_ARCH_LIST=8.0+PTX -# copy files necessary for installing Isaac Lab and its dependencies -# this way we minimize the chance of cache invalidation on file edits -COPY ../isaaclab.* ../environment.yml ../pyproject.toml ${ISAACLAB_PATH}/ -COPY ../tools/ ${ISAACLAB_PATH}/tools/ -COPY ../source/ ${ISAACLAB_PATH}/source/ - -# Fix the line endings for the shell scripts (Windows git may add \r) -RUN find ${ISAACLAB_PATH} -type f -name "*.sh" -exec sed -i 's/\r$//' {} + - -# Ensure isaaclab.sh has execute permissions -RUN chmod +x ${ISAACLAB_PATH}/isaaclab.sh - -# Set up a symbolic link between the installed Isaac Sim root folder and _isaac_sim in the Isaac Lab directory -RUN ln -sf ${ISAACSIM_ROOT_PATH} ${ISAACLAB_PATH}/_isaac_sim - -# Install toml (used by tools/install_deps.py) and pytest (needed to run -# in-container tests; moved to optional [test] extra in pyproject.toml) -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install toml pytest - -# Install apt dependencies for extensions that declare them in their extension.toml, -RUN ${ISAACLAB_PATH}/isaaclab.sh -p ${ISAACLAB_PATH}/tools/install_deps.py apt ${ISAACLAB_PATH}/source && \ - apt-get -y autoremove && apt-get clean && \ - rm -rf /var/lib/apt/lists/* - # for singularity usage, have to create the directories that will binded RUN mkdir -p ${ISAACSIM_ROOT_PATH}/kit/cache && \ mkdir -p ${DOCKER_USER_HOME}/.cache/ov && \ @@ -149,57 +135,108 @@ RUN touch /bin/nvidia-smi && \ mkdir -p /var/run/nvidia-persistenced && \ touch /var/run/nvidia-persistenced/socket -# HACK: Remove pre-bundled torch BEFORE installing Isaac Lab dependencies. -# This forces isaaclab.sh --install to install torch fresh to site-packages, -# rather than skipping because it detects the pre-bundled version. -RUN rm -rf ${ISAACSIM_ROOT_PATH}/exts/omni.isaac.ml_archive/pip_prebundle/torch* +# Created before the venv so the install layers can set ownership as they write, instead of +# a recursive chown afterwards: on overlayfs a chown copies up every file it touches, which +# costs both build time and a duplicate of the venv in the image. +# The uid/gid 1000 match GitHub runner bind mounts used by the cuRobo tests. +# --non-unique is required because some base image revisions already carry +# another user or group at uid/gid 1000. +RUN groupadd --non-unique --gid 1000 isaaclab \ + && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab -# HACK: Reinstall pip properly after removing prebundled packages -# The removal of torch* corrupts pip's vendor packages (_structures.py is deleted). -# Verify the pinned bootstrap before removing pip, then do a clean install. -RUN set -euo pipefail && \ - bootstrap_dir="$(mktemp -d)" && \ - trap 'rm -rf -- "${bootstrap_dir}"' EXIT && \ - curl --fail --location --proto '=https' --proto-redir '=https' \ - --retry 2 --connect-timeout 60 --max-time 300 --output "${bootstrap_dir}/get-pip.py" \ - https://raw.githubusercontent.com/pypa/get-pip/f6f644156f23dfe9acc06e7b9ca75eee311f2e37/public/get-pip.py && \ - printf '%s %s\n' fb24e693bab954209a063d90953621412ccad4a500905a726286e038f508ddf6 \ - "${bootstrap_dir}/get-pip.py" | sha256sum --check --strict - && \ - rm -rf ${ISAACSIM_ROOT_PATH}/kit/python/lib/python3.12/site-packages/pip* && \ - ${ISAACLAB_PATH}/_isaac_sim/kit/python/bin/python3 "${bootstrap_dir}/get-pip.py" +# copy files necessary for installing Isaac Lab and its dependencies +# this way we minimize the chance of cache invalidation on file edits +COPY --from=uv /uv /uvx /usr/local/bin/ +COPY ../isaaclab.* ../environment.yml ../pyproject.toml ../uv.lock ${ISAACLAB_PATH}/ +COPY ../tools/ ${ISAACLAB_PATH}/tools/ + +# Fix the line endings for the shell scripts (Windows git may add \r), then restore the +# launcher's mode. ``sed -i`` rewrites the file under the build umask, so an explicit 755 +# keeps it runnable by a container started with a uid other than the image's own. +RUN find ${ISAACLAB_PATH} -type f -name "*.sh" -exec sed -i 's/\r$//' {} + \ + && chmod 755 ${ISAACLAB_PATH}/isaaclab.sh + +# Set up a symbolic link between the installed Isaac Sim root folder and _isaac_sim in the Isaac Lab directory +RUN ln -sf ${ISAACSIM_ROOT_PATH} ${ISAACLAB_PATH}/_isaac_sim + + + + + +# The image's package set. Kit ships in the base image, so the teleop stack comes without the +# ``isaacsim`` wheel; ``teleop`` itself is left exactly as tuned. +ARG IMAGE_EXTRAS="--extra sb3 --extra skrl --extra rl-games --extra rsl-rl --extra viser --extra rerun --extra mimic --extra teleop --extra test" + +# Workspace manifests only: ``uv sync --no-install-workspace`` needs every member's +# pyproject.toml to build an installation plan, but not their sources. Resolving the +# third-party packages here means a source edit re-runs only the workspace install below. +COPY --parents ../source/*/pyproject.toml ${ISAACLAB_PATH}/ -RUN --mount=type=cache,target=${DOCKER_USER_HOME}/.cache/pip \ - ${ISAACLAB_PATH}/isaaclab.sh --install +# Resolving into a venv leaves Isaac Sim's site-packages untouched, so the pre-bundled torch +# no longer has to be deleted to get a fresh one (which corrupted pip's vendored +# ``_structures.py`` and forced a get-pip.py re-bootstrap). ``quadprog`` is not a lock entry, +# so it is never installed and needs no uninstall. ``isaaclab_teleop`` (needed by the Pink IK +# tests) is a workspace member and comes from the sync. +RUN --mount=type=cache,target=/root/.cache/uv \ + cd ${ISAACLAB_PATH} && \ + uv venv --seed "${VIRTUAL_ENV}" && \ + uv sync --frozen --inexact ${IMAGE_EXTRAS} \ + --no-install-workspace --no-install-package imageio-ffmpeg && \ + chown -R isaaclab:isaaclab ${VIRTUAL_ENV} && \ + chmod -R a+rwX ${VIRTUAL_ENV}/lib ${VIRTUAL_ENV}/bin -# HACK: Uninstall quadprog as it causes issues with some reinforcement learning frameworks -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip uninstall -y quadprog +COPY ../source/ ${ISAACLAB_PATH}/source/ + +# Fix the line endings of the scripts the COPY above just wrote. +RUN find ${ISAACLAB_PATH}/source -type f -name "*.sh" -exec sed -i 's/\r$//' {} + -# Install cuRobo from source (pinned commit); needs CUDA env and Torch -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --no-build-isolation \ - "nvidia-curobo @ git+https://github.com/NVlabs/curobo.git@ebb71702f3f70e767f40fd8e050674af0288abe8" +# Install apt dependencies for extensions that declare them in their extension.toml, +RUN ${ISAACLAB_PATH}/isaaclab.sh -p ${ISAACLAB_PATH}/tools/install_deps.py apt ${ISAACLAB_PATH}/source && \ + apt-get -y autoremove && apt-get clean && \ + rm -rf /var/lib/apt/lists/* -# Install isaaclab_teleop (needed by Pink IK tests and related env configs) -RUN ${ISAACLAB_PATH}/isaaclab.sh -p -m pip install --editable ${ISAACLAB_PATH}/source/isaaclab_teleop +# The workspace members install editable now that their sources are present; ``find`` fixes +# only what this layer wrote, so overlayfs does not copy up the venv again. +RUN --mount=type=cache,target=/root/.cache/uv \ + cd ${ISAACLAB_PATH} && \ + uv sync --frozen --inexact ${IMAGE_EXTRAS} \ + --no-install-package imageio-ffmpeg && \ + find ${VIRTUAL_ENV} \! -user isaaclab \ + -exec chown isaaclab:isaaclab {} + -exec chmod a+rwX {} + + +# Isaac Sim's prebundled packages sit on PYTHONPATH ahead of the venv for launch paths that +# never import isaaclab (``runheadless.sh``, ``isaac-sim.streaming.sh``), so its torch would +# shadow the venv's and load a libtorch_cuda.so needing an NCCL symbol the bundled NCCL does +# not export (nvbugs 6343978). Repoint them at the venv; the pip install path did this itself. +RUN ${ISAACLAB_PATH}/isaaclab.sh -p -c \ + "from isaaclab.cli.commands.install import _repoint_prebundle_packages; _repoint_prebundle_packages()" + +# uv venv/uv sync above needed Kit's interpreter as the venv base; from here on ``uv pip`` +# should target the venv, including the interactive ``pip`` alias below. +ENV UV_PYTHON=${VENV_PATH_ARG}/bin/python + +# cuRobo builds CUDA extensions against the torch already in the venv, so build isolation +# stays off; it is pinned to a commit rather than a release. ``--seed`` only provides pip on +# 3.12, so the build backend has to be installed for the isolation-free build to import it. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install setuptools wheel && \ + uv pip install --no-build-isolation \ + "nvidia-curobo @ git+https://github.com/NVlabs/curobo.git@ebb71702f3f70e767f40fd8e050674af0288abe8" \ + && find ${VIRTUAL_ENV} \! -user isaaclab \ + -exec chown isaaclab:isaaclab {} + -exec chmod a+rwX {} + # aliasing isaaclab.sh and python for convenience RUN echo "export ISAACLAB_PATH=${ISAACLAB_PATH}" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "alias isaaclab=${ISAACLAB_PATH}/isaaclab.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias python=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias python3=${ISAACLAB_PATH}/_isaac_sim/python.sh" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias pip='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias pip3='${ISAACLAB_PATH}/_isaac_sim/python.sh -m pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ - echo "alias tensorboard='${ISAACLAB_PATH}/_isaac_sim/python.sh ${ISAACLAB_PATH}/_isaac_sim/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python='${ISAACLAB_PATH}/isaaclab.sh -p'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias python3='${ISAACLAB_PATH}/isaaclab.sh -p'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip='uv pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias pip3='uv pip'" >> ${DOCKER_USER_HOME}/.bashrc && \ + echo "alias tensorboard='${VIRTUAL_ENV}/bin/tensorboard'" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "export TZ=$(date +%Z)" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "shopt -s histappend" >> ${DOCKER_USER_HOME}/.bashrc && \ echo "PROMPT_COMMAND='history -a'" >> ${DOCKER_USER_HOME}/.bashrc -# Create the non-root runtime user after root-only image setup is complete. -# The uid/gid 1000 match GitHub runner bind mounts used by the cuRobo tests. -# --non-unique is required because some base image revisions already carry -# another user or group at uid/gid 1000. -RUN groupadd --non-unique --gid 1000 isaaclab \ - && useradd --non-unique --uid 1000 --gid 1000 -M -l -s /bin/bash -d ${DOCKER_USER_HOME} isaaclab - # The Isaac Sim image sets HUB__ARGS__DETECT_ONLY=true, which forbids OmniHub from starting. # omni.client still asks it to launch, so every Kit startup retries ~39 times before giving # up. Letting OmniHub start also needs its cache at /var/cache/hub, owned by the isaac-sim diff --git a/docker/Dockerfile.kitless b/docker/Dockerfile.kitless index e22d5f9717aa..f45d0f2327e5 100644 --- a/docker/Dockerfile.kitless +++ b/docker/Dockerfile.kitless @@ -7,7 +7,7 @@ # Newton physics, OVRTX rendering, and every core RL framework. ARG BASE_IMAGE_ARG=ubuntu:24.04 -FROM ghcr.io/astral-sh/uv:0.9.25@sha256:13e233d08517abdafac4ead26c16d881cd77504a2c40c38c905cf3a0d70131a6 AS uv +FROM ghcr.io/astral-sh/uv:0.12.9@sha256:8b940d3a9d65bed080436972241af2e21c84b5e8c9193f7014ed71479ee795ff AS uv FROM ${BASE_IMAGE_ARG} AS builder @@ -73,7 +73,7 @@ COPY isaaclab.sh ./ # is not a lock entry. ``uv sync`` reuses the venv only because UV_PYTHON pins the same interpreter # it was created with; otherwise it deletes and rebuilds it. RUN uv venv --seed "${VIRTUAL_ENV}" \ - && uv sync --frozen --inexact --extra all --extra importers \ + && uv sync --frozen --inexact --extra all --extra importers --extra test \ && chmod +x "${ISAACLAB_PATH}/isaaclab.sh" \ && chmod -R a+rwX "${VIRTUAL_ENV}"/lib/python3.*/site-packages/ovrtx \ && python -c "import importlib.metadata as m; \ diff --git a/docker/test/test_container_profiles.py b/docker/test/test_container_profiles.py index 0a55ad914251..aca74d328123 100644 --- a/docker/test/test_container_profiles.py +++ b/docker/test/test_container_profiles.py @@ -289,6 +289,27 @@ def test_kitless_compose_service_has_no_isaac_sim_mounts(): assert all("/kit/" not in mount["target"].lower() for mount in mounts) +def test_image_is_verified_before_it_is_published(): + """A published image must be a verified one. + + The push steps publish under both the commit tag and the deps tag, and a later deps-cache hit + serves that image without rebuilding it, so anything published unverified stays unverified. + """ + action = yaml.safe_load( + (REPO_ROOT / ".github" / "actions" / "ecr-build-push-pull" / "action.yml").read_text(encoding="utf-8") + ) + names = [step["name"] for step in action["runs"]["steps"] if "name" in step] + + assert names.index("Verify freshly built image") < names.index("Push to ECR") < names.index("Push deps tag") + + build = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "build.yaml").read_text(encoding="utf-8")) + (base_build,) = [ + step for step in build["jobs"]["build"]["steps"] if step.get("uses") == "./.github/actions/ecr-build-push-pull" + ] + + assert base_build["with"]["verify-test-path"] == "docker/test/test_image_invariants.py" + + def test_run_tests_links_isaac_sim_only_where_kit_is_installed(): """The kit-less image has no Kit under ``/isaac-sim``, which the runtime mounts create anyway. diff --git a/docker/test/test_dockerfile_nonroot.py b/docker/test/test_dockerfile_nonroot.py index 65a7f848de4b..7cc54ab3289c 100644 --- a/docker/test/test_dockerfile_nonroot.py +++ b/docker/test/test_dockerfile_nonroot.py @@ -28,6 +28,11 @@ def _load_module(name: str, path: Path): ROOT_USERS = {"root", "0"} +# Pinned by digest so a uv release cannot silently change how the lock resolves. Matches the uv +# that regenerated ``uv.lock``; a mismatch reintroduces the marker churn that refresh removed. +UV_PIN = "ghcr.io/astral-sh/uv:0.12.9@sha256:8b940d3a9d65bed080436972241af2e21c84b5e8c9193f7014ed71479ee795ff" + + # Keep every Dockerfile in this map so new containers must make an explicit # runtime-user decision instead of silently escaping this regression test. # Keys are Dockerfile *names* (unique across the repo); values are the @@ -110,16 +115,27 @@ def test_ros2_dockerfile_restores_non_root_runtime_user(): assert _user_directives(dockerfile_text) == ["root", "isaaclab"] +def test_images_share_one_pinned_uv(): + """Every image that installs with uv agrees on the pinned version.""" + pinned = { + path.relative_to(REPO_ROOT).as_posix(): re.findall( + r"FROM (ghcr\.io/astral-sh/uv:\S+) AS uv", path.read_text(encoding="utf-8") + ) + for path in DOCKERFILES + } + pinned = {name: refs for name, refs in pinned.items() if refs} + + assert pinned, "no Dockerfile pins uv" + offenders = {name: refs for name, refs in pinned.items() if refs != [UV_PIN]} + assert not offenders, f"every image must pin {UV_PIN}; got {offenders}" + + def test_kitless_dockerfile_installs_newton_rl_ov_and_visualizers_without_isaac_sim(): """The kit-less image installs its runtime features and importers without the full Isaac Sim runtime.""" dockerfile_text = (DOCKER_DIR / "Dockerfile.kitless").read_text(encoding="utf-8") with (REPO_ROOT / "pyproject.toml").open("rb") as file: extras = tomllib.load(file)["project"]["optional-dependencies"] - assert ( - "FROM ghcr.io/astral-sh/uv:0.9.25@sha256:13e233d08517abdafac4ead26c16d881cd77504a2c40c38c905cf3a0d70131a6 AS uv" - in dockerfile_text - ) # Installed from the lock rather than through isaaclab.sh: only the lock applies # ``[tool.uv] override-dependencies``, the table that holds ``packaging`` above ovphysx's # ``<24`` pin. ``all`` carries rl/visualizer/ov, ``importers`` the standalone wheels. @@ -145,8 +161,21 @@ def test_kitless_dockerfile_installs_newton_rl_ov_and_visualizers_without_isaac_ assert "'rerun-sdk' in names" in dockerfile_text assert "libxrender1" in dockerfile_text assert 'test ! -e "${ISAACLAB_PATH}/_isaac_sim"' in dockerfile_text - assert "COPY docker/docker-compose.yaml docker/docker-compose.yaml" in dockerfile_text - assert "COPY docker/utils/volume_mounts.py docker/utils/volume_mounts.py" in dockerfile_text + # volume_mounts.py parses docker-compose.yaml at runtime, so both must reach the image - + # either named individually or via a whole-tree copy. + for required in ("docker/docker-compose.yaml", "docker/utils/volume_mounts.py"): + assert f"COPY {required} {required}" in dockerfile_text or "COPY . ." in dockerfile_text, ( + f"{required} must be copied into the kit-less image" + ) + + +def test_container_test_runner_only_links_an_actual_isaac_sim_runtime(): + """Cache mount points under /isaac-sim must not masquerade as an Isaac Sim installation.""" + runner_text = (REPO_ROOT / ".github/actions/run-tests/run_tests.sh").read_text(encoding="utf-8") + guarded_link = re.compile(r"if \[ -x /isaac-sim/python\.sh \]; then\s+ln -s /isaac-sim _isaac_sim;?\s+fi") + + assert guarded_link.search(runner_text) + assert runner_text.count("ln -s /isaac-sim _isaac_sim") == 1 # --------------------------------------------------------------------------- # diff --git a/docker/test/test_image_invariants.py b/docker/test/test_image_invariants.py new file mode 100644 index 000000000000..74aea9920244 --- /dev/null +++ b/docker/test/test_image_invariants.py @@ -0,0 +1,54 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Invariants asserted against a *built* container image. + +The image under test is named by ``IMAGE_TAG``; the tests skip when it is unset so a plain +``pytest docker/test`` stays green on a machine with no image. Run one explicitly with:: + + IMAGE_TAG=isaac-lab-base:latest pytest docker/test/test_image_invariants.py +""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +IMAGE_TAG = os.environ.get("IMAGE_TAG", "") + + +def _in_image(script: str) -> str: + """Run ``script`` with bash inside the image under test and return its stdout.""" + result = subprocess.run( + ["docker", "run", "--rm", "--entrypoint", "bash", IMAGE_TAG, "-lc", script], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +@pytest.fixture(autouse=True) +def _require_image(): + if not IMAGE_TAG: + pytest.skip("IMAGE_TAG is unset; no built image to assert against") + + +def test_no_prebundled_package_lost_its_entry_point(): + """A dangling ``__init__.py`` in a prebundle stops Isaac Sim extensions loading. + + Isaac Sim shares prebundled packages between extensions as per-file symlinks, so deleting + or replacing one strands every symlink into it. #6329 added this invariant to the pip + install path after nvbugs 6343978, where it cost 438 error lines and 14 failed extensions. + The images now install with ``uv sync``, which never calls that code, so assert it here. + + Only ``__init__.py`` is fatal: the shipped image already carries dangling submodules and + ``.pyi`` stubs that no extension imports - 41 of them, against develop's 48 - mostly + generated protobuf stubs inside an Omniverse extension's own prebundle. + """ + broken = _in_image('find / -path "*pip_prebundle*" -xtype l -name "__init__.py" 2>/dev/null || true').strip() + assert not broken, "prebundled packages lost their entry point:\n" + broken diff --git a/docker/test/test_security_dependencies.py b/docker/test/test_security_dependencies.py index 6cdc8065ac05..2872cd7a6695 100644 --- a/docker/test/test_security_dependencies.py +++ b/docker/test/test_security_dependencies.py @@ -83,54 +83,12 @@ def test_contract_workflows_use_hash_locked_dependencies(self): self.assertNotIn("--with pytest", text) self.assertIn('"${contract_env}/bin/python" -m pytest', text) - def test_curobo_verifies_bootstrap_before_removing_or_executing_pip(self): + def test_curobo_avoids_get_pip_bootstrap(self): text = (REPO_ROOT / "docker/Dockerfile.curobo").read_text(encoding="utf-8") - bootstrap = text.split("# HACK: Reinstall pip", 1)[1].split("\n\n", 1)[0] - self.assertIn("--proto '=https' --proto-redir '=https'", bootstrap) - self.assertIn( - "https://raw.githubusercontent.com/pypa/get-pip/f6f644156f23dfe9acc06e7b9ca75eee311f2e37/", bootstrap - ) - self.assertIn("fb24e693bab954209a063d90953621412ccad4a500905a726286e038f508ddf6", bootstrap) - verified = bootstrap.index("sha256sum --check --strict -") - self.assertLess(verified, bootstrap.index("rm -rf ${ISAACSIM_ROOT_PATH}/kit/python")) - self.assertLess(verified, bootstrap.index('python3 "${bootstrap_dir}/get-pip.py"')) - - def test_curobo_checksum_failure_preserves_pip_and_prevents_execution(self): - text = (REPO_ROOT / "docker/Dockerfile.curobo").read_text(encoding="utf-8") - bootstrap = text.split("# HACK: Reinstall pip", 1)[1].split("\n\n", 1)[0].split("\nRUN ", 1)[1] - for valid_checksum in (False, True): - with self.subTest(valid_checksum=valid_checksum), tempfile.TemporaryDirectory() as directory: - root = Path(directory) - bin_dir = root / "bin" - bin_dir.mkdir() - pip_dir = root / "kit/python/lib/python3.12/site-packages/pip" - pip_dir.mkdir(parents=True) - python_exe = root / "_isaac_sim/kit/python/bin/python3" - python_exe.parent.mkdir(parents=True) - commands = { - bin_dir / "curl": 'while [ "$1" != --output ]; do shift; done\nprintf fixture > "$2"', - bin_dir / "sha256sum": f"cat >/dev/null\nexit {0 if valid_checksum else 1}", - python_exe: 'touch "$ISAACLAB_PATH/executed"', - } - for path, body in commands.items(): - path.write_text("#!/bin/sh\nset -eu\n" + body + "\n", encoding="utf-8") - path.chmod(0o755) - result = subprocess.run( - ["bash", "-c", bootstrap], - env={ - **os.environ, - "PATH": f"{bin_dir}:/usr/bin:/bin", - "TMPDIR": directory, - "ISAACSIM_ROOT_PATH": directory, - "ISAACLAB_PATH": directory, - }, - capture_output=True, - text=True, - check=False, - ) - self.assertEqual(result.returncode == 0, valid_checksum, result.stderr) - self.assertEqual((root / "executed").exists(), valid_checksum) - self.assertEqual(pip_dir.exists(), not valid_checksum) + self.assertNotIn("raw.githubusercontent.com/pypa/get-pip", text) + self.assertNotIn('python3 "${bootstrap_dir}/get-pip.py"', text) + self.assertNotIn("site-packages/pip*", text) + self.assertIn("uv sync --frozen --inexact", text) class TestGitLfsInstaller(unittest.TestCase): diff --git a/docs/README.md b/docs/README.md index 7309d0b73a5b..6e23250872e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ This section describes how to build the documentation for the current version of ```bash # 1. Build the current documentation -uv run --isolated --extra test -- make -C docs current-docs +uv run --isolated --extra dev -- make -C docs current-docs # 2. Open the current docs xdg-open docs/_build/current/index.html @@ -27,7 +27,7 @@ xdg-open docs/_build/current/index.html ```batch :: 1. Build the current documentation -uv run --isolated --extra test -- cmd /c docs\make.bat current-docs +uv run --isolated --extra dev -- cmd /c docs\make.bat current-docs :: 2. Open the current docs start docs\_build\current\index.html @@ -43,7 +43,7 @@ This section describes how to build the multi-version documentation, which inclu ```bash # 1. Build the multi-version documentation -uv run --isolated --extra test -- make -C docs multi-docs +uv run --isolated --extra dev -- make -C docs multi-docs # 2. Open the multi-version docs xdg-open docs/_build/index.html @@ -54,7 +54,7 @@ xdg-open docs/_build/index.html ```batch :: 1. Build the multi-version documentation -uv run --isolated --extra test -- cmd /c docs\make.bat multi-docs +uv run --isolated --extra dev -- cmd /c docs\make.bat multi-docs :: 2. Open the multi-version docs start docs\_build\index.html diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index b40b0f503ec9..e2aef9ecfdf1 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -91,6 +91,7 @@ ["IsaacContrib-Lift-Cube-Franka-IK-Abs", "", "", "", "", "tasks/manipulation/franka_lift.jpg"], ["IsaacContrib-Lift-Cube-Franka-IK-Rel", "", "", "", "", "tasks/manipulation/franka_lift.jpg"], ["IsaacContrib-Lift-Cube-OpenArm", "rl_games,rsl_rl", "", "", "", "tasks/manipulation/openarm_uni_lift.jpg"], + ["IsaacContrib-Multitask-Manipulation", "rsl_rl", "", "", "", "tasks/manipulation/multitask_manipulation.jpg"], ["IsaacContrib-Navigation-3DObstacles-ARL-Robot-1", "rl_games,rsl_rl,skrl", "", "", "", "tasks/drone_arl/arl_robot_1_navigation.jpg"], ["IsaacContrib-Navigation-Flat-AnymalC", "rsl_rl,skrl", "", "", "", "tasks/navigation/anymal_c_nav.jpg"], ["IsaacContrib-NutPour-GR1T2-Pink-IK-Abs", "", "", "", ""], diff --git a/docs/source/concepts/ovphysx.rst b/docs/source/concepts/ovphysx.rst index 824cb7fcf524..172dad7e47e2 100644 --- a/docs/source/concepts/ovphysx.rst +++ b/docs/source/concepts/ovphysx.rst @@ -85,24 +85,17 @@ supported. Installation ------------ -The Isaac Lab source install includes the ``isaaclab_ov`` package, but it -does not install the heavier ``ovphysx`` runtime wheel by default. After a -standard source install, install the optional OvPhysX runtime dependency from -the repository root: +The public ``ovphysx`` extra remains pinned to OvPhysX 0.5.11. Install it from +the repository root with: .. code-block:: bash - ./isaaclab.sh -i 'ov[ovphysx]' + uv sync --inexact --extra ovphysx -You can also install all OV runtime wheels with: - -.. code-block:: bash - - ./isaaclab.sh -i 'ov[all]' - -The ``ov[ovphysx]`` selector installs the ``ovphysx`` runtime wheel declared by -the root ``pyproject.toml`` ``ov`` extra. If the wheel is missing, OvPhysX-specific -tests skip with ``ovphysx wheel not installed`` and user code fails at import time. +The ``--inexact`` flag preserves packages installed through other extras. +Use ``--extra ov`` to install both public OvPhysX and OVRTX runtimes. The legacy +Isaac Lab installer also supports ``./isaaclab.sh -i 'ov[ovphysx]'`` and +``./isaaclab.sh -i 'ov[all]'``. Testing the Installation ------------------------ @@ -115,7 +108,7 @@ First check that the Python package and runtime wheel import correctly: .. code-block:: bash - uv run python -c "import ovphysx.types; from isaaclab_ov.physics import OvPhysxCfg; print('OvPhysX runtime OK')" + uv run --extra ovphysx --extra test python -c "import ovphysx.types; from isaaclab_ov.physics import OvPhysxCfg; print('OvPhysX runtime OK')" .. tab-item:: isaaclab.sh / isaaclab.bat @@ -131,7 +124,7 @@ Then run a small backend smoke test: .. code-block:: bash - uv run python -m pytest source/isaaclab_ov/test/assets/test_rigid_object.py::test_initialization -k cpu + uv run --extra ovphysx --extra test python -m pytest source/isaaclab_ov/test/assets/test_rigid_object.py::test_initialization -k cpu .. tab-item:: isaaclab.sh / isaaclab.bat @@ -148,7 +141,7 @@ syntax as the other backends: .. code-block:: bash - uv run isaaclab zero_agent --task Isaac-Cartpole-Direct \ + uv run --extra ovphysx isaaclab zero_agent --task Isaac-Cartpole-Direct \ --num_envs 128 --max_steps 64 --viz none physics=ovphysx .. tab-item:: isaaclab.sh / isaaclab.bat diff --git a/docs/source/concepts/using_mpm.rst b/docs/source/concepts/using_mpm.rst index 49c9c4d8ec86..ccbc7692a57a 100644 --- a/docs/source/concepts/using_mpm.rst +++ b/docs/source/concepts/using_mpm.rst @@ -103,6 +103,58 @@ states, collision geometry, or a stable timestep. Coupled MPM entries do not support this manager-level projection pass. +Render a Particle Surface +------------------------- + +MPM simulation state remains a set of particles. Surface reconstruction is an +optional visualization pass: it does not change particle motion, collisions, or +material behavior. Run the teapot example to compare the available modes: + +.. code-block:: bash + + # Reconstructed surface (default) + uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + --visualizer newton_gl --fluid_render_mode surface + # Surface and source particles together + uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + --visualizer newton_gl --fluid_render_mode both + # Path-traced translucent surface + uv run --extra ovrtx python scripts/demos/mpm/teapot_fill.py --device cuda:0 \ + --visualizer newton_rtx --fluid_render_mode surface + +Surface rendering is available in the Newton GL and Newton RTX visualizers. +The Kit visualizer continues to render the MPM particles directly. + +To reconstruct a surface in another Newton MPM script, create one reusable +``newton.geometry.ParticleSurface`` after ``sim.reset()``. On each render update, +extract from the current Newton particle positions, radii, flags, and world indices, +then pass the returned vertex, triangle-index, and normal arrays to +``NewtonGLVisualizer.log_mesh()`` or ``NewtonRTXVisualizer.log_mesh()`` with +``dynamic=True`` before calling ``sim.render()``. The visualizer stages the latest +mesh by name and submits it inside Newton's required viewer-frame lifecycle. + +Tune reconstruction independently from the simulation: + +* ``voxel_size`` controls surface detail and memory use. It can be smaller than + the MPM solver voxel size. +* ``kernel_radius`` controls how far each particle contributes to the surface. + Start near three times the particle spacing. +* ``max_grid_cells`` provides fixed-capacity storage for CUDA graph capture. + Increase it if the reconstructed domain outgrows the reserved grid. +* Anisotropic kernels preserve sheets and stretched fluid features better, but + cost more than isotropic kernels. + +The demo handles CUDA graph capture, empty surfaces, inactive particles, and +dynamic topology in one reusable helper: + +.. dropdown:: ``FluidSurfaceRenderer`` implementation + :icon: code + + .. literalinclude:: ../../../scripts/demos/mpm/teapot_fill.py + :language: python + :pyobject: FluidSurfaceRenderer + + Tune Resolution, Time, Then Convergence --------------------------------------- diff --git a/docs/source/features/imitation-learning/humanoids_imitation.rst b/docs/source/features/imitation-learning/humanoids_imitation.rst index 11c6d77efb32..f745fedebf28 100644 --- a/docs/source/features/imitation-learning/humanoids_imitation.rst +++ b/docs/source/features/imitation-learning/humanoids_imitation.rst @@ -81,7 +81,7 @@ Collect five demonstrations by running the following command: .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-PickPlace-GR1T2-Abs \ --visualizer kit \ --xr \ @@ -103,7 +103,7 @@ You can replay the collected demonstrations by running the following command: .. code:: bash - uv run --extra teleop isaaclab teleop replay \ + uv run --extra teleop,isaacsim isaaclab teleop replay \ --task IsaacContrib-PickPlace-GR1T2-Abs \ --visualizer kit \ --device cpu \ @@ -274,7 +274,7 @@ generated using Isaac Lab Mimic for the ``Isaac-NutPour-GR1T2-Pink-IK-Abs-Mimic- .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-NutPour-GR1T2-Pink-IK-Abs \ --visualizer kit \ --device cpu \ @@ -430,7 +430,7 @@ Follow the same data collection, annotation, and generation process as demonstra .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --device cpu \ --xr \ --visualizer kit \ @@ -446,7 +446,7 @@ Follow the same data collection, annotation, and generation process as demonstra .. code:: bash - uv run --extra teleop isaaclab teleop replay \ + uv run --extra teleop,isaacsim isaaclab teleop replay \ --device cpu \ --visualizer kit \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ @@ -774,12 +774,11 @@ Integrating 3D Gaussian Splatting into SDG This section extends :ref:`locomanipulation SDG ` -by replacing the synthetic background with a 3D Gaussian Splatting (NuRec) scene. As in the -base pipeline, the workflow takes a manipulation dataset as input and produces a combined -navigation and manipulation dataset as an HDF5 file — but here the robot navigates and -manipulates objects inside a neurally-rendered environment, and an ego-centric camera -captures the result, producing more realistic training data than a purely synthetic scene. -NVIDIA Isaac Sim renders 3DGS models stored as USD assets; see +by replacing the synthetic background with a 3D Gaussian Splatting (NuRec) scene. The workflow +takes a manipulation dataset as input and produces a combined navigation and manipulation +dataset as an HDF5 file. The navigation replay happens in a neurally-reconstructed environment, +and an ego-centric camera captures the result, producing more realistic training data. +For more info on NuRec rendering in Isaac Sim, see `Neural Volume Rendering `__ for details. @@ -805,21 +804,17 @@ Log in to Hugging Face: hf auth login --token -Download the required USDZ stage files and occupancy maps: +Download the required USDZ stage file and occupancy maps: .. code:: bash hf download nvidia/PhysicalAI-Robotics-NuRec \ - hand_hold-voyager-babyboom/stage_volume.usdz \ - hand_hold-voyager-babyboom/stage_particle.usdz \ + hand_hold-voyager-babyboom/particle_sh_optimized.usdz \ hand_hold-voyager-babyboom/occupancy_map.png \ hand_hold-voyager-babyboom/occupancy_map.yaml \ --repo-type dataset \ --local-dir -The sample includes both a volume-based USD (``stage_volume.usdz``) and a particle-field USD -(``stage_particle.usdz``). Either can be used as the background asset. - Asset requirements """""""""""""""""" @@ -831,11 +826,6 @@ compatible with the SDG pipeline: object placement. - An occupancy map is required for path planning. - - If your scene was reconstructed using the `Stereo Workflow `__, - the occupancy map is generated via ``nvblox``. - - If your background includes a mesh, use the `Occupancy Map Generator `__ - to create a map via physical simulation. - Generating the dataset """""""""""""""""""""" @@ -843,13 +833,7 @@ Before proceeding, ensure you have generated a manipulation dataset or downloade dataset provided in the :ref:`Generate the manipulation dataset ` section. -Once you have gathered: - -- A manipulation dataset -- A background USD asset -- A matched occupancy map - -you can run the generation command. At runtime, the script adds a ground plane at ``z=0`` to +At runtime, the script adds a ground plane at ``z=0`` to the scene. It then proceeds through four stages: 1. **Pick**: The robot picks up an object at the start location by replaying the manipulation @@ -873,18 +857,20 @@ Run the generation command: --num_runs 1 \ --lift_step 60 \ --navigate_step 130 \ - --output_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ - --visualizer kit \ - --background_usd_path /stage_particle.usdz \ - --background_occupancy_yaml_file /occupancy_map.yaml \ - --randomize_placement \ + --output_file /generated_dataset_g1_locomanipulation_sdg_nurec.hdf5 \ + --background_usd_path /hand_hold-voyager-babyboom/particle_sh_optimized.usdz \ + --background_occupancy_yaml_file /hand_hold-voyager-babyboom/occupancy_map.yaml \ --high_res_video +To inspect the run in the GUI, add ``--viz kit``; adjust the perspective camera, or switch +the viewport to the robot camera view for clearer visualization. + The key parameters are: -- ``--background_usd_path``: Path to the 3D Gaussian background USD asset. -- ``--background_occupancy_yaml_file``: Path to the occupancy map file. -- ``--high_res_video``: Capture the ego-centric camera at 960×540 instead of the default +- ``--background_usd_path``: Path to the 3D Gaussian NuRec USD asset. +- ``--background_occupancy_yaml_file``: Path to the NuRec occupancy map file. +- Placement randomization is enabled automatically when a NuRec scene is used. +- ``--high_res_video``: Capture the ego-centric camera at 512×320 instead of the default 256×160. When the run completes successfully, an HDF5 dataset is generated containing camera @@ -892,23 +878,15 @@ observations. You can convert the ego-centric camera view to MP4: .. code:: bash - uv run python scripts/tools/hdf5_to_mp4.py \ - --input_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ + uv run --extra mimic python scripts/tools/hdf5_to_mp4.py \ + --input_file /generated_dataset_g1_locomanipulation_sdg_nurec.hdf5 \ --output_dir / \ --input_keys robot_pov_cam \ - --video_width 960 \ - --video_height 540 + --video_width 512 \ + --video_height 320 Set ``--video_width`` and ``--video_height`` to match the resolution captured during -generation: 960×540 with ``--high_res_video``, or 256×160 without it. - -To play the generated MP4 video on Ubuntu, install the following multimedia packages: - -.. code:: bash - - sudo apt update - sudo apt install libavcodec-extra gstreamer1.0-libav gstreamer1.0-plugins-ugly - +generation: 512×320 with ``--high_res_video``, or 256×160 without it. .. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/locomanipulation_sdg_gaussian_background_2x.webp :width: 100% diff --git a/docs/source/features/imitation-learning/skillgen.rst b/docs/source/features/imitation-learning/skillgen.rst index 999bf5e43530..c045a04a085c 100644 --- a/docs/source/features/imitation-learning/skillgen.rst +++ b/docs/source/features/imitation-learning/skillgen.rst @@ -227,7 +227,7 @@ Download and Setup .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel-Skillgen \ --teleop_device spacemouse \ --dataset_file ./datasets/dataset_skillgen.hdf5 \ diff --git a/docs/source/features/imitation-learning/teleop_imitation.rst b/docs/source/features/imitation-learning/teleop_imitation.rst index b888528b2ee6..9f2ca6e1a378 100644 --- a/docs/source/features/imitation-learning/teleop_imitation.rst +++ b/docs/source/features/imitation-learning/teleop_imitation.rst @@ -107,7 +107,7 @@ the environment by quitting the script with Ctrl+C. .. code:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel \ --viz kit \ --num_envs 1 \ @@ -138,7 +138,7 @@ To use a SpaceMouse, simply change ``--teleop_device`` accordingly: .. code:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel \ --viz kit \ --num_envs 1 \ @@ -211,7 +211,7 @@ variant of the task (``IsaacContrib-Stack-Cube-Franka-IK-Abs``): .. code:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-Stack-Cube-Franka-IK-Abs \ --viz kit \ --xr @@ -242,7 +242,7 @@ Select the tab that matches your input device: .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel \ --viz kit \ --dataset_file ./datasets/dataset.hdf5 \ @@ -253,7 +253,7 @@ Select the tab that matches your input device: .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel \ --viz kit \ --dataset_file ./datasets/dataset.hdf5 \ @@ -268,7 +268,7 @@ Select the tab that matches your input device: .. code:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-Stack-Cube-Franka-IK-Abs \ --viz kit \ --dataset_file ./datasets/dataset.hdf5 \ @@ -296,7 +296,7 @@ You can replay the collected demonstrations by running: .. code:: bash - uv run --extra teleop isaaclab teleop replay \ + uv run --extra teleop,isaacsim isaaclab teleop replay \ --task IsaacContrib-Stack-Cube-Franka-IK-Rel \ --viz kit \ --num_envs 1 \ diff --git a/docs/source/features/include/docker_details.inc b/docs/source/features/include/docker_details.inc index 3ef4c9bd23ea..a133ffe19aff 100644 --- a/docs/source/features/include/docker_details.inc +++ b/docs/source/features/include/docker_details.inc @@ -166,9 +166,10 @@ The display will be forwarded to the host machine. .. rubric:: Python Interpreter -The Isaac Sim-based profiles use the Python interpreter provided by Isaac Sim at ``/isaac-sim/python.sh``. -The kit-less profile instead uses the locked Python 3.12 virtual environment at -``/workspace/isaaclab/.venv``. In either container, use the following command: +Every profile installs from ``uv.lock`` into a Python 3.12 virtual environment at +``/opt/isaaclab-venv``. On the Isaac Sim-based profiles the environment is built on Isaac Sim's +own interpreter, and Isaac Sim itself stays outside it, reached through the ``_isaac_sim`` +symlink; ``isaaclab.sh`` puts it on the path. In either container, use the following command: .. code:: bash diff --git a/docs/source/features/isaac_teleop.rst b/docs/source/features/isaac_teleop.rst index e2399d068964..4ffceebe34b5 100644 --- a/docs/source/features/isaac_teleop.rst +++ b/docs/source/features/isaac_teleop.rst @@ -155,7 +155,7 @@ Enable the visualization when launching a teleoperation session: .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit \ --xr \ @@ -461,7 +461,7 @@ on launch -- no headset connection is needed (see :ref:`isaac-teleop-standalone` .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-Stack-Cube-SO101-Joint-Teleop-v0 \ --num_envs 1 \ --visualizer kit \ @@ -490,7 +490,7 @@ only controls whether the scene is rendered to the headset. Follow the connectio .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-Stack-Cube-SO101-Joint-Teleop-v0 \ --num_envs 1 \ --visualizer kit --xr \ @@ -1179,7 +1179,7 @@ for the headless profile, or pass a full file path for a custom profile: .. code-block:: bash # Use the AVP profile - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs \ --visualizer kit --xr \ --cloudxr_env avp @@ -1202,7 +1202,7 @@ Copy a shipped profile and edit it: .. code-block:: bash # Start from the Quest/Pico profile - cp $(uv run --extra teleop python -c \ + cp $(uv run --extra teleop,isaacsim python -c \ "from isaaclab_teleop import CLOUDXR_JS_ENV; print(CLOUDXR_JS_ENV)") ~/my-cloudxr.env Edit ``~/my-cloudxr.env`` to change any values (e.g. ``NV_CXR_ENABLE_PUSH_DEVICES=1`` for @@ -1225,13 +1225,13 @@ If you prefer to run the CloudXR runtime manually in a separate terminal .. code-block:: bash # Disable via CLI flag - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit --xr \ --no-auto_launch_cloudxr # Or disable via environment variable - ISAACLAB_CXR_SKIP_AUTOLAUNCH=1 uv run --extra teleop isaaclab teleop run \ + ISAACLAB_CXR_SKIP_AUTOLAUNCH=1 uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit --xr @@ -1378,7 +1378,7 @@ recorded view follows robot motion: .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-Abs \ --xr --device cpu @@ -1637,7 +1637,7 @@ uses ``create_isaac_teleop_device()`` -- no ``--teleop_device`` flag is needed: .. code-block:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit \ --xr @@ -1657,7 +1657,7 @@ task with the PhysX and differential IK presets as follows: .. code-block:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task Isaac-Reach-Franka \ --visualizer kit \ --dataset_file ./datasets/dataset.hdf5 \ @@ -1670,7 +1670,7 @@ the registered task ID, but not the command-line selector values: .. code-block:: bash - uv run --extra teleop isaaclab teleop replay \ + uv run --extra teleop,isaacsim isaaclab teleop replay \ --task Isaac-Reach-Franka \ --visualizer kit \ --dataset_file ./datasets/dataset.hdf5 \ @@ -1686,7 +1686,7 @@ the input device: .. code-block:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow \ --visualizer kit \ --teleop_device keyboard @@ -1925,7 +1925,7 @@ Optimize XR Performance .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit --xr \ --disable_external_cameras @@ -1946,7 +1946,7 @@ Optimize XR Performance .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --viz none --xr diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index 015ae7b0787f..264fe8cf1f22 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -13,10 +13,9 @@ by hand would be hopelessly slow. Cloning is Isaac Lab's answer: you author a small representative scene under ``/World/envs/env_n`` and the cloner expands it across the rest of the env population for you, optionally with per-env variation. -The expansion itself is performed by each physics backend's native replicator — -USD, PhysX, or Newton — wrapped by Isaac Lab's core :mod:`isaaclab.cloner` module -behind a single uniform surface so the same user code works regardless of which -backend is active. +The expansion itself is performed by USD and the active physics backend's native +replicator, wrapped by Isaac Lab's core :mod:`isaaclab.cloner` module behind a +single uniform surface. .. contents:: On this page :local: @@ -26,24 +25,29 @@ backend is active. The Backend Layer ----------------- -At the bottom of the stack, each backend exposes a single function that takes a -flat description of the world layout and materializes it on its runtime. The -signatures are deliberately parallel so the layers above can target every backend -through one interface: +At the bottom of the stack, each backend exposes a raw function that takes a flat +description of the world layout. These functions are useful for standalone tools +and tests and deliberately have parallel signatures: .. code-block:: text - backend_replicate(stage, sources, destinations, env_ids, mask, positions=None, quaternions=None, ...) + backend_replicate(stage, sources, destinations, env_ids, selection, positions=None, quaternions=None, ...) The arguments are parallel arrays describing the layout: * ``sources`` — source prim paths already authored on the stage. * ``destinations`` — destination templates containing ``"{}"``, formatted with each env id. -* ``env_ids`` — long tensor of target env indices. -* ``mask`` — bool tensor of shape ``[len(sources), num_envs]``; ``mask[i, j]`` is - ``True`` when env ``j`` should be populated from source ``i``. +* ``env_ids`` — NumPy integer array of target env indices. +* ``selection`` — NumPy boolean array of shape ``[len(sources), num_envs]``; + ``selection[i, j]`` is ``True`` when env ``j`` should be populated from source ``i``. + The raw USD function names this argument ``mask``; physics functions name it ``mapping``. * ``positions`` / ``quaternions`` — optional per-env world transforms. +Production scene construction stores those arrays once in a +:class:`~isaaclab.cloner.ClonePlan`. Simulation-owned backend contexts consume the +same value through ``context.replicate(plan)``; no backend rebuilds the mapping +from a second queue of array arguments. + Standalone Examples ~~~~~~~~~~~~~~~~~~~ @@ -56,7 +60,7 @@ control. Production code reaches for one of the ways in .. code-block:: python - import torch + import numpy as np import isaaclab.sim as sim_utils from isaaclab.cloner import usd_replicate @@ -67,10 +71,10 @@ control. Production code reaches for one of the ways in usd_replicate( stage, - sources=["/World/envs/env_0/Cube"], - destinations=["/World/envs/env_{}/Cube"], - env_ids=torch.arange(num_envs, device="cuda:0"), - mask=torch.ones((1, num_envs), dtype=torch.bool, device="cuda:0"), + sources=("/World/envs/env_0/Cube",), + destinations=("/World/envs/env_{}/Cube",), + env_ids=np.arange(num_envs), + mask=np.ones((1, num_envs), dtype=np.bool_), ) **PhysX** — call PhysX and USD on the same sources and destinations (either order): @@ -79,8 +83,12 @@ control. Production code reaches for one of the ways in from isaaclab_physx.cloner import physx_replicate - physx_replicate(stage, sources, destinations, env_ids, mask) - usd_replicate(stage, sources, destinations, env_ids, mask) + sources = ("/World/envs/env_0/Cube",) + destinations = ("/World/envs/env_{}/Cube",) + env_ids = np.arange(num_envs) + mapping = np.ones((1, num_envs), dtype=np.bool_) + physx_replicate(stage, sources, destinations, env_ids, mapping=mapping) + usd_replicate(stage, sources, destinations, env_ids, mask=mapping) **Newton**: @@ -88,7 +96,15 @@ control. Production code reaches for one of the ways in from isaaclab_newton.cloner import newton_physics_replicate - newton_physics_replicate(stage, sources, destinations, env_ids, mapping=mask) + newton_physics_replicate(stage, sources, destinations, env_ids, mapping=mapping) + +**OvPhysX**: + +.. code-block:: python + + from isaaclab_ov.cloner import ovphysx_replicate + + ovphysx_replicate(stage, sources, destinations, env_ids, mapping=mapping) Cloning in a Backend-Agnostic Way @@ -108,13 +124,10 @@ code never branches on the backend. ClonePlan ~~~~~~~~~ -A plan holds the parallel arrays a backend replicate consumes — sources, +A plan holds the parallel arrays used by production clone contexts — sources, destinations, mask, env ids — in one place. Conceptually it is a small table where each row describes one distinct prototype-to-destination mapping; the -fields listed below are that table's columns. Every entry point in -:mod:`isaaclab.cloner` either produces a plan, consumes a plan, or both, so a -quick look at the fields is the fastest way to build intuition for the rest of -this page: +fields listed below are that table's columns: .. list-table:: :header-rows: 1 @@ -127,16 +140,18 @@ this page: * - ``destinations`` - Destination templates with ``"{}"`` for the env id, one per row. * - ``clone_mask`` - - Bool tensor ``[len(sources), num_envs]``; ``True`` when env ``j`` comes from row ``i``. + - NumPy boolean array ``[len(sources), num_envs]``; ``True`` when env ``j`` comes from row ``i``. * - ``env_ids`` - - Long tensor of target env ids. + - Optional NumPy integer array of target env ids; execution requires it. * - ``positions`` - Optional per-env world positions [m], shape ``[num_envs, 3]``. * - ``global_paths`` - Unique prim paths for scene assets shared by every env and therefore not replicated. + * - ``context_rows`` + - Clone-context types mapped to the rows they consume. -The plan is stage-agnostic by design — the same instance can be replayed against a -different stage, inspected by tooling, or serialized. +The plan does not own a stage. Simulation-owned contexts supply their own runtime +when they consume it. When every env is a copy of env_0: @@ -194,19 +209,15 @@ need every variant behind a template. Note that environment ids are not mask col column ``j`` stands for ``env_ids[j]``, and the queries speak ids throughout. A plan is the *what*. Putting one together and handing it to the backends is -the *how*, and Isaac Lab exposes three idiomatic ways to do that. All three end -in the same ``cloner.replicate(plan, stage=...)`` call, so the choice between +the *how*, and Isaac Lab exposes two idiomatic ways to do that. Both end +in the same ``cloner.replicate(plan)`` call, so the choice between them is purely about ergonomics: * The first wraps both phases in a context manager and is what :class:`~isaaclab.scene.InteractiveScene` runs under the hood. Reach for it when you want the lifecycle hidden and you are authoring assets through a scene config. -* The second spells the same flow out as plain function calls, leaving a moment - between the build and the drain where you can inspect or mutate the plan. - Reach for it when you are assembling a scene outside - :class:`~isaaclab.scene.InteractiveScene` or want fine control over timing. -* The third is a one-shot shortcut for the case where every env is just a copy +* The second is a one-shot shortcut for the case where every env is just a copy of env_0. Reach for it in :class:`~isaaclab.envs.DirectRLEnv` and standalone scripts that hand-build the env-0 prototype prim by prim. @@ -214,13 +225,12 @@ them is purely about ergonomics: ~~~~~~~~~~~~~~~~~~~~ :class:`~isaaclab.cloner.ReplicateSession` is a context manager that brackets the -whole cloning lifecycle. Entering the block builds the plan, the body is where -you construct your assets (each one registers itself as part of its constructor), -and exiting the block drains every registration against the plan: +whole cloning lifecycle. Entering the block builds and publishes the plan, the body +constructs assets at their planned source paths, and exiting dispatches that same plan: .. code-block:: python - with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0, device=device, stage=stage): + with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0): for cfg in cfgs: cfg.class_type(cfg) @@ -244,22 +254,6 @@ When envs need to differ across the population, use :class:`~isaaclab.sim.spawners.wrappers.MultiUsdFileCfg`; see :doc:`multi_asset_spawning`. -``make_clone_plan`` + ``replicate`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The same two phases as the session, written as separate function calls. The plan -is built first, asset construction happens in between, and the drain runs -explicitly at the end. The gap between the build and the drain is the point — -that is where you can read the plan back, mutate it, log it, or otherwise -intervene before replication actually happens: - -.. code-block:: python - - plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0, device=device) - for cfg in cfgs: - cfg.class_type(cfg) - cloner.replicate(plan, stage=stage) - ``clone_plan_from_env_0`` + ``replicate`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -278,101 +272,30 @@ subclasses use — they author the env-0 prototype prim by prim in # ... any other assets ... src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) -Every env receives the same prototype. When envs need to differ, use one of the -other two. Hand-built scenes must pass every shared asset root in ``global_paths``; -use ``()`` when there are none. +Every env receives the same prototype. When envs need to differ, declare their +assets on :class:`~isaaclab.scene.InteractiveSceneCfg` so the scene owns the +session-backed lifecycle. Hand-built scenes must pass every shared asset root in +``global_paths``; use ``()`` when there are none. Under the Hood -------------- -To see how the backend-agnostic surface works, follow one asset through the -system. Suppose you write ``Articulation(cfg)`` for a PhysX articulation -somewhere inside a :class:`~isaaclab.cloner.ReplicateSession`. The constructor -does not actually clone anything yet — at that moment the plan describing how -the full env population should be laid out may not even exist. Instead the -constructor *registers* the asset with the cloner, the cloner files the -registration into a queue, and later — when the session exits and the cloner -runs replication — that registration is handed to the backend code that knows -how to replicate a PhysX articulation, with the plan telling it where each -clone goes. - -The story has to look like this because the engines underneath disagree about -*when* and *how* replication actually happens: - -* **PhysX** defers the real work to physics runtime. At construction time the - only thing user code can do is register intent; PhysX replays those - registrations entity by entity when the simulation comes up. -* **USD** is declarative and immediate — calling :func:`~isaaclab.cloner.usd_replicate` - materializes the clones in place, right then and there. -* **Newton** is also declarative and immediate, but it insists on replicating - the whole world in one shot rather than asset by asset, so the framework - cannot just hand it one cfg at a time — everything Newton-related has to be - assembled first. - -Isaac Lab reconciles these into one surface with two small pieces of plumbing. -Every backend supplies its own :class:`~isaaclab.cloner.UsdReplicateContext` / -``PhysxReplicateContext`` / ``NewtonReplicateContext``, a class that hides the -timing and granularity differences above behind a single uniform interface. A -shared :data:`~isaaclab.cloner.REPLICATION_QUEUE` then remembers which asset -cfgs participate until it is time to run. The three -subsections below explain the queue, the contexts, and the function that joins -them against a plan. - -The registration queue -~~~~~~~~~~~~~~~~~~~~~~ - -Assets do not replicate inline. Construction only registers the asset's cfg -through :func:`~isaaclab.cloner.queue_replication`; the queue records *which* -cfgs participate: - -.. code-block:: text +Planning maps each cfg to rows in ``cfg_rows`` and each participating backend to +its subset in ``context_rows``. The active physics manager registers its clone +context during simulation initialization. Assets use that context by default; +:attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts` can select an explicitly +registered context instead. Planning also registers +:class:`~isaaclab.cloner.UsdReplicateContext` for spawned assets when Kit is +available. - REPLICATION_QUEUE - cartpole_cfg - cube_cfg - camera_cfg - ... - -*How* each cfg is cloned is resolved by :func:`~isaaclab.cloner.replicate` at -dispatch. The physics side comes from the cfg's -:attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts` when set, otherwise the -active backend's default physics context, which each backend cloner exports as -``PHYSICS_CONTEXT`` (PhysX and Newton replicate natively; OvPhysX replays its -own clones). :func:`~isaaclab.cloner.replicate` adds -:class:`~isaaclab.cloner.UsdReplicateContext` automatically whenever a cfg has a -spawner and Kit is available, so USD clones accompany physics replication under -Kit and are skipped by default in headless runs. An explicit cfg override may -still request USD replication without Kit. With -:attr:`~isaaclab.cloner.CloneCfg.replicate_physics` disabled, cloning is -USD-only: every physics context is dropped and the physics engine parses the -per-env USD prims directly. - -Deferring the work like this buys three things at once: - -* Replication can wait until the plan is fully built, so the final layout is - known before any prims are spawned. -* Every asset's request is batched into a single backend call instead of one - call per asset. -* Asset code stays free of any branching on which backend is active — it just - registers and lets the framework take it from there. - -:attr:`~isaaclab.scene.InteractiveSceneCfg.replicate_physics` is piped into -:attr:`~isaaclab.cloner.CloneCfg.replicate_physics` and applied at dispatch; -an asset whose only cloning mechanism is physics replication is then simply -not cloned. - -Backend contexts -~~~~~~~~~~~~~~~~ - -Each backend ships a small adapter class — its *replicate context* — that -knows how to take a registered cfg and replicate it on the backend's specific -runtime: +The backend packages expose different context implementations behind one +execution contract: .. code-block:: text @@ -380,29 +303,23 @@ runtime: PhysxReplicateContext # replicates PhysX rigid bodies and articulations NewtonReplicateContext # replicates Newton bodies in its parallel pipeline -A single asset usually resolves to more than one context — PhysX pairs its -context with USD so physics and visuals both follow; Newton's default stack -includes USD only under Kit, so kitless runs skip the authoring cost. This is -where backend differences are absorbed: swapping a scene from PhysX to Newton -swaps which default stack resolves at dispatch, while the cfgs and the rest of -the user code stay unchanged. - -Running replication -~~~~~~~~~~~~~~~~~~~ - -:func:`~isaaclab.cloner.replicate` is what actually runs the registered work. -The dispatch shape is roughly: +:func:`~isaaclab.cloner.replicate` resolves these types through the +:class:`~isaaclab.sim.SimulationContext` backend registry, orders them by +``replicate_priority``, and passes the published plan to each one: .. code-block:: python - def replicate(plan, stage): - for context_cls, rows in group_queue_by_context(plan): - context_cls().replicate(rows=rows, stage=stage) - publish(plan) + plan = published_clone_plan + for context_type in plan.context_rows: + simulation_backends[context_type].replicate(plan) + +The cfg-first lifecycle publishes before ``construct_prototypes()``. The direct +single-source workflow remains post-construction and is published by +:func:`~isaaclab.cloner.replicate` immediately before dispatch. In either form, +each maintained lifecycle passes that exact object to every backend. -Contexts run in a priority order that puts physics ahead of visuals, and the -plan is published to :class:`~isaaclab.sim.SimulationContext` so the rest of the -framework can read the per-env layout back. +USD runs before native physics contexts so the destination topology exists when +they consume it. No fallback context is constructed during dispatch. Collision Filtering ------------------- diff --git a/docs/source/how-to/cloudxr_teleoperation.rst b/docs/source/how-to/cloudxr_teleoperation.rst index 21f77dd54cb1..1132b0a0a98f 100644 --- a/docs/source/how-to/cloudxr_teleoperation.rst +++ b/docs/source/how-to/cloudxr_teleoperation.rst @@ -119,7 +119,7 @@ To use the check on its own -- for example to qualify a machine before setting u .. code-block:: bash - uv run --extra teleop python -c "from isaaclab_teleop import check_system_requirements; print(check_system_requirements().format_table())" + uv run --extra teleop,isaacsim python -c "from isaaclab_teleop import check_system_requirements; print(check_system_requirements().format_table())" .. _install-isaac-teleop: @@ -240,7 +240,7 @@ terminal or ``source`` step is needed. Launch a teleoperation session directly: .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --visualizer kit \ --xr @@ -296,7 +296,7 @@ so pair it with a hand-tracking task such as .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs \ --visualizer kit \ --xr \ @@ -459,7 +459,7 @@ choose the tab that matches your hardware. .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs \ --visualizer kit --xr \ --cloudxr_env avp @@ -711,7 +711,7 @@ Launch a teleoperation session paired with a hand-tracking task, as shown in .. code-block:: bash - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs \ --visualizer kit --xr @@ -737,11 +737,11 @@ Launch a teleoperation session paired with a hand-tracking task, as shown in .. code-block:: bash # Copy a shipped profile and enable push devices - cp $(uv run --extra teleop python -c \ + cp $(uv run --extra teleop,isaacsim python -c \ "from isaaclab_teleop import CLOUDXR_JS_ENV; print(CLOUDXR_JS_ENV)") ~/manus.env sed -i 's/NV_CXR_ENABLE_PUSH_DEVICES=0/NV_CXR_ENABLE_PUSH_DEVICES=1/' ~/manus.env - uv run --extra teleop isaaclab teleop run \ + uv run --extra teleop,isaacsim isaaclab teleop run \ --task IsaacContrib-PickPlace-GR1T2-WaistEnabled-Abs \ --visualizer kit --xr \ --cloudxr_env ~/manus.env @@ -886,7 +886,7 @@ Run the teleop script (e.g. ``record_demos.py`` to record demonstrations): .. code-block:: bash - uv run --extra teleop isaaclab teleop record \ + uv run --extra teleop,isaacsim isaaclab teleop record \ --task IsaacContrib-PickPlace-Locomanipulation-G1-Abs \ --num_demos 5 \ --dataset_file ./datasets/dataset.hdf5 \ diff --git a/docs/source/how-to/configure_rendering.rst b/docs/source/how-to/configure_rendering.rst index 841413beb794..29707779d45e 100644 --- a/docs/source/how-to/configure_rendering.rst +++ b/docs/source/how-to/configure_rendering.rst @@ -17,6 +17,17 @@ Override individual settings to tune the renderer for your workflow, as described below. For camera-heavy workloads that need higher throughput, switch to the RTX Minimal renderer instead. +.. note:: + + Requesting one of the ``simple_shading_*`` camera data types without a regular color output + switches that camera's render product to RTX Minimal mode; the data type selects the shading + level. The switch applies per render product, so other cameras and the Kit viewport keep their + configured render mode. RTX Minimal uses only the first ``DistantLight`` prim, ignores + ``DomeLight`` prims, and may also use configured ambient lighting. When ``rgb``, ``rgba``, or + ``rgb_hdr`` is requested from the same render product, it retains its configured render mode to + preserve the color output; the ``simple_shading_*`` output remains available but does not receive + the RTX Minimal performance improvement. + Overriding Specific Rendering Settings -------------------------------------- diff --git a/docs/source/migration/include/from_isaacgymenvs.rst b/docs/source/migration/include/from_isaacgymenvs.rst index 8494f732b7c1..455b45e52968 100644 --- a/docs/source/migration/include/from_isaacgymenvs.rst +++ b/docs/source/migration/include/from_isaacgymenvs.rst @@ -237,7 +237,7 @@ adding any other optional objects into the scene, such as lights. | | prim_path="/World/ground", cfg=GroundPlaneCfg()) | | self.sim = super().create_sim(self.device_id, self.graphics_device_id, | # create and apply a clone plan | | self.physics_engine, self.sim_params) | plan = cloner.clone_plan_from_env_0(..., global_paths=...) | -| self._create_ground_plane() | cloner.replicate(plan, stage=self.scene.stage) | +| self._create_ground_plane() | cloner.replicate(plan) | | self._create_envs(self.num_envs, self.cfg["env"]['envSpacing'], | # add articulation to scene | | int(np.sqrt(self.num_envs))) | self.scene.articulations["cartpole"] = self.cartpole | | | # add lights | @@ -365,11 +365,9 @@ The scene creation process is as follow: self.cartpole = Articulation(self.cfg.robot_cfg) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - positions = cloner.grid_transforms( - self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device - )[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, positions) - cloner.replicate(plan, stage=self.scene.stage) + positions = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, positions) + cloner.replicate(plan) if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) @@ -670,13 +668,13 @@ the need to set simulation parameters for actors in the task implementation. | self.sim = super().create_sim(self.device_id, | cfg=GroundPlaneCfg()) | | self.graphics_device_id, self.physics_engine, | src, dest = "/World/envs/env_0", "/World/envs/env_{}" | | self.sim_params) | positions = cloner.grid_transforms( | -| self._create_ground_plane() | self.scene.num_envs, self.scene.cfg.env_spacing, | -| self._create_envs(self.num_envs, | device=self.device)[0] | +| self._create_ground_plane() | self.scene.num_envs, self.scene.cfg.env_spacing)[0] | +| self._create_envs(self.num_envs, | | | self.cfg["env"]['envSpacing'], | global_paths = ("/World/ground",) | | int(np.sqrt(self.num_envs))) | plan = cloner.clone_plan_from_env_0( | -| | src, dest, self.scene.num_envs, self.device, positions, | +| | src, dest, self.scene.num_envs, positions, | | | global_paths=global_paths) | -| | cloner.replicate(plan, stage=self.scene.stage) | +| | cloner.replicate(plan) | | def _create_ground_plane(self): | if "physx" in self.scene.physics_backend: | | plane_params = gymapi.PlaneParams() | self.scene.filter_collisions(global_prim_paths=[]) | | # set the normal force to be z dimension | self.scene.articulations["cartpole"] = self.cartpole | diff --git a/docs/source/refs/issues.rst b/docs/source/refs/issues.rst index 09f574ffe9d9..8d17d43e06cc 100644 --- a/docs/source/refs/issues.rst +++ b/docs/source/refs/issues.rst @@ -118,22 +118,13 @@ OpenUSD provider that contains the fix or to Isaac Sim 6.1 or later. .. _known-issues-closed-loop-newton: -Closed-loop articulations are not available on Newton (e.g. Agility Digit) +Closed-loop articulations are not validated on Kamino (e.g. Agility Digit) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Affects:** ``physics=newton_mjwarp``, ``physics=newton_kamino``. +**Affects:** ``physics=newton_kamino``. Robots whose USD encodes a closed kinematic loop — such as the achilles rod and toe push-rods -on the Agility Digit — are not currently validated on the Newton backends. The Digit-based -contrib tasks are PhysX-only and do not expose a Newton preset at all: - -* ``IsaacContrib-Velocity-Flat-Digit`` -* ``IsaacContrib-Velocity-Rough-Digit`` -* ``IsaacContrib-Tracking-LocoManip-Digit`` - -Passing ``presets=newton_mjwarp`` to these tasks is rejected, because the preset never -selected a Newton backend on them; it only stripped a center-of-mass randomization from a -PhysX run. Use the default PhysX configuration for Digit-based environments. +on the Agility Digit — are not validated on ``newton_kamino``. Renderers diff --git a/docs/source/setup/installation/index.rst b/docs/source/setup/installation/index.rst index 92d85b6cd726..f9c7a3006036 100644 --- a/docs/source/setup/installation/index.rst +++ b/docs/source/setup/installation/index.rst @@ -239,6 +239,11 @@ See :ref:`installation-optional-extras` for the available extras. ``uv run --extra `` syncs the selected extra into the project environment and then runs the command. +The source checkout selects PyTorch's CUDA build through platform-specific indexes: CUDA 12.8 on +Linux x86_64 and Windows, and CUDA 13.0 on Linux aarch64. No additional command flags are needed. +The published wheel pins the PyTorch versions, but downstream uv projects must configure their own +PyTorch indexes because uv does not inherit a dependency project's ``tool.uv.sources`` settings. + Head over to the :doc:`/source/setup/quickstart`, which starts with your first task and introduces the available commands, RL libraries, backends, and visualizers. @@ -567,6 +572,24 @@ To create a project built on Isaac Lab, see :ref:`template-generator`. Isaac Lab wheels are published for major releases, not every patch release. +Installing an unreleased Git revision +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The aggregate package can also be built directly from an Isaac Lab Git revision. Point uv at the +``tools/wheel_builder`` subdirectory so it uses the same dependency metadata and packaged runtime +resources as a released wheel: + +.. code-block:: toml + + [project] + dependencies = ["isaaclab"] + + [tool.uv.sources] + isaaclab = { git = "https://github.com/isaac-sim/IsaacLab.git", rev = "", subdirectory = "tools/wheel_builder" } + +Use a commit hash or release tag for reproducible environments. A branch name is accepted, but +updating the lockfile can then select a newer Isaac Lab revision and dependency set. + Choose how you want uv to manage the dependency. Both workflows start with the base ``isaaclab`` package; add optional capabilities only when your project needs them. diff --git a/isaaclab.sh b/isaaclab.sh index d01be1110a7c..5119d1a4c176 100755 --- a/isaaclab.sh +++ b/isaaclab.sh @@ -23,12 +23,29 @@ for arg in "$@"; do fi done +# A virtual environment reuses the interpreter it was created from, so one created on the +# package's own Python runs that exact binary and loads Kit's extensions unchanged. Only +# environments that supply their own interpreter, such as conda, are rejected. Anything +# unreadable counts as foreign so the check fails closed. +venv_on_bundled_python=false +if [ -n "$VIRTUAL_ENV" ] && [ -z "$CONDA_PREFIX" ] && [ -f "$VIRTUAL_ENV/pyvenv.cfg" ]; then + venv_home=$(sed -n 's/^[[:space:]]*home[[:space:]]*=[[:space:]]*//p' "$VIRTUAL_ENV/pyvenv.cfg" | head -1) + sim_root=$(readlink -f "$ISAACLAB_PATH/_isaac_sim" 2>/dev/null) + venv_home=$(readlink -f "$venv_home" 2>/dev/null) + if [ -n "$sim_root" ] && [ -n "$venv_home" ]; then + case "$venv_home" in + "$sim_root" | "$sim_root"/*) venv_on_bundled_python=true ;; + esac + fi +fi + downloaded_isaac_sim=false if [ -d "$ISAACLAB_PATH/_isaac_sim" ] && [ ! -f "$ISAACLAB_PATH/_isaac_sim/.isaaclab_source_build" ]; then downloaded_isaac_sim=true - if [ "$isaacsim_source_requested" = false ] && { [ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_PREFIX" ]; }; then + if [ "$isaacsim_source_requested" = false ] && [ "$venv_on_bundled_python" = false ] \ + && { [ -n "$VIRTUAL_ENV" ] || [ -n "$CONDA_PREFIX" ]; }; then echo "[ERROR] Downloaded Isaac Sim packages cannot be combined with a Python virtual environment." >&2 - echo "[ERROR] Use the bundled Python after deactivating the virtual environment, or run '--isaacsim_source PATH' to link a live source build." >&2 + echo "[ERROR] Use the bundled Python after deactivating the virtual environment, create the environment on that Python ('uv venv --python \$ISAACLAB_PATH/_isaac_sim/kit/python/bin/python3'), or run '--isaacsim_source PATH' to link a live source build." >&2 exit 1 fi fi diff --git a/pyproject.toml b/pyproject.toml index 521034112ef7..ba7685519d88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,9 +38,9 @@ dependencies = [ "isaaclab-visualizers", # ----- generic ----- "numpy>=2", - "torch>=2.11", - "torchvision>=0.26.0", - "torchaudio>=2.11", + "torch==2.11.0", + "torchvision==0.26.0", + "torchaudio==2.11.0", "onnx>=1.18.0", "prettytable>=3.3.0", "psutil", @@ -51,7 +51,7 @@ dependencies = [ "pyglet>=2.1.6,<3", "transformers==5.10.4", "einops", - "warp-lang==1.16.0", + "warp-lang==1.17.0", "matplotlib>=3.10.3", # pillow: floor, not exact — an exact pin below Isaac Sim's prebundled version forces a # downgrade that deletes the prebundled copy other extensions symlink into (nvbugs 6410989). @@ -84,13 +84,11 @@ dependencies = [ "packaging", "rich", "tqdm>=4.67.1", - "rsl-rl-lib==5.4.1", # default RL framework + "rsl-rl-lib==5.5.1", # default RL framework "gitpython>=3.1.59", # used by RL training and experiment logging - "onnxscript>=0.5", # ----- newton (default physics engine) ----- - # Loose bound so the wheel co-resolves with isaacsim's newton[sim]==1.2.0 pin; the - # exact PyPI release is forced via [tool.uv].override-dependencies (uv sync only). - "newton[sim]>=1.2.0", + # Pin the Newton release used by both workspace and wheel installs. + "newton[sim]==1.6.0", # Import and mesh-processing packages used by Newton, including the ones that honoring # USD-authored ``physics:approximation`` requires. Keep these explicit instead of selecting # newton[importers], whose standalone USD dependency would overlap with usd-exchange. @@ -122,6 +120,8 @@ importers = [ "tinyobjloader==2.0.0rc13", ] +# The test suite only. Kept free of the docs stack: Sphinx pulls docutils, which is +# GPL-3.0-or-later and not allowed in the distributed images. test = [ "pytest", "pytest-mock", @@ -129,6 +129,11 @@ test = [ "flaky", # numba subclasses coverage.types.Tracer at import; >=7.6.1 restores that shim "coverage>=7.6.1", +] + +# Everything a contributor needs: the test suite plus the documentation toolchain. +dev = [ + "isaaclab-dev[test]", "sphinx>=7.0,<9", "sphinx-book-theme>=1.1", "myst-parser", @@ -156,7 +161,7 @@ rl-games = [ wandb = ["wandb>=0.13.11"] -rsl-rl = ["rsl-rl-lib==5.4.1", "onnxscript>=0.5"] +rsl-rl = ["rsl-rl-lib==5.5.1"] viser = ["viser>=1.0.16"] @@ -217,7 +222,7 @@ torchaudio = "2.11.0" ovphysx = "0.5.11" ovrtx = "0.4.1.364340" ovstage = "0.1.1.355824" -warp = "1.16.0" +warp = "1.17.0" [tool.ruff] line-length = 120 @@ -396,20 +401,17 @@ environments = [ "sys_platform == 'linux' and platform_machine == 'aarch64'", "sys_platform == 'win32' and platform_machine == 'AMD64'", ] -# Isaac Lab owns the Newton, MuJoCo, and torch versions. Overrides replace requirements +# Isaac Lab owns the Newton and MuJoCo versions. Overrides replace requirements # for every requester, unlike constraints, which only intersect. The ``isaacsim`` and # ``importers`` extras pin older versions of these packages, so the overrides prevent downgrades. -# Torch routes through [tool.uv.sources]. Values mirror [tool.isaaclab.versions] where applicable. +# Torch is pinned in project.dependencies and routes through [tool.uv.sources]. override-dependencies = [ "numpy>=2", - "mujoco~=3.11.0", - "mujoco-warp~=3.11.0", - "newton[sim]==1.5.2", + "mujoco~=3.12.0", + "mujoco-warp~=3.12.0", + "newton[sim]==1.6.0", # Force the Newton-matched schemas over isaacsim's ==0.2.0 pin. "newton-usd-schemas>=0.4.1", - "torch==2.11.0", - "torchvision==0.26.0", - "torchaudio==2.11.0", # ovphysx caps packaging at <24 and isaacsim-core pins ==26.0; both caps are # stricter than the code needs, so widening lets them resolve together at 26.0. "packaging>=20,<27", diff --git a/scripts/benchmarks/benchmark_renderer.py b/scripts/benchmarks/benchmark_renderer.py new file mode 100644 index 000000000000..4cb93e3d032c --- /dev/null +++ b/scripts/benchmarks/benchmark_renderer.py @@ -0,0 +1,360 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# +# Command to run: +# uv run --no-sync python scripts/benchmarks/benchmark_renderer.py [PROFILE] +# + +import argparse +import fnmatch +import json +import os +import re +import shutil +import site +import statistics +import subprocess +import sys +from pathlib import Path + +PROFILES = [ + { + "name": "ovrtx_constant_diffuse_oldpipe", + "preset": "ovrtx_renderer,simple_shading_constant_diffuse", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_constant_diffuse_newpipe", + "preset": "ovrtx_renderer,simple_shading_constant_diffuse", + "settings": {"min-pipe": True}, + }, + { + "name": "ovrtx_diffuse_mdl_oldpipe", + "preset": "ovrtx_renderer,simple_shading_diffuse_mdl", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_diffuse_mdl_newpipe", + "preset": "ovrtx_renderer,simple_shading_diffuse_mdl", + "settings": {"min-pipe": True}, + }, + { + "name": "ovrtx_full_mdl_oldpipe", + "preset": "ovrtx_renderer,simple_shading_full_mdl", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_full_mdl_newpipe", + "preset": "ovrtx_renderer,simple_shading_full_mdl", + "settings": {"min-pipe": True}, + }, + {"name": "newton_lbvh_lbvh", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "lbvh"}}, + {"name": "newton_lbvh_sah", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "sah"}}, + {"name": "newton_lbvh_cubql", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "cubql"}}, + {"name": "newton_sah_lbvh", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "lbvh"}}, + {"name": "newton_sah_sah", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "sah"}}, + {"name": "newton_sah_cubql", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "cubql"}}, +] + +TASK_NAME = "Isaac-RenderBenchmark-Franka-Cabinet" +FRAME_PADDING = 5 + +# Resolved from this file rather than the working directory, so the script runs from anywhere. +SCRIPT_DIR = Path(__file__).resolve().parent +RUNTIME_SCRIPT = SCRIPT_DIR / "runtime.py" +OUTPUT_PATH = str(SCRIPT_DIR.parent.parent / "benchmarks") + +OVRTX_RENDERER = "ovrtx_renderer" +NEWTON_RENDERER = "newton_renderer" + +RENDER_SCOPE = "IsaacLab::Renderer::render" +"""Backend-agnostic timer name around ``BaseRenderer.render``, enabled by ``ISAACLAB_RENDER_PROFILE``. + +See :data:`isaaclab.renderers.render_context.RENDER_PROFILE_SCOPE`. It brackets the render alone, +excluding the scene-state sync before it and the output readback after it. ``wp.ScopedTimer`` prints +one ``" took X.XX ms"`` line per call, which :func:`parse_log` regexes out of the run's log. +""" + +RENDER_SCOPE_PATTERN = re.compile(rf"{re.escape(RENDER_SCOPE)} took ([\d.]+) ms") + +log_stream = sys.stdout +"""Destination for progress and diagnostics. ``--json`` points it at stderr so stdout holds only JSON.""" + + +def log(message: str = "") -> None: + """Write one human-readable line to :data:`log_stream`.""" + print(message, file=log_stream) + + +def pixels_per_second(median_ms: float, num_envs: int, resolution: int) -> float: + """Rendered pixels per second [px/s] implied by a median frame time. + + Args: + median_ms: Median time to render one frame [ms]. + num_envs: Number of environments rendered per frame. + resolution: Width and height of each environment's tile [px]. + + Returns: + Throughput in pixels per second [px/s]. + """ + return num_envs * resolution * resolution * 1000.0 / median_ms + + +def build_record(profile: dict, results: dict | None, num_envs: int, resolution: int) -> dict: + """Summarize one profile's outcome for reporting. + + Args: + profile: Profile entry from :data:`PROFILES`. + results: Timing statistics from :func:`parse_log`, or ``None`` if the run failed. + num_envs: Number of environments the profile rendered. + resolution: Width and height of each environment's tile [px]. + + Returns: + A record carrying the profile's identity plus either its timings or the log to inspect. + """ + record = {"name": profile["name"], "preset": profile["preset"], "settings": profile["settings"]} + if not results: + return record | {"status": "failed", "log": os.path.join(OUTPUT_PATH, profile["name"] + ".log")} + return ( + record + | { + "status": "ok", + "size": results["size"], + "pixels_per_second": pixels_per_second(results["median"], num_envs, resolution), + } + | {f"{key}_ms": results[key] for key in ("median", "mean", "min", "max", "stdev")} + ) + + +def parse_log(filename: str, num_frames: int): + """Summarize per-frame render times [ms] from a run's captured log. + + Every backend is measured the same way: the wall time of :data:`RENDER_SCOPE`, printed once per + render by ``wp.ScopedTimer`` when ``ISAACLAB_RENDER_PROFILE`` is set. The timer synchronizes the + device on both ends, so it covers completed rather than merely submitted work — including for + the RTX backends, whose Vulkan render is consumed by warp extraction kernels inside the scope. + + Args: + filename: Path to the captured run log. + num_frames: Number of frames to measure, after skipping :data:`FRAME_PADDING` warm-up frames. + + Returns: + Timing statistics, or ``None`` if the log holds no usable frames. + """ + with open(filename) as file: + frames = [float(match.group(1)) for line in file if (match := RENDER_SCOPE_PATTERN.search(line))] + + if not frames: + log(f"No '{RENDER_SCOPE}' timings in {filename}; was ISAACLAB_RENDER_PROFILE set for this run?") + return None + out = frames[FRAME_PADDING : FRAME_PADDING + num_frames] + + if out: + return { + "size": len(out), + "median": statistics.median(out), + "mean": statistics.mean(out), + "min": min(out), + "max": max(out), + "stdev": statistics.stdev(out) if len(out) > 1 else 0, + } + return None + + +def run_profile(profile: dict, args: argparse.Namespace): + """Run one entry of :data:`PROFILES` and summarize its render times from the captured log. + + Args: + profile: Profile entry naming the preset and its backend settings. + args: Parsed command-line arguments. + + Returns: + Timing statistics from :func:`parse_log`, or ``False`` if the run failed. + """ + warp_cache_path = os.path.join(OUTPUT_PATH, "warp-cache") + + env = { + "NEWTON_USE_CUDA_GRAPH": "0", + "ISAACLAB_RENDER_PROFILE": "1", + "BENCHMARK_SAVE_IMAGE": "1" if args.save_image else "0", + "BENCHMARK_RENDER_RESOLUTION": f"{args.resolution}", + "WARP_CACHE_PATH": warp_cache_path, + } + + # Kernel compilation lands in the warm-up frames that FRAME_PADDING discards, so a warm cache + # does not reach the measured window -- it only saves a recompile per profile. + if not args.keep_warp_cache and os.path.exists(warp_cache_path): + shutil.rmtree(warp_cache_path) + + profile_name: str = profile["name"] + preset: str = profile["preset"] + renderer: str = preset.split(",")[0] + + if renderer == OVRTX_RENDERER: + env["LD_PRELOAD"] = os.path.join( + site.getsitepackages()[0], "ovrtx/bin/plugins/omni.client.lib/libomniclient.so" + ) + env["CUDA_VISIBLE_DEVICES"] = "0" + env["OMNI_KIT_ACCEPT_EULA"] = "YES" + env["OVRTX_rtx_post_tonemap_op"] = "0" + env["OVRTX_rtx_minimal_useMinimalPipeline"] = "1" if profile["settings"]["min-pipe"] else "0" + + if renderer == NEWTON_RENDERER: + env["NEWTON_BVH_SCENE"] = profile["settings"]["tlas"] + env["NEWTON_BVH_GEOMETRY"] = profile["settings"]["blas"] + + os.makedirs(OUTPUT_PATH, exist_ok=True) + log_filename = os.path.join(OUTPUT_PATH, profile_name + ".log") + + cmd = [ + sys.executable, + str(RUNTIME_SCRIPT), + "--task", + args.task, + "--num_envs", + f"{args.num_envs}", + "--warmup_steps", + "0", + "--num_steps", + f"{args.num_frames + FRAME_PADDING * 2}", + "--output_path", + OUTPUT_PATH, + f"presets={preset}", + ] + + with open(log_filename, "w") as file: + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=dict(os.environ) | env, text=True + ) + + for line in process.stdout: + file.write(line) + if args.verbose: + log_stream.write(f"\x1b[90m{line}\x1b[0m") + log_stream.flush() + + return_code = process.wait() + if return_code != 0: + log(f"Failed with exit code {process.returncode}, see {log_filename} for details.") + return False + + return parse_log(log_filename, args.num_frames) + + +def _build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI parser, kept separate from module import so tests can import pure helpers.""" + parser = argparse.ArgumentParser("IsaacLab Benchmark: Sweep Franka Cabinet") + parser.add_argument("--num_frames", type=int, default="20", help="Number of frames to render") + parser.add_argument("--num_envs", type=int, default="1024", help="Number of environments to render") + parser.add_argument("--resolution", type=int, default="256", help="Render resolution") + parser.add_argument("--task", default=TASK_NAME, help="Gym task id to profile") + parser.add_argument( + "--keep_warp_cache", + action="store_true", + help="Reuse the warp kernel cache instead of recompiling per profile", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-s", "--save_image", action="store_true", help="Save image for debugging purposes") + parser.add_argument("--json", action="store_true", help="Write results to stdout as JSON instead of a table") + parser.add_argument("profile", nargs="*", help="Profiles to run") + return parser + + +def main() -> None: + """Parse CLI arguments, run the selected profiles, and report their results.""" + global log_stream + + args = _build_arg_parser().parse_args() + + # Keep stdout free of anything but the JSON document. + if args.json: + log_stream = sys.stderr + + if not args.profile: + if args.json: + print(json.dumps({"available_profiles": [profile["name"] for profile in PROFILES]}, indent=2)) + else: + log("Available profiles:") + for profile in PROFILES: + log(" " + profile["name"]) + exit(0) + + matched_names = set() + for profile_name in args.profile: + matches = [profile["name"] for profile in PROFILES if fnmatch.fnmatch(profile["name"], profile_name)] + if not matches: + print(f"No profile found matching: {profile_name}", file=sys.stderr) + exit(1) + matched_names.update(matches) + + # Run in declaration order so a given selection always reports in the same order. + selected_profiles = [profile for profile in PROFILES if profile["name"] in matched_names] + + all_results = {} + for profile in selected_profiles: + log(f"profile: {profile['name']}") + log(f" preset: {profile['preset']}") + for key, value in profile["settings"].items(): + log(f" {key}: {value}") + + try: + all_results[profile["name"]] = run_profile(profile, args) + except KeyboardInterrupt: + break + + if results := all_results[profile["name"]]: + log(f" size: {results['size']}") + for key, value in results.items(): + if key != "size": + log(f" {key}: {value:.2f}ms") + log("") + + # A KeyboardInterrupt leaves the remaining profiles unrun; report only what completed. + records = [ + build_record(profile, all_results[profile["name"]], args.num_envs, args.resolution) + for profile in selected_profiles + if profile["name"] in all_results + ] + benchmark_failed = any(record["status"] == "failed" for record in records) + + if args.json: + print( + json.dumps( + { + "task": args.task, + "num_envs": args.num_envs, + "num_frames": args.num_frames, + "resolution": args.resolution, + "profiles": records, + }, + indent=2, + ) + ) + else: + separator = "|------------------------------------------|------|--------------|--------------|--------------|--------------|--------------|--------------|" # noqa: E501 + log("") + log( + "| PROFILE | SIZE | PIXEL / SEC | MEDIAN | MEAN | MIN | MAX | STDEV |" # noqa: E501 + ) + log(separator) + for record in records: + if record["status"] == "ok": + gpxs = record["pixels_per_second"] / 1e9 + log( + f"| {record['name']:<40} | {record['size']:>4} | {gpxs:>6.2f} Gpx/s | {record['median_ms']:>10.2f}ms | {record['mean_ms']:>10.2f}ms | {record['min_ms']:>10.2f}ms | {record['max_ms']:>10.2f}ms | {record['stdev_ms']:>10.2f}ms |" # noqa: E501 + ) + else: + log(f"| {record['name']:<40} | FAILED {record['log']:<87} |") + log(separator) + log("") + + if benchmark_failed: + exit(1) + exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/test/test_benchmark_renderer.py b/scripts/benchmarks/test/test_benchmark_renderer.py new file mode 100644 index 000000000000..05c57d63ff5b --- /dev/null +++ b/scripts/benchmarks/test/test_benchmark_renderer.py @@ -0,0 +1,136 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for the pure helpers in ``scripts/benchmarks/benchmark_renderer.py``. + +The script drives a rendering backend end-to-end, so that path is not covered here. These tests +exercise the record-building, log-parsing, and CLI-parsing logic that does not require a GPU. +""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / "scripts" / "benchmarks" / "benchmark_renderer.py" + + +def _load_module(): + """Import ``benchmark_renderer.py`` as a module, bypassing its CLI entry point.""" + spec = importlib.util.spec_from_file_location("benchmark_renderer", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def benchmark_renderer(): + return _load_module() + + +def test_render_scope_matches_render_context(benchmark_renderer): + """The script's timer name must match the one the renderer prints, or nothing is parsed.""" + from isaaclab.renderers.render_context import RENDER_PROFILE_SCOPE + + assert benchmark_renderer.RENDER_SCOPE == RENDER_PROFILE_SCOPE + + +def test_pixels_per_second(benchmark_renderer): + """Throughput is env count times tile area, scaled from milliseconds to seconds.""" + assert benchmark_renderer.pixels_per_second(median_ms=1000.0, num_envs=4, resolution=256) == pytest.approx( + 4 * 256 * 256 + ) + + +def test_build_record_ok(benchmark_renderer): + profile = {"name": "p", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah"}} + results = {"size": 20, "median": 2.0, "mean": 2.1, "min": 1.5, "max": 3.0, "stdev": 0.2} + + record = benchmark_renderer.build_record(profile, results, num_envs=4, resolution=256) + + assert record["status"] == "ok" + assert record["size"] == 20 + assert record["median_ms"] == 2.0 + assert record["pixels_per_second"] == pytest.approx(benchmark_renderer.pixels_per_second(2.0, 4, 256)) + + +def test_build_record_failed(benchmark_renderer): + profile = {"name": "p", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah"}} + + record = benchmark_renderer.build_record(profile, None, num_envs=4, resolution=256) + + assert record == { + "name": "p", + "preset": "newton_renderer,rgb", + "settings": {"tlas": "sah"}, + "status": "failed", + "log": str(Path(benchmark_renderer.OUTPUT_PATH) / "p.log"), + } + + +_RENDER_SCOPE = "IsaacLab::Renderer::render" + + +def _write_log(path: Path, timings_ms: list[float]) -> None: + """Write a synthetic run log with one ``wp.ScopedTimer`` print line per timing. + + Interleaves unrelated lines to mimic real subprocess output (warp init banner, other timers), + so the parser is exercised against noise rather than a file with only matching lines. + """ + lines = ["Warp 1.17.0 initialized:", "SomeOtherScope took 0.10 ms"] + for value in timings_ms: + lines.append(f"{_RENDER_SCOPE} took {value:.2f} ms") + path.write_text("\n".join(lines) + "\n") + + +def test_parse_log_skips_padding_and_keeps_num_frames(benchmark_renderer, tmp_path): + """Only the ``num_frames`` timings after :data:`FRAME_PADDING` warm-up frames are summarized.""" + log_path = tmp_path / "profile.log" + padding = benchmark_renderer.FRAME_PADDING + # padding warm-up frames of 1ms each, then 4 measured frames of 2ms each (only 3 are kept). + timings = [1.0] * padding + [2.0] * 4 + _write_log(log_path, timings) + + results = benchmark_renderer.parse_log(str(log_path), num_frames=3) + + assert results["size"] == 3 + assert results["median"] == pytest.approx(2.0) + assert results["min"] == pytest.approx(2.0) + assert results["max"] == pytest.approx(2.0) + + +def test_parse_log_returns_none_without_matching_lines(benchmark_renderer, tmp_path): + """A log with no ``RENDER_SCOPE`` timings means profiling was never enabled for that run.""" + log_path = tmp_path / "profile.log" + _write_log(log_path, []) + + assert benchmark_renderer.parse_log(str(log_path), num_frames=3) is None + + +def _run_cli(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], capture_output=True, text=True, cwd=ROOT, timeout=30 + ) + + +def test_cli_lists_available_profiles_as_json(benchmark_renderer): + """With no profile selected, the CLI reports every declared profile name and exits cleanly.""" + result = _run_cli(["--json"]) + + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["available_profiles"] == [profile["name"] for profile in benchmark_renderer.PROFILES] + + +def test_cli_rejects_unmatched_profile_glob(): + """An unmatched profile pattern fails fast, before any profiling subprocess is launched.""" + result = _run_cli(["does-not-exist-*"]) + + assert result.returncode == 1 + assert "No profile found matching: does-not-exist-*" in result.stderr diff --git a/scripts/demos/bin_packing.py b/scripts/demos/bin_packing.py index ea14401cb6c7..3f8cf063e2a0 100644 --- a/scripts/demos/bin_packing.py +++ b/scripts/demos/bin_packing.py @@ -54,6 +54,7 @@ from random import Random import torch +import warp as wp import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils @@ -175,18 +176,18 @@ def spawn_grocery( return prim -def grocery_cfg(slot: int) -> RigidObjectCfg: +def grocery_cfg(slot: int) -> AssetBaseCfg: """Declare the grocery that occupies one slot of the spawn grid. Args: slot: Index into the spawn grid built by :func:`grocery_spawn_poses`. Returns: - The rigid-object configuration for that slot. + The spawn-only asset configuration for that slot. """ - return RigidObjectCfg( + return AssetBaseCfg( prim_path=f"{{ENV_REGEX_NS}}/Groceries/Grocery_{slot:02d}", - init_state=RigidObjectCfg.InitialStateCfg(pos=tuple(grocery_spawn_poses()[slot, :3].tolist())), + init_state=AssetBaseCfg.InitialStateCfg(pos=tuple(grocery_spawn_poses()[slot, :3].tolist())), spawn=sim_utils.UsdFileCfg(func=spawn_grocery, usd_path=GROCERY_USDS[slot % len(GROCERY_USDS)]), ) @@ -248,30 +249,30 @@ class BinPackingSceneCfg(InteractiveSceneCfg): ) # grocery slots, one per object that may end up in the bin - grocery_00: RigidObjectCfg = grocery_cfg(0) - grocery_01: RigidObjectCfg = grocery_cfg(1) - grocery_02: RigidObjectCfg = grocery_cfg(2) - grocery_03: RigidObjectCfg = grocery_cfg(3) - grocery_04: RigidObjectCfg = grocery_cfg(4) - grocery_05: RigidObjectCfg = grocery_cfg(5) - grocery_06: RigidObjectCfg = grocery_cfg(6) - grocery_07: RigidObjectCfg = grocery_cfg(7) - grocery_08: RigidObjectCfg = grocery_cfg(8) - grocery_09: RigidObjectCfg = grocery_cfg(9) - grocery_10: RigidObjectCfg = grocery_cfg(10) - grocery_11: RigidObjectCfg = grocery_cfg(11) - grocery_12: RigidObjectCfg = grocery_cfg(12) - grocery_13: RigidObjectCfg = grocery_cfg(13) - grocery_14: RigidObjectCfg = grocery_cfg(14) - grocery_15: RigidObjectCfg = grocery_cfg(15) - grocery_16: RigidObjectCfg = grocery_cfg(16) - grocery_17: RigidObjectCfg = grocery_cfg(17) - grocery_18: RigidObjectCfg = grocery_cfg(18) - grocery_19: RigidObjectCfg = grocery_cfg(19) - grocery_20: RigidObjectCfg = grocery_cfg(20) - grocery_21: RigidObjectCfg = grocery_cfg(21) - grocery_22: RigidObjectCfg = grocery_cfg(22) - grocery_23: RigidObjectCfg = grocery_cfg(23) + grocery_00: AssetBaseCfg = grocery_cfg(0) + grocery_01: AssetBaseCfg = grocery_cfg(1) + grocery_02: AssetBaseCfg = grocery_cfg(2) + grocery_03: AssetBaseCfg = grocery_cfg(3) + grocery_04: AssetBaseCfg = grocery_cfg(4) + grocery_05: AssetBaseCfg = grocery_cfg(5) + grocery_06: AssetBaseCfg = grocery_cfg(6) + grocery_07: AssetBaseCfg = grocery_cfg(7) + grocery_08: AssetBaseCfg = grocery_cfg(8) + grocery_09: AssetBaseCfg = grocery_cfg(9) + grocery_10: AssetBaseCfg = grocery_cfg(10) + grocery_11: AssetBaseCfg = grocery_cfg(11) + grocery_12: AssetBaseCfg = grocery_cfg(12) + grocery_13: AssetBaseCfg = grocery_cfg(13) + grocery_14: AssetBaseCfg = grocery_cfg(14) + grocery_15: AssetBaseCfg = grocery_cfg(15) + grocery_16: AssetBaseCfg = grocery_cfg(16) + grocery_17: AssetBaseCfg = grocery_cfg(17) + grocery_18: AssetBaseCfg = grocery_cfg(18) + grocery_19: AssetBaseCfg = grocery_cfg(19) + grocery_20: AssetBaseCfg = grocery_cfg(20) + grocery_21: AssetBaseCfg = grocery_cfg(21) + grocery_22: AssetBaseCfg = grocery_cfg(22) + grocery_23: AssetBaseCfg = grocery_cfg(23) # A slot claimed by some layout but active in none of the layouts the environments # actually receive is never spawned, which the scene rejects. Environments cycle @@ -297,30 +298,33 @@ class BinPackingSceneCfg(InteractiveSceneCfg): def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: """Runs the simulation loop that coordinates spawn randomization and stepping. - A heterogeneous scene gives each grocery slot its own physics view whose - instance count is the number of environments containing that slot, not - ``num_envs``. + A flat physics view manages every grocery present in the heterogeneous scene. Returns: None: The simulator side-effects are applied through ``scene`` and ``sim``. """ device = scene.device - # Resolve each spawned slot once: (asset, env ids owning an instance, world spawn poses). - # The spawn pose is the asset's default (init-state) pose, which carries the jittered - # grid position sampled by :func:`grocery_spawn_poses` at scene-config build time. - groceries = [] - for name in GROCERY_NAMES: - asset = scene[name] - prim_paths = getattr(asset.root_view, "prim_paths", None) - if prim_paths is not None and len(prim_paths) == asset.num_instances: - env_ids = [int(path.partition("/env_")[2].partition("/")[0]) for path in prim_paths] - else: - # Homogeneous fallback: one instance per environment. - env_ids = range(asset.num_instances) - env_ids = torch.tensor(list(env_ids), dtype=torch.long, device=device) - spawn_pose_w = asset.data.default_root_pose.torch.clone() - spawn_pose_w[:, :3] += scene.env_origins[env_ids] - groceries.append((asset, env_ids, spawn_pose_w)) + physics_sim_view = sim.physics_manager.get_physics_sim_view() + root_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/Groceries/Grocery_*") + if root_view.count == 0: + raise RuntimeError("The flat grocery view matched no rigid bodies.") + + env_ids = [] + slot_ids = [] + for path in root_view.prim_paths: + env_ids.append(int(path.partition("/env_")[2].partition("/")[0])) + slot_ids.append(int(path.rpartition("/Grocery_")[2])) + env_ids_t = torch.tensor(env_ids, dtype=torch.long, device=device) + slot_ids_t = torch.tensor(slot_ids, dtype=torch.long, device=device) + env_origins = scene.env_origins[env_ids_t] + spawn_pose_w = grocery_spawn_poses(device)[slot_ids_t] + spawn_pose_w[:, :3] += env_origins + + all_indices_t = torch.arange(root_view.count, dtype=torch.int32, device=device) + cpu_indices_t = torch.arange(root_view.count, dtype=torch.int32, device="cpu") + all_indices = wp.from_torch(all_indices_t) + cpu_indices = wp.from_torch(cpu_indices_t) + print(f"[INFO] Grocery RigidBodyView: 1 flat view managing {root_view.count} rigid bodies") pose_ranges = torch.tensor([POSE_RANGE[axis] for axis in ("roll", "pitch", "yaw")], device=device) velocity_ranges = torch.tensor([VELOCITY_RANGE[axis] for axis in ("roll", "pitch", "yaw")], device=device) @@ -335,24 +339,22 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> count = 0 # Retrieve positions with Timer("[INFO] Time to reset scene: "): - for asset, env_ids, spawn_pose_w in groceries: - num_instances = len(env_ids) - # Only randomize the [roll, pitch, yaw] rotation; keep the spawn position fixed. - noise = math_utils.sample_uniform( - pose_ranges[:, 0], pose_ranges[:, 1], (num_instances, 3), device=device - ) - pose = spawn_pose_w.clone() - noise_quat = math_utils.quat_from_euler_xyz(noise[:, 0], noise[:, 1], noise[:, 2]) - pose[:, 3:7] = math_utils.quat_mul(pose[:, 3:7], noise_quat) - asset.write_root_pose_to_sim_index(root_pose=pose) - # Only randomize the angular velocity; leave the linear velocity at zero. - root_velocity = torch.zeros((num_instances, 6), device=device) - root_velocity[:, 3:6] = math_utils.sample_uniform( - velocity_ranges[:, 0], velocity_ranges[:, 1], (num_instances, 3), device=device - ) - asset.write_root_velocity_to_sim_index(root_velocity=root_velocity) - # Vary the mass so the drop behavior differs between resets. - asset.set_masses_index(masses=torch.rand((num_instances, 1), device=device) * 0.2 + 0.2) + shape = (root_view.count,) + # Only randomize the [roll, pitch, yaw] rotation; keep the spawn position fixed. + noise = math_utils.sample_uniform(pose_ranges[:, 0], pose_ranges[:, 1], (*shape, 3), device=device) + pose = spawn_pose_w.clone() + noise_quat = math_utils.quat_from_euler_xyz(noise[..., 0], noise[..., 1], noise[..., 2]) + pose[..., 3:7] = math_utils.quat_mul(pose[..., 3:7], noise_quat) + root_view.set_transforms(wp.from_torch(pose.contiguous()), all_indices) + # Only randomize the angular velocity; leave the linear velocity at zero. + body_velocity = torch.zeros((*shape, 6), device=device) + body_velocity[..., 3:6] = math_utils.sample_uniform( + velocity_ranges[:, 0], velocity_ranges[:, 1], (*shape, 3), device=device + ) + root_view.set_velocities(wp.from_torch(body_velocity.contiguous()), all_indices) + # Vary the mass so the drop behavior differs between resets. + masses = torch.rand((root_view.count, 1), device="cpu") * 0.2 + 0.2 + root_view.set_masses(wp.from_torch(masses), cpu_indices) scene.reset() # Write data to sim @@ -360,14 +362,21 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> # Perform step sim.step() # Bring out-of-bounds objects back to the bin. - for asset, env_ids, spawn_pose_w in groceries: - xy = asset.data.root_link_pose_w.torch[:, :2] - scene.env_origins[env_ids, :2] - stray = torch.nonzero(~((xy >= bounds_xy[0]) & (xy <= bounds_xy[1])).all(dim=-1)).flatten() - if stray.numel(): - asset.write_root_pose_to_sim_index(root_pose=spawn_pose_w[stray], env_ids=stray) - asset.write_root_velocity_to_sim_index( - root_velocity=torch.zeros((stray.numel(), 6), device=device), env_ids=stray - ) + transforms = root_view.get_transforms() + transforms_t = wp.to_torch(transforms) if isinstance(transforms, wp.array) else transforms + xy = transforms_t[:, :2] - env_origins[:, :2] + stray = torch.nonzero(~((xy >= bounds_xy[0]) & (xy <= bounds_xy[1])).all(dim=-1)).flatten() + if stray.numel(): + stray_indices = wp.from_torch(stray.to(dtype=torch.int32)) + corrected_transforms = transforms_t.clone() + corrected_transforms[stray] = spawn_pose_w[stray] + velocities = root_view.get_velocities() + corrected_velocities = ( + wp.to_torch(velocities).clone() if isinstance(velocities, wp.array) else velocities.clone() + ) + corrected_velocities[stray] = 0.0 + root_view.set_transforms(wp.from_torch(corrected_transforms.contiguous()), stray_indices) + root_view.set_velocities(wp.from_torch(corrected_velocities.contiguous()), stray_indices) # Increment counter count += 1 # Update buffers diff --git a/scripts/demos/mpm/newton_mpm_granular.py b/scripts/demos/mpm/newton_mpm_granular.py index 965cbe704e44..a7c009b5e516 100644 --- a/scripts/demos/mpm/newton_mpm_granular.py +++ b/scripts/demos/mpm/newton_mpm_granular.py @@ -146,7 +146,7 @@ class GranularSceneCfg(InteractiveSceneCfg): ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.30, 0.30, 0.30)), + spawn=sim_utils.GroundPlaneCfg(), ) dome_light = AssetBaseCfg( diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 04e44b753c99..2c60956c0399 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -230,7 +230,7 @@ class CoupledSceneCfg(InteractiveSceneCfg): ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.30, 0.30, 0.30)), + spawn=sim_utils.GroundPlaneCfg(), init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -wall_t)), ) dome_light = AssetBaseCfg( diff --git a/scripts/demos/mpm/snowball_smash.py b/scripts/demos/mpm/snowball_smash.py index 137398441cd0..0be3833d4aed 100644 --- a/scripts/demos/mpm/snowball_smash.py +++ b/scripts/demos/mpm/snowball_smash.py @@ -279,7 +279,7 @@ class SnowballSmashSceneCfg(InteractiveSceneCfg): # the MPM model view. ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.32, 0.34, 0.38)), + spawn=sim_utils.GroundPlaneCfg(), ) # The co-located hidden kinematic slab belongs only to the MPM entry. diff --git a/scripts/demos/mpm/teapot_fill.py b/scripts/demos/mpm/teapot_fill.py index 4d13d49efada..a3055a6b6a8f 100644 --- a/scripts/demos/mpm/teapot_fill.py +++ b/scripts/demos/mpm/teapot_fill.py @@ -12,8 +12,12 @@ # Fast Newton visualizer (the default): uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer newton_gl - # The translucent water material needs the RTX/Kit visualizer: - uv run python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer kit + # Display both the raw particles and reconstructed surface: + uv run python scripts/demos/mpm/teapot_fill.py --visualizer newton_gl --fluid_render_mode both + # Newton RTX path-traced visualizer: + uv run --extra ovrtx python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer newton_rtx + # Isaac Sim Kit visualizer (particles only): + uv run --extra isaacsim python scripts/demos/mpm/teapot_fill.py --device cuda:0 --visualizer kit # Fuller / coarser (faster) fill: uv run python scripts/demos/mpm/teapot_fill.py --fill_level 1.0 --fill_spacing 0.003 """ @@ -107,6 +111,13 @@ def _unit_interval_float(value: str) -> float: f" (1-6). Higher drops thin features like the spout/handle. Defaults to {DEFAULT_MIN_RAY_HITS}." ), ) +parser.add_argument( + "--fluid_render_mode", + type=str, + default="surface", + choices=["particles", "surface", "both"], + help="Fluid visualization: raw MPM particles, reconstructed surface mesh, or both.", +) parser.add_argument( "--container_usd", type=str, @@ -117,13 +128,13 @@ def _unit_interval_float(value: str) -> float: "--island_usd", type=str, default=DEFAULT_ISLAND_USD, - help="Optional Kit kitchen-island visual. An empty or unavailable path uses the procedural table.", + help="Optional RTX kitchen-island visual. An empty or unavailable path uses the procedural table.", ) parser.add_argument( "--bowl_usd", type=str, default=DEFAULT_BOWL_USD, - help="Optional Kit catch-bowl visual. An empty or unavailable path uses the procedural bowl.", + help="Optional RTX catch-bowl visual. An empty or unavailable path uses the procedural bowl.", ) add_launcher_args(parser) parser.set_defaults(visualizer=["newton_gl"]) @@ -139,6 +150,8 @@ def _unit_interval_float(value: str) -> float: ) FILL_LEVEL = args_cli.fill_level MIN_RAY_HITS = args_cli.min_ray_hits +SHOW_FLUID_PARTICLES = args_cli.fluid_render_mode in ("particles", "both") +SHOW_FLUID_SURFACE = args_cli.fluid_render_mode in ("surface", "both") # Sparse grids reserve capture-stable storage; fixed grids need padding for the full pour trajectory. GRID_TYPE = args_cli.grid_type @@ -184,27 +197,136 @@ def _unit_interval_float(value: str) -> float: TABLE_COLOR = (0.48, 0.38, 0.26) BOWL_COLOR = (1.0, 1.0, 1.0) WATER_COLOR = (0.12, 0.35, 0.78) +WATER_OPACITY = 0.65 -CAMERA_EYE = (0.0, -1.45, 1.35) -CAMERA_TARGET = (-0.01, 0.0, TABLE_TOP_Z + 0.07) +CAMERA_EYE = (0.0, -0.72, 1.18) +CAMERA_TARGET = (0.0, 0.0, TABLE_TOP_Z + 0.10) + +# Use tighter kernels than the open-tank dam-break preset so the reconstructed +# surface stays inside narrow features such as the teapot spout. +SURFACE_VOXEL_SIZE = 0.5 * VOXEL_SIZE +SURFACE_KERNEL_RADIUS = max(3.0 * FILL_SPACING, 1.5 * SURFACE_VOXEL_SIZE) +SURFACE_MAX_GRID_CELLS = 4_000_000 +SURFACE_PATH = "/fluid_surface" def create_visualizer_cfgs(): """Create demo-specific visualizer configs for requested backends.""" - if not any(v in (args_cli.visualizer or []) for v in ("newton", "newton_gl", "newton_rtx")): + requested = args_cli.visualizer or [] + if not any(name in requested for name in ("newton_gl", "newton_rtx")): return [] from isaaclab_visualizers.newton import NewtonGLVisualizerCfg, NewtonRTXVisualizerCfg - cfg_type = NewtonRTXVisualizerCfg if args_cli.visualizer == ["newton_rtx"] else NewtonGLVisualizerCfg + cfg_types = {"newton_gl": NewtonGLVisualizerCfg, "newton_rtx": NewtonRTXVisualizerCfg} return [ - cfg_type( - show_particles=True, + cfg_types[name]( + show_particles=SHOW_FLUID_PARTICLES, particle_color=WATER_COLOR, ) + for name in requested + if name in cfg_types ] +class FluidSurfaceRenderer: + """Extract and display a dynamic water surface in Newton visualizers.""" + + def __init__(self, sim) -> None: + import warp as wp + from isaaclab_newton.physics import NewtonManager + from isaaclab_visualizers.newton import NewtonGLVisualizer, NewtonRTXVisualizer + from newton.geometry import ParticleSurface + + self._wp = wp + self._visualizers = tuple( + visualizer + for visualizer in sim.visualizers + if isinstance(visualizer, (NewtonGLVisualizer, NewtonRTXVisualizer)) + ) + if not self._visualizers: + raise RuntimeError("Particle surface rendering requires a Newton GL or RTX visualizer.") + + self._model = NewtonManager.get_model() + self._state = NewtonManager.get_state_0() + self._surface = ParticleSurface( + voxel_size=SURFACE_VOXEL_SIZE, + max_grid_cells=SURFACE_MAX_GRID_CELLS, + world_count=max(self._model.world_count, 1), + kernel_radius=SURFACE_KERNEL_RADIUS, + threshold=0.4, + smooth_lambda=0.0, + anisotropic=True, + kernel_scale=0.5, + anisotropy_ratio=16.0, + anisotropy_scale=1.0, + anisotropy_min_neighbors=4, + anisotropy_binning=True, + anisotropy_strength=0.95, + field_smooth_iterations=0, + mesh_smooth_iterations=1, + device=self._model.device, + ) + self._empty_points = wp.empty(0, dtype=wp.vec3, device=self._model.device) + self._empty_indices = wp.empty(0, dtype=wp.int32, device=self._model.device) + self._empty_normals = wp.empty(0, dtype=wp.vec3, device=self._model.device) + self._surface_mesh = None + self._surface_graph = None + self._capture_surface_extraction() + + def _extract_surface(self): + """Extract the water surface from the current Newton particle state.""" + return self._surface.extract( + self._state.particle_q, + self._model.particle_radius, + particle_flags=self._model.particle_flags, + particle_world=self._model.particle_world if self._surface.world_count > 1 else None, + ) + + def _capture_surface_extraction(self) -> None: + """Capture reconstruction separately from the MPM physics graph.""" + if not self._model.device.is_cuda or args_cli.disable_cuda_graph: + return + self._surface_mesh = self._extract_surface() + with self._wp.ScopedCapture(device=self._model.device) as capture: + self._surface_mesh = self._extract_surface() + self._surface_graph = capture.graph + + def update(self) -> int: + """Reconstruct and publish the current water surface, returning its triangle count.""" + if self._surface_graph is None: + self._surface_mesh = self._extract_surface() + else: + self._wp.capture_launch(self._surface_graph) + + vertices, indices, normals = self._surface_mesh.to_arrays() + if vertices is None: + vertices = self._empty_points + indices = self._empty_indices + normals = self._empty_normals + hidden = True + triangle_count = 0 + else: + hidden = False + triangle_count = indices.shape[0] // 3 + + for visualizer in self._visualizers: + visualizer.log_mesh( + SURFACE_PATH, + vertices, + indices, + normals=normals, + hidden=hidden, + backface_culling=False, + color=WATER_COLOR, + roughness=0.1, + metallic=0.0, + dynamic=True, + opacity=WATER_OPACITY, + ) + return triangle_count + + def quat_y(angle_rad: float) -> tuple[float, float, float, float]: """Return an XYZW quaternion for a rotation about +Y.""" half = 0.5 * angle_rad @@ -448,7 +570,7 @@ def create_sim_cfg(): strain_basis="P0", transfer_scheme="apic", integration_scheme="pic", - air_drag=0.2, + air_drag=1.0e-3, collider_velocity_mode="forward", # Grid contact avoids impulses from projecting particles out of colliders. project_outside_colliders=False, @@ -607,11 +729,11 @@ class TeapotFillSceneCfg(InteractiveSceneCfg): mass=particle_mass, radius=particle_radius, material=MPMParticleMaterialCfg( - viscosity=0.1, + viscosity=1.0e-3, friction=0.0, - damping=0.02, + damping=1.0e-3, yield_pressure=1.0e15, - tensile_yield_ratio=5.0, + tensile_yield_ratio=1.0, ), visual_color=WATER_COLOR, visual_material=sim_utils.PreviewSurfaceCfg( @@ -623,10 +745,7 @@ class TeapotFillSceneCfg(InteractiveSceneCfg): init_state=MPMObjectCfg.InitialStateCfg(pos=container_pos), ) - ground = AssetBaseCfg( - prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(20.0, 20.0), color=(0.30, 0.30, 0.30)), - ) + ground = AssetBaseCfg(prim_path="/World/Ground", spawn=sim_utils.GroundPlaneCfg()) dome_light = AssetBaseCfg( prim_path="/World/DomeLight", @@ -658,7 +777,7 @@ def write_container_state(container, sim_time: float) -> None: container.write_root_link_velocity_to_sim_index(root_velocity=velocity) -def run_simulator(sim, scene) -> None: +def run_simulator(sim, scene, surface_renderer: FluidSurfaceRenderer | None) -> None: """Run the scripted teapot-fill MPM loop.""" sim_dt = sim.get_physics_dt() container = scene["container"] @@ -669,6 +788,8 @@ def run_simulator(sim, scene) -> None: sim.step(render=False) scene.update(sim_dt) if sim.is_rendering and count % RENDER_INTERVAL == 0: + if surface_renderer is not None: + surface_renderer.update() sim.render() count += 1 @@ -688,7 +809,7 @@ def main() -> None: # Resolve after launching so Kit runs never import USD modules before # AppLauncher; Newton-only runs still use standalone omni.client. container_usd = retrieve_file_path(args_cli.container_usd) - if "kit" in (args_cli.visualizer or []): + if {"kit", "newton_rtx"}.intersection(args_cli.visualizer or []): island_usd = retrieve_optional_visual_asset(args_cli.island_usd, "kitchen island") bowl_usd = retrieve_optional_visual_asset(args_cli.bowl_usd, "catch bowl") else: @@ -701,15 +822,23 @@ def main() -> None: scene = InteractiveScene(create_scene_cfg(container_usd, island_usd, bowl_usd)) sim.reset() sim.set_camera_view(eye=CAMERA_EYE, target=CAMERA_TARGET) + surface_renderer = ( + FluidSurfaceRenderer(sim) + if SHOW_FLUID_SURFACE and any(v in (args_cli.visualizer or []) for v in ("newton_gl", "newton_rtx")) + else None + ) + surface_triangle_count = surface_renderer.update() if surface_renderer is not None else 0 print( "[INFO]: Isaac Lab Newton teapot-fill MPM demo ready." f" Sampled {particle_count(scene)} MPM particles inside the teapot;" + f" extracted {surface_triangle_count} surface triangles;" + f" rendering {args_cli.fluid_render_mode};" f" fill spacing {FILL_SPACING:.4g} m;" f" the teapot will tilt after {HOLD_TIME:.2f}s.", flush=True, ) - run_simulator(sim, scene) + run_simulator(sim, scene, surface_renderer) if __name__ == "__main__": diff --git a/scripts/demos/pick_and_place.py b/scripts/demos/pick_and_place.py index 4295204ec0ab..748f90c65a15 100644 --- a/scripts/demos/pick_and_place.py +++ b/scripts/demos/pick_and_place.py @@ -227,10 +227,10 @@ def _setup_scene(self): # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=["/World/ground"]) diff --git a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py index f71916309190..399f89be47c3 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py +++ b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py @@ -95,13 +95,13 @@ "--background_usd_path", type=str, default=None, - help="Path to the USD file for the background asset", + help="Path to the NuRec USD file.", ) parser.add_argument( "--background_occupancy_yaml_file", type=str, default=None, - help="Path to the occupancy map YAML file for the background asset", + help="Path to the NuRec occupancy map YAML file.", ) parser.add_argument( "--high_res_video", @@ -126,7 +126,7 @@ # forward unrecognized args as Hydra-style task config overrides args_cli, hydra_overrides = parser.parse_known_args() -app_launcher = AppLauncher(args_cli) +app_launcher = AppLauncher(args_cli, enable_cameras=True) simulation_app = app_launcher.app import enum @@ -358,7 +358,9 @@ def project_robot_state_into_env(env: LocomanipulationSDGEnv, input_episode_data ) default_vel = env.scene["robot"].data.default_root_vel.torch.clone() default_vel[0] = torch.zeros(6, device=env.device) - env.scene["robot"].data.default_root_vel.warp.assign(wp.from_torch(default_vel.to(env.device).contiguous())) + env.scene["robot"].data.default_root_vel.warp.assign( + wp.from_torch(default_vel.to(env.device).contiguous()).view(wp.spatial_vectorf) + ) robot_state = recording_initial_state["articulation"]["robot"] joint_position = robot_state["joint_position"][0].to(env.device) @@ -409,7 +411,9 @@ def project_object_state_into_env(env: LocomanipulationSDGEnv, input_episode_dat ) default_vel = env.scene["object"].data.default_root_vel.torch.clone() default_vel[0] = torch.zeros(6, device=env.device) - env.scene["object"].data.default_root_vel.warp.assign(wp.from_torch(default_vel.to(env.device).contiguous())) + env.scene["object"].data.default_root_vel.warp.assign( + wp.from_torch(default_vel.to(env.device).contiguous()).view(wp.spatial_vectorf) + ) return new_object_pose @@ -438,8 +442,7 @@ def setup_navigation_scene( if background_fixture is not None: occupancy_map = background_fixture.get_occupancy_map() fixtures = [env.get_start_fixture(), env.get_end_fixture()] + env.get_obstacle_fixtures() - if not randomize_placement: - raise ValueError("randomize_placement needs to be True when background_usd_path is provided") + randomize_placement = True else: occupancy_map = merge_occupancy_maps( [ @@ -484,17 +487,20 @@ def setup_navigation_scene( env.obs_buf = env.observation_manager.compute(update_history=True) nav_map = occupancy_map.buffered_meters(0.15) - nav_fs = nav_map.freespace_mask() - start_pos = env.get_base().get_pose()[0, :2].detach().cpu().numpy() - start_px = nav_map.world_to_pixel_numpy(start_pos[None])[0].astype(int) - sx, sy = int(start_px[0]), int(start_px[1]) - - # Clear the buffer zone around the robot's start pixel if it falls inside it. - # The robot stands adjacent to the start table so its position sits within the 0.15m buffer. - if 0 <= sy < nav_fs.shape[0] and 0 <= sx < nav_fs.shape[1] and not nav_fs[sy, sx]: + + def clear_nav_map_buffer(world_xy: torch.Tensor | np.ndarray): + nav_fs = nav_map.freespace_mask() + pixel_xy = nav_map.world_to_pixel_numpy(np.asarray(world_xy)[None])[0].astype(int) + x_px, y_px = int(pixel_xy[0]), int(pixel_xy[1]) + if not (0 <= y_px < nav_fs.shape[0] and 0 <= x_px < nav_fs.shape[1]) or nav_fs[y_px, x_px]: + return clear_r = int(0.15 / nav_map.resolution) yy, xx = np.ogrid[: nav_map.data.shape[0], : nav_map.data.shape[1]] - nav_map.data[(xx - sx) ** 2 + (yy - sy) ** 2 <= clear_r**2] = OccupancyMapDataValue.FREESPACE + nav_map.data[(xx - x_px) ** 2 + (yy - y_px) ** 2 <= clear_r**2] = OccupancyMapDataValue.FREESPACE + + # The robot start and approach goal can sit adjacent to their tables, inside the 0.15 m buffer. + clear_nav_map_buffer(env.get_base().get_pose()[0, :2].detach().cpu().numpy()) + clear_nav_map_buffer(base_goal_approach.get_pose()[0, :2].detach().cpu().numpy()) base_path = plan_path(start=env.get_base(), end=base_goal_approach, occupancy_map=nav_map) @@ -899,6 +905,11 @@ def replay( print("Failed to setup navigation scene", flush=True) return False + # Background placement can move fixtures and re-project robot/object root poses + # after reset_to records initial_state. Re-record from the projected scene. + env.recorder_manager.reset(env_ids=[0]) + env.recorder_manager.record_post_reset(env_ids=[0]) + if sensor_camera_view: _set_sensor_camera_view() @@ -1001,7 +1012,17 @@ def replay( env_cfg.recorders.dataset_export_mode = DatasetExportMode.EXPORT_SUCCEEDED_ONLY env_cfg.recorders.export_in_record_pre_reset = True - if args_cli.background_usd_path is not None and args_cli.background_occupancy_yaml_file is not None: + nurec_scene_used = ( + args_cli.background_usd_path is not None or args_cli.background_occupancy_yaml_file is not None + ) + if nurec_scene_used and ( + args_cli.background_usd_path is None or args_cli.background_occupancy_yaml_file is None + ): + raise ValueError( + "Both --background_usd_path and --background_occupancy_yaml_file must be provided for NuRec scenes." + ) + + if nurec_scene_used: env_cfg.background_usd_path = args_cli.background_usd_path env_cfg.background_occupancy_yaml_file = args_cli.background_occupancy_yaml_file @@ -1048,7 +1069,7 @@ def replay( following_offset=args_cli.following_offset, angle_threshold=args_cli.angle_threshold, approach_distance=args_cli.approach_distance, - randomize_placement=args_cli.randomize_placement, + randomize_placement=args_cli.randomize_placement or nurec_scene_used, sensor_camera_view=args_cli.sensor_camera_view, ) diff --git a/scripts/tutorials/06_deploy/anymal_c_env.py b/scripts/tutorials/06_deploy/anymal_c_env.py index 895bd8da7fc7..53f7181a6706 100644 --- a/scripts/tutorials/06_deploy/anymal_c_env.py +++ b/scripts/tutorials/06_deploy/anymal_c_env.py @@ -65,10 +65,10 @@ def _setup_scene(self): self.cfg.terrain.env_spacing = self.scene.cfg.env_spacing self._terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = (self.cfg.terrain.prim_path,) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[self.cfg.terrain.prim_path]) diff --git a/skills/developer/issue-audit/SKILL.md b/skills/developer/issue-audit/SKILL.md new file mode 100644 index 000000000000..c3d4b5247bfd --- /dev/null +++ b/skills/developer/issue-audit/SKILL.md @@ -0,0 +1,142 @@ +--- +name: isaaclab-auditing-an-issue +description: Audits one open GitHub issue against current code to decide close or keep-open, and drafts the closing comment. Use when deciding whether a specific issue is still valid, whether a reported bug was fixed, or what to reply before closing. +audience: developer +status: experimental +owners: + - isaaclab-maintainers +--- + +# Auditing an Issue + +## When To Use + +Use this skill for a single issue: deciding whether it is still valid against current code, and drafting the reply if it is being closed. + +For auditing many issues at once, use `isaaclab-triaging-issue-backlog`, which applies this skill per issue and adds the batch controls. + +Do not close an issue purely because it is old. Age is not evidence. + +## Core Principle + +A comment that reads well is not evidence that its claims are true. Verify every factual claim against current code before posting, and ask what is lost by closing, not only whether closing is defensible. + +## Workflow + +1. Read the whole thread, not just the first post: `gh issue view NUMBER --repo isaac-sim/IsaacLab --comments`. +2. Identify each checkable claim you intend to make in the reply. +3. Verify each one using the table below. Prefer running code over reading it. +4. Check the Do Not Close list before deciding. +5. Draft the comment in the style below, then confirm every link resolves. + +## Mandatory Checks + +| Claim shape | Required check | +|---|---| +| "X is fixed / landed in #N" | `gh pr view N` returns MERGED, and its diff touches the reported behavior. A generic "Merges changes from main" PR is never an acceptable citation. | +| "Isaac Lab doesn't have X" | Grep `source/isaaclab_tasks/isaaclab_tasks/contrib/`, `source/isaaclab_contrib/` and the `*_experimental` packages. Features move to contrib and absence claims go stale. | +| "`SYMBOL` no longer exists" | `grep -rn "SYMBOL" source/` — any live hit refutes it. | +| "`BACKEND` supports X" | Check that backend's own package. Backends differ; several classes raise `NotImplementedError` on one and not another. | +| Behavioral claim about an error or a limit | Write a temporary script and run it with `uv run python`. Reading the raise site is not enough — the guard may be narrower than it looks. | +| A usage answer | Confirm it is still the best current advice, not merely that some answer was given. Check the relevant capability on the Isaac Sim side too. | +| Any URL | `curl -s -o /dev/null -w "%{http_code}" -L "URL"` returns 200. | + +## URL Traps + +- `github.com/isaac-sim/IsaacLab/blob/main/...` **404s** for anything under `source/isaaclab_newton/`, `source/isaaclab_ov/` or `source/isaaclab_contrib/` — those packages are not on `main`. Use `blob/develop/...`, or cite the path in backticks with no link. Package layout shifts over time (e.g. `isaaclab_ovphysx` was merged into `isaaclab_ov`), so verify the path exists on the ref you link. +- `isaac-sim.github.io/IsaacLab/main/...` is the stale 2.x docs build. Use `release/3.0.0/...`, and confirm the anchor still exists — several 2.x anchors were removed in the 3.x reorganisation. + +## Do Not Close + +Hold the issue when any of these apply, even if the stated close reason is technically defensible: + +- A maintainer asked the reporter a question and the thread is still recent. +- It is pre-merge feedback on an open PR. +- It is a duplicate of an issue that was itself closed without a fix. +- The titled ask is unmet, even if a related bug in the thread was fixed. +- It is blocked on upstream work that is planned but not landed. Confirm the upstream issue exists; "file it upstream" is not a resolution if nobody has. + +When holding, record the concrete next step and the evidence, so the next audit does not redo the work. + +## Comment Style + +- Thank the reporter by handle in the first sentence, specifically and briefly. +- State what actually resolves it, with file paths in backticks and a verified PR link. +- Invite a reopen if it still reproduces on a current release. +- Do not assign fault. Avoid "we never heard back" (blames the reporter) and "nothing on our side" (blames a sibling team). Describe where the behavior lives, and route rather than deflect. +- Do not name a colleague as the source of a negative finding. Crediting a helpful answer is fine. +- If the reply was AI-assisted, end with the disclaimer footer below, and do not claim a level of human review that did not happen. + +### Disclaimer Footer + +End an AI-assisted reply with a horizontal rule and a one-line footer: + +```markdown +--- +*🤖 This issue was reviewed as part of an AI-assisted triage of the Isaac Lab backlog, with automated verification against the current codebase. If we got this wrong, please reopen — we'd rather hear about it!* +``` + +Match the claim to what actually happened. "with automated verification against the current codebase" is accurate for a machine-checked reply. Only say "verified by a maintainer" if a maintainer read that specific comment. + +### Worked Example + +A close for a bug that was genuinely fixed: + +```markdown +Thanks @Mitchell-Torok for catching that `reset_joints_by_offset` ignored the `joint_names` you passed in `SceneEntityCfg`. + +It's fixed: both `reset_joints_by_offset` and `reset_joints_by_scale` in `isaaclab/envs/mdp/events.py` now index the default joint state and the soft-limit clamp with `asset_cfg.joint_ids` (broadcasting `env_ids[:, None]` for subsets), so only the named joints are touched. Shipped in https://github.com/isaac-sim/IsaacLab/pull/2899. Sorry for the delay — please reopen if extra joints still move on a current release. + +--- +*🤖 This issue was reviewed as part of an AI-assisted triage of the Isaac Lab backlog, with automated verification against the current codebase. If we got this wrong, please reopen — we'd rather hear about it!* +``` + +Each part is doing work: the handle and the specific symptom in sentence one, the mechanism plus file path so the comment stands alone in search results, a PR link confirmed MERGED, a three-word apology rather than a paragraph, and a reopen invitation tied to a current release. + +### Fault Assignment + +The rewrites below are the difference between a reply that reads as closure and one that reads as a brush-off. + +| Instead of | Write | +|---|---| +| "We asked twice and never heard back." | "We weren't able to get to the bottom of this at the time, and a lot has changed since." | +| "There's nothing on our side to fix." | "That code path lives in ``, so `` is the better home for it." | +| "This is an Isaac Sim bug that their team owns." | "The `sim.reset()` requirement lives in that layer and is tracked there." | +| "As @SomeDev confirmed, it's broken." | State the finding directly, without attributing it to a colleague. | + +## Common Mistakes + +- Trusting a polished draft. Fluency correlates with neither accuracy nor completeness. +- Answering "was a reply given?" instead of "is that answer still correct and complete?" +- Citing a PR whose title matches the topic without reading its diff. +- Closing an issue whose stated reason is sound but which is the only remaining record of a live problem. + +## Validation + +Verify citations and links before posting: + +```bash +gh pr view PR_NUMBER --repo isaac-sim/IsaacLab --json state,mergedAt,title +curl -s -o /dev/null -w "%{http_code}" -L "URL" +``` + +For a behavioral claim, write a temporary script and run it: + +```bash +uv run python PATH_TO_SCRIPT +``` + +If skills changed, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep the URL traps section synchronized with the current package layout. When a package moves between `main` and `develop`, or the docs build version changes, update it before it produces a dead link in a public comment. + +## References + +- [Backlog triage skill](../issue-backlog-triage/SKILL.md) +- [Contributing guide](../../../docs/source/refs/contributing.rst) diff --git a/skills/developer/issue-backlog-triage/SKILL.md b/skills/developer/issue-backlog-triage/SKILL.md new file mode 100644 index 000000000000..a89965a9eec9 --- /dev/null +++ b/skills/developer/issue-backlog-triage/SKILL.md @@ -0,0 +1,88 @@ +--- +name: isaaclab-triaging-issue-backlog +description: Triages many open GitHub issues at once into close/keep-open with drafted closing comments. Use when auditing the whole issue backlog, running a bulk close pass, or reporting how many issues can be retired. +audience: developer +status: experimental +owners: + - isaaclab-maintainers +--- + +# Triaging the Issue Backlog + +## When To Use + +Use this skill when auditing many issues at once: a backlog sweep, a bulk close pass, or a report on how much of the backlog can be retired. + +For a single issue, use `isaaclab-auditing-an-issue` directly. + +**REQUIRED SUB-SKILL:** Use `isaaclab-auditing-an-issue` for every individual issue. This skill only adds batch orchestration; it does not restate the per-issue checks. + +## Core Principle + +Scale multiplies confident errors. A batch of drafted comments will read uniformly well whether or not the claims are true, so verification must be adversarial and the human review gate is not optional. + +## Workflow + +1. Dump the backlog with metadata and split it into batches: + + ```bash + gh issue list --repo isaac-sim/IsaacLab --state open --limit 500 \ + --json number,title,author,createdAt,updatedAt,labels,comments,assignees,url + ``` + +2. Audit each issue per `isaaclab-auditing-an-issue`, recording a verdict, the evidence, and a confidence level. +3. Run an adversarial pass over every close recommendation. Instruct the reviewer to **refute**, and to return REFUTED or UNCERTAIN whenever it cannot independently confirm the evidence. Verify low-confidence recommendations as strictly as the rest — they refute at a far higher rate. +4. Sweep the drafted comments for claims that a per-issue audit misses across a batch: capability assertions, citations whose PR does not match, and dead links. +5. Review every comment with a maintainer, in batches, before anything is posted. +6. Post only what a maintainer explicitly approved. + +## Batch Controls + +- **Never infer approval.** Approve on an explicit statement, not on a reply that merely discusses a batch. Record approvals so a resumed session cannot post something unreviewed. +- **Keep one plan file** listing every issue to be closed with its final comment, plus appendices for held issues and for issues pulled out during review with their next steps. +- **Re-show anything edited after approval.** Later passes rewrite earlier comments; approval does not survive a rewrite. +- **Report what was excluded.** State how many issues were refuted, held, or pulled out, not only how many are closeable. + +## Expect These Failure Modes + +Measured over one full pass of the backlog; treat them as the default, not the exception. + +| Failure mode | What it looks like | +|---|---| +| Stale absence claims | "Isaac Lab doesn't support X" when X moved to `contrib/`. | +| Mismatched citations | A merged PR whose title fits the topic but whose diff never touches the behavior. | +| Capability drift | Asserting a backend or release supports something it does not. | +| Answered-but-wrong | A usage answer that was correct when written and is now outdated. | +| Blame framing | "We never heard back" or "nothing on our side", which read badly in public. | +| Defensible but lossy | A close whose reason is sound but which discards the only record of a live problem. | + +## Common Mistakes + +- Treating a high-confidence label as a substitute for verification. +- Batching approval across issues that were edited at different times. +- Reporting a reduction figure without the refuted and held counts. +- Posting before a maintainer has read the specific comment being posted. + +## Validation + +Before posting, confirm the batch is internally consistent: every comment ends with the intended marker, every cited PR is merged, and every URL resolves. + +```bash +gh pr view PR_NUMBER --repo isaac-sim/IsaacLab --json state,mergedAt,title +curl -s -o /dev/null -w "%{http_code}" -L "URL" +``` + +If skills changed, run: + +```bash +uv run --no-project python tools/skills/cli.py check +``` + +## Maintenance + +Keep the per-issue checks in `isaaclab-auditing-an-issue` and do not duplicate them here. Update the failure-mode table when a new class of error survives verification into a posted comment. + +## References + +- [Issue audit skill](../issue-audit/SKILL.md) +- [Contributing guide](../../../docs/source/refs/contributing.rst) diff --git a/source/isaaclab/changelog.d/bump-newton-1-6-release.rst b/source/isaaclab/changelog.d/bump-newton-1-6-release.rst new file mode 100644 index 000000000000..d852412c6d6a --- /dev/null +++ b/source/isaaclab/changelog.d/bump-newton-1-6-release.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed the pinned Newton version from ``1.6.0rc1`` to the final ``1.6.0`` release. No user + action is required; new environments now resolve the stable release instead of the release candidate. diff --git a/source/isaaclab/changelog.d/fix-headless-video-idle-rendering.rst b/source/isaaclab/changelog.d/fix-headless-video-idle-rendering.rst new file mode 100644 index 000000000000..986dfeb1641d --- /dev/null +++ b/source/isaaclab/changelog.d/fix-headless-video-idle-rendering.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed ``--video`` training continuously updating PhysX Fabric and the capture-only Kit visualizer + between recording windows. Physics transforms are now synchronized on demand before each captured frame. diff --git a/source/isaaclab/changelog.d/fix-marl-play-benchmark.rst b/source/isaaclab/changelog.d/fix-marl-play-benchmark.rst new file mode 100644 index 000000000000..cf99cbc74412 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-marl-play-benchmark.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed play benchmarks failing on ``DirectMARLEnv`` tasks by creating and converting environments + with the same backend-specific rules as training. diff --git a/source/isaaclab/changelog.d/fix-osc-link-velocity.rst b/source/isaaclab/changelog.d/fix-osc-link-velocity.rst new file mode 100644 index 000000000000..87556a701d28 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-osc-link-velocity.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed operational-space controller velocity feedback to use the same link origins as the end-effector pose and + Jacobian in the action term and integration tests. diff --git a/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip b/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip new file mode 100644 index 000000000000..f08e4d9ff050 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-standalone-soak-deadline.skip @@ -0,0 +1 @@ +Allowed standalone smoke tests that reached readiness before the startup deadline to complete their full soak interval. diff --git a/source/isaaclab/changelog.d/fix-venv-on-isaac-sim-python.rst b/source/isaaclab/changelog.d/fix-venv-on-isaac-sim-python.rst new file mode 100644 index 000000000000..fd68288250a0 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-venv-on-isaac-sim-python.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed ``isaaclab.sh`` and the ``isaaclab`` CLI rejecting a virtual environment that was created on + a downloaded Isaac Sim package's own Python. Such an environment reuses that interpreter, so Kit's + extension modules load exactly as they do under the bundled Python; only environments supplying + their own interpreter, such as conda, are still refused. Create one with + ``uv venv --python _isaac_sim/kit/python/bin/python3``. diff --git a/source/isaaclab/changelog.d/flatten-wheel-package.rst b/source/isaaclab/changelog.d/flatten-wheel-package.rst new file mode 100644 index 000000000000..6a090515e8c0 --- /dev/null +++ b/source/isaaclab/changelog.d/flatten-wheel-package.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the Isaac Lab wheel to install core modules in a flat ``isaaclab`` package instead of + exposing a nested source package through a modified package search path. diff --git a/source/isaaclab/changelog.d/git-source-wheel.rst b/source/isaaclab/changelog.d/git-source-wheel.rst new file mode 100644 index 000000000000..acbe206d3897 --- /dev/null +++ b/source/isaaclab/changelog.d/git-source-wheel.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added a PEP 517 build target under ``tools/wheel_builder`` so package managers can install the aggregate Isaac Lab + package directly from a Git source without duplicating Isaac Lab's dependency list downstream. diff --git a/source/isaaclab/changelog.d/jichuanh-curobo-uv-install.minor.rst b/source/isaaclab/changelog.d/jichuanh-curobo-uv-install.minor.rst new file mode 100644 index 000000000000..d14ad422b65b --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-curobo-uv-install.minor.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Changed the cuRobo Docker image to install from ``uv.lock`` into the virtual environment at + ``/opt/isaaclab-venv``, matching the base image. Isaac Sim's site-packages is left untouched, + so the image no longer deletes the prebundled torch, re-bootstraps ``pip`` afterwards, or + uninstalls ``quadprog``. cuRobo itself is still built from its pinned commit against the + environment's torch. diff --git a/source/isaaclab/changelog.d/jichuanh-newton-pin-4017.minor.rst b/source/isaaclab/changelog.d/jichuanh-newton-pin-4017.minor.rst new file mode 100644 index 000000000000..c5e7238976a0 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-newton-pin-4017.minor.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Changed the pinned Newton version from ``1.5.1`` to ``1.6.0rc1``, the first release carrying + `newton#4017 `_, which makes + ``ArticulationView`` generic over custom frequencies. Reading a MuJoCo actuator attribute such + as ``mujoco.actuator_trntype`` through an articulation view previously raised *"has custom + frequency 'mujoco:actuator' which is not supported by ArticulationView"*. +* Changed ``warp-lang`` from ``1.16.0`` to ``1.17.0`` and ``mujoco``/``mujoco-warp`` from + ``3.11.0`` to ``3.12.0``, which Newton ``1.6.0rc1`` requires. diff --git a/source/isaaclab/changelog.d/jichuanh-uv-lock-docker-install.minor.rst b/source/isaaclab/changelog.d/jichuanh-uv-lock-docker-install.minor.rst new file mode 100644 index 000000000000..215a4b5a03c1 --- /dev/null +++ b/source/isaaclab/changelog.d/jichuanh-uv-lock-docker-install.minor.rst @@ -0,0 +1,15 @@ +Changed +^^^^^^^ + +* Changed the Docker images to install from ``uv.lock`` into a virtual environment at + ``/opt/isaaclab-venv`` instead of into Isaac Sim's own site-packages, leaving the shipped + Isaac Sim environment untouched and applying ``[tool.uv] override-dependencies``. +* Changed the ``teleop`` extra to carry only the teleop stack; Isaac Sim is no longer bundled + with it. Environments that already provide Kit, such as the container images, no longer + install a second copy of it. Install ``--extra teleop,isaacsim`` to get the Isaac Sim wheel + as before. +* Renamed the ``test`` extra to ``dev``, which still carries the test suite plus the + documentation toolchain. Use ``--extra dev`` where ``--extra test`` was used to build docs. +* Changed the ``test`` extra to carry the test suite alone, without the documentation + toolchain, so the container images can install it without shipping Sphinx and its + GPL-3.0-or-later ``docutils`` dependency. diff --git a/source/isaaclab/changelog.d/loading-screen-animations.minor.rst b/source/isaaclab/changelog.d/loading-screen-animations.minor.rst new file mode 100644 index 000000000000..65f96a519c5d --- /dev/null +++ b/source/isaaclab/changelog.d/loading-screen-animations.minor.rst @@ -0,0 +1,10 @@ +Added +^^^^^ + +* Added static Isaac Lab 3 and NVIDIA greetings to the loading screen, selected at random for each run. + +Changed +^^^^^^^ + +* Changed the loading screen's wide layout to start at 130 columns rather than 120, giving the + Isaac Lab 3 wordmark room to fit. Terminals between 120 and 129 columns now use the 80-column layout. diff --git a/source/isaaclab/changelog.d/mimic-max-num-failures.rst b/source/isaaclab/changelog.d/mimic-max-num-failures.rst new file mode 100644 index 000000000000..9f811b3a0612 --- /dev/null +++ b/source/isaaclab/changelog.d/mimic-max-num-failures.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed :attr:`~isaaclab.envs.mimic_env_cfg.DataGenConfig.max_num_failures` being ignored. The field + documented a cap on failed generation attempts but was never read, so a run with + :attr:`~isaaclab.envs.mimic_env_cfg.DataGenConfig.generation_guarantee` enabled retried without + bound on a task with a low success rate. Its default is now ``None`` (no limit) and setting it to + an integer stops generation once that many attempts have failed. diff --git a/source/isaaclab/changelog.d/ooctipus-clone-plan-contract.major.rst b/source/isaaclab/changelog.d/ooctipus-clone-plan-contract.major.rst new file mode 100644 index 000000000000..7ded5c2ffe48 --- /dev/null +++ b/source/isaaclab/changelog.d/ooctipus-clone-plan-contract.major.rst @@ -0,0 +1,15 @@ +Changed +^^^^^^^ + +* **Breaking:** Routed clone backends through one :class:`~isaaclab.cloner.ClonePlan` using its + ``context_rows`` mapping. Replace ``UsdReplicateContext.queue(...)`` or + ``queue_mapping(...)`` followed by ``replicate()`` with + ``UsdReplicateContext.replicate(plan)``. Standalone callers can use + :func:`~isaaclab.cloner.usd_replicate`. Removed the former ``stage`` argument from + :func:`~isaaclab.cloner.replicate` and :class:`~isaaclab.cloner.ReplicateSession`. + Custom ``cloning_contexts`` must be registered with + ``sim.get_or_create_backend(ContextType, ...)`` before dispatch; built-in physics managers + register their own context. Changed clone-plan arrays, grid transforms, and clone strategies + to NumPy and removed their ``device`` arguments, including ``CloneCfg.device``. Changed + :func:`~isaaclab.cloner.usd_replicate` to accept NumPy arrays instead of tensors. Convert an + array to a runtime tensor only where its consuming component requires one. diff --git a/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst new file mode 100644 index 000000000000..e4dd338b5a06 --- /dev/null +++ b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **Breaking:** Published :class:`~isaaclab.cloner.ReplicateSession` plans on entry and dispatched + them on exit. Empty :class:`~isaaclab.scene.InteractiveScene` configurations now author only + ``env_0``; direct workflows must finish setup with :func:`~isaaclab.cloner.clone_plan_from_env_0` + and :func:`~isaaclab.cloner.replicate`. diff --git a/source/isaaclab/changelog.d/ovphysx-06.rst b/source/isaaclab/changelog.d/ovphysx-06.rst new file mode 100644 index 000000000000..bb4c7b87d73b --- /dev/null +++ b/source/isaaclab/changelog.d/ovphysx-06.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Allowed the shared generalized-force ordering kernel to omit direction signs for backends that already returned + forces in the public joint basis. Callers supplying direction signs retained their existing behavior. diff --git a/source/isaaclab/changelog.d/pin-published-torch.rst b/source/isaaclab/changelog.d/pin-published-torch.rst new file mode 100644 index 000000000000..13bc69074af2 --- /dev/null +++ b/source/isaaclab/changelog.d/pin-published-torch.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Pinned the PyTorch stack in the published package dependencies so downstream projects no + longer selected newer, untested builds when installing the Isaac Lab wheel. diff --git a/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst b/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst new file mode 100644 index 000000000000..0d21d986a7ad --- /dev/null +++ b/source/isaaclab/changelog.d/pr-7453-expanded-prebundles.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed Docker installs leaving dangling package links in expanded Isaac Sim prebundles. diff --git a/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst b/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst new file mode 100644 index 000000000000..df22fa38fe68 --- /dev/null +++ b/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added a synchronized timer around the renderer call inside + :meth:`~isaaclab.renderers.render_context.RenderContext.render_into_camera`, gated by the ``ISAACLAB_RENDER_PROFILE`` + environment variable, so any rendering backend can be profiled through the same scope name + (:data:`~isaaclab.renderers.render_context.RENDER_PROFILE_SCOPE`). When enabled, each render prints its elapsed + time to the log. diff --git a/source/isaaclab/changelog.d/replication-names-copies.skip b/source/isaaclab/changelog.d/replication-names-copies.skip new file mode 100644 index 000000000000..29811b819395 --- /dev/null +++ b/source/isaaclab/changelog.d/replication-names-copies.skip @@ -0,0 +1 @@ +Dependency pin coverage is documented by the Newton package fragment. diff --git a/source/isaaclab/changelog.d/skip-checkout-only-unit-tests.skip b/source/isaaclab/changelog.d/skip-checkout-only-unit-tests.skip new file mode 100644 index 000000000000..632421fdaec8 --- /dev/null +++ b/source/isaaclab/changelog.d/skip-checkout-only-unit-tests.skip @@ -0,0 +1 @@ +Test-only: added a source checkout root fixture for tests that inspect repository artifacts. diff --git a/source/isaaclab/isaaclab/__init__.py b/source/isaaclab/isaaclab/__init__.py index dea1b4c9c649..c7dff524ab64 100644 --- a/source/isaaclab/isaaclab/__init__.py +++ b/source/isaaclab/isaaclab/__init__.py @@ -131,3 +131,25 @@ def _expose_mujoco_usd_schemas(): __version__ = importlib.metadata.version("isaaclab") except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" + + +# TODO(myurasov-nv): bootstrap_kernel() is ported from the internal GitLab wheel builder +# for backwards compatibility. It is not called currently, but may be needed if Isaac Sim +# requires explicit kernel bootstrapping before use. Remove once confirmed unnecessary. +def bootstrap_kernel(): + """Import Isaac Sim so it can initialize its kernel when available.""" + isaaclab_path = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) + if importlib.util.find_spec("isaacsim") is not None: + import isaacsim # noqa: F401 + + if importlib.util.find_spec("carb") is not None: + import carb + + carb.log_info(f"Isaac Lab path: {isaaclab_path}") + + +def main(): + """Run the ``isaaclab`` command through its compatibility dispatcher.""" + from isaaclab.__main__ import main as _main + + sys.exit(_main()) diff --git a/tools/wheel_builder/res/__main__.py b/source/isaaclab/isaaclab/__main__.py similarity index 100% rename from tools/wheel_builder/res/__main__.py rename to source/isaaclab/isaaclab/__main__.py diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 7552853e8bb3..8bc108266c54 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -36,6 +36,7 @@ from isaaclab.app.loading_screen import report_activity from isaaclab.app.logging_utils import apply_python_logging_level, resolve_python_logging_level from isaaclab.app.settings_manager import get_settings_manager, initialize_carb_settings +from isaaclab.paths import ISAACLAB_ROOT from isaaclab.utils._device import set_cuda_device from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -1202,7 +1203,7 @@ def _resolve_experience_file(self, launcher_args: dict): ) from e kit_app_exp_path = os.path.join(os.path.dirname(_isaacsim_for_paths.__file__), "apps") os.environ["EXP_PATH"] = kit_app_exp_path - isaaclab_app_exp_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 4, "apps") + isaaclab_app_exp_path = str(ISAACLAB_ROOT / "apps") # For Isaac Sim 4.5 compatibility, we use the 4.5 app files in a different folder # if launcher_args.get("use_isaacsim_45", False): if self.is_isaac_sim_version_5(): diff --git a/source/isaaclab/isaaclab/app/loading_screen.py b/source/isaaclab/isaaclab/app/loading_screen.py index 357d593541d0..a5e5847d65ef 100644 --- a/source/isaaclab/isaaclab/app/loading_screen.py +++ b/source/isaaclab/isaaclab/app/loading_screen.py @@ -8,6 +8,8 @@ from __future__ import annotations import os +import random +import re import shutil import sys import tempfile @@ -27,7 +29,7 @@ _REFRESH_PER_SECOND = 10 _SUMMARY_WIDTH = 50 _STANDARD_WIDTH = 80 -_WIDE_WIDTH = 120 +_WIDE_WIDTH = 130 _COLUMN_GAP = 6 # Reported steps per stage that fill the stage's slice of the bar, and the share # of that slice they may fill. Sub-steps are not known in advance, so a stage @@ -39,24 +41,64 @@ _ASCII_BOX = ("+", "+", "+", "+", "-", "|") _WRAP_CONSOLE = Console(color_system=None, force_terminal=False, width=120) -LOGO = r"""Welcome to Isaac Lab! - - \ / - .-------. - | o o | - | _ | - '-------'""" -"""Greeting drawn beside the run summary. Kept short enough to fit alongside it.""" - -LOGO_WIDE = r"""Welcome to Isaac Lab! - -██╗███████╗ █████╗ █████╗ ██████╗ ██╗ █████╗ ██████╗ -██║██╔════╝██╔══██╗██╔══██╗██╔════╝ ██║ ██╔══██╗██╔══██╗ -██║███████╗███████║███████║██║ ██║ ███████║██████╔╝ -██║╚════██║██╔══██║██╔══██║██║ ██║ ██╔══██║██╔══██╗ -██║███████║██║ ██║██║ ██║╚██████╗ ███████╗██║ ██║██████╔╝ -╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═════╝""" -"""Wide greeting used when a 120-column display is available.""" +LOGO = ( + "\x1b[0m \n" + "\x1b[0m \n" + "\x1b[0m \x1b[38;2;118;185;0m\\\x1b[0m \x1b[38;2;118;185;0m/\x1b[0m \n" + "\x1b[0m \x1b[38;2;232;228;214m.-------.\x1b[0m \n" + "\x1b[0m \x1b[38;2;232;228;214m|\x1b[0m \x1b[38;2;77;217;232mo\x1b[0m \x1b[38;2;77;217;232mo\x1b[0m \x1b[38;2;232;228;214m|\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;232;228;214m|\x1b[0m \x1b[38;2;232;228;214m_\x1b[0m \x1b[38;2;232;228;214m|\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;232;228;214m'-------'\x1b[0m \n" + "\x1b[0m \n" + "\x1b[0m\x1b[38;2;150;154;160mWelcome\x1b[0m \x1b[38;2;150;154;160mto\x1b[0m \x1b[38;2;150;154;160mIsaac\x1b[0m \x1b[38;2;150;154;160mLab\x1b[0m \x1b[38;2;150;154;160m3!\x1b[0m \n" # noqa: E501 + "\x1b[0m \n" + "\x1b[0m \n" + "\x1b[0m \n" +) +"""Static Isaac Lab 3 greeting used on standard-width displays.""" +LOGO_WIDE = ( + "\x1b[0m \n" + "\x1b[0m \n" + "\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m█████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m█████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m█████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╗\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔════╝\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔════╝\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;52;84;0m╚════\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╔╝\x1b[0m \x1b[38;2;118;185;0m█████\x1b[38;2;52;84;0m╔╝\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║╚════\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╔══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;52;84;0m╚═══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║╚\x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╔╝\x1b[0m \x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╔╝\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[38;2;52;84;0m╚═╝╚══════╝╚═╝\x1b[0m \x1b[38;2;52;84;0m╚═╝╚═╝\x1b[0m \x1b[38;2;52;84;0m╚═╝\x1b[0m \x1b[38;2;52;84;0m╚═════╝\x1b[0m \x1b[38;2;52;84;0m╚══════╝╚═╝\x1b[0m \x1b[38;2;52;84;0m╚═╝╚═════╝\x1b[0m \x1b[38;2;52;84;0m╚═════╝\x1b[0m \n" # noqa: E501 + "\x1b[0m \n" + "\x1b[0m \n" + "\x1b[0m \n" +) +"""Static Isaac Lab 3 greeting used when a 130-column display is available.""" +LOGO_NVIDIA = ( + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\x1b[0m\n" + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▄▄▄▛▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▗▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▀▀▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄▄▀▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" # noqa: E501 + "\x1b[0m\x1b[38;2;118;185;0m▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▀▚▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▀▙▖\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" # noqa: E501 + "\x1b[0m\x1b[38;2;118;185;0m▝\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▖▝\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙\x1b[0m \x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▘▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" # noqa: E501 + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▝\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄▝▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀▚▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▘\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▝▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" # noqa: E501 + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀▀▗▄▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" # noqa: E501 + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀▀▀▙▄▄▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\n" + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[0m\n" +) +"""NVIDIA greeting used on standard-width displays.""" + +LOGO_NVIDIA_WIDE = ( + "\x1b[0m \x1b[0m \x1b[0m\x1b[0m \x1b[0m\x1b[0m \x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[0m \x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▄▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▀▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m \x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╗\x1b[0m \x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀▄▄▟▛▀▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄▝▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;52;84;0m╚════\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▐\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀\x1b[0m \x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙▖▗\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;118;185;0m█████\x1b[38;2;52;84;0m╔╝\x1b[0m\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄\x1b[0m \x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▘▄\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀▜\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[0m \x1b[38;2;52;84;0m╚═══\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m╗\x1b[0m\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▄▝▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▙▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▀▘\x1b[0m \x1b[0m\x1b[38;2;118;185;0m▗▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;118;185;0m██\x1b[38;2;52;84;0m║\x1b[38;2;118;185;0m███████\x1b[38;2;52;84;0m╗\x1b[38;2;118;185;0m██████\x1b[38;2;52;84;0m╔╝\x1b[0m\x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m▀▀\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[38;2;118;185;0m▛▀▀▀▚▄▄▟\x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[38;2;52;84;0m╚═╝╚══════╝╚═════╝\x1b[0m \x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[38;2;118;185;0m\x1b[48;2;118;185;0m \x1b[0m\x1b[0m \x1b[0m\x1b[0m \x1b[0m \n" # noqa: E501 + "\x1b[0m \x1b[0m \x1b[0m\x1b[0m \x1b[0m\x1b[0m \x1b[0m \n" # noqa: E501 +) +"""NVIDIA greeting used when a 130-column display is available.""" + +_LOGO_PAIRS = ((LOGO, LOGO_WIDE), (LOGO_NVIDIA, LOGO_NVIDIA_WIDE)) +"""Static logo pairs, ordered standard width then wide.""" _active_screen: LoadingScreen | None = None @@ -142,9 +184,13 @@ def _format_header( return summary, display_width +_ANSI = re.compile(r"\x1b\[[0-9;]*m") +"""Colour escapes, which occupy no columns and must not be measured as if they did.""" + + def _block_width(block: str) -> int: """Return the width of the widest line in a multiline text block.""" - return max((cell_len(line) for line in block.splitlines()), default=0) + return max((cell_len(_ANSI.sub("", line)) for line in block.splitlines()), default=0) def _join_columns(left: str, right: str, gap: int = _COLUMN_GAP) -> str: @@ -274,7 +320,7 @@ def __init__(self, num_stages: int, *, enabled: bool | None = None, logo: bool = the run summary. Defaults to True. """ self._num_stages = num_stages - self._logos = (LOGO, LOGO_WIDE) if logo else () + self._logos = random.choice(_LOGO_PAIRS) if logo else () self._enabled = _console_is_interactive() if enabled is None else enabled self._console: IO[str] = sys.stdout self._ascii_only = not _supports_box_drawing(self._console) @@ -463,7 +509,7 @@ def _render(self, terminal_width: int) -> RenderableType: ascii_only=self._ascii_only, ) if header: - renderables.extend((Text(""), Text(header, no_wrap=True, overflow="crop"))) + renderables.extend((Text(""), Text.from_ansi(header, no_wrap=True, overflow="crop"))) if self._show_progress: if renderables: renderables.append(Text("")) diff --git a/source/isaaclab/isaaclab/assets/articulation/ordering_kernels.py b/source/isaaclab/isaaclab/assets/articulation/ordering_kernels.py index 1d0af90bb27e..42dd19175412 100644 --- a/source/isaaclab/isaaclab/assets/articulation/ordering_kernels.py +++ b/source/isaaclab/isaaclab/assets/articulation/ordering_kernels.py @@ -445,7 +445,8 @@ def reorder_generalized_vector_backend_to_user( shaped [num_envs, num_dofs], in backend joint order. joint_user_to_backend: Read-only map from public actuated-joint indices to backend actuated-joint indices. - joint_dof_signs: Backend-to-USD direction sign for each joint DoF. + joint_dof_signs: Backend-to-USD direction sign for each joint DoF, or + ``None`` when the backend already uses the public joint basis. num_base_dofs: Number of leading floating-base DoFs, either 0 or 6. has_joint_ordering: Whether to apply the map after the leading base DoFs. user_data: Destination with the same shape and units in public joint @@ -458,7 +459,7 @@ def reorder_generalized_vector_backend_to_user( backend_dof_id = num_base_dofs + joint_user_to_backend[user_dof_id - num_base_dofs] sign = 1.0 - if user_dof_id >= num_base_dofs: + if user_dof_id >= num_base_dofs and joint_dof_signs.shape[0] > 0: sign = wp.float32(joint_dof_signs[backend_dof_id - num_base_dofs]) user_data[env_id, user_dof_id] = sign * backend_data[env_id, backend_dof_id] diff --git a/source/isaaclab/isaaclab/assets/asset_base_cfg.py b/source/isaaclab/isaaclab/assets/asset_base_cfg.py index ce1c51f927fe..41d4d0764381 100644 --- a/source/isaaclab/isaaclab/assets/asset_base_cfg.py +++ b/source/isaaclab/isaaclab/assets/asset_base_cfg.py @@ -49,10 +49,9 @@ class InitialStateCfg: cloning_contexts: tuple[str | type, ...] | None = None """Cloning contexts for this asset. Defaults to None. - Entries are ``"module:ContextClass"`` references (or classes). If None, - :func:`~isaaclab.cloner.replicate` uses the backend's default physics context - (for example, ``isaaclab_physx.cloner.PHYSICS_CONTEXT`` or - ``isaaclab_ov.cloner.PHYSICS_CONTEXT``). An empty tuple requests no explicit context. + Entries are ``"module:ContextClass"`` references (or classes). If None, planning + routes the asset to the active physics manager's clone context. An empty tuple + requests no explicit physics context. :class:`~isaaclab.cloner.UsdReplicateContext` is still added automatically when ``spawn`` is set and Kit is available; listing it explicitly forces USD replication even without Kit. """ diff --git a/source/isaaclab/isaaclab/assets/visual_material/visual_material.py b/source/isaaclab/isaaclab/assets/visual_material/visual_material.py index 5a406d647678..a3e4a370ada4 100644 --- a/source/isaaclab/isaaclab/assets/visual_material/visual_material.py +++ b/source/isaaclab/isaaclab/assets/visual_material/visual_material.py @@ -138,8 +138,8 @@ def _initialize_impl(self) -> None: plan = SimulationContext.instance().get_clone_plan() if self._is_per_env: assert plan is not None and plan.env_ids is not None - plan_env_ids = plan.env_ids.detach().cpu().tolist() - columns = {env_id: column for column, env_id in enumerate(plan_env_ids)} + plan_env_ids = plan.env_ids + columns = {int(env_id): column for column, env_id in enumerate(plan_env_ids)} material_paths = [""] * len(plan_env_ids) for source_root, destination, source_path, env_ids in cloner.query.iter_sources(plan, self.cfg.prim_path): for env_id in env_ids: diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_play_rl_games.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_play_rl_games.py index e8c295b61bcf..06bf1f0ef6d1 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_play_rl_games.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_play_rl_games.py @@ -21,7 +21,7 @@ from isaaclab.benchmark.entrypoints.backends.rl_games.registry import register_scoped_rl_games_environment -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _parse_args(argv: list[str]): @@ -84,9 +84,10 @@ def _parse_args(argv: list[str]): ), ) add_launcher_args(parser) + common.add_frontend_args(parser) args_cli, remaining_args = setup_preset_cli(parser, argv) - _common.enable_cameras_for_video(args_cli) + common.enable_cameras_for_video(args_cli) sys.argv = [sys.argv[0]] + remaining_args return args_cli, remaining_args @@ -105,7 +106,6 @@ def run(argv: list[str]) -> BenchmarkResult: import re import time - import gymnasium as gym from rl_games.common import env_configurations, vecenv from rl_games.common.player import BasePlayer from rl_games.torch_runner import Runner @@ -128,7 +128,7 @@ def run(argv: list[str]) -> BenchmarkResult: args_cli, remaining_args = _parse_args(argv) env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) - _common.pre_launch_video_config(env_cfg, args_cli=args_cli) + common.pre_launch_video_config(env_cfg, args_cli=args_cli) start_utc = capture.now_utc_iso() app_t0 = time.perf_counter_ns() @@ -136,7 +136,7 @@ def run(argv: list[str]) -> BenchmarkResult: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: app_t1 = time.perf_counter_ns() - _common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") + common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") if args_cli.num_envs is not None: env_cfg.scene.num_envs = args_cli.num_envs @@ -146,8 +146,8 @@ def run(argv: list[str]) -> BenchmarkResult: config_name = agent_cfg["params"]["config"]["name"] log_root_path = os.path.abspath(os.path.join("logs", "rl_games", config_name)) - if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: - resume_path = _common.resolve_checkpoint_selector( + if args_cli.checkpoint in common.CHECKPOINT_SELECTORS: + resume_path = common.resolve_checkpoint_selector( log_root_path, args_cli.checkpoint, library="rl_games", @@ -158,7 +158,7 @@ def run(argv: list[str]) -> BenchmarkResult: metadata={"agent": args_cli.agent}, ) else: - resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "rl_games", args_cli.task, env_cfg) + resume_path = common.resolve_play_checkpoint(args_cli.checkpoint, "rl_games", args_cli.task, env_cfg) cfg = capture.run_config_from_env_cfg(env_cfg) formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] @@ -193,7 +193,7 @@ def run(argv: list[str]) -> BenchmarkResult: concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True) env_t0 = time.perf_counter_ns() - env = gym.make(args_cli.task, cfg=env_cfg) + env = common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_train_rl_games.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_train_rl_games.py index b6dd72dd4ad0..4cbaab40c3a2 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_train_rl_games.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rl_games/benchmark_train_rl_games.py @@ -18,7 +18,7 @@ from isaaclab.benchmark.entrypoints.backends.rl_games.registry import register_scoped_rl_games_environment from isaaclab.benchmark.entrypoints.training import _resolve_training_checkpoint_path -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _close_rl_games_writer(observer: Any) -> None: @@ -51,8 +51,8 @@ def _parse_args(argv: list[str]): from isaaclab_tasks.utils import setup_preset_cli - add_common_train_args = _common.add_common_train_args - enable_cameras_for_video = _common.enable_cameras_for_video + add_common_train_args = common.add_common_train_args + enable_cameras_for_video = common.enable_cameras_for_video parser = argparse.ArgumentParser(description="Benchmark RL training with RL-Games.") add_common_train_args( @@ -156,7 +156,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: from isaaclab_tasks.utils import resolve_task_config - apply_env_overrides = _common.apply_env_overrides + apply_env_overrides = common.apply_env_overrides from isaaclab.benchmark.entrypoints.early_stop import ( RlGamesEarlyStopObserver, build_success_kwargs, @@ -179,7 +179,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: cleanup.enter_context( - _common.scoped_torch_backend_flags( + common.scoped_torch_backend_flags( cuda_matmul_allow_tf32=True, cudnn_allow_tf32=True, cudnn_deterministic=False, @@ -197,7 +197,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: if args_cli.max_iterations is not None: agent_cfg["params"]["config"]["max_epochs"] = args_cli.max_iterations - _common.validate_distributed_device(args_cli) + common.validate_distributed_device(args_cli) if distributed.enabled: # Mirror the regular training entrypoint: the launcher pinned this rank to its own # device, and offsetting the seed by the rank decorrelates exploration across ranks. @@ -247,18 +247,18 @@ def run(argv: list[str]) -> BenchmarkResult | None: # RL-Games silences logging on every rank but global rank 0, so only that rank has a # populated run directory to describe. if distributed.is_main: - _common.write_run_manifest( + common.write_run_manifest( run_log_dir, library="rl_games", task=args_cli.task, metadata={"agent": args_cli.agent} ) env_cfg.log_dir = run_log_dir - _common.apply_video_recording(env_cfg, run_log_dir, args_cli, subdir="benchmark") + common.apply_video_recording(env_cfg, run_log_dir, args_cli, subdir="benchmark") rl_device = agent_cfg["params"]["config"]["device"] clip_obs = agent_cfg["params"]["env"].get("clip_observations", math.inf) clip_actions = agent_cfg["params"]["env"].get("clip_actions", math.inf) env_t0 = time.perf_counter_ns() - env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_play_rsl_rl.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_play_rsl_rl.py index 52e64262abb4..d32bc8675c8a 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_play_rsl_rl.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_play_rsl_rl.py @@ -20,7 +20,7 @@ import argparse import sys -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: @@ -85,9 +85,10 @@ def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: ), ) add_launcher_args(parser) + common.add_frontend_args(parser) args, remaining = setup_preset_cli(parser, argv) - _common.enable_cameras_for_video(args) + common.enable_cameras_for_video(args) sys.argv = [sys.argv[0]] + remaining return args, remaining @@ -104,7 +105,6 @@ def run(argv: list[str]) -> BenchmarkResult: import os import time - import gymnasium as gym from rsl_rl.runners import DistillationRunner, OnPolicyRunner from isaaclab.app import launch_simulation @@ -125,7 +125,7 @@ def run(argv: list[str]) -> BenchmarkResult: args, remaining = _parse_args(argv) env_cfg, agent_cfg = resolve_task_config(args.task, args.agent) - _common.pre_launch_video_config(env_cfg, args_cli=args) + common.pre_launch_video_config(env_cfg, args_cli=args) start_utc = capture.now_utc_iso() app_t0 = time.perf_counter_ns() @@ -133,7 +133,7 @@ def run(argv: list[str]) -> BenchmarkResult: with launch_simulation(env_cfg, args): with contextlib.ExitStack() as cleanup: app_t1 = time.perf_counter_ns() - _common.apply_video_recording(env_cfg, args.output_path, args, subdir="play") + common.apply_video_recording(env_cfg, args.output_path, args, subdir="play") if args.num_envs is not None: env_cfg.scene.num_envs = args.num_envs @@ -145,8 +145,8 @@ def run(argv: list[str]) -> BenchmarkResult: agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_rsl_rl) log_root_path = os.path.abspath(os.path.join("logs", "rsl_rl", agent_cfg.experiment_name)) - if args.checkpoint in _common.CHECKPOINT_SELECTORS: - resume_path = _common.resolve_checkpoint_selector( + if args.checkpoint in common.CHECKPOINT_SELECTORS: + resume_path = common.resolve_checkpoint_selector( log_root_path, args.checkpoint, library="rsl_rl", @@ -155,7 +155,7 @@ def run(argv: list[str]) -> BenchmarkResult: metadata={"agent": args.agent}, ) else: - resume_path = _common.resolve_play_checkpoint(args.checkpoint, "rsl_rl", args.task, env_cfg) + resume_path = common.resolve_play_checkpoint(args.checkpoint, "rsl_rl", args.task, env_cfg) cfg = capture.run_config_from_env_cfg(env_cfg) formatter_types = [value.strip() for value in args.benchmark_formatter.split(",") if value.strip()] @@ -183,7 +183,7 @@ def run(argv: list[str]) -> BenchmarkResult: ) env_t0 = time.perf_counter_ns() - env = gym.make(args.task, cfg=env_cfg) + env = common.create_isaaclab_env(args.task, env_cfg, args, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_train_rsl_rl.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_train_rsl_rl.py index cec0ce323d98..027db2f1d551 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_train_rsl_rl.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/rsl_rl/benchmark_train_rsl_rl.py @@ -16,7 +16,7 @@ import time from typing import Any -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _disable_code_state_capture(runner: Any) -> None: @@ -42,8 +42,8 @@ def _parse_args(argv: list[str]): from isaaclab_tasks.utils import setup_preset_cli - add_common_train_args = _common.add_common_train_args - enable_cameras_for_video = _common.enable_cameras_for_video + add_common_train_args = common.add_common_train_args + enable_cameras_for_video = common.enable_cameras_for_video from isaaclab_rl.entrypoints.backends import cli_args_rsl_rl as cli_args parser = argparse.ArgumentParser(description="Benchmark RL training with RSL-RL.") @@ -147,7 +147,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: from isaaclab_tasks.utils import get_checkpoint_path, resolve_task_config - apply_env_overrides = _common.apply_env_overrides + apply_env_overrides = common.apply_env_overrides from isaaclab.benchmark.entrypoints.early_stop import ( RslRlEarlyStopWrapper, build_success_kwargs, @@ -170,7 +170,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: cleanup.enter_context( - _common.scoped_torch_backend_flags( + common.scoped_torch_backend_flags( cuda_matmul_allow_tf32=True, cudnn_allow_tf32=True, cudnn_deterministic=False, @@ -188,7 +188,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: agent_cfg = handle_deprecated_rsl_rl_cfg(agent_cfg, installed_rsl_rl) env_cfg.seed = agent_cfg.seed - _common.validate_distributed_device(args_cli) + common.validate_distributed_device(args_cli) if distributed.enabled: # Mirror the regular training entrypoint: the launcher pinned this rank to its own # device, and offsetting the seed by the rank decorrelates exploration across ranks. @@ -237,14 +237,14 @@ def run(argv: list[str]) -> BenchmarkResult | None: # RSL-RL silences logging on every rank but global rank 0, so only that rank has a # populated run directory to describe. if distributed.is_main: - _common.write_run_manifest( + common.write_run_manifest( log_dir, library="rsl_rl", task=args_cli.task, metadata={"agent": args_cli.agent} ) env_cfg.log_dir = log_dir - _common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") + common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") env_t0 = time.perf_counter_ns() - env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_play_sb3.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_play_sb3.py index 7f3aa1e6f171..cd7683763e79 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_play_sb3.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_play_sb3.py @@ -22,7 +22,7 @@ import time from pathlib import Path -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _parse_args(argv: list[str]): @@ -91,9 +91,10 @@ def _parse_args(argv: list[str]): ), ) add_launcher_args(parser) + common.add_frontend_args(parser) args_cli, remaining_args = setup_preset_cli(parser, argv) - _common.enable_cameras_for_video(args_cli) + common.enable_cameras_for_video(args_cli) sys.argv = [sys.argv[0]] + remaining_args return args_cli, remaining_args @@ -109,7 +110,6 @@ def run(argv: list[str]) -> BenchmarkResult: import contextlib import os - import gymnasium as gym from stable_baselines3 import PPO from stable_baselines3.common.vec_env import VecNormalize @@ -131,7 +131,7 @@ def run(argv: list[str]) -> BenchmarkResult: args_cli, remaining_args = _parse_args(argv) env_cfg, agent_cfg = resolve_task_config(args_cli.task, args_cli.agent) - _common.pre_launch_video_config(env_cfg, args_cli=args_cli) + common.pre_launch_video_config(env_cfg, args_cli=args_cli) start_utc = capture.now_utc_iso() app_t0 = time.perf_counter_ns() @@ -139,7 +139,7 @@ def run(argv: list[str]) -> BenchmarkResult: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: app_t1 = time.perf_counter_ns() - _common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") + common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") if args_cli.num_envs is not None: env_cfg.scene.num_envs = args_cli.num_envs @@ -147,8 +147,8 @@ def run(argv: list[str]) -> BenchmarkResult: env_cfg.seed = agent_cfg["seed"] log_root_path = os.path.abspath(os.path.join("logs", "sb3", args_cli.task)) - if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: - resume_path = _common.resolve_checkpoint_selector( + if args_cli.checkpoint in common.CHECKPOINT_SELECTORS: + resume_path = common.resolve_checkpoint_selector( log_root_path, args_cli.checkpoint, library="sb3", @@ -158,7 +158,7 @@ def run(argv: list[str]) -> BenchmarkResult: metadata={"agent": args_cli.agent}, ) else: - resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "sb3", args_cli.task, env_cfg) + resume_path = common.resolve_play_checkpoint(args_cli.checkpoint, "sb3", args_cli.task, env_cfg) cfg = capture.run_config_from_env_cfg(env_cfg) formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] @@ -186,7 +186,7 @@ def run(argv: list[str]) -> BenchmarkResult: ) env_t0 = time.perf_counter_ns() - env = gym.make(args_cli.task, cfg=env_cfg) + env = common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_train_sb3.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_train_sb3.py index a1a1a80d14cb..2aa189615801 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_train_sb3.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/sb3/benchmark_train_sb3.py @@ -15,7 +15,7 @@ import sys import time -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _build_benchmark_callback_class(): @@ -102,8 +102,8 @@ def _parse_args(argv: list[str]): from isaaclab_tasks.utils import setup_preset_cli - add_common_train_args = _common.add_common_train_args - enable_cameras_for_video = _common.enable_cameras_for_video + add_common_train_args = common.add_common_train_args + enable_cameras_for_video = common.enable_cameras_for_video parser = argparse.ArgumentParser(description="Benchmark RL training with Stable-Baselines3.") add_common_train_args( @@ -210,7 +210,7 @@ def run(argv: list[str]) -> BenchmarkResult: from isaaclab_tasks.utils import resolve_task_config - apply_env_overrides = _common.apply_env_overrides + apply_env_overrides = common.apply_env_overrides from isaaclab.benchmark.entrypoints.early_stop import ( SuccessRateTrackerWrapper, @@ -275,16 +275,16 @@ def run(argv: list[str]) -> BenchmarkResult: run_info = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_root_path = os.path.abspath(os.path.join("logs", "sb3", args_cli.task)) log_dir = os.path.join(log_root_path, run_info) - _common.write_run_manifest(log_dir, library="sb3", task=args_cli.task, metadata={"agent": args_cli.agent}) + common.write_run_manifest(log_dir, library="sb3", task=args_cli.task, metadata={"agent": args_cli.agent}) env_cfg.log_dir = log_dir - _common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") + common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") agent_cfg = process_sb3_cfg(agent_cfg, env_cfg.scene.num_envs) policy_arch = agent_cfg.pop("policy") n_timesteps = agent_cfg.pop("n_timesteps") env_t0 = time.perf_counter_ns() - env = _common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) + env = common.create_isaaclab_env(args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=True) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() success_kwargs = build_success_kwargs(args_cli) diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_play_skrl.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_play_skrl.py index ba9d2e713d81..8d6c9232dab7 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_play_skrl.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_play_skrl.py @@ -20,7 +20,7 @@ import sys import time -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _parse_args(argv: list[str]): @@ -100,9 +100,10 @@ def _parse_args(argv: list[str]): ), ) add_launcher_args(parser) + common.add_frontend_args(parser) args_cli, remaining_args = setup_preset_cli(parser, argv) - _common.enable_cameras_for_video(args_cli) + common.enable_cameras_for_video(args_cli) sys.argv = [sys.argv[0]] + remaining_args return args_cli, remaining_args @@ -118,8 +119,6 @@ def run(argv: list[str]) -> BenchmarkResult: import contextlib import os - import gymnasium as gym - from isaaclab.app import launch_simulation from isaaclab.benchmark import BaseIsaacLabBenchmark, BenchmarkMonitor, BenchmarkResult, builders, capture, stepping from isaaclab.benchmark.schema import StartupTime @@ -141,7 +140,7 @@ def run(argv: list[str]) -> BenchmarkResult: env_cfg, agent_cfg = resolve_task_config(args_cli.task, agent_cfg_entry_point) algorithm = resolve_skrl_algorithm(agent_cfg, args_cli.algorithm) - _common.pre_launch_video_config(env_cfg, args_cli=args_cli) + common.pre_launch_video_config(env_cfg, args_cli=args_cli) start_utc = capture.now_utc_iso() app_t0 = time.perf_counter_ns() @@ -149,12 +148,12 @@ def run(argv: list[str]) -> BenchmarkResult: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: app_t1 = time.perf_counter_ns() - _common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") + common.apply_video_recording(env_cfg, args_cli.output_path, args_cli, subdir="play") if args_cli.ml_framework.startswith("jax"): import skrl - cleanup.enter_context(_common.preserve_attribute(skrl.config.jax, "backend")) + cleanup.enter_context(common.preserve_attribute(skrl.config.jax, "backend")) skrl.config.jax.backend = "jax" if args_cli.ml_framework == "jax" else "numpy" if args_cli.num_envs is not None: @@ -163,8 +162,8 @@ def run(argv: list[str]) -> BenchmarkResult: env_cfg.seed = agent_cfg["seed"] log_root_path = os.path.abspath(os.path.join("logs", "skrl", agent_cfg["agent"]["experiment"]["directory"])) - if args_cli.checkpoint in _common.CHECKPOINT_SELECTORS: - resume_path = _common.resolve_checkpoint_selector( + if args_cli.checkpoint in common.CHECKPOINT_SELECTORS: + resume_path = common.resolve_checkpoint_selector( log_root_path, args_cli.checkpoint, library="skrl", @@ -178,7 +177,7 @@ def run(argv: list[str]) -> BenchmarkResult: }, ) else: - resume_path = _common.resolve_play_checkpoint(args_cli.checkpoint, "skrl", args_cli.task, env_cfg) + resume_path = common.resolve_play_checkpoint(args_cli.checkpoint, "skrl", args_cli.task, env_cfg) cfg = capture.run_config_from_env_cfg(env_cfg) formatter_types = [value.strip() for value in args_cli.benchmark_formatter.split(",") if value.strip()] @@ -207,7 +206,9 @@ def run(argv: list[str]) -> BenchmarkResult: ) env_t0 = time.perf_counter_ns() - env = gym.make(args_cli.task, cfg=env_cfg) + env = common.create_isaaclab_env( + args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=algorithm == "ppo" + ) cleanup.callback(lambda: env.close()) env_t1 = time.perf_counter_ns() diff --git a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_train_skrl.py b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_train_skrl.py index 900312158d89..684420c31caf 100644 --- a/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_train_skrl.py +++ b/source/isaaclab/isaaclab/benchmark/entrypoints/backends/skrl/benchmark_train_skrl.py @@ -17,7 +17,7 @@ from isaaclab.benchmark.entrypoints.training import _resolve_training_checkpoint_path -from isaaclab_rl.entrypoints import common as _common +from isaaclab_rl.entrypoints import common def _build_benchmark_trainer_class(): @@ -134,8 +134,8 @@ def _parse_args(argv: list[str]): from isaaclab_tasks.utils import setup_preset_cli - add_common_train_args = _common.add_common_train_args - enable_cameras_for_video = _common.enable_cameras_for_video + add_common_train_args = common.add_common_train_args + enable_cameras_for_video = common.enable_cameras_for_video parser = argparse.ArgumentParser(description="Benchmark RL training with SKRL.") add_common_train_args( @@ -245,7 +245,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: from isaaclab_tasks.utils import resolve_task_config - apply_env_overrides = _common.apply_env_overrides + apply_env_overrides = common.apply_env_overrides from isaaclab.benchmark.entrypoints.early_stop import ( SuccessRateTrackerWrapper, build_success_kwargs, @@ -271,7 +271,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: with launch_simulation(env_cfg, args_cli): with contextlib.ExitStack() as cleanup: cleanup.enter_context( - _common.scoped_torch_backend_flags( + common.scoped_torch_backend_flags( cuda_matmul_allow_tf32=True, cudnn_allow_tf32=True, cudnn_deterministic=False, @@ -289,7 +289,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: agent_cfg["trainer"]["close_environment_at_exit"] = False agent_cfg["seed"] = args_cli.seed if args_cli.seed is not None else agent_cfg.get("seed", 0) - _common.validate_distributed_device(args_cli) + common.validate_distributed_device(args_cli) if distributed.enabled: # skrl reads the rank environment itself and pins the device; offsetting the seed by # the rank decorrelates exploration across ranks, as in regular skrl training. @@ -309,7 +309,7 @@ def run(argv: list[str]) -> BenchmarkResult | None: # skrl silences experiment logging on every rank but global rank 0, so only that rank # has a populated run directory to describe. if distributed.is_main: - _common.write_run_manifest( + common.write_run_manifest( log_dir, library="skrl", task=args_cli.task, @@ -349,10 +349,10 @@ def run(argv: list[str]) -> BenchmarkResult | None: ) env_cfg.log_dir = log_dir - _common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") + common.apply_video_recording(env_cfg, log_dir, args_cli, subdir="benchmark") env_t0 = time.perf_counter_ns() - env = _common.create_isaaclab_env( + env = common.create_isaaclab_env( args_cli.task, env_cfg, args_cli, convert_marl_to_single_agent=algorithm == "ppo" ) cleanup.callback(lambda: env.close()) diff --git a/source/isaaclab/isaaclab/benchmark/microbenchmark.py b/source/isaaclab/isaaclab/benchmark/microbenchmark.py index be0de73368d4..e5537d113536 100644 --- a/source/isaaclab/isaaclab/benchmark/microbenchmark.py +++ b/source/isaaclab/isaaclab/benchmark/microbenchmark.py @@ -14,6 +14,7 @@ from types import MappingProxyType from isaaclab.cli.utils import run_python_command +from isaaclab.paths import ISAACLAB_ROOT @dataclass(frozen=True) @@ -62,7 +63,7 @@ def repository_root(cls) -> Path: Returns: Repository root containing the backend benchmark entrypoints. """ - return Path(__file__).parents[4] + return ISAACLAB_ROOT @classmethod def physics_variants(cls) -> tuple[str, ...]: diff --git a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py index f5cc62d1e860..330363f4fd0c 100644 --- a/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py +++ b/source/isaaclab/isaaclab/benchmark/recorders/record_version_info.py @@ -10,9 +10,10 @@ from isaaclab.benchmark.interfaces import MeasurementData, MeasurementDataRecorder from isaaclab.benchmark.measurements import DictMetadata, StringMetadata +from isaaclab.paths import ISAACLAB_ROOT -# Path to the repository root. -_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 6)) +# Path to the source checkout or installed wheel resources. +_REPO_ROOT = str(ISAACLAB_ROOT) class VersionInfoRecorder(MeasurementDataRecorder): diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index ce1a0c4f5a09..436f3b703135 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -1062,10 +1062,14 @@ def _repoint_prebundle_packages() -> None: print_debug("No pip_prebundle directories found under Isaac Sim.") return + # Extras are expanded as wheel trees nested below pip_prebundle. + package_roots = prebundle_dirs | { + path for prebundle_dir in prebundle_dirs for path in prebundle_dir.glob("*[[]*[]]/*") if path.is_dir() + } repointed = 0 - for prebundle_dir in prebundle_dirs: + for package_root in package_roots: for pkg_name in _PREBUNDLE_REPOINT_PACKAGES: - prebundled = prebundle_dir / pkg_name + prebundled = package_root / pkg_name venv_pkg = site_packages / pkg_name if not venv_pkg.exists(): @@ -1118,9 +1122,9 @@ def _repoint_prebundle_packages() -> None: # env package into the prebundle, which is a real directory by design. if use_symlinks and (site_packages / "torch").exists(): shadowing = [ - prebundle_dir / "torch" - for prebundle_dir in prebundle_dirs - if (prebundle_dir / "torch").is_dir() and not (prebundle_dir / "torch").is_symlink() + package_root / "torch" + for package_root in package_roots + if (package_root / "torch").is_dir() and not (package_root / "torch").is_symlink() ] if shadowing: raise RuntimeError( diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index 6b5bba51ef3c..d585ce303d7b 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -13,8 +13,7 @@ from pathlib import Path from typing import IO, Any -# Path to Isaac Lab installation. -ISAACLAB_ROOT = Path(__file__).parents[4].resolve() +from isaaclab.paths import ISAACLAB_ROOT # Default path to look for Isaac Sim is _isaac_sim symlink. DEFAULT_ISAAC_SIM_PATH = ISAACLAB_ROOT / "_isaac_sim" @@ -60,6 +59,70 @@ def is_isaac_sim_source_build(isaac_sim_path: Path = DEFAULT_ISAAC_SIM_PATH) -> return (isaac_sim_path / ISAAC_SIM_SOURCE_BUILD_MARKER).is_file() +def _pyvenv_home(venv_path: Path) -> Path | None: + """Return the interpreter directory a virtual environment was created from. + + Args: + venv_path: Virtual environment root. + + Returns: + The ``home`` directory recorded in ``pyvenv.cfg``, or ``None`` when it cannot be read. + """ + config = venv_path / "pyvenv.cfg" + if not config.is_file(): + return None + for line in config.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition("=") + if separator and key.strip() == "home": + return Path(value.strip()) + return None + + +def _is_within(path: Path, root: Path) -> bool: + """Check whether a path resolves inside a directory.""" + try: + resolved = path.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents + + +def runs_isaac_sim_python( + isaac_sim_path: Path = DEFAULT_ISAAC_SIM_PATH, python_exe: str | None = None, venv_path: str | None = None +) -> bool: + """Check whether the interpreter about to run Isaac Sim is the package's own Python. + + A virtual environment adds a ``site-packages`` directory but reuses the interpreter it was + created from, so one created on the Isaac Sim package's Python runs that exact binary and loads + Kit's extension modules unchanged. Environments that supply their own interpreter and native + libraries, such as conda, do not qualify: a conda environment has no ``pyvenv.cfg``, and a + virtual environment layered on one records that interpreter instead. + + Args: + isaac_sim_path: Isaac Sim installation directory. + python_exe: Interpreter that will be launched, if known. + venv_path: Virtual environment root, usually ``VIRTUAL_ENV``. + + Returns: + Whether the interpreter resolves inside ``isaac_sim_path``. Anything unproven is ``False`` + so the caller keeps rejecting environments it cannot verify. + """ + try: + root = isaac_sim_path.resolve() + except OSError: + return False + + # ``uv venv`` symlinks ``bin/python`` at its base interpreter; resolving it is the direct answer. + if python_exe and _is_within(Path(python_exe), root): + return True + # Fall back to the recorded base for environments whose interpreter is a copy, not a symlink. + if venv_path: + home = _pyvenv_home(Path(venv_path)) + if home is not None and _is_within(home, root): + return True + return False + + def _colorize(text: str, color: str, stream: IO[str]) -> str: """Colorize bit of text, if the stream supports colors or colors aren't disabled. @@ -636,11 +699,13 @@ def run_python_command( and python_launcher.is_file() and not is_isaac_sim_source_build(local_sim) and using_virtual_environment + and not runs_isaac_sim_python(local_sim, python_exe, command_env.get("VIRTUAL_ENV")) ): print_error("Downloaded Isaac Sim packages cannot be combined with a Python virtual environment.") print_error( - "Use the bundled Python through isaaclab.sh/isaaclab.bat, or remove '_isaac_sim' and install " - "Isaac Sim from pip in the virtual environment." + "Use the bundled Python through isaaclab.sh/isaaclab.bat, create the virtual environment on " + f"that Python ('uv venv --python {local_sim / 'kit' / 'python' / 'bin' / 'python3'}'), or " + "remove '_isaac_sim' and install Isaac Sim from pip in the virtual environment." ) raise SystemExit(1) isaac_env_active = ( diff --git a/source/isaaclab/isaaclab/cloner/_fabric_notices.py b/source/isaaclab/isaaclab/cloner/_fabric_notices.py index d4a582bb4452..0342d23d5054 100644 --- a/source/isaaclab/isaaclab/cloner/_fabric_notices.py +++ b/source/isaaclab/isaaclab/cloner/_fabric_notices.py @@ -21,8 +21,10 @@ import logging import threading from collections.abc import Iterator +from typing import TYPE_CHECKING -from pxr import Usd, UsdUtils +if TYPE_CHECKING: + from pxr import Usd logger = logging.getLogger(__name__) @@ -182,6 +184,9 @@ def disabled_fabric_change_notifies(stage: Usd.Stage, *, restore: bool = True) - yield return + # Like the rest of the cloner, this module must remain importable before Kit starts. + from pxr import UsdUtils # noqa: PLC0415 + # usdrt only works with a live Kit app — defer import so module load stays cheap. try: import usdrt diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 3b47592ac026..e49815048c04 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -26,13 +26,16 @@ from dataclasses import dataclass, field from typing import Any -import torch +import numpy as np import isaaclab.sim as sim_utils +from isaaclab.utils.string import string_to_callable +from isaaclab.utils.version import has_kit from .cloner_cfg import DEFAULT_ENV_TEMPLATE, InclusionSet from .cloner_strategies import sequential from .path import match +from .usd import UsdReplicateContext @dataclass(frozen=True, eq=False) @@ -45,75 +48,57 @@ class ClonePlan: destinations: tuple[str, ...] """Destination path templates with ``"{}"`` for the env id, one per row.""" - clone_mask: torch.Tensor - """Bool tensor ``[len(sources), num_clones]``; ``True`` if env ``j`` comes from row ``i``.""" + clone_mask: np.ndarray + """Boolean array ``[len(sources), num_clones]``; ``True`` if env ``j`` comes from row ``i``.""" - env_ids: torch.Tensor | None = None - """Long tensor ``[num_clones]`` of target env ids. + env_ids: np.ndarray | None = None + """Integer array ``[num_clones]`` of target env ids. Optional for plans used only with :func:`~isaaclab.cloner.query.iter_sources` or :func:`~isaaclab.cloner.query.path_to_source`; required by :func:`~isaaclab.cloner.replicate`. """ - positions: torch.Tensor | None = None + positions: np.ndarray | None = None """Per-env world positions [m], shape ``[num_clones, 3]``, or ``None``.""" cfg_rows: dict[int, tuple[int, ...]] = field(default_factory=dict) """``id(cfg)`` to the row indices the cfg owns.""" + context_rows: dict[type[object], tuple[int, ...]] = field(default_factory=dict) + """Clone-context types to the rows they consume.""" + global_paths: tuple[str, ...] = () """Unique prim paths for scene assets shared by every environment.""" -def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cpu"): - """Create a centered grid of transforms for ``N`` instances. - - Computes ``(x, y)`` coordinates in a roughly square grid centered at the origin - with the provided spacing, places the third coordinate according to ``up_axis``, - and returns identity orientations. This matches the grid layout used by - :class:`isaaclab.terrains.TerrainImporter` for consistent environment positioning. +def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z") -> tuple[np.ndarray, np.ndarray]: + """Create centered grid transforms as host arrays. Args: N: Number of instances. spacing: Distance between neighboring grid positions [m]. - up_axis: Up axis for positions ("z", "y", or "x"). - device: Torch device for returned tensors. + up_axis: Up axis for positions (``"z"``, ``"y"``, or ``"x"``). Returns: - A tuple ``(pos, ori)`` where: - - ``pos`` is a tensor of shape ``(N, 3)`` with positions [m]. - - ``ori`` is a tensor of shape ``(N, 4)`` with identity quaternions in ``(x, y, z, w)``. + Positions [m], shape ``[N, 3]``, and identity xyzw orientations, shape ``[N, 4]``. """ - # Match terrain_importer._compute_env_origins_grid layout for consistency num_rows = int(math.ceil(N / math.sqrt(N))) num_cols = int(math.ceil(N / num_rows)) - - # Create meshgrid matching terrain's "ij" indexing - ii, jj = torch.meshgrid( - torch.arange(num_rows, device=device, dtype=torch.float32), - torch.arange(num_cols, device=device, dtype=torch.float32), - indexing="ij", - ) - # Flatten and take first N elements - ii = ii.flatten()[:N] - jj = jj.flatten()[:N] - - # Match terrain's coordinate system: X from rows (negated), Y from cols + ii, jj = np.meshgrid(np.arange(num_rows, dtype=np.float32), np.arange(num_cols, dtype=np.float32), indexing="ij") + ii = ii.reshape(-1)[:N] + jj = jj.reshape(-1)[:N] x = -(ii - (num_rows - 1) / 2) * spacing y = (jj - (num_cols - 1) / 2) * spacing - z0 = torch.zeros(N, device=device) - - # place on plane based on up_axis + zero = np.zeros(N, dtype=np.float32) if up_axis.lower() == "z": - pos = torch.stack([x, y, z0], dim=1) + positions = np.stack((x, y, zero), axis=1) elif up_axis.lower() == "y": - pos = torch.stack([x, z0, y], dim=1) - else: # up_axis == "x" - pos = torch.stack([z0, x, y], dim=1) - - # identity orientations (x,y,z,w): w=1 is index 3 - ori = torch.nn.functional.one_hot(torch.full((N,), 3, device=device), num_classes=4).float() - return pos, ori + positions = np.stack((x, zero, y), axis=1) + else: + positions = np.stack((zero, x, y), axis=1) + orientations = np.zeros((N, 4), dtype=np.float32) + orientations[:, 3] = 1.0 + return positions.astype(np.float32, copy=False), orientations def num_spawn_variants(spawn_cfg: Any) -> int: @@ -140,28 +125,26 @@ def make_valid_clone_combinations( asset_names: Sequence[str], variant_counts: Sequence[int], clone_combinations: Sequence[InclusionSet] | None = None, - device: str = "cpu", *, all_asset_names: Sequence[str] | None = None, -) -> torch.Tensor: - """Build the valid clone-combination variant tensor. +) -> np.ndarray: + """Build the valid clone-combination variant array. Each combination contributes rows in proportion to its weight, split evenly across its spawn variants and interleaved round-robin, so any prefix of the - tensor samples every combination. + array samples every combination. Args: - asset_names: Clone-planned scene asset names, one per tensor column. + asset_names: Clone-planned scene asset names, one per array column. variant_counts: Number of spawn variants per clone-planned asset. clone_combinations: Legal clone combinations; assets not mentioned by any combination are active in every row. ``None`` uses the full cartesian product of variants. - device: Torch device for the output tensor. Defaults to ``"cpu"``. all_asset_names: Optional full scene asset-name list; combination entries may reference assets that are not clone-planned. Returns: - A ``[num_valid_combinations, num_assets]`` tensor of source variant + A ``[num_valid_combinations, num_assets]`` array of source variant indices, ``-1`` where an asset is absent. Raises: @@ -176,7 +159,7 @@ def make_valid_clone_combinations( if not clone_combinations: rows = itertools.product(*[range(count) for count in variant_counts]) - return torch.tensor(list(rows), dtype=torch.long, device=device) + return np.asarray(list(rows), dtype=np.int64) clone_asset_names = set(asset_names) known_assets = set(all_asset_names) if all_asset_names is not None else clone_asset_names @@ -216,17 +199,54 @@ def make_valid_clone_combinations( for _ in range(weight): rows.append(variants[cursors[index] % len(variants)]) cursors[index] += 1 - return torch.tensor(rows, dtype=torch.long, device=device) + return np.asarray(rows, dtype=np.int64) + + +def _context_rows( + cfgs: tuple[Any, ...], cfg_rows: dict[int, tuple[int, ...]], populated_rows: set[int] +) -> dict[type[object], tuple[int, ...]]: + """Route plan rows to the clone contexts registered for this simulation.""" + sim = sim_utils.SimulationContext.instance() + if sim is None: + return {} + + physics_context = sim.physics_manager.clone_context_type + if physics_context is not None and not isinstance(physics_context, type): + raise TypeError("PhysicsManager.clone_context_type must be a context class.") + rows_by_context: dict[type[object], set[int]] = {} + + for cfg in cfgs: + rows = cfg_rows.get(id(cfg)) + if rows is None: + continue + references = cfg.cloning_contexts + if references is None: + contexts = () if physics_context is None else (physics_context,) + else: + contexts = tuple(string_to_callable(value) if isinstance(value, str) else value for value in references) + if cfg.spawn is not None and has_kit(): + contexts = tuple(dict.fromkeys((*contexts, UsdReplicateContext))) + if any(not isinstance(context, type) for context in contexts): + raise TypeError(f"{type(cfg).__name__}.cloning_contexts must contain only context classes.") + for context_type in contexts: + rows_by_context.setdefault(context_type, set()).update(rows) + + if UsdReplicateContext in rows_by_context: + sim.get_or_create_backend(UsdReplicateContext, sim.stage) + return { + context_type: tuple(sorted(rows & populated_rows)) + for context_type, rows in rows_by_context.items() + if rows & populated_rows + } def make_clone_plan( cfgs: Iterable[Any], num_clones: int, env_spacing: float, - device: str, global_paths: tuple[str, ...] = (), - clone_strategy: Callable = sequential, - valid_set: torch.Tensor | None = None, + clone_strategy: Callable[[np.ndarray, int], np.ndarray] = sequential, + valid_set: np.ndarray | None = None, env_template: str = DEFAULT_ENV_TEMPLATE, ) -> ClonePlan: """Build a :class:`ClonePlan` from asset cfgs. @@ -245,11 +265,10 @@ def make_clone_plan( cfgs: Cloneable asset cfgs with resolved env-scoped ``prim_path`` and ``spawn``. num_clones: Number of target envs. env_spacing: Distance between neighboring grid env origins [m]. - device: Torch device for plan tensors. global_paths: Complete shared-asset roots declared by the scene composition root. Defaults to none. clone_strategy: Function that assigns prototype combinations to envs. Defaults to :func:`~isaaclab.cloner.sequential`. - valid_set: Optional ``[num_combos, num_groups]`` long tensor of valid prototype + valid_set: Optional ``[num_combos, num_groups]`` integer array of valid prototype combinations. ``None`` (default) uses the full cartesian product of every group's prototype indices. @@ -271,6 +290,8 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: raise ValueError("Single spawner expects exactly one planned source path.") spawn_cfg.spawn_path = active[0] + cfgs = tuple(cfgs) + # 1) Build per-group records: (cfg, spawn_cfg, destination_template, num_variants). groups: list[tuple[Any, Any, str, int]] = [] for cfg in cfgs: @@ -279,12 +300,12 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: if count <= 0: raise ValueError(f"Spawner at '{cfg.prim_path}' must have at least one variant.") groups.append((cfg, cfg.spawn, env_template + matched.suffix, count)) - env_ids = torch.arange(num_clones, dtype=torch.long, device=device) - positions, _ = grid_transforms(num_clones, env_spacing, device=device) + env_ids = np.arange(num_clones, dtype=np.int64) + positions, _ = grid_transforms(num_clones, env_spacing) # 2) No env-scoped cfgs: emit an empty plan so the scene can still proceed. if not groups: - empty_mask = torch.zeros((0, num_clones), dtype=torch.bool, device=device) + empty_mask = np.zeros((0, num_clones), dtype=np.bool_) return ClonePlan( sources=(), destinations=(), @@ -300,66 +321,71 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: for cfg, spawn_cfg, destination, _ in groups: set_spawn_paths(spawn_cfg, [destination.format(0)]) cfg_rows = {id(cfg): (0,) for cfg, _, _, _ in groups} + clone_mask = np.ones((1, num_clones), dtype=np.bool_) return ClonePlan( sources=(env_template.format(0),), destinations=(env_template,), - clone_mask=torch.ones((1, num_clones), dtype=torch.bool, device=device), + clone_mask=clone_mask, env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, + context_rows=_context_rows(cfgs, cfg_rows, {0}), global_paths=global_paths, ) # 4) Heterogeneous: enumerate prototype combos, build per-row mask, mutate spawn paths. group_sizes = [count for _, _, _, count in groups] - def validate_combo_tensor(combos: torch.Tensor, name: str, expected_rows: int | None = None) -> torch.Tensor: - if combos.dtype == torch.bool or torch.is_floating_point(combos): + def validate_combinations(combos: np.ndarray, name: str, expected_rows: int | None = None) -> np.ndarray: + combos = np.asarray(combos) + if not np.issubdtype(combos.dtype, np.integer): raise ValueError(f"{name} must contain integer prototype indices.") - combos = combos.to(device=device, dtype=torch.long) + combos = combos.astype(np.int64, copy=False) if combos.ndim != 2: - raise ValueError(f"{name} must be a 2-D tensor, got shape {tuple(combos.shape)}.") + raise ValueError(f"{name} must be a 2-D array, got shape {tuple(combos.shape)}.") if combos.shape[0] == 0: raise ValueError(f"{name} must contain at least one row.") if expected_rows is not None and combos.shape[0] != expected_rows: raise ValueError(f"{name} must contain {expected_rows} rows, got {combos.shape[0]}.") if combos.shape[1] != len(group_sizes): raise ValueError(f"{name} must contain {len(group_sizes)} columns, got {combos.shape[1]}.") - group_sizes_tensor = torch.tensor(group_sizes, dtype=torch.long, device=device).view(1, -1) - invalid = (combos < -1) | ((combos >= group_sizes_tensor) & (combos != -1)) + invalid = (combos < -1) | ((combos >= np.asarray(group_sizes)[None]) & (combos != -1)) if invalid.any(): raise ValueError(f"{name} contains prototype indices outside [-1, group_size).") return combos if valid_set is None: all_combos = list(itertools.product(*[range(s) for s in group_sizes])) - combos = torch.tensor(all_combos, dtype=torch.long, device=device) + combos = np.asarray(all_combos, dtype=np.int64) else: - combos = validate_combo_tensor(valid_set, "valid_set") - chosen = validate_combo_tensor(clone_strategy(combos, num_clones, device), "clone_strategy result", num_clones) + combos = validate_combinations(valid_set, "valid_set") + chosen = validate_combinations(clone_strategy(combos, num_clones), "clone_strategy result", num_clones) - group_offsets = torch.tensor([0] + list(itertools.accumulate(group_sizes[:-1])), dtype=torch.long, device=device) + group_offsets = np.asarray([0] + list(itertools.accumulate(group_sizes[:-1])), dtype=np.int64) active = chosen >= 0 - rows = (chosen + group_offsets).view(-1) - cols = torch.arange(num_clones, device=device).view(-1, 1).expand(-1, len(group_sizes)).reshape(-1) - active_flat = active.view(-1) + rows = (chosen + group_offsets).reshape(-1) + cols = np.broadcast_to(np.arange(num_clones)[:, None], chosen.shape).reshape(-1) + active_flat = active.reshape(-1) num_rows = sum(group_sizes) - clone_mask = torch.zeros((num_rows, num_clones), dtype=torch.bool, device=device) + clone_mask = np.zeros((num_rows, num_clones), dtype=np.bool_) if active_flat.any(): clone_mask[rows[active_flat], cols[active_flat]] = True sources_list: list[str] = [] destinations_list: list[str] = [] cfg_rows: dict[int, tuple[int, ...]] = {} + populated_rows: set[int] = set() row = 0 for cfg, spawn_cfg, destination, count in groups: cfg_rows[id(cfg)] = tuple(range(row, row + count)) group_mask = clone_mask[row : row + count] - env_ids_assigned = group_mask.to(torch.int).argmax(dim=1).tolist() - active = group_mask.any(dim=1).tolist() + env_ids_assigned = group_mask.argmax(axis=1) + active_variants = group_mask.any(axis=1) + populated_rows.update(row + i for i, is_active in enumerate(active_variants) if is_active) paths = [ - destination.format(env_id) if is_active else None for env_id, is_active in zip(env_ids_assigned, active) + destination.format(int(env_id)) if is_active else None + for env_id, is_active in zip(env_ids_assigned, active_variants) ] for i, path in enumerate(paths): destinations_list.append(destination) @@ -376,6 +402,7 @@ def validate_combo_tensor(combos: torch.Tensor, name: str, expected_rows: int | env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, + context_rows=_context_rows(cfgs, cfg_rows, populated_rows), global_paths=global_paths, ) @@ -384,8 +411,7 @@ def clone_plan_from_env_0( source: str, destination: str, num_clones: int, - device: str, - positions: torch.Tensor | None = None, + positions: np.ndarray | None = None, global_paths: tuple[str, ...] = (), ) -> ClonePlan: """Build a single-source clone plan that targets every env from one source row. @@ -401,7 +427,6 @@ def clone_plan_from_env_0( source: Source prim path (typically ``/World/envs/env_0``). destination: Destination template with ``"{}"`` for the env id. num_clones: Number of target envs. - device: Torch device for the mask and env id buffers. positions: Optional per-env world positions [m], shape ``[num_clones, 3]``. global_paths: Complete shared-asset roots for the hand-built scene. Defaults to none. @@ -410,13 +435,16 @@ def clone_plan_from_env_0( """ from .replicate_session import REPLICATION_QUEUE # noqa: PLC0415 - cfg_rows = {id(cfg): (0,) for cfg in REPLICATION_QUEUE if match(cfg.prim_path, destination) is not None} + queued = tuple(REPLICATION_QUEUE) + cfg_rows = {id(cfg): (0,) for cfg in queued if match(cfg.prim_path, destination) is not None} + clone_mask = np.ones((1, num_clones), dtype=np.bool_) return ClonePlan( sources=(source,), destinations=(destination,), - clone_mask=torch.ones((1, num_clones), dtype=torch.bool, device=device), - env_ids=torch.arange(num_clones, dtype=torch.long, device=device), + clone_mask=clone_mask, + env_ids=np.arange(num_clones, dtype=np.int64), positions=positions, cfg_rows=cfg_rows, + context_rows=_context_rows(queued, cfg_rows, {0}), global_paths=global_paths, ) diff --git a/source/isaaclab/isaaclab/cloner/cloner_cfg.py b/source/isaaclab/isaaclab/cloner/cloner_cfg.py index 6b8e4f297a3a..a174185841fc 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_cfg.py +++ b/source/isaaclab/isaaclab/cloner/cloner_cfg.py @@ -8,6 +8,8 @@ from collections.abc import Callable from dataclasses import MISSING +import numpy as np + from isaaclab.utils import configclass from .cloner_strategies import sequential @@ -54,7 +56,7 @@ class CloneCfg: :func:`~isaaclab.cloner.make_clone_plan` when building per-env layouts. """ - clone_strategy: Callable[..., object] = sequential + clone_strategy: Callable[[np.ndarray, int], np.ndarray] = sequential """Function used to build prototype-to-environment mapping. Default is :func:`sequential`.""" clone_combinations: list[InclusionSet] = [] @@ -65,9 +67,6 @@ class CloneCfg: empty list keeps the homogeneous/default behavior. """ - device: str = "cpu" - """Torch device on which mapping buffers are allocated.""" - clone_template: str = DEFAULT_ENV_TEMPLATE """Path template for every replicated env prim, where ``{}`` is the environment index. diff --git a/source/isaaclab/isaaclab/cloner/cloner_strategies.py b/source/isaaclab/isaaclab/cloner/cloner_strategies.py index 4e54c33be40c..e449c203f1a0 100644 --- a/source/isaaclab/isaaclab/cloner/cloner_strategies.py +++ b/source/isaaclab/isaaclab/cloner/cloner_strategies.py @@ -3,44 +3,40 @@ # # SPDX-License-Identifier: BSD-3-Clause -import torch +import numpy as np -def random(combinations: torch.Tensor, num_clones: int, device: str) -> torch.Tensor: +def random(combinations: np.ndarray, num_clones: int) -> np.ndarray: """Randomly assign prototypes to environments. Each environment is assigned a random prototype combination sampled uniformly from :attr:`combinations`. Args: - combinations: Tensor of shape (num_combos, num_prototypes) containing all possible + combinations: Array of shape (num_combos, num_prototypes) containing all possible prototype combinations. num_clones: Number of environments to assign combinations to. - device: Torch device on which the output tensor is allocated. Returns: - Tensor of shape (num_clones, num_prototypes) containing the chosen prototype + Array of shape (num_clones, num_prototypes) containing the chosen prototype combination for each environment. """ - chosen = combinations[torch.randint(len(combinations), (num_clones,), device=device)] - return chosen + return combinations[np.random.randint(len(combinations), size=num_clones)] -def sequential(combinations: torch.Tensor, num_clones: int, device: str) -> torch.Tensor: +def sequential(combinations: np.ndarray, num_clones: int) -> np.ndarray: """Deterministically assign prototypes to environments in round-robin fashion. Each environment is assigned a prototype combination based on its index modulo the number of available combinations. Args: - combinations: Tensor of shape (num_combos, num_prototypes) containing all possible + combinations: Array of shape (num_combos, num_prototypes) containing all possible prototype combinations. num_clones: Number of environments to assign combinations to. - device: Torch device on which the output tensor is allocated. Returns: - Tensor of shape (num_clones, num_prototypes) containing the chosen prototype + Array of shape (num_clones, num_prototypes) containing the chosen prototype combination for each environment. """ - chosen = combinations[torch.arange(num_clones, device=device) % len(combinations)] - return chosen + return combinations[np.arange(num_clones) % len(combinations)] diff --git a/source/isaaclab/isaaclab/cloner/query.py b/source/isaaclab/isaaclab/cloner/query.py index bbc80f550bda..36c35905f163 100644 --- a/source/isaaclab/isaaclab/cloner/query.py +++ b/source/isaaclab/isaaclab/cloner/query.py @@ -29,6 +29,8 @@ from collections.abc import Iterator from typing import TYPE_CHECKING +import numpy as np + from . import path as pth if TYPE_CHECKING: @@ -37,11 +39,10 @@ def _row_env_ids(plan: ClonePlan, row: int) -> tuple[int, ...]: """Env ids populated from a plan row: the plan's env ids at the row's ``True`` columns.""" - columns = plan.clone_mask[row].nonzero(as_tuple=False).flatten() + columns = np.flatnonzero(plan.clone_mask[row]) if plan.env_ids is None: - return tuple(columns.tolist()) - columns = columns.to(plan.env_ids.device) - return tuple(plan.env_ids[columns].tolist()) + return tuple(map(int, columns)) + return tuple(map(int, plan.env_ids[columns])) def _column_for_env_id(plan: ClonePlan, env_id: int) -> int | None: @@ -52,8 +53,8 @@ def _column_for_env_id(plan: ClonePlan, env_id: int) -> int | None: """ if plan.env_ids is None: return env_id if 0 <= env_id < plan.clone_mask.shape[1] else None - columns = (plan.env_ids == env_id).nonzero(as_tuple=False).flatten().tolist() - return int(columns[0]) if columns else None + columns = np.flatnonzero(plan.env_ids == env_id) + return int(columns[0]) if columns.size else None def _source_rows(plan: ClonePlan, path: str) -> list[int]: diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index 34dcb3afc94c..7181f1fccd72 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -3,17 +3,16 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Replication queue, :func:`replicate` drain, and :class:`ReplicateSession` sugar.""" +"""Clone-plan publication and dispatch.""" from __future__ import annotations -import importlib from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Any -from isaaclab.utils.backend_utils import FactoryBase -from isaaclab.utils.string import string_to_callable -from isaaclab.utils.version import has_kit +import numpy as np + +from isaaclab.sim import SimulationContext from .clone_plan import make_clone_plan from .cloner_cfg import DEFAULT_ENV_TEMPLATE @@ -21,113 +20,73 @@ from .usd import UsdReplicateContext if TYPE_CHECKING: - import torch - - from pxr import Usd - from .clone_plan import ClonePlan REPLICATION_QUEUE: list[Any] = [] -"""Asset cfgs registered by :func:`queue_replication` and drained by :func:`replicate`. +"""Constructed cfgs consumed by post-construction :func:`clone_plan_from_env_0` workflows. -The queue only records *which* cfgs participate in cloning; how each cfg is cloned is -resolved at dispatch from :attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts` or the -active backend's default stack. +Cfg-first :class:`ReplicateSession` planning does not read the queue. Dispatch clears it +without deriving any backend mapping from it. """ def queue_replication(cfg: Any) -> None: - """Register ``cfg`` for cloning when :func:`replicate` next runs. + """Register a constructed cfg when no clone plan is active. Args: cfg: Asset cfg with resolved ``prim_path``. """ - REPLICATION_QUEUE.append(cfg) + if (sim := SimulationContext.instance()) is None or sim.get_clone_plan() is None: + REPLICATION_QUEUE.append(cfg) -def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = True) -> None: - """Drain :data:`REPLICATION_QUEUE` against ``plan``, dispatch each backend, publish the plan. +def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: + """Publish and dispatch a fully routed clone plan. - Physics contexts come from :attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts` when - set, otherwise from the backend's ``PHYSICS_CONTEXT`` class. - :class:`~isaaclab.cloner.UsdReplicateContext` is added automatically when the cfg has a - spawner and Kit is available. Explicit contexts are honored regardless of Kit availability. - With ``replicate_physics=False`` physics contexts are dropped; USD replication still fires - when the spawner+Kit condition is met or the cfg explicitly requests it. - - Cfgs absent from ``plan.cfg_rows`` are silently skipped. Backend contexts run in - ascending ``replicate_priority`` order. The queue is cleared up front, so a backend - failure cannot leak stale entries into the next call. Every context receives the plan's - explicitly declared shared assets when it is constructed. + Planning derives routing from the input cfgs; dispatch does not rediscover or reshape that mapping. + Every context is owned by the active :class:`~isaaclab.sim.SimulationContext` and receives + only ``plan``. The queue is cleared up front, so a backend failure cannot leak stale entries + into the next lifecycle. Args: plan: Replication layout to dispatch. - stage: USD stage to author replicated prim specs into. replicate_physics: Whether physics replication clones each environment. If False, cloning is USD-only; an asset whose contexts are all physics-based is not cloned. """ - from isaaclab.sim import SimulationContext # noqa: PLC0415 - - queued = REPLICATION_QUEUE.copy() REPLICATION_QUEUE.clear() - - backend_package = FactoryBase._get_package_name(FactoryBase._get_backend()) - backend_physics_ctx = importlib.import_module(f"{backend_package}.cloner").PHYSICS_CONTEXT - - # Group queued cfgs by backend, taking the union of row indices each backend owns. - # In the homogeneous plan every cfg maps to row 0, so multiple queue_replication - # calls (e.g. one per body type in RigidObjectCollection) all contribute {0} and the set - # union keeps it as a single row — no redundant copy specs are authored. - kit_available = has_kit() - backend_rows: dict[type, set[int]] = {} - for cfg in queued: - rows = plan.cfg_rows.get(id(cfg)) - if rows is None: - continue - if cfg.cloning_contexts is None: - contexts = [backend_physics_ctx] - else: - contexts = [string_to_callable(c) if isinstance(c, str) else c for c in cfg.cloning_contexts] - if not replicate_physics: - contexts = [c for c in contexts if c is UsdReplicateContext] - ctx_set = dict.fromkeys(contexts) - if cfg.spawn is not None and kit_available: - ctx_set.setdefault(UsdReplicateContext, None) - for BackendCtxCls in ctx_set: - backend_rows.setdefault(BackendCtxCls, set()).update(rows) - - backend_ctxs: dict[type, Any] = {} - for BackendCtxCls, row_set in backend_rows.items(): - ctx = BackendCtxCls(stage, global_paths=plan.global_paths) - backend_ctxs[BackendCtxCls] = ctx - row_list = sorted(row_set) - ctx.queue_mapping( - [plan.sources[i] for i in row_list], - [plan.destinations[i] for i in row_list], - plan.env_ids, - plan.clone_mask[row_list], - positions=plan.positions, - ) - - for ctx in sorted(backend_ctxs.values(), key=lambda ctx: ctx.replicate_priority): - ctx.replicate() - - SimulationContext.instance().set_clone_plan(plan) + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError("Clone-plan replication requires an active SimulationContext.") + context_types = tuple( + context_type for context_type in plan.context_rows if replicate_physics or context_type is UsdReplicateContext + ) + missing = [context_type for context_type in context_types if context_type not in sim._backend_registry] + if missing: + names = ", ".join(f"{context_type.__module__}.{context_type.__qualname__}" for context_type in missing) + raise RuntimeError(f"Clone contexts must be registered before plan dispatch: {names}.") + + if (active_plan := sim.get_clone_plan()) is None: + sim.set_clone_plan(plan) + elif active_plan is not plan: + raise ValueError("replicate() requires the active SimulationContext's ClonePlan.") + + contexts = [sim._backend_registry[context_type] for context_type in context_types] + for context in sorted(contexts, key=lambda item: item.replicate_priority): + context.replicate(plan) class ReplicateSession: """Folds :func:`make_clone_plan` and :func:`replicate` into a ``with`` block. - ``__enter__`` builds the plan (and mutates each cfg's ``spawn_path``); asset - constructors inside the block register their cfgs into - :data:`REPLICATION_QUEUE`; ``__exit__`` drains and dispatches. + ``__enter__`` builds and publishes the complete plan while assigning each cfg's + ``spawn_path``; ``__exit__`` dispatches that same plan. Example: .. code-block:: python - with cloner.ReplicateSession(cfgs, num_clones=128, env_spacing=2.0, device="cuda:0", stage=sim.stage): + with cloner.ReplicateSession(cfgs, num_clones=128, env_spacing=2.0): for cfg in cfgs: cfg.class_type(cfg) """ @@ -137,12 +96,10 @@ def __init__( cfgs: Iterable[Any], num_clones: int, env_spacing: float, - device: str, *, - stage: Usd.Stage, global_paths: tuple[str, ...] = (), - clone_strategy: Callable = sequential, - valid_set: torch.Tensor | None = None, + clone_strategy: Callable[[np.ndarray, int], np.ndarray] = sequential, + valid_set: np.ndarray | None = None, replicate_physics: bool = True, env_template: str = DEFAULT_ENV_TEMPLATE, ): @@ -152,23 +109,19 @@ def __init__( cfgs: Asset cfgs with resolved ``prim_path``. num_clones: Number of target envs. env_spacing: Grid spacing between env origins [m]. - device: Torch device for plan tensors. - stage: USD stage to author replicated prim specs into. global_paths: Complete shared-asset roots declared by the composition root. Defaults to none. clone_strategy: Prototype-to-env assignment function. - valid_set: Optional ``[num_combos, num_groups]`` long tensor of valid + valid_set: Optional ``[num_combos, num_groups]`` integer array of valid prototype combinations; ``None`` uses the full cartesian product. replicate_physics: Whether physics replication clones each environment; forwarded to :func:`replicate`. env_template: Path template for a replicated env prim, ``{}`` marking the env index. """ self._cfgs = cfgs - self._stage = stage self._replicate_physics = replicate_physics self._kwargs = dict( num_clones=num_clones, env_spacing=env_spacing, - device=device, global_paths=global_paths, clone_strategy=clone_strategy, valid_set=valid_set, @@ -177,16 +130,23 @@ def __init__( self._plan: ClonePlan | None = None def __enter__(self) -> ReplicateSession: + if (sim := SimulationContext.instance()) is None: + raise RuntimeError("Clone planning requires an active SimulationContext.") + if sim.get_clone_plan() is not None: + raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.") self._plan = make_clone_plan(self._cfgs, **self._kwargs) + sim.set_clone_plan(self._plan) return self def __exit__(self, exc_type, exc_value, traceback) -> None: if exc_type is None: assert self._plan is not None - replicate(self._plan, stage=self._stage, replicate_physics=self._replicate_physics) + replicate(self._plan, replicate_physics=self._replicate_physics) else: # Drop cfgs registered before the failure so the next session is clean. REPLICATION_QUEUE.clear() + if (sim := SimulationContext.instance()) is not None and sim.get_clone_plan() is self._plan: + sim.set_clone_plan(None) @property def plan(self) -> ClonePlan: diff --git a/source/isaaclab/isaaclab/cloner/usd.py b/source/isaaclab/isaaclab/cloner/usd.py index 187a00429154..3298781a38b9 100644 --- a/source/isaaclab/isaaclab/cloner/usd.py +++ b/source/isaaclab/isaaclab/cloner/usd.py @@ -6,104 +6,73 @@ from __future__ import annotations from collections.abc import Sequence +from typing import TYPE_CHECKING -import torch - -from pxr import Gf, Sdf, Usd, UsdGeom, Vt +import numpy as np from ._fabric_notices import disabled_fabric_change_notifies from .path import split +if TYPE_CHECKING: + from pxr import Usd + + from .clone_plan import ClonePlan -def _select_env_ids(env_ids: torch.Tensor, mask: torch.Tensor | None, row: int) -> torch.Tensor: - """Return the environment ids selected by a replication row.""" + +def _select_columns(env_ids: np.ndarray, mask: np.ndarray | None, row: int) -> np.ndarray: + """Return the mask columns selected by a replication row.""" if mask is None: - return env_ids - row_mask = mask if mask.dim() == 1 else mask[row] - if row_mask.dtype != torch.bool: - row_mask = row_mask.to(dtype=torch.bool) - return env_ids[row_mask] + return np.arange(len(env_ids)) + row_mask = mask if mask.ndim == 1 else mask[row] + return np.flatnonzero(row_mask) class UsdReplicateContext: - """Queue and apply USD replication work for one stage.""" + """Apply routed clone-plan rows to one USD stage.""" - replicate_priority = 100 + # USD destinations must exist before native physics contexts consume them. + replicate_priority = -100 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): + def __init__(self, stage: Usd.Stage): """Initialize the context. Args: stage: USD stage to author replicated prim specs into. """ self.stage = stage - self._queue: list[tuple[str, str, torch.Tensor, torch.Tensor | None, torch.Tensor | None]] = [] - - def queue( - self, - source: str, - destination: str, - env_ids: torch.Tensor, - *, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - ) -> None: - """Queue one USD source row for replication. - - Args: - source: Source prim path. - destination: Destination path template with ``"{}"`` for env id. - env_ids: Environment ids selected for this source row. - positions: Optional per-environment world positions [m]. Authored only for - instance-root destination templates (for example, ``.../env_{}``). - quaternions: Optional per-environment orientations in xyzw order. Authored only - for instance-root destination templates (for example, ``.../env_{}``). - """ - self._queue.append((source, destination, env_ids, positions, quaternions)) - def queue_mapping( - self, - sources: Sequence[str], - destinations: Sequence[str], - env_ids: torch.Tensor, - mask: torch.Tensor | None = None, - *, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - ) -> None: - """Queue replication rows from the current flat clone mapping. + def replicate(self, plan: ClonePlan) -> None: + """Apply this context's routed rows from a clone plan. Args: - sources: Source prim paths. - destinations: Destination path templates with ``"{}"`` for env id. - env_ids: Environment indices. - mask: Optional per-source or shared mask. - positions: Optional per-environment world positions [m]. Authored only for - instance-root destination templates (for example, ``.../env_{}``). - quaternions: Optional per-environment orientations in xyzw order. Authored only - for instance-root destination templates (for example, ``.../env_{}``). + plan: Replication layout shared by every clone backend. """ - for i, source in enumerate(sources): - self.queue( - source, - destinations[i], - _select_env_ids(env_ids, mask, i), - positions=positions, - quaternions=quaternions, - ) - - def replicate(self) -> None: - """Apply all queued USD copy specs in parent-before-child order.""" - if not self._queue: + if plan.env_ids is None: + raise ValueError("ClonePlan.env_ids is required for replication.") + rows = plan.context_rows[type(self)] + replication_rows = [] + for row in rows: + columns = _select_columns(plan.env_ids, plan.clone_mask, row) + target_envs = plan.env_ids[columns] + positions = None if plan.positions is None else plan.positions[columns] + replication_rows.append((plan.sources[row], plan.destinations[row], target_envs, positions, None)) + if not replication_rows: return # Suspend Fabric's per-Sdf.CopySpec notice listener for the duration of the copy work; # no-op outside a live Kit application. with disabled_fabric_change_notifies(self.stage): - self._apply_queue() + self._apply(replication_rows) + + def _apply( + self, + replication_rows: list[tuple[str, str, np.ndarray, np.ndarray | None, np.ndarray | None]], + ) -> None: + """Author the supplied copy specs into the stage's root layer.""" + # pxr must be imported after Kit starts; importing it with this module can bind + # a different USD runtime before Kit initializes its plugins. + from pxr import Gf, Sdf, UsdGeom, Vt # noqa: PLC0415 - def _apply_queue(self) -> None: - """Author the queued copy specs into the stage's root layer.""" rl = self.stage.GetRootLayer() def dp_depth(template: str) -> int: @@ -111,17 +80,17 @@ def dp_depth(template: str) -> int: dp = template.format(0) return Sdf.Path(dp).pathElementCount - depth_to_items: dict[int, list[tuple[str, str, torch.Tensor, torch.Tensor | None, torch.Tensor | None]]] = {} - for item in self._queue: - depth_to_items.setdefault(dp_depth(item[1]), []).append(item) + rows_by_depth: dict[int, list[tuple[str, str, np.ndarray, np.ndarray | None, np.ndarray | None]]] = {} + for row in replication_rows: + rows_by_depth.setdefault(dp_depth(row[1]), []).append(row) - for depth in sorted(depth_to_items.keys()): + for depth in sorted(rows_by_depth): with Sdf.ChangeBlock(): - for src, tmpl, target_envs, positions, quaternions in depth_to_items[depth]: + for src, tmpl, target_envs, positions, quaternions in rows_by_depth[depth]: _, clone_suffix = split(tmpl) is_instance_root = clone_suffix == "" - for wid in target_envs.tolist(): + for column, wid in enumerate(target_envs): wid = int(wid) dp = tmpl.format(wid) Sdf.CreatePrimInLayer(rl, dp) @@ -145,14 +114,14 @@ def dp_depth(template: str) -> int: ps = rl.GetPrimAtPath(dp) op_names = [] if positions is not None: - p = positions[wid] + p = positions[column] t_attr = ps.GetAttributeAtPath(dp + ".xformOp:translate") if t_attr is None: t_attr = Sdf.AttributeSpec(ps, "xformOp:translate", Sdf.ValueTypeNames.Double3) t_attr.default = Gf.Vec3d(float(p[0]), float(p[1]), float(p[2])) op_names.append("xformOp:translate") if quaternions is not None: - q = quaternions[wid] + q = quaternions[column] o_attr = ps.GetAttributeAtPath(dp + ".xformOp:orient") if o_attr is None: o_attr = Sdf.AttributeSpec(ps, "xformOp:orient", Sdf.ValueTypeNames.Quatd) @@ -169,16 +138,16 @@ def usd_replicate( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mask: torch.Tensor | None = None, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, + env_ids: np.ndarray, + mask: np.ndarray | None = None, + positions: np.ndarray | None = None, + quaternions: np.ndarray | None = None, ) -> None: - """Replicate USD prims to per-environment destinations. + """Replicate USD prims directly for standalone tooling and tests. - Copies each source prim spec to destination templates for selected environments - (``mask``). Optionally authors translate/orient from position/quaternion buffers. - Replication runs in path-depth order (parents before children) for robust composition. + Production clone lifecycles route a :class:`~isaaclab.cloner.ClonePlan` through + :meth:`UsdReplicateContext.replicate`; this wrapper retains direct control over raw + mappings for tools that do not own a clone plan. Args: stage: USD stage. @@ -191,6 +160,14 @@ def usd_replicate( quaternions: Optional orientations in xyzw order, shape ``[E, 4]``. Authored as ``xformOp:orient`` only for env-instance root destinations (``.../env_{}``). """ - ctx = UsdReplicateContext(stage) - ctx.queue_mapping(sources, destinations, env_ids, mask, positions=positions, quaternions=quaternions) - ctx.replicate() + replication_rows = [] + for row, source in enumerate(sources): + columns = _select_columns(env_ids, mask, row) + target_envs = env_ids[columns] + row_positions = None if positions is None else positions[columns] + row_quaternions = None if quaternions is None else quaternions[columns] + replication_rows.append((source, destinations[row], target_envs, row_positions, row_quaternions)) + context = UsdReplicateContext(stage) + if replication_rows: + with disabled_fabric_change_notifies(stage): + context._apply(replication_rows) diff --git a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py index ea9d319938c5..e34343ef3194 100644 --- a/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py +++ b/source/isaaclab/isaaclab/envs/mdp/actions/task_space_actions.py @@ -712,10 +712,10 @@ def _compute_ee_pose(self): def _compute_ee_velocity(self): """Computes the velocity of the ee frame in root frame.""" - # Extract end-effector velocity in the world frame - self._ee_vel_w[:] = self._asset.data.body_vel_w.torch[:, self._ee_body_idx, :] + # Match the link-origin reference point used by the pose and Jacobian. + self._ee_vel_w[:] = self._asset.data.body_link_vel_w.torch[:, self._ee_body_idx, :] # Compute the relative velocity in the world frame - relative_vel_w = self._ee_vel_w - self._asset.data.root_vel_w.torch + relative_vel_w = self._ee_vel_w - self._asset.data.root_link_vel_w.torch # Convert ee velocities from world to root frame root_quat_w = self._asset.data.root_quat_w.torch diff --git a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py index a98c34bf5f90..990c792b895c 100644 --- a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py @@ -38,8 +38,21 @@ class DataGenConfig: Keeping failed demonstrations is useful for visualizing and debugging low success rates. """ - max_num_failures: int = 50 - """Maximum number of failures allowed before stopping generation.""" + max_num_failures: int | None = None + """Maximum number of failed generation attempts before stopping, or None for no limit. + + Only applies together with :attr:`generation_guarantee`. With the guarantee enabled, generation + keeps retrying until :attr:`generation_num_trials` demos succeed, so a task whose success rate is + low can run for an unbounded number of attempts; this caps that. Defaults to None so the + guarantee keeps its usual meaning unless a limit is asked for. + + With the guarantee disabled, generation already stops after :attr:`generation_num_trials` + attempts and this field is ignored, so setting it cannot cut a fixed-attempt run short. + + The bound is read once per simulation step. Attempts that end on the step that crosses it are + already complete, so a run over ``num_envs`` parallel environments can record up to + ``num_envs - 1`` failures beyond the bound; it is exact whenever attempts end on separate steps. + """ seed: int = 1 """Seed for randomization to ensure reproducibility.""" diff --git a/source/isaaclab/isaaclab/envs/utils/video_recorder.py b/source/isaaclab/isaaclab/envs/utils/video_recorder.py index 3d99fb2b2303..dd669224bd58 100644 --- a/source/isaaclab/isaaclab/envs/utils/video_recorder.py +++ b/source/isaaclab/isaaclab/envs/utils/video_recorder.py @@ -201,6 +201,8 @@ def _frame_from_visualizer(self, viz_type: str, sub: str) -> np.ndarray | None: ) viz = candidates[0] + if not sim.is_rendering: + sim.forward() if sub == "streaming_view": if not hasattr(viz, "render_tiled_rgb_array"): raise RuntimeError( diff --git a/source/isaaclab/isaaclab/paths.py b/source/isaaclab/isaaclab/paths.py new file mode 100644 index 000000000000..5706465e81fd --- /dev/null +++ b/source/isaaclab/isaaclab/paths.py @@ -0,0 +1,25 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Resolve paths shared by source checkouts and installed wheels.""" + +from pathlib import Path + + +def _resolve_isaaclab_root() -> Path: + """Return the directory containing Isaac Lab runtime resources.""" + package_root = Path(__file__).resolve().parent + if (package_root / "apps").is_dir(): + return package_root + + for parent in package_root.parents: + if parent / "source" / "isaaclab" / "isaaclab" == package_root: + return parent + + raise RuntimeError(f"Could not locate the Isaac Lab root from {package_root}") + + +ISAACLAB_ROOT = _resolve_isaaclab_root() +"""Directory containing Isaac Lab runtime resources.""" diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index 161b2c585569..631daca21166 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -86,6 +86,7 @@ class PhysicsManager(ABC): _callbacks: ClassVar[dict[int, tuple[Any, Callable, int, str | None, Any]]] = {} _callback_id: ClassVar[int] = 0 views: ClassVar[dict[tuple[type, str], Any]] = {} + clone_context_type: ClassVar[type[object] | None] = None supports_anim_recording: ClassVar[bool] = False """Whether this backend can service ``--anim_recording_enabled`` (OVD Recorder). diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index aa132a98b2d8..c08fe2e7f143 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import os from typing import Any import torch @@ -21,6 +22,22 @@ logger = logging.getLogger(__name__) +RENDER_PROFILE_SCOPE = "IsaacLab::Renderer::render" +"""Name of the timed scope bracketing :meth:`BaseRenderer.render`, emitted when render profiling is on. + +Every backend renders through the same call, so a profile can compare them under one scope name +instead of one internal name per backend. ``wp.ScopedTimer`` prints one ``" took X.XX ms"`` +line per call, which ``scripts/benchmarks/benchmark_renderer.py`` parses back out of the run log. +""" + +_RENDER_PROFILE_ENABLED = os.environ.get("ISAACLAB_RENDER_PROFILE", "0") != "0" +"""Whether to time and print :data:`RENDER_PROFILE_SCOPE`, read once from ``ISAACLAB_RENDER_PROFILE``. + +Off by default because the timer synchronizes the device on entry and exit. That is what lets it +measure completed device work rather than submitted work, but it also removes CPU/GPU overlap, so +an enabled run is a profiling aid and not a throughput measurement. +""" + @wp.kernel(enable_backward=False) def _write_material( @@ -340,9 +357,20 @@ def render_into_camera( camera_data: CameraData, physics_step_count: int, ) -> None: - """Sync scene state, render, and read outputs into ``camera_data``.""" + """Sync scene state, render, and read outputs into ``camera_data``. + + Only the render itself is bracketed by :data:`RENDER_PROFILE_SCOPE`, so a profile + attributes neither the scene-state sync before it nor the output readback after it to + rendering. See :data:`_RENDER_PROFILE_ENABLED` for how to turn the timer on. + """ self.update_scene_state(physics_step_count) - renderer.render(render_data) + with wp.ScopedTimer( + RENDER_PROFILE_SCOPE, + active=_RENDER_PROFILE_ENABLED, + print=True, + synchronize=True, + ): + renderer.render(render_data) renderer.read_output(render_data, camera_data) def reset_stage_prepare_flag(self) -> None: diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 67797d680611..b1be7b95571f 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -10,6 +10,8 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any +import numpy as np + if TYPE_CHECKING: from isaaclab_physx.assets import SurfaceGripper @@ -118,9 +120,9 @@ class MySceneCfg(InteractiveSceneCfg): scene = InteractiveScene(cfg=InteractiveSceneCfg(num_envs=128, replicate_physics=True)) robot = Articulation(robot_cfg) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(scene.num_envs, scene.cfg.env_spacing, device=scene.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, scene.num_envs, scene.device, pos) - cloner.replicate(plan, stage=scene.stage) + pos = cloner.grid_transforms(scene.num_envs, scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, scene.num_envs, pos) + cloner.replicate(plan) .. note:: It is important to note that the scene only performs common operations on the entities. For example, @@ -162,46 +164,46 @@ def __init__(self, cfg: InteractiveSceneCfg): self._physics_scene_path = None # prepare cloner for environment replication self.cloner_cfg = copy.deepcopy(self.cfg.clone_cfg) - self.cloner_cfg.device = self.device self.cloner_cfg.replicate_physics = self.cfg.replicate_physics # the template is authoritative; the regex form is the same namespace spelled for matching self._env_fmt = self.cloner_cfg.clone_template self.env_prim_paths = [self._env_fmt.format(i) for i in range(self.cfg.num_envs)] - self._scene_asset_names: list[str] = [] - self._clone_valid_set: torch.Tensor | None = None - - # create source prim - self.stage.DefinePrim(self.env_prim_paths[0], "Xform") - # allocate env indices self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device) - # clone env_0 xform to env_1..env_{N-1} at grid origins - env_origins, _ = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing, device=self.device) - with cloner.disabled_fabric_change_notifies(self.stage, restore=False): - cloner.usd_replicate( - self.stage, - [self.env_prim_paths[0]], - [self._env_fmt], - self._ALL_INDICES, - positions=env_origins, - ) - # Always enter so a ClonePlan is published even when the scene cfg has no entities. self._global_prim_paths = list() - clone_cfgs, global_paths = self._collect_asset_cfgs() - with cloner.ReplicateSession( - clone_cfgs, - num_clones=self.num_envs, - env_spacing=self.cfg.env_spacing, - device=self.device, - global_paths=global_paths, - env_template=self._env_fmt, - stage=self.stage, - clone_strategy=self.cloner_cfg.clone_strategy, - valid_set=self._clone_valid_set, - replicate_physics=self.cloner_cfg.replicate_physics, - ): - if self._is_scene_setup_from_cfg(): + asset_cfgs, global_paths, valid_set = self._collect_asset_cfgs() + scene_from_cfg = any( + name not in InteractiveSceneCfg.__dataclass_fields__ and cfg is not None + for name, cfg in self.cfg.__dict__.items() + ) + if scene_from_cfg: + with cloner.ReplicateSession( + asset_cfgs, + num_clones=self.num_envs, + env_spacing=self.cfg.env_spacing, + global_paths=global_paths, + env_template=self._env_fmt, + clone_strategy=self.cloner_cfg.clone_strategy, + valid_set=valid_set, + replicate_physics=self.cloner_cfg.replicate_physics, + ) as session: + self.stage.DefinePrim(self.env_prim_paths[0], "Xform") + with cloner.disabled_fabric_change_notifies(self.stage, restore=False): + cloner.usd_replicate( + self.stage, + [self.env_prim_paths[0]], + [self._env_fmt], + session.plan.env_ids, + positions=session.plan.positions, + ) self._add_entities_from_cfg() + positions = session.plan.positions + else: + positions = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0] + env_0 = self.stage.DefinePrim(self.env_prim_paths[0], "Xform") + sim_utils.standardize_xform_ops(env_0, translation=tuple(map(float, positions[0]))) + self._env_origins = positions + self._env_origins_plan = self.sim.get_clone_plan() # Every sensor exists by now, so all visualizer and camera-renderer requirements are visible. cam_types = [s.cfg.renderer_cfg.renderer_type for s in self._sensors.values() if isinstance(s.cfg, CameraCfg)] @@ -211,20 +213,20 @@ def __init__(self, cfg: InteractiveSceneCfg): self.sim.requires_newton_model |= requires_model # Collision filtering is PhysX-only (matches both physx and ovphysx). - if self.cfg.filter_collisions and "physx" in self.physics_backend and self._is_scene_setup_from_cfg(): + if self.cfg.filter_collisions and "physx" in self.physics_backend and scene_from_cfg: self.filter_collisions(self._global_prim_paths) - def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: + def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...], np.ndarray | None]: """Flatten user-declared cfgs and declare shared prim roots for clone planning. Expands :class:`~isaaclab.assets.RigidObjectCollectionCfg` into its members, resolves ``{ENV_REGEX_NS}`` macros, lets an enclosing asset's row own nested materials, - and returns only env-scoped configs with a spawner. Global roots are returned separately. + and returns env-scoped configs with a spawner, global roots, and valid clone combinations. """ cfg_fields = InteractiveSceneCfg.__dataclass_fields__ items = [(name, cfg) for name, cfg in self.cfg.__dict__.items() if name not in cfg_fields and cfg is not None] - self._scene_asset_names = [name for name, _ in items] + scene_asset_names = [name for name, _ in items] flat_items: list[tuple[str, Any]] = [] for asset_name, asset_cfg in items: children = ( @@ -250,7 +252,7 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: and any(cloner.path.relative_to(cfg.prim_path, owner) not in (None, "") for owner in owner_paths) } nested_material_names = {name for name, cfg in flat_items if id(cfg) in nested_visual_material_ids} - self._scene_asset_names = [name for name in self._scene_asset_names if name not in nested_material_names] + scene_asset_names = [name for name in scene_asset_names if name not in nested_material_names] cfgs: list[Any] = [] global_paths: tuple[str, ...] = () @@ -270,16 +272,15 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: variant_counts.append(cloner.num_spawn_variants(child.spawn)) if self.cloner_cfg.clone_combinations and clone_asset_names: - self._clone_valid_set = cloner.make_valid_clone_combinations( + valid_set = cloner.make_valid_clone_combinations( clone_asset_names, variant_counts, self.cloner_cfg.clone_combinations, - self.device, - all_asset_names=self._scene_asset_names, + all_asset_names=scene_asset_names, ) else: - self._clone_valid_set = None - return cfgs, global_paths + valid_set = None + return cfgs, global_paths, valid_set def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. @@ -379,12 +380,16 @@ def num_envs(self) -> int: @property def env_origins(self) -> torch.Tensor: - """Per-env world origins, shape ``(num_envs, 3)``. From the terrain when registered, - else from the published :class:`~isaaclab.cloner.ClonePlan`. - """ + """Per-env world origins, shape ``(num_envs, 3)``.""" if self._terrain is not None: return self._terrain.env_origins - return self.sim.get_clone_plan().positions + plan = self.sim.get_clone_plan() + if plan is not None and plan is not self._env_origins_plan: + self._env_origins = plan.positions + self._env_origins_plan = plan + if not isinstance(self._env_origins, torch.Tensor): + self._env_origins = torch.as_tensor(self._env_origins, device=self._ALL_INDICES.device) + return self._env_origins @property def terrain(self) -> TerrainImporter | None: @@ -438,11 +443,12 @@ def visual_materials(self) -> dict[str, VisualMaterial]: @property def clone_plan(self) -> cloner.ClonePlan | None: - """Clone plan produced by the most recent replication. + """Clone plan owned by the active simulation. Forwards to :meth:`SimulationContext.get_clone_plan`, which is the canonical owner. The plan records the source paths, destination templates, and the per-env source - assignment mask. ``None`` until :func:`isaaclab.cloner.replicate` has run. + assignment mask. Cfg-owned scenes publish it before constructing their entities; + direct scenes publish it when their explicit clone lifecycle begins. """ return self.sim.get_clone_plan() @@ -787,17 +793,6 @@ def __getitem__(self, key: str) -> Any: Internal methods. """ - def _is_scene_setup_from_cfg(self) -> bool: - """Check if scene entities are setup from the config or not. - - Returns: - True if scene entities are setup from the config, False otherwise. - """ - return any( - not (asset_name in InteractiveSceneCfg.__dataclass_fields__ or asset_cfg is None) - for asset_name, asset_cfg in self.cfg.__dict__.items() - ) - def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 969bc4e36d4e..bca7dfc07a87 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -198,6 +198,7 @@ def __init__(self, cfg: CameraCfg): settings = get_settings_manager() settings.set_bool("/isaaclab/render/rtx_sensors", True) + settings.set_bool("/physics/fabricUpdateTransformations", True) if require_hdr_output: settings.set_bool("/rtx/rtpt/gaussian/skipTonemapping/enabled", False) elif renderer_type == "ovrtx" and require_hdr_output: diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index c3db3278e5a9..aac968b886ca 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -250,7 +250,7 @@ def _initialize_impl(self): elif clone_plan is not None: env_prim_path_expr = "/".join(sim_utils.split_path_expr(self.cfg.prim_path)[:-1]) self._parent_prims = sim_utils.find_matching_prims(env_prim_path_expr) - self._num_envs = int(clone_plan.env_ids.numel()) + self._num_envs = int(clone_plan.env_ids.size) else: env_prim_path_expr = "/".join(sim_utils.split_path_expr(self.cfg.prim_path)[:-1]) self._parent_prims = sim_utils.find_matching_prims(env_prim_path_expr) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 63491aba65e0..9c57b32d8eef 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -199,9 +199,8 @@ def __init__(self, cfg: SimulationCfg | None = None): # Set by the visualizers and renderers in use; read by the scene data provider. self.requires_usd_stage = False self.requires_newton_model = False - # Clone plan published by InteractiveScene after cloning. Providers (e.g. the - # Newton visualizer model rebuilder on a PhysX backend) consume this to derive - # their own backend args. None until a replication session publishes a plan. + # Clone plan published before cfg-owned scene construction. Constructors and + # backends therefore consume the same immutable layout through one lifecycle. self._clone_plan: ClonePlan | None = None # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) @@ -217,6 +216,9 @@ def __init__(self, cfg: SimulationCfg | None = None): # cameras rather than inheriting a stale True from a previously torn-down simulation. RTX # cameras created for this instance re-set it to True before it is read. self.set_setting("/isaaclab/render/rtx_sensors", False) + # Preserve rendering initialization, then avoid continuous Fabric synchronization when + # the only visualizer is used for on-demand headless capture. + self.set_setting("/physics/fabricUpdateTransformations", self.is_rendering) # Set by camera sensors, which draw visual-only geometry regardless of renderer backend. self._visual_shapes_required = False self._pending_camera_view: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None @@ -348,7 +350,7 @@ def is_rendering(self) -> bool: return ( self._has_gui or self.get_setting("/isaaclab/render/rtx_sensors") - or bool(self.resolve_visualizer_types()) + or self._has_continuous_visualizers() or self._xr_enabled ) @@ -522,6 +524,31 @@ def resolve_visualizer_types(self) -> list[str]: visualizer_cfgs = [visualizer_cfgs] return [cfg.visualizer_type for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None)] + def _has_continuous_visualizers(self) -> bool: + """Return whether the resolved visualizers require per-step updates.""" + visualizer_types = self.resolve_visualizer_types() + if not visualizer_types: + return False + + visualizer_cfgs = self.cfg.visualizer_cfgs + if visualizer_cfgs is None: + visualizer_cfgs = [] + elif not isinstance(visualizer_cfgs, list): + visualizer_cfgs = [visualizer_cfgs] + + if self._is_cli_visualizer_explicit(): + for visualizer_type in visualizer_types: + matching_cfgs = [ + cfg for cfg in visualizer_cfgs if getattr(cfg, "visualizer_type", None) == visualizer_type + ] + if not matching_cfgs or any(not getattr(cfg, "headless", False) for cfg in matching_cfgs): + return True + return False + + return any( + getattr(cfg, "visualizer_type", None) and not getattr(cfg, "headless", False) for cfg in visualizer_cfgs + ) + def _resolve_visualizer_cfgs(self) -> list[Any]: """Resolve final visualizer configs from cfg and optional CLI override. @@ -692,14 +719,13 @@ def register_interactive_scene(self, scene) -> None: def get_clone_plan(self) -> ClonePlan | None: """Return the clone plan published by the scene. - Set after replication. Consumed by scene data providers that build backend models - (e.g. Newton visualizer model on a PhysX backend) from the same plan the cloner used. - ``None`` until the scene replicates. + Set before cfg-owned scene construction and retained through backend replication. + ``None`` until a clone lifecycle begins. """ return self._clone_plan def set_clone_plan(self, plan: ClonePlan | None) -> None: - """Set the cloner's clone plan.""" + """Set the cloner's active clone plan.""" self._clone_plan = plan @property diff --git a/source/isaaclab/isaaclab/terrains/terrain_importer.py b/source/isaaclab/isaaclab/terrains/terrain_importer.py index 85f92025e479..ec124b4ef361 100644 --- a/source/isaaclab/isaaclab/terrains/terrain_importer.py +++ b/source/isaaclab/isaaclab/terrains/terrain_importer.py @@ -411,8 +411,8 @@ def _compute_env_origins_grid(self, num_envs: int, env_spacing: float) -> torch. """Compute the origins of the environments in a grid based on configured spacing.""" from isaaclab.cloner import grid_transforms - env_origins, _ = grid_transforms(num_envs, env_spacing, device=self.device) - return env_origins + env_origins, _ = grid_transforms(num_envs, env_spacing) + return torch.as_tensor(env_origins, device=self.device) """ Deprecated. diff --git a/source/isaaclab/isaaclab/utils/assets.py b/source/isaaclab/isaaclab/utils/assets.py index 0bc217e29e05..92b49891d134 100644 --- a/source/isaaclab/isaaclab/utils/assets.py +++ b/source/isaaclab/isaaclab/utils/assets.py @@ -29,6 +29,8 @@ from filelock import FileLock +from isaaclab.paths import ISAACLAB_ROOT + logger = logging.getLogger(__name__) _UDIM_RE = re.compile(r"", re.IGNORECASE) @@ -45,9 +47,7 @@ ) -_KIT_EXPERIENCE_PATH = os.path.normpath( - os.path.join(os.path.dirname(__file__), *([".."] * 4), "apps", "isaaclab.python.kit") -) +_KIT_EXPERIENCE_PATH = str(ISAACLAB_ROOT / "apps" / "isaaclab.python.kit") # Isaac Sim resolves ``persistent.isaac.asset_root.default``, so it is read first. The # legacy ``cloud`` setting is only consulted for experience files that predate it. diff --git a/source/isaaclab/test/actuators/test_newton_adapter.py b/source/isaaclab/test/actuators/test_newton_adapter.py index 92f75f01e6c8..db6b4ec5681f 100644 --- a/source/isaaclab/test/actuators/test_newton_adapter.py +++ b/source/isaaclab/test/actuators/test_newton_adapter.py @@ -10,7 +10,7 @@ import numpy as np import pytest import torch -from newton.actuators import ClampingDCMotor, ClampingMaxEffort, ClampingPositionBased, ControllerPD +from newton.actuators import ClampingDCMotor, ClampingMaxEffort, ClampingPositionBased, DrivePD from pxr import Usd, UsdGeom, UsdPhysics @@ -146,7 +146,7 @@ def test_from_usd_groups_by_structure_and_preserves_per_dof_values(): assert len(actuators) == 4 pd = next(actuator for actuator in actuators if [type(c) for c in actuator.clamping] == [ClampingMaxEffort]) - assert type(pd.controller) is ControllerPD + assert type(pd.controller) is DrivePD np.testing.assert_array_equal(pd.indices.numpy(), [0, 1, 6, 7]) np.testing.assert_allclose(pd.controller.kp.numpy(), [11.0, 22.0, 11.0, 22.0]) np.testing.assert_allclose(pd.controller.kd.numpy(), [1.5, 2.5, 1.5, 2.5]) @@ -155,7 +155,7 @@ def test_from_usd_groups_by_structure_and_preserves_per_dof_values(): assert pd.delay.buf_depth == 4 dc = next(actuator for actuator in actuators if [type(c) for c in actuator.clamping] == [ClampingDCMotor]) - assert type(dc.controller) is ControllerPD + assert type(dc.controller) is DrivePD assert dc.delay is None np.testing.assert_array_equal(dc.indices.numpy(), [2, 3, 8, 9]) np.testing.assert_allclose(dc.controller.kp.numpy(), [33.0, 44.0, 33.0, 44.0]) diff --git a/source/isaaclab/test/app/standalone_script_cases.py b/source/isaaclab/test/app/standalone_script_cases.py index d9a5e89351b7..f220f033ab16 100644 --- a/source/isaaclab/test/app/standalone_script_cases.py +++ b/source/isaaclab/test/app/standalone_script_cases.py @@ -489,7 +489,7 @@ def read_available_output(timeout: float) -> bool: _terminate_process_group(process) returncode = process.poll() break - if now - start_time >= startup_timeout: + if ready_at is None and now - start_time >= startup_timeout: _terminate_process_group(process) returncode = process.poll() break diff --git a/source/isaaclab/test/app/test_experience_files.py b/source/isaaclab/test/app/test_experience_files.py index 175bfba3fd12..26b7071d4db1 100644 --- a/source/isaaclab/test/app/test_experience_files.py +++ b/source/isaaclab/test/app/test_experience_files.py @@ -12,19 +12,17 @@ pytestmark = pytest.mark.unit -APPS_DIR = Path(__file__).resolve().parents[4] / "apps" - # ``.kit`` files repeat section headers, so they cannot be parsed as TOML. _SECTION_RE = re.compile(r"^\s*\[+(?P[^\[\]]+)\]+\s*$") _DEPENDENCY_RE = re.compile(r'^\s*"(?P[^"]+)"\s*=') -def _kit_dependencies(experience: str) -> set[str]: +def _kit_dependencies(apps_dir: Path, experience: str) -> set[str]: """Collect the extension names declared in an experience file's dependency sections.""" dependencies: set[str] = set() in_dependencies = False - for line in (APPS_DIR / experience).read_text(encoding="utf-8").splitlines(): + for line in (apps_dir / experience).read_text(encoding="utf-8").splitlines(): section = _SECTION_RE.match(line) if section is not None: in_dependencies = section.group("name").strip() == "dependencies" @@ -38,9 +36,9 @@ def _kit_dependencies(experience: str) -> set[str]: @pytest.mark.parametrize("experience", ["isaaclab.python.kit", "isaaclab.python.headless.kit"]) -def test_base_experiences_enable_native_storage(experience: str): +def test_base_experiences_enable_native_storage(source_checkout_root: Path, experience: str): """Test the base experiences load the extension that applies ``ISAACSIM_ASSET_ROOT``.""" - assert "isaacsim.storage.native" in _kit_dependencies(experience) + assert "isaacsim.storage.native" in _kit_dependencies(source_checkout_root / "apps", experience) @pytest.mark.parametrize( @@ -52,6 +50,6 @@ def test_base_experiences_enable_native_storage(experience: str): ("isaaclab.python.xr.openxr.headless.kit", "isaaclab.python.xr.openxr"), ], ) -def test_derived_experiences_inherit_native_storage(experience: str, base: str): +def test_derived_experiences_inherit_native_storage(source_checkout_root: Path, experience: str, base: str): """Test the derived experiences inherit native storage from a base experience.""" - assert base in _kit_dependencies(experience) + assert base in _kit_dependencies(source_checkout_root / "apps", experience) diff --git a/source/isaaclab/test/app/test_loading_screen.py b/source/isaaclab/test/app/test_loading_screen.py index 7b380b948e4e..9f5f6f0c32b7 100644 --- a/source/isaaclab/test/app/test_loading_screen.py +++ b/source/isaaclab/test/app/test_loading_screen.py @@ -25,19 +25,11 @@ def _render(display: RenderableType, width: int) -> str: @pytest.mark.parametrize( - ("terminal_width", "display_width", "logo"), - [ - (4, 4, None), - (5, 5, None), - (79, 79, None), - (80, 80, ".-------."), - (119, 80, ".-------."), - (120, 120, "██╗"), - (140, 120, "██╗"), - ], + ("terminal_width", "display_width"), + [(4, 4), (5, 5), (79, 79), (80, 80), (129, 80), (130, 130), (150, 130)], ) -def test_display_reflows_at_responsive_boundaries(terminal_width: int, display_width: int, logo: str | None): - screen = loading_screen.LoadingScreen(2, enabled=True) +def test_display_reflows_at_responsive_boundaries(terminal_width: int, display_width: int): + screen = loading_screen.LoadingScreen(2, enabled=True, logo=False) screen.summary("Run", {"Task": "Cartpole", "Description": "A long description that wraps cleanly."}) screen.stage("Loading task") @@ -46,15 +38,9 @@ def test_display_reflows_at_responsive_boundaries(terminal_width: int, display_w assert max(map(cell_len, lines)) <= display_width assert cell_len(lines[-1]) == display_width - assert ("Welcome to Isaac Lab!" in output) is (logo is not None) - assert (".-------." in output) is (logo == ".-------.") - assert ("██╗" in output) is (logo == "██╗") if terminal_width >= 79: assert "Loading task" in lines[-1] assert "cleanly." in output - if logo is not None: - header = next(line for line in lines if line.startswith("╭")) - assert header.index("╭") < header.index("Welcome") def test_live_updates_refresh_after_releasing_state_lock(): @@ -154,3 +140,20 @@ def test_redirect_uses_stream_swapping_on_platforms_without_descriptor_redirecti assert screen._console is original_stdout assert loading_screen.sys.stdout is original_stdout assert loading_screen.sys.stderr is original_stderr + + +def test_static_logos_omit_the_tagline(): + assert all("trained" not in logo for logo in loading_screen.LoadingScreen(1)._logos) + + +def test_static_isaac_lab_logos_use_the_original_coloured_artwork(): + expected_colour = "\x1b[38;2;118;185;0m" + + assert expected_colour in loading_screen.LOGO + assert expected_colour in loading_screen.LOGO_WIDE + + +def test_static_nvidia_logo_uses_artwork(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(loading_screen.random, "choice", lambda pairs: pairs[1]) + + assert "▄▄" in loading_screen.LoadingScreen(1)._logos[0] diff --git a/source/isaaclab/test/app/test_standalone_scripts.py b/source/isaaclab/test/app/test_standalone_scripts.py index 3d0b8c6480cf..a43cb2349b27 100644 --- a/source/isaaclab/test/app/test_standalone_scripts.py +++ b/source/isaaclab/test/app/test_standalone_scripts.py @@ -22,6 +22,7 @@ import sys from dataclasses import replace from pathlib import Path +from unittest import mock import pytest import standalone_script_cases as script_cases @@ -371,6 +372,37 @@ def test_subprocess_supervisor_soaks_then_stops_process_group(): assert result.elapsed < 2.0 +def test_subprocess_supervisor_completes_soak_after_startup_deadline(monkeypatch): + """Readiness just before the startup deadline must still receive the full soak.""" + process = mock.Mock(returncode=None) + process.poll.side_effect = lambda: process.returncode + process.communicate.return_value = (b"", None) + selector = mock.Mock() + now = 0.0 + poll_times = iter((299.0, 300.0, 304.0)) + + def select(timeout): + nonlocal now + if timeout == 0.0: + return [] + now = next(poll_times) + if now == 299.0: + return [(mock.Mock(fileobj=process.stdout), script_cases.selectors.EVENT_READ)] + return [] + + selector.select.side_effect = select + monkeypatch.setattr(script_cases.subprocess, "Popen", lambda *args, **kwargs: process) + monkeypatch.setattr(script_cases.selectors, "DefaultSelector", lambda: selector) + monkeypatch.setattr(script_cases.os, "read", lambda *args: b"READY\n") + monkeypatch.setattr(script_cases.time, "monotonic", lambda: now) + monkeypatch.setattr(script_cases, "_terminate_process_group", lambda process: setattr(process, "returncode", -15)) + + result = run_until_ready(["demo.py"], r"READY", startup_timeout=300.0, soak_time=5.0) + assert result.ready + assert result.stopped_after_soak + assert result.elapsed == 304.0 + + def test_subprocess_supervisor_ignores_fatal_output_after_intentional_teardown(monkeypatch): """Fatal-looking output caused by intentional teardown must not fail a healthy launch.""" diff --git a/source/isaaclab/test/assets/test_articulation_ordering_kernels.py b/source/isaaclab/test/assets/test_articulation_ordering_kernels.py index 0948e64d6e93..2e35fecffbf3 100644 --- a/source/isaaclab/test/assets/test_articulation_ordering_kernels.py +++ b/source/isaaclab/test/assets/test_articulation_ordering_kernels.py @@ -15,6 +15,7 @@ reorder_2d_user_to_backend, reorder_3d_backend_to_user, reorder_body_state_backend_to_user, + reorder_generalized_vector_backend_to_user, reorder_joint_state_backend_to_user, write_2d_user_to_backend_with_indices, write_2d_user_to_backend_with_mask, @@ -685,3 +686,35 @@ def test_joint_state_writer_accepts_selector_widths(env_dtype: type, joint_dtype np.testing.assert_array_equal(user_position.numpy(), [[121.0, 0.0, 120.0], [111.0, 0.0, 110.0]]) np.testing.assert_array_equal(backend_position.numpy(), [[120.0, 121.0, 0.0], [110.0, 111.0, 0.0]]) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("num_base_dofs", [0, 6]) +@pytest.mark.parametrize("correct_signs", [False, True]) +def test_generalized_force_ordering_with_optional_signs(device, num_base_dofs, correct_signs): + """Preserve floating-base forces and optionally correct actuated-joint signs.""" + if device.startswith("cuda") and not wp.is_cuda_available(): + pytest.skip("CUDA is unavailable") + joint_order = np.array([2, 0, 1], dtype=np.int32) + signs = np.array([1, -1, 1], dtype=np.int32) + values = np.arange(1, 2 * (num_base_dofs + len(joint_order)) + 1, dtype=np.float32).reshape(2, -1) + backend = wp.array(values, dtype=wp.float32, device=device) + result = wp.zeros_like(backend) + wp.launch( + reorder_generalized_vector_backend_to_user, + dim=result.shape, + inputs=[ + backend, + wp.array(joint_order, dtype=wp.int32, device=device), + wp.array(signs, dtype=wp.int32, device=device) if correct_signs else None, + num_base_dofs, + True, + ], + outputs=[result], + device=device, + ) + expected = values.copy() + expected[:, num_base_dofs:] = values[:, num_base_dofs:][:, joint_order] + if correct_signs: + expected[:, num_base_dofs:] *= signs[joint_order] + np.testing.assert_array_equal(result.numpy(), expected) diff --git a/source/isaaclab/test/benchmark/test_api.py b/source/isaaclab/test/benchmark/test_api.py index 0cf850d966ee..e1bd364ca683 100644 --- a/source/isaaclab/test/benchmark/test_api.py +++ b/source/isaaclab/test/benchmark/test_api.py @@ -151,6 +151,7 @@ def test_play_request_uses_backend_arguments(backend: str, monkeypatch) -> None: assert args.warmup_steps == 12 assert args.video is True assert args.video_length == 37 + assert args.frontend == "torch" assert args.enable_cameras is True assert remaining_args == [] @@ -163,9 +164,7 @@ class VideoConfigured(Exception): pass entrypoint = importlib.import_module(dispatch._workflow_module("play", "rsl_rl")) - monkeypatch.setattr(entrypoint._common, "resolve_play_checkpoint", lambda *args: "/tmp/checkpoint") - - import gymnasium as gym + monkeypatch.setattr(entrypoint.common, "resolve_play_checkpoint", lambda *args: "/tmp/checkpoint") import isaaclab.app as app @@ -179,7 +178,8 @@ def launch_simulation(env_cfg, args): env_cfg.video_recorders = [VideoRecorderCfg(output_dir=configured_output_dir)] yield - def make_environment(task, *, cfg): + def create_environment(task, cfg, args, *, convert_marl_to_single_agent): + assert convert_marl_to_single_agent is True recorder = cfg.video_recorders[0] expected_output_dir = configured_output_dir or str(tmp_path / "videos" / "play") assert recorder.output_dir == expected_output_dir @@ -187,7 +187,7 @@ def make_environment(task, *, cfg): raise VideoConfigured monkeypatch.setattr(app, "launch_simulation", launch_simulation) - monkeypatch.setattr(gym, "make", make_environment) + monkeypatch.setattr(entrypoint.common, "create_isaaclab_env", create_environment) with pytest.raises(VideoConfigured): entrypoint.run( @@ -338,10 +338,10 @@ def test_backend_entrypoints_register_environment_cleanup_before_wrapping() -> N for entrypoint in entrypoints: source = entrypoint.read_text() compile(source, str(entrypoint), "exec") - creation_index = max(source.find("gym.make("), source.find("_common.create_isaaclab_env(")) + creation_index = max(source.find("gym.make("), source.find("common.create_isaaclab_env(")) cleanup_index = source.find("cleanup.callback(lambda: env.close())", creation_index) wrapper_indices = [ - source.find(wrapper, creation_index) for wrapper in ("_common.wrap_record_video(env", "VecEnvWrapper(env") + source.find(wrapper, creation_index) for wrapper in ("common.wrap_record_video(env", "VecEnvWrapper(env") ] first_wrapper_index = min(index for index in wrapper_indices if index >= 0) diff --git a/source/isaaclab/test/cli/test_env_commands.py b/source/isaaclab/test/cli/test_env_commands.py index 8596d153b786..d29d7d0f668d 100644 --- a/source/isaaclab/test/cli/test_env_commands.py +++ b/source/isaaclab/test/cli/test_env_commands.py @@ -101,6 +101,49 @@ def test_launcher_uses_bundled_python_with_inactive_default_environment(tmp_path assert "inactive-python" not in result.stdout +@pytest.mark.skipif(envs.is_windows(), reason="Linux launcher behavior") +def test_launcher_accepts_virtual_environment_on_bundled_python(tmp_path): + """A virtual environment created on the package's own Python runs that interpreter, so it is allowed.""" + launcher = tmp_path / "isaaclab.sh" + shutil.copy2(envs.ISAACLAB_ROOT / "isaaclab.sh", launcher) + bundled_python = tmp_path / "_isaac_sim" / "python.sh" + bundled_python.parent.mkdir() + bundled_python.write_text("#!/usr/bin/env bash\necho bundled-python\n") + bundled_python.chmod(0o755) + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "python").symlink_to(bundled_python) + # ``home`` points into the Isaac Sim tree, exactly as ``uv venv --python`` on it would record. + (venv / "pyvenv.cfg").write_text(f"home = {bundled_python.parent}\n") + + environment = os.environ.copy() + environment["VIRTUAL_ENV"] = str(venv) + environment.pop("CONDA_PREFIX", None) + result = subprocess.run(["bash", str(launcher), "-h"], capture_output=True, text=True, check=False, env=environment) + + assert result.returncode == 0, result.stderr + assert "cannot be combined" not in result.stderr + + +@pytest.mark.skipif(envs.is_windows(), reason="Linux launcher behavior") +def test_launcher_rejects_virtual_environment_on_foreign_python(tmp_path): + """A virtual environment built on another interpreter stays rejected.""" + launcher = tmp_path / "isaaclab.sh" + shutil.copy2(envs.ISAACLAB_ROOT / "isaaclab.sh", launcher) + (tmp_path / "_isaac_sim").mkdir() + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "pyvenv.cfg").write_text("home = /usr/bin\n") + + environment = os.environ.copy() + environment["VIRTUAL_ENV"] = str(venv) + environment.pop("CONDA_PREFIX", None) + result = subprocess.run(["bash", str(launcher), "-h"], capture_output=True, text=True, check=False, env=environment) + + assert result.returncode == 1 + assert "Downloaded Isaac Sim packages cannot be combined" in result.stderr + + @pytest.mark.skipif(envs.is_windows(), reason="Linux launcher behavior") def test_launcher_allows_relinking_unmarked_source_build(tmp_path): """The source-build command must bypass downloaded-package environment rejection.""" diff --git a/source/isaaclab/test/cli/test_install.py b/source/isaaclab/test/cli/test_install.py index a7d068811879..12ad116b3216 100644 --- a/source/isaaclab/test/cli/test_install.py +++ b/source/isaaclab/test/cli/test_install.py @@ -86,6 +86,49 @@ def test_run_python_command_uses_live_isaac_sim_with_active_python(tmp_path): assert run.call_args.kwargs["env"]["PYTHONEXE"] == active_python +def test_run_python_command_accepts_virtual_environment_on_bundled_python(tmp_path): + """A virtual environment created on a downloaded package's Python runs that interpreter.""" + local_sim = tmp_path / "_isaac_sim" + local_sim.mkdir() + (local_sim / "python.sh").touch() + bundled_python = local_sim / "kit" / "python" / "bin" / "python3" + bundled_python.parent.mkdir(parents=True) + bundled_python.touch() + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "bin" / "python").symlink_to(bundled_python) + (venv / "pyvenv.cfg").write_text(f"home = {bundled_python.parent}\n") + + with ( + mock.patch("isaaclab.cli.utils.DEFAULT_ISAAC_SIM_PATH", local_sim), + mock.patch("isaaclab.cli.utils.extract_python_exe", return_value=str(venv / "bin" / "python")), + mock.patch("isaaclab.cli.utils.run_command") as run, + mock.patch.dict(os.environ, {"VIRTUAL_ENV": str(venv)}, clear=True), + ): + run_python_command("script.py", []) + + assert run.call_args is not None + + +def test_run_python_command_rejects_virtual_environment_on_foreign_python(tmp_path): + """A virtual environment built on another interpreter stays rejected.""" + local_sim = tmp_path / "_isaac_sim" + local_sim.mkdir() + (local_sim / "python.sh").touch() + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + (venv / "pyvenv.cfg").write_text("home = /usr/bin\n") + + with ( + mock.patch("isaaclab.cli.utils.DEFAULT_ISAAC_SIM_PATH", local_sim), + mock.patch("isaaclab.cli.utils.extract_python_exe", return_value=str(venv / "bin" / "python")), + mock.patch("isaaclab.cli.utils.run_command"), + mock.patch.dict(os.environ, {"VIRTUAL_ENV": str(venv)}, clear=True), + pytest.raises(SystemExit), + ): + run_python_command("script.py", []) + + def test_run_python_command_does_not_wrap_an_active_isaac_sim_environment(tmp_path): """The legacy wrapper path must not source the same Isaac Sim environment twice.""" local_sim = tmp_path / "_isaac_sim" @@ -433,11 +476,12 @@ class TestPinkIkStack: stack from there instead of mirroring the versions. """ - def test_stack_derived_from_root_pyproject_pins(self): + def test_stack_derived_from_root_pyproject_pins(self, source_checkout_root: Path): """The derived stack covers every stack package, exactly pinned, markers stripped.""" from isaaclab.cli.commands import install - stack = install._pink_ik_stack() + with mock.patch.object(install, "ISAACLAB_ROOT", source_checkout_root): + stack = install._pink_ik_stack() assert [install._requirement_name(r) for r in stack] == list(install._PINK_IK_PACKAGES) assert any(r.startswith("pin-pink==") for r in stack), "pin-pink must stay exactly pinned" assert any(r.startswith("daqp==") for r in stack), "daqp must stay exactly pinned" diff --git a/source/isaaclab/test/cli/test_install_commands.py b/source/isaaclab/test/cli/test_install_commands.py index 43581295240c..c0c0d09b7403 100644 --- a/source/isaaclab/test/cli/test_install_commands.py +++ b/source/isaaclab/test/cli/test_install_commands.py @@ -930,6 +930,25 @@ def test_repoints_across_multiple_prebundle_dirs(self, tmp_path): for pb in (pb1, pb2): assert (pb / "torch").is_symlink(), f"torch in {pb} should be repointed" + def test_repoints_package_inside_expanded_extra_bundle(self, tmp_path): + """Expanded extras bundles must not retain file links into a replaced package.""" + isaacsim_path, prebundle = self._sim_with_prebundle(tmp_path / "sim", ["newton"]) + shared_init = prebundle / "newton" / "legacy" / "__init__.py" + shared_init.parent.mkdir() + shared_init.write_text("") + bundled_newton = prebundle / "newton[sim]" / "newton-wheel" / "newton" + bundled_init = bundled_newton / "legacy" / "__init__.py" + bundled_init.parent.mkdir(parents=True) + bundled_init.symlink_to(shared_init) + site_pkgs = _make_site_packages(tmp_path / "env", ["newton"]) + + with self._patch(isaacsim_path, site_pkgs, str(tmp_path / "env" / "bin" / "python")): + _repoint_prebundle_packages() + + assert (prebundle / "newton").resolve() == (site_pkgs / "newton").resolve() + assert bundled_newton.is_symlink() + assert bundled_newton.resolve() == (site_pkgs / "newton").resolve() + # ---- Windows: copy instead of symlink ----------------------------------- def test_copies_package_on_windows_instead_of_symlinking(self, tmp_path): diff --git a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py index 1738764ccb45..e43cbcf5fe8c 100644 --- a/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py +++ b/source/isaaclab/test/cli/test_installed_workflow_entrypoints.py @@ -13,7 +13,10 @@ import pytest +import isaaclab +import isaaclab.__main__ as package_main import isaaclab.cli as cli +import isaaclab.paths as paths pytestmark = pytest.mark.unit @@ -30,6 +33,24 @@ def test_cli_import_does_not_require_runtime_dependencies(): assert result.returncode == 0, result.stderr +def test_resolves_partial_source_checkout_root(tmp_path): + """Source root resolution must not require resources copied by later Docker layers.""" + package_root = tmp_path / "source" / "isaaclab" / "isaaclab" + package_root.mkdir(parents=True) + + with mock.patch.object(paths, "__file__", str(package_root / "paths.py")): + assert paths._resolve_isaaclab_root() == tmp_path + + +def test_top_level_compatibility_api_is_preserved(): + """The flattened package must retain the aggregate wheel's public shims.""" + assert callable(isaaclab.bootstrap_kernel) + with mock.patch.object(package_main, "main", return_value=0) as main, pytest.raises(SystemExit, match="0"): + isaaclab.main() + + main.assert_called_once_with() + + def test_editor_option_uses_cli_dispatcher(): """The installed CLI must forward editor-specific arguments to the editor command.""" with ( diff --git a/source/isaaclab/test/cli/test_source_package_metadata.py b/source/isaaclab/test/cli/test_source_package_metadata.py index 4c13ca1fa839..9f3d1fac25c7 100644 --- a/source/isaaclab/test/cli/test_source_package_metadata.py +++ b/source/isaaclab/test/cli/test_source_package_metadata.py @@ -15,22 +15,14 @@ pytestmark = pytest.mark.unit -def _repo_root() -> Path: - """Find the Isaac Lab repository root from this test file.""" - for parent in Path(__file__).resolve().parents: - if (parent / "pyproject.toml").is_file() and (parent / "source").is_dir(): - return parent - raise RuntimeError("Could not find Isaac Lab repository root.") - - -def test_isaaclab_uses_one_standalone_usd_provider(): +def test_isaaclab_uses_one_standalone_usd_provider(source_checkout_root: Path): """Isaac Lab must install only the USD provider shared with its importer dependencies. ``usd-core`` and ``usd-exchange`` each install a complete ``pxr`` into the same directory, so a second provider silently overwrites the first and removing either one leaves ``pxr`` broken. Nothing detects that, because the two are separate distributions. """ - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) dependencies = pyproject["project"]["dependencies"] @@ -43,14 +35,14 @@ def test_isaaclab_uses_one_standalone_usd_provider(): assert usd_providers == ["usd-exchange==2.3.0"] -def test_resolved_environment_has_no_second_usd_provider(): +def test_resolved_environment_has_no_second_usd_provider(source_checkout_root: Path): """No dependency may pull ``usd-core`` back in behind an extra. ``newton[importers]``, ``mujoco[usd]`` and ``warp-lang[examples]`` all require it, so selecting any of them would reinstate the overlap that the direct dependencies avoid. Checking the lock catches that, where checking ``pyproject.toml`` alone would not. """ - with (_repo_root() / "uv.lock").open("rb") as f: + with (source_checkout_root / "uv.lock").open("rb") as f: lock = tomllib.load(f) locked = {package["name"] for package in lock["package"]} @@ -59,9 +51,9 @@ def test_resolved_environment_has_no_second_usd_provider(): assert "usd-exchange" in locked -def test_standalone_importers_are_opt_in(): +def test_standalone_importers_are_opt_in(source_checkout_root: Path): """Standalone URDF/MJCF importers must not constrain the base environment.""" - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) project = pyproject["project"] diff --git a/source/isaaclab/test/cli/test_teleop_entrypoints.py b/source/isaaclab/test/cli/test_teleop_entrypoints.py index c94f0385eb0f..a459e5f5bb69 100644 --- a/source/isaaclab/test/cli/test_teleop_entrypoints.py +++ b/source/isaaclab/test/cli/test_teleop_entrypoints.py @@ -47,7 +47,7 @@ def test_teleop_workflow_help_exposes_task_preset_selectors(command, script_part @pytest.mark.parametrize(("command", "script_parts"), TELEOP_WORKFLOWS.items()) -def test_teleop_dispatches_to_an_existing_script(command, script_parts): +def test_teleop_dispatches_to_an_existing_script(source_checkout_root: Path, command, script_parts): """``isaaclab teleop`` forwards the remaining arguments to a script that exists.""" args = [command, "--task", "IsaacContrib-PickPlace-Locomanipulation-G1-Abs", "--xr"] @@ -57,7 +57,7 @@ def test_teleop_dispatches_to_an_existing_script(command, script_parts): ): cli.cli() - script = cli.ISAACLAB_ROOT.joinpath(*script_parts) + script = source_checkout_root.joinpath(*script_parts) run_python.assert_called_once_with(script, args[1:], check=True) assert script.is_file() @@ -73,21 +73,21 @@ def _requirement_names(requirements: list[str]) -> set[str]: @pytest.mark.parametrize(("command", "script_parts"), TELEOP_WORKFLOWS.items()) -def test_teleop_workflow_isaaclab_imports_are_covered_by_the_extras(command, script_parts): +def test_teleop_workflow_isaaclab_imports_are_covered_by_the_extras(source_checkout_root: Path, command, script_parts): """Every ``isaaclab_*`` package a teleop script imports must ship with the extras. ``record_demos.py`` imports ``isaaclab_mimic`` at module level, so an environment built from ``--extra teleop`` alone used to die with ``ModuleNotFoundError`` only after Isaac Sim had finished booting. This catches that class of gap without needing an install. """ - with (cli.ISAACLAB_ROOT / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) project = pyproject["project"] available = _requirement_names(project["dependencies"]) available |= _requirement_names(project["optional-dependencies"]["teleop"]) - source = Path(cli.ISAACLAB_ROOT).joinpath(*script_parts).read_text(encoding="utf-8") + source = source_checkout_root.joinpath(*script_parts).read_text(encoding="utf-8") imported = set(re.findall(r"^\s*(?:import|from)\s+(isaaclab_\w+)", source, re.MULTILINE)) missing = imported - available diff --git a/source/isaaclab/test/cli/test_uv_run_pyproject.py b/source/isaaclab/test/cli/test_uv_run_pyproject.py index 16c921e06b61..3b2b341f6cfa 100644 --- a/source/isaaclab/test/cli/test_uv_run_pyproject.py +++ b/source/isaaclab/test/cli/test_uv_run_pyproject.py @@ -16,38 +16,31 @@ pytestmark = pytest.mark.unit -def _repo_root() -> Path: - """Find the Isaac Lab repository root from this test file.""" - for parent in Path(__file__).resolve().parents: - if (parent / "pyproject.toml").is_file() and (parent / "source").is_dir(): - return parent - raise RuntimeError("Could not find Isaac Lab repository root.") - - -def _root_pyproject() -> dict: +def _root_pyproject(source_checkout_root: Path) -> dict: """Load the root development ``pyproject.toml``.""" - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: return tomllib.load(f) -def test_uv_run_extra_names_match_documented_workflow(): +def test_uv_run_extra_names_match_documented_workflow(source_checkout_root: Path): """Docs must only reference ``uv run --extra`` names that pyproject defines.""" - repo_root = _repo_root() + repo_root = source_checkout_root docs = (repo_root / "docs/source/setup/installation/index.rst").read_text(encoding="utf-8") documented_extras = set(re.findall(r"--extra\s+([A-Za-z0-9_-]+)", docs)) - optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + optional_dependencies = _root_pyproject(source_checkout_root)["project"]["optional-dependencies"] assert documented_extras assert documented_extras <= set(optional_dependencies) -def test_uv_run_exposes_centralized_feature_extras(): +def test_uv_run_exposes_centralized_feature_extras(source_checkout_root: Path): """The root project centralizes optional third-party deps into named extras.""" - optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] + optional_dependencies = _root_pyproject(source_checkout_root)["project"]["optional-dependencies"] # Feature extras a user can activate with ``uv run --extra``. expected_extras = { "test", + "dev", "sb3", "skrl", "rl-games", @@ -84,9 +77,9 @@ def test_uv_run_exposes_centralized_feature_extras(): assert any(dep.startswith("ovstage") for dep in optional_dependencies["ovrtx"]) -def test_all_extra_aggregates_curated_ov_rl_and_visualizer_extras(): +def test_all_extra_aggregates_curated_ov_rl_and_visualizer_extras(source_checkout_root: Path): """``all`` aggregates only the curated OV, RL, and visualizer extras.""" - optional = _root_pyproject()["project"]["optional-dependencies"] + optional = _root_pyproject(source_checkout_root)["project"]["optional-dependencies"] assert len(optional["all"]) == 1 aggregated = set(re.fullmatch(r"isaaclab-dev\[(.+)\]", optional["all"][0]).group(1).split(",")) @@ -105,12 +98,13 @@ def test_all_extra_aggregates_curated_ov_rl_and_visualizer_extras(): "leapp", "wandb", "test", + "dev", } -def test_tetrahedralization_is_explicit_extra_only(): +def test_tetrahedralization_is_explicit_extra_only(source_checkout_root: Path): """TetWild and its visualization stack are installed only when requested.""" - project = _root_pyproject()["project"] + project = _root_pyproject(source_checkout_root)["project"] optional = project["optional-dependencies"] assert not any(dep.startswith("pytetwild") for dep in project["dependencies"]) @@ -122,14 +116,14 @@ def test_tetrahedralization_is_explicit_extra_only(): assert not any("tetrahedralization" in dep or dep.startswith("pytetwild") for dep in deps) -def test_version_single_source_matches_literal_pins(): +def test_version_single_source_matches_literal_pins(source_checkout_root: Path): """``[tool.isaaclab.versions]`` is the single source for externally-pinned versions. TOML cannot interpolate, so the literal pins in ``[project.dependencies]``, ``[project.optional-dependencies]``, and ``[tool.uv].override-dependencies`` must mirror the table exactly. This test fails if any of them drift apart. """ - pyproject = _root_pyproject() + pyproject = _root_pyproject(source_checkout_root) versions = pyproject["tool"]["isaaclab"]["versions"] dependencies = pyproject["project"]["dependencies"] optional = pyproject["project"]["optional-dependencies"] @@ -138,9 +132,8 @@ def test_version_single_source_matches_literal_pins(): assert versions["ovphysx"] == "0.5.11" assert "omniverseclient==2.72.3" in dependencies - # Isaac Sim extra mirrors the table, and the teleop extra repeats the same pin. + # Isaac Sim extra mirrors the table; it is the only place the wheel is pinned. assert optional["isaacsim"] == [f"isaacsim[all,extscache]=={versions['isaacsim']}"] - assert f"isaacsim[all,extscache]=={versions['isaacsim']}" in optional["teleop"] # OV extras mirror the table. Table values may be an exact version ("1.2.3", # mirrored as ``pkg==1.2.3``) or a range spec (">=1.2.3", mirrored as ``pkg>=1.2.3``). @@ -157,7 +150,7 @@ def spec(package: str) -> str: # ovrtx`` ignores this ceiling). Each such install must therefore be pinned: # either by carrying the literal range, or by referencing the ``resolve-ov-pins`` # action output, which reads the pin from this same table. Never a bare ``ovrtx``. - build_workflow = (_repo_root() / ".github/workflows/build.yaml").read_text(encoding="utf-8") + build_workflow = (source_checkout_root / ".github/workflows/build.yaml").read_text(encoding="utf-8") assert "ovphysx==0.4.13" not in build_workflow ovrtx_install_lines = [ line.strip() for line in build_workflow.splitlines() if "extra-pip-packages:" in line and "ovrtx" in line @@ -165,9 +158,9 @@ def spec(package: str) -> str: assert ovrtx_install_lines assert all(spec("ovrtx") in line or "steps.ov_pins.outputs.ovrtx" in line for line in ovrtx_install_lines) - # uv torch-stack overrides mirror the table. + # Direct torch-stack pins apply to both source and wheel installations. for package in ("torch", "torchvision", "torchaudio"): - assert f"{package}=={versions[package]}" in overrides + assert f"{package}=={versions[package]}" in dependencies # The Newton uv override is its single pin and may select a release or Git revision. newton_spec = next(requirement for requirement in overrides if requirement.startswith("newton[sim]")) @@ -180,9 +173,9 @@ def spec(package: str) -> str: assert warp_spec in dependencies -def test_public_ov_packages_use_public_pypi_index(): +def test_public_ov_packages_use_public_pypi_index(source_checkout_root: Path): """Public OV packages must not resolve from the NVIDIA package index.""" - pyproject = _root_pyproject() + pyproject = _root_pyproject(source_checkout_root) indexes = {index.get("name"): index for index in pyproject["tool"]["uv"]["index"]} sources = pyproject["tool"]["uv"]["sources"] @@ -195,7 +188,7 @@ def test_public_ov_packages_use_public_pypi_index(): assert sources[package] == {"index": "pypi-public"} -def test_uv_run_declares_no_extra_conflicts(): +def test_uv_run_declares_no_extra_conflicts(source_checkout_root: Path): """No extra is forked: every combination resolves into a single environment. ``[tool.uv].conflicts`` used to fork ``isaacsim`` / ``teleop`` away from the OV runtimes, @@ -203,20 +196,20 @@ def test_uv_run_declares_no_extra_conflicts(): ovphysx. The importers install beside Isaac Sim without displacing it: the two distributions share no files, and Kit serves ``isaacsim.asset`` from its extension roots either way. """ - tool_uv = _root_pyproject()["tool"]["uv"] + tool_uv = _root_pyproject(source_checkout_root)["tool"]["uv"] assert "conflicts" not in tool_uv assert "packaging>=20,<27" in tool_uv["override-dependencies"] -def test_uv_run_isaacsim_is_an_opt_in_extra(): +def test_uv_run_isaacsim_is_an_opt_in_extra(source_checkout_root: Path): """Isaac Sim is never a base dependency, but it is a real workspace extra. PhysX/Isaac Sim must stay out of ``[project.dependencies]`` so the bare ``uv run`` keeps working without Kit, while still being an ``optional-dependencies`` entry so ``uv run --extra isaacsim`` resolves. """ - pyproject = _root_pyproject() + pyproject = _root_pyproject(source_checkout_root) project = pyproject["project"] base_dependency_names = {re.split(r"[\s<>=!~\[;]", dep, maxsplit=1)[0] for dep in project["dependencies"]} @@ -229,25 +222,20 @@ def test_uv_run_isaacsim_is_an_opt_in_extra(): assert "wheel-extras" not in pyproject.get("tool", {}).get("isaaclab", {}) -def test_uv_run_teleop_extra_bundles_isaacsim(): - """``--extra teleop`` is the single flag for the XR teleoperation workflow. - - XR teleop needs the Kit XR runtime, so the extra carries Isaac Sim through the - ``isaacsim`` extra rather than repeating its pin. - """ - optional_dependencies = _root_pyproject()["project"]["optional-dependencies"] +def test_uv_run_teleop_extra_bundles_isaacsim(source_checkout_root: Path): + """``--extra teleop`` is the single flag for the XR teleoperation workflow.""" + optional_dependencies = _root_pyproject(source_checkout_root)["project"]["optional-dependencies"] teleop = optional_dependencies["teleop"] - # Isaac Sim is listed explicitly; test_version_single_source keeps the pin from drifting. assert any(dep.startswith("isaacsim[all,extscache]==") for dep in teleop) # record_demos.py imports isaaclab_mimic at module level; robomimic stays in ``mimic``. assert "isaaclab-mimic" in teleop assert not any(dep.startswith("robomimic") for dep in teleop) -def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(): +def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(source_checkout_root: Path): """The documented bare ``uv run isaaclab train`` command needs Newton and RSL-RL in core.""" - dependencies = _root_pyproject()["project"]["dependencies"] + dependencies = _root_pyproject(source_checkout_root)["project"]["dependencies"] # Newton is the default physics engine and RSL-RL the default training library, # so both ship as core third-party requirements (not opt-in extras). @@ -255,8 +243,8 @@ def test_uv_run_base_dependencies_cover_newton_rsl_rl_training(): assert any(dep.startswith("rsl-rl-lib") for dep in dependencies) -def test_uv_run_uses_managed_python(): +def test_uv_run_uses_managed_python(source_checkout_root: Path): """Avoid building the project venv from conda Python and its older C++ runtime.""" - tool_uv = _root_pyproject()["tool"]["uv"] + tool_uv = _root_pyproject(source_checkout_root)["tool"]["uv"] assert tool_uv["python-preference"] == "only-managed" diff --git a/source/isaaclab/test/cli/test_wheel_builder_metadata.py b/source/isaaclab/test/cli/test_wheel_builder_metadata.py index f3c13863d56d..52e6e57a51b1 100644 --- a/source/isaaclab/test/cli/test_wheel_builder_metadata.py +++ b/source/isaaclab/test/cli/test_wheel_builder_metadata.py @@ -19,17 +19,9 @@ pytestmark = pytest.mark.unit -def _repo_root() -> Path: - """Find the Isaac Lab repository root from this test file.""" - for parent in Path(__file__).resolve().parents: - if (parent / "pyproject.toml").is_file() and (parent / "source").is_dir(): - return parent - raise RuntimeError("Could not find Isaac Lab repository root.") - - -def _root_rsl_rl_pin() -> str: +def _root_rsl_rl_pin(source_checkout_root: Path) -> str: """Return the ``rsl-rl-lib`` pin declared by the root ``pyproject.toml`` core deps.""" - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: data = tomllib.load(f) for dependency in data["project"]["dependencies"]: if dependency.startswith("rsl-rl-lib=="): @@ -37,15 +29,14 @@ def _root_rsl_rl_pin() -> str: raise AssertionError("Could not find rsl-rl-lib pin in the root pyproject.toml") -def _generate_wheel_pyproject(tmp_path: Path) -> dict: +def _generate_wheel_pyproject(source_checkout_root: Path, tmp_path: Path) -> dict: """Run ``gen_pyproject.py`` against the root pyproject and return the parsed result.""" - repo_root = _repo_root() output = tmp_path / "pyproject.toml" subprocess.run( [ sys.executable, - str(repo_root / "tools/wheel_builder/gen_pyproject.py"), - str(repo_root / "pyproject.toml"), + str(source_checkout_root / "tools/wheel_builder/gen_pyproject.py"), + str(source_checkout_root / "pyproject.toml"), str(output), "3.0.0", ], @@ -55,15 +46,14 @@ def _generate_wheel_pyproject(tmp_path: Path) -> dict: return tomllib.load(f) -def _generate_uv_overrides(tmp_path: Path) -> list[str]: +def _generate_uv_overrides(source_checkout_root: Path, tmp_path: Path) -> list[str]: """Run ``gen_uv_overrides.py`` against the root pyproject and return its requirements.""" - repo_root = _repo_root() output = tmp_path / "uv-overrides.txt" subprocess.run( [ sys.executable, - str(repo_root / "tools/wheel_builder/gen_uv_overrides.py"), - str(repo_root / "pyproject.toml"), + str(source_checkout_root / "tools/wheel_builder/gen_uv_overrides.py"), + str(source_checkout_root / "pyproject.toml"), str(output), ], check=True, @@ -71,26 +61,26 @@ def _generate_uv_overrides(tmp_path: Path) -> list[str]: return output.read_text(encoding="utf-8").splitlines() -def test_wheel_builder_drops_workspace_members(tmp_path): +def test_wheel_builder_drops_workspace_members(source_checkout_root: Path, tmp_path): """The generated wheel metadata must not depend on the bundled ``isaaclab*`` packages.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) dependencies = generated["project"]["dependencies"] assert not [dep for dep in dependencies if dep.lower().startswith("isaaclab")] -def test_wheel_builder_includes_template_generator_dependencies(tmp_path): +def test_wheel_builder_includes_template_generator_dependencies(source_checkout_root: Path, tmp_path): """The generated wheel must install everything required by the project generator.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) dependencies = set(generated["project"]["dependencies"]) assert {"Jinja2", "rich"} <= dependencies assert "InquirerPy" not in dependencies -def test_wheel_console_delegates_to_the_full_isaaclab_cli(): +def test_wheel_console_delegates_to_the_full_isaaclab_cli(source_checkout_root: Path): """The wheel console command must expose the same workflows as a source installation.""" - module_path = _repo_root() / "tools" / "wheel_builder" / "res" / "__main__.py" + module_path = source_checkout_root / "source" / "isaaclab" / "isaaclab" / "__main__.py" spec = util.spec_from_file_location("_isaaclab_wheel_main", module_path) assert spec is not None assert spec.loader is not None @@ -103,20 +93,27 @@ def test_wheel_console_delegates_to_the_full_isaaclab_cli(): cli.assert_called_once_with() -def test_wheel_builder_includes_isaacsim_extra(tmp_path): +def test_wheel_console_uses_compatibility_dispatcher(source_checkout_root: Path, tmp_path): + """The generated console script must preserve legacy installed-wheel options.""" + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) + + assert generated["project"]["scripts"]["isaaclab"] == "isaaclab.__main__:main" + + +def test_wheel_builder_includes_isaacsim_extra(source_checkout_root: Path, tmp_path): """The ``isaacsim`` extra must ship in the generated wheel metadata.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) optional_dependencies = generated["project"]["optional-dependencies"] assert "isaacsim" in optional_dependencies assert any(dep.startswith("isaacsim[") for dep in optional_dependencies["isaacsim"]) -def test_wheel_builder_omits_integrations_without_published_wheels(tmp_path): +def test_wheel_builder_omits_integrations_without_published_wheels(source_checkout_root: Path, tmp_path): """Git-only RL-Games and Robomimic requirements must stay out of published metadata.""" - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: source_project = tomllib.load(f)["project"] - generated_project = _generate_wheel_pyproject(tmp_path)["project"] + generated_project = _generate_wheel_pyproject(source_checkout_root, tmp_path)["project"] # Source checkouts retain the integrations through their Git requirements. assert any(dep.startswith("rl-games @ git+") for dep in source_project["optional-dependencies"]["rl-games"]) @@ -131,9 +128,9 @@ def test_wheel_builder_omits_integrations_without_published_wheels(tmp_path): assert not any(dep.startswith("robomimic") for dep in generated_requirements) -def test_wheel_builder_keeps_standalone_importers_explicit(tmp_path): +def test_wheel_builder_keeps_standalone_importers_explicit(source_checkout_root: Path, tmp_path): """The wheel must expose standalone importers only through their explicit extra.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) project = generated["project"] assert "isaacsim-asset-isolated==6.1.0.0" not in project["dependencies"] @@ -144,9 +141,9 @@ def test_wheel_builder_keeps_standalone_importers_explicit(tmp_path): ] -def test_wheel_builder_expands_all_extra_into_concrete_requirements(tmp_path): +def test_wheel_builder_expands_all_extra_into_concrete_requirements(source_checkout_root: Path, tmp_path): """``isaaclab[all]`` must contain concrete curated requirements.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) optional_dependencies = generated["project"]["optional-dependencies"] all_extra = optional_dependencies["all"] @@ -168,10 +165,10 @@ def test_wheel_builder_expands_all_extra_into_concrete_requirements(tmp_path): assert not any(dep.startswith(prefix) for dep in all_extra), f"'{prefix}' must not be in the 'all' extra" -def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(tmp_path): +def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(source_checkout_root: Path, tmp_path): """The bundled wheel metadata must install the RSL-RL version declared at the root.""" - expected_pin = _root_rsl_rl_pin() - generated = _generate_wheel_pyproject(tmp_path) + expected_pin = _root_rsl_rl_pin(source_checkout_root) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) # RSL-RL is a core dependency (default training library) and also exposed as an extra. core_pins = [dep for dep in generated["project"]["dependencies"] if dep.startswith("rsl-rl-lib==")] @@ -183,9 +180,9 @@ def test_wheel_builder_rsl_rl_pin_matches_root_pyproject(tmp_path): assert rsl_rl_pins == [expected_pin] -def test_wheel_builder_keeps_tetrahedralization_explicit(tmp_path): +def test_wheel_builder_keeps_tetrahedralization_explicit(source_checkout_root: Path, tmp_path): """The generated wheel must expose PyTetWild only through its explicit extra.""" - generated = _generate_wheel_pyproject(tmp_path) + generated = _generate_wheel_pyproject(source_checkout_root, tmp_path) project = generated["project"] optional_dependencies = project["optional-dependencies"] @@ -197,17 +194,17 @@ def test_wheel_builder_keeps_tetrahedralization_explicit(tmp_path): assert not any(dep.startswith("pytetwild") for dep in deps) -def test_wheel_builder_uv_overrides_match_root_pyproject(tmp_path): +def test_wheel_builder_uv_overrides_match_root_pyproject(source_checkout_root: Path, tmp_path): """The wheel resolver override file must mirror the root uv overrides exactly.""" - with (_repo_root() / "pyproject.toml").open("rb") as f: + with (source_checkout_root / "pyproject.toml").open("rb") as f: root = tomllib.load(f) - generated_overrides = _generate_uv_overrides(tmp_path) + generated_overrides = _generate_uv_overrides(source_checkout_root, tmp_path) published_overrides = ( - (_repo_root() / "tools" / "wheel_builder" / "uv-overrides.txt").read_text(encoding="utf-8").splitlines() + (source_checkout_root / "tools" / "wheel_builder" / "uv-overrides.txt").read_text(encoding="utf-8").splitlines() ) install_ci_overrides = ( - (_repo_root() / "source" / "isaaclab" / "test" / "install_ci" / "uv_pip" / "uv-overrides.txt") + (source_checkout_root / "source" / "isaaclab" / "test" / "install_ci" / "uv_pip" / "uv-overrides.txt") .read_text(encoding="utf-8") .splitlines() ) diff --git a/source/isaaclab/test/cloner/test_clone_plan_algebra.py b/source/isaaclab/test/cloner/test_clone_plan_algebra.py index 3794e964dbd9..3161591496eb 100644 --- a/source/isaaclab/test/cloner/test_clone_plan_algebra.py +++ b/source/isaaclab/test/cloner/test_clone_plan_algebra.py @@ -6,15 +6,15 @@ """Tests for the cloner path/query algebra. These exercise :mod:`isaaclab.cloner.path` and :mod:`isaaclab.cloner.query`, which are pure -string/tensor operations over a :class:`~isaaclab.cloner.ClonePlan`. They need no stage, no +string/array operations over a :class:`~isaaclab.cloner.ClonePlan`. They need no stage, no simulator and no USD, so they live outside ``test/sim/``. """ import subprocess import sys +import numpy as np import pytest -import torch from isaaclab import cloner from isaaclab.cloner import ClonePlan @@ -149,7 +149,7 @@ def _plan(sources, destinations, mask) -> ClonePlan: return ClonePlan( sources=tuple(sources), destinations=tuple(destinations), - clone_mask=torch.tensor(mask, dtype=torch.bool), + clone_mask=np.asarray(mask, dtype=np.bool_), ) @@ -160,7 +160,7 @@ def _robot_plan(mask_row=(True, True, True, True)) -> ClonePlan: def _wide_env_id_plan() -> ClonePlan: """Two variants of one asset over 12 envs, the second starting at a two-digit env id.""" - mask = torch.zeros((2, 12), dtype=torch.bool) + mask = np.zeros((2, 12), dtype=np.bool_) mask[0, :10] = True mask[1, 10:] = True return ClonePlan( @@ -497,8 +497,8 @@ def test_query_translates_env_ids_through_the_plan(): plan = ClonePlan( sources=("/World/envs/env_2/Robot",), destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.tensor([[True, True]], dtype=torch.bool), - env_ids=torch.tensor([2, 5], dtype=torch.long), + clone_mask=np.asarray([[True, True]], dtype=np.bool_), + env_ids=np.asarray([2, 5], dtype=np.int64), ) path = "/World/envs/env_2/Robot/base" @@ -512,21 +512,6 @@ def test_query_translates_env_ids_through_the_plan(): assert source + suffix == path -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is unavailable") -def test_query_gathers_env_ids_across_devices(): - """Environment ids can remain on CPU when the clone mask is on CUDA.""" - plan = ClonePlan( - sources=("/World/envs/env_2/Robot",), - destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.tensor([[True, False, True]], dtype=torch.bool, device="cuda"), - env_ids=torch.tensor([2, 5, 8], dtype=torch.long), - ) - path = "/World/envs/env_2/Robot/base" - - assert cloner.query.path_env_ids(plan, path) == (2, 8) - assert next(iter(cloner.query.iter_sources(plan, "/World/envs/env_[^/]+/Robot")))[3] == (2, 8) - - @pytest.mark.parametrize("env_id", [-1, 4, 99]) def test_query_rejects_env_ids_outside_the_plan(env_id): """Out-of-range and negative ids resolve to nothing instead of wrapping the mask.""" @@ -557,7 +542,7 @@ def test_query_agrees_across_duplicate_source_rows(): def test_env_0_plan_defaults_to_no_global_paths(): - plan = cloner.clone_plan_from_env_0("/World/envs/env_0", "/World/envs/env_{}", 2, "cpu") + plan = cloner.clone_plan_from_env_0("/World/envs/env_0", "/World/envs/env_{}", 2) assert plan.global_paths == () @@ -581,7 +566,10 @@ def test_cloner_imports_without_kit(): import ``isaaclab.sim``, so this guards both against an import cycle and against pulling pxr in before Kit boots, which corrupts Kit's own USD runtime. """ - probe = "import isaaclab.cloner, sys; print(any(n == 'pxr' or n.startswith('pxr.') for n in sys.modules))" + probe = ( + "from isaaclab.cloner import ClonePlan; import sys; " + "print(any(n == 'pxr' or n.startswith('pxr.') for n in sys.modules))" + ) result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) assert result.returncode == 0, result.stderr diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 638a0ed86fa8..75cb28936f8d 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -3,74 +3,162 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for clone-context dispatch without a simulator runtime.""" +"""Tests for clone-plan routing and dispatch without a simulator runtime.""" from types import SimpleNamespace +import numpy as np import pytest -import torch +import isaaclab.cloner.clone_plan as clone_plan import isaaclab.cloner.replicate_session as replicate_session -from isaaclab.cloner import ClonePlan -from isaaclab.sensors import SensorBaseCfg +from isaaclab.cloner import ClonePlan, UsdReplicateContext, make_clone_plan from isaaclab.sim import SimulationContext -def test_sensor_default_does_not_request_a_cloning_context(): - """Sensors rely on automatic Kit replication unless a user explicitly overrides it.""" +class _Context: + replicate_priority = 0 - assert SensorBaseCfg().cloning_contexts == () + def __init__(self, calls): + self.calls = calls + def replicate(self, plan): + self.calls.append((type(self), plan)) -@pytest.mark.parametrize( - ("kit_available", "explicit_request", "expected_instances"), - [(False, False, 0), (False, True, 1), (True, False, 1)], -) -def test_replicate_distinguishes_automatic_and_explicit_usd_contexts( - monkeypatch, kit_available, explicit_request, expected_instances -): - """Kit gates automatic USD cloning without overriding an explicit cfg request.""" - class FakeUsdContext: - replicate_priority = 100 - instances: list["FakeUsdContext"] = [] +def _plan(*context_types): + return ClonePlan( + sources=("/World/envs/env_0",), + destinations=("/World/envs/env_{}",), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), + context_rows={context_type: (0,) for context_type in context_types}, + ) + + +def test_make_clone_plan_routes_default_and_explicit_contexts(monkeypatch): + """Planning records the rows consumed by default and explicit clone contexts.""" + calls = [] + + class Unrelated(_Context): + pass + + simulation = SimpleNamespace( + physics_manager=SimpleNamespace(clone_context_type=_Context), + _backend_registry={_Context: _Context(calls), Unrelated: Unrelated(calls)}, + stage=object(), + ) + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + monkeypatch.setattr(clone_plan, "has_kit", lambda: False) + cfg = SimpleNamespace( + prim_path="/World/envs/env_[^/]+/Robot", spawn=SimpleNamespace(spawn_path=None), cloning_contexts=None + ) + + plan = make_clone_plan((cfg,), 2, 1.0) - def __init__(self, stage, *, global_paths): - self.global_paths = global_paths - FakeUsdContext.instances.append(self) + assert plan.context_rows == {_Context: (0,)} - def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): - pass + class Explicit(_Context): + pass - def replicate(self): - pass + cfg.cloning_contexts = (Explicit,) + assert make_clone_plan((cfg,), 2, 1.0).context_rows == {Explicit: (0,)} - published = SimpleNamespace(plan=None) - published.set_clone_plan = lambda plan: setattr(published, "plan", plan) - monkeypatch.setattr(replicate_session, "UsdReplicateContext", FakeUsdContext) - monkeypatch.setattr(replicate_session, "has_kit", lambda: kit_available) - monkeypatch.setattr(replicate_session.FactoryBase, "_get_backend", lambda: "newton") - monkeypatch.setattr(SimulationContext, "instance", lambda: published) +def test_queue_collects_only_before_plan_publication(monkeypatch): + """Post-construction planning ignores cfgs built after a plan is active.""" + cfg = object() + plan = _plan() + published = None + simulation = SimpleNamespace(get_clone_plan=lambda: published) + replicate_session.REPLICATION_QUEUE.clear() + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + + replicate_session.queue_replication(cfg) + assert [cfg] == replicate_session.REPLICATION_QUEUE + + published = plan + replicate_session.queue_replication(object()) + assert [cfg] == replicate_session.REPLICATION_QUEUE + + +@pytest.mark.parametrize("valid_set", [np.asarray([["0"]]), np.asarray([[0 + 1j]])]) +def test_make_clone_plan_rejects_non_integer_combinations(valid_set): + """Prototype indices must be integer data rather than values NumPy can coerce to integers.""" cfg = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Robot", - cloning_contexts=(FakeUsdContext,) if explicit_request else (), - spawn=object(), + prim_path="/World/envs/env_[^/]+/Robot", spawn=SimpleNamespace(spawn_path=None), cloning_contexts=None + ) + + with pytest.raises(ValueError, match="integer prototype indices"): + make_clone_plan((cfg,), 2, 1.0, valid_set=valid_set) + + +def test_grid_transforms_always_returns_float32(): + """NumPy scalar inputs do not widen the public transform arrays.""" + positions, orientations = clone_plan.grid_transforms(2, np.float64(1.0)) + + assert positions.dtype == orientations.dtype == np.float32 + + +def test_replicate_dispatches_the_same_plan_in_priority_order(monkeypatch): + """Registered contexts receive one shared plan in backend priority order.""" + calls = [] + + class Late(_Context): + replicate_priority = 1 + + class Early(_Context): + replicate_priority = -1 + + plan = _plan(Late, Early) + published = [] + simulation = SimpleNamespace( + physics_manager=SimpleNamespace(clone_context_type=Late), + _backend_registry={Late: Late(calls), Early: Early(calls)}, + get_clone_plan=lambda: None, + set_clone_plan=published.append, ) - replicate_session.REPLICATION_QUEUE.append(cfg) - plan = ClonePlan( - sources=("/World/envs/env_0/Robot",), - destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2, dtype=torch.long), - positions=torch.zeros((2, 3)), - cfg_rows={id(cfg): (0,)}, - global_paths=("/World/Ground", "/World/Light"), + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + + replicate_session.replicate(plan) + + assert calls == [(Early, plan), (Late, plan)] + assert published == [plan] + + +def test_replicate_physics_false_runs_only_usd(monkeypatch): + """Disabling physics replication preserves only USD authoring.""" + calls = [] + + class Physics(_Context): + pass + + class Usd(_Context): + pass + + plan = _plan(Physics, UsdReplicateContext) + simulation = SimpleNamespace( + physics_manager=SimpleNamespace(clone_context_type=Physics), + _backend_registry={UsdReplicateContext: Usd(calls)}, + get_clone_plan=lambda: plan, ) + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) - replicate_session.replicate(plan, stage=object()) + replicate_session.replicate(plan, replicate_physics=False) + + assert calls == [(Usd, plan)] + + +def test_replicate_rejects_unregistered_context(monkeypatch): + """A routed context must register before dispatch rather than using a fallback.""" + plan = _plan(_Context) + simulation = SimpleNamespace( + physics_manager=SimpleNamespace(clone_context_type=_Context), + _backend_registry={}, + get_clone_plan=lambda: plan, + ) + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) - assert len(FakeUsdContext.instances) == expected_instances - if FakeUsdContext.instances: - assert FakeUsdContext.instances[0].global_paths == ("/World/Ground", "/World/Light") - assert published.plan is plan + with pytest.raises(RuntimeError, match="must be registered"): + replicate_session.replicate(plan) diff --git a/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py b/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py index b9dedcd7f7a5..6b0b5113fbb9 100644 --- a/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py +++ b/source/isaaclab/test/cloner/test_usd_replicate_ancestors.py @@ -5,7 +5,7 @@ """Tests for ancestor authoring in :func:`~isaaclab.cloner.usd_replicate`.""" -import torch +import numpy as np from pxr import Sdf, Usd @@ -28,7 +28,7 @@ def test_usd_replicate_defines_nested_destination_ancestors(): stage, sources=["/World/envs/env_0/Groceries/Object"], destinations=["/World/envs/env_{}/Groceries/Object"], - env_ids=torch.tensor([0, 1]), + env_ids=np.asarray([0, 1], dtype=np.int64), ) copied_scope = stage.GetPrimAtPath("/World/envs/env_1/Groceries") @@ -47,7 +47,7 @@ def test_usd_replicate_keeps_existing_ancestor_specs(): stage, sources=["/World/envs/env_0/Groceries/Object"], destinations=["/World/envs/env_{}/Groceries/Object"], - env_ids=torch.tensor([0, 1]), + env_ids=np.asarray([0, 1], dtype=np.int64), ) scope = stage.GetPrimAtPath("/World/envs/env_1/Groceries") diff --git a/source/isaaclab/test/cloner/test_usd_replicate_relationships.py b/source/isaaclab/test/cloner/test_usd_replicate_relationships.py index a2286e59209f..6efc7dcb9ad5 100644 --- a/source/isaaclab/test/cloner/test_usd_replicate_relationships.py +++ b/source/isaaclab/test/cloner/test_usd_replicate_relationships.py @@ -8,7 +8,7 @@ import ast from pathlib import Path -import torch +import numpy as np from pxr import Sdf, Usd @@ -38,7 +38,7 @@ def test_asset_clone_uses_native_relationship_path_mapping() -> None: stage, sources=["/World/envs/env_0/Robot"], destinations=["/World/envs/env_{}/Robot"], - env_ids=torch.tensor([0, 2]), + env_ids=np.asarray([0, 2], dtype=np.int64), ) cloned_binding = layer.GetRelationshipAtPath("/World/envs/env_2/Robot/source.material:binding") diff --git a/source/isaaclab/test/conftest.py b/source/isaaclab/test/conftest.py new file mode 100644 index 000000000000..d40a77be87e4 --- /dev/null +++ b/source/isaaclab/test/conftest.py @@ -0,0 +1,33 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Shared fixtures for Isaac Lab tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _find_source_checkout_root() -> Path | None: + """Find the Isaac Lab source checkout containing this test tree, if present.""" + for parent in Path(__file__).resolve().parents: + if ( + (parent / "isaaclab.sh").is_file() + and (parent / "pyproject.toml").is_file() + and (parent / "source").is_dir() + ): + return parent + return None + + +@pytest.fixture(scope="session") +def source_checkout_root() -> Path: + """Return the Isaac Lab source checkout root, or skip tests from installed packages.""" + root = _find_source_checkout_root() + if root is None: + pytest.skip("test requires an Isaac Lab source checkout") + return root diff --git a/source/isaaclab/test/controllers/test_differential_ik.py b/source/isaaclab/test/controllers/test_differential_ik.py index 67f6b6732ffa..232e89878d78 100644 --- a/source/isaaclab/test/controllers/test_differential_ik.py +++ b/source/isaaclab/test/controllers/test_differential_ik.py @@ -12,6 +12,7 @@ """Rest everything follows.""" +import numpy as np import pytest import torch @@ -56,8 +57,8 @@ def sim(): # Create environment clones using Isaac Lab's cloner utilities env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = cloner.grid_transforms(num_envs, spacing=2.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = cloner.grid_transforms(num_envs, spacing=2.0) # create source prim stage.DefinePrim(env_prim_paths[0], "Xform") # clone the env xform diff --git a/source/isaaclab/test/controllers/test_operational_space.py b/source/isaaclab/test/controllers/test_operational_space.py index a7484f8d58d5..1018d3c34726 100644 --- a/source/isaaclab/test/controllers/test_operational_space.py +++ b/source/isaaclab/test/controllers/test_operational_space.py @@ -12,6 +12,9 @@ """Rest everything follows.""" +from types import SimpleNamespace + +import numpy as np import pytest import torch from flaky import flaky @@ -30,6 +33,7 @@ ## from isaaclab.envs import ManagerBasedEnv, ManagerBasedEnvCfg from isaaclab.envs.mdp.actions.actions_cfg import OperationalSpaceControllerActionCfg +from isaaclab.envs.mdp.actions.task_space_actions import OperationalSpaceControllerAction from isaaclab.managers import ObservationGroupCfg as ObsGroup from isaaclab.managers import ObservationTermCfg as ObsTerm from isaaclab.managers import SceneEntityCfg @@ -87,8 +91,8 @@ def sim(): # Create environment clones using Isaac Lab's cloner utilities env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = cloner.grid_transforms(num_envs, spacing=2.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = cloner.grid_transforms(num_envs, spacing=2.0) # create source prim stage.DefinePrim(env_prim_paths[0], "Xform") # clone the env xform @@ -860,6 +864,23 @@ def test_franka_hybrid_variable_kp_impedance(sim): ) +@pytest.mark.isaacsim_ci +def test_task_frame_conversion_preserves_absolute_target(): + """A rounded pose command must resolve to the same target through either reference frame.""" + osc_cfg = OperationalSpaceControllerCfg(target_types=["pose_abs"]) + osc = OperationalSpaceController(osc_cfg, num_envs=1, device="cpu") + target_b = torch.tensor([[0.5, -0.4, 0.6, 0.707, 0.0, 0.0, 0.707]]) + command = target_b.clone() + resolved_targets = [] + for frame in ("root", "task"): + converted_command, task_frame_pose_b = _convert_to_task_frame(osc, command, target_b, frame) + osc.set_command(converted_command, current_task_frame_pose_b=task_frame_pose_b) + resolved_targets.append(osc.desired_ee_pose_b.clone()) + + torch.testing.assert_close(resolved_targets[0], resolved_targets[1], atol=1e-6, rtol=0.0) + torch.testing.assert_close(command, target_b, atol=0.0, rtol=0.0) + + @pytest.mark.isaacsim_ci def test_franka_taskframe_pose_abs(sim): """Test absolute pose control in task frame with fixed impedance and inertial dynamics decoupling.""" @@ -1335,6 +1356,51 @@ class _FloatingBaseOscEnvCfg(ManagerBasedEnvCfg): sim: sim_utils.SimulationCfg = sim_utils.SimulationCfg(dt=0.01) +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("feedback_source", ["test_helper", "action"]) +def test_franka_velocity_feedback_matches_jacobian(sim, feedback_source): + """Both OSC callers must measure velocity at the link origin used by the Jacobian.""" + sim_context, num_envs, robot_cfg, *_ = sim + robot = Articulation(cfg=robot_cfg) + sim_context.reset() + arm_joint_ids, _ = robot.find_joints("panda_joint.*") + ee_frame_idx = robot.find_bodies("panda_hand")[0][0] + + joint_vel = torch.zeros_like(robot.data.default_joint_vel.torch) + joint_vel[:, arm_joint_ids] = torch.linspace(0.1, 0.7, len(arm_joint_ids), device=sim_context.device) + robot.write_joint_state_to_sim_index(position=robot.data.default_joint_pos.torch, velocity=joint_vel) + sim_context.step(render=False) + robot.update(sim_context.get_physics_dt()) + + # Angular motion and the hand's COM offset must expose the reference-point mismatch. + assert not torch.allclose( + robot.data.body_com_vel_w.torch[:, ee_frame_idx, :3], + robot.data.body_link_vel_w.torch[:, ee_frame_idx, :3], + atol=1e-4, + rtol=1e-4, + ) + if feedback_source == "test_helper": + states = _update_states(robot, ee_frame_idx, arm_joint_ids, sim_context, None, num_envs) + jacobian_b, _, _, _, ee_vel_b, _, _, _, _, joint_vel = states + else: + env = SimpleNamespace(scene={"robot": robot}, sim=sim_context, num_envs=num_envs, device=sim_context.device) + action_cfg = OperationalSpaceControllerActionCfg( + asset_name="robot", + joint_names=["panda_joint.*"], + body_name="panda_hand", + controller_cfg=OperationalSpaceControllerCfg(target_types=["pose_abs"]), + ) + action_term = OperationalSpaceControllerAction(action_cfg, env) + action_term._compute_ee_jacobian() + action_term._compute_ee_velocity() + jacobian_b, ee_vel_b = action_term._jacobian_b, action_term._ee_vel_b + joint_vel = robot.data.joint_vel.torch[:, arm_joint_ids] + + # With a stationary fixed base, the link twist must equal J(q) * q_dot. + expected_vel_b = torch.bmm(jacobian_b, joint_vel.unsqueeze(-1)).squeeze(-1) + torch.testing.assert_close(ee_vel_b, expected_vel_b, atol=1e-4, rtol=1e-4) + + @pytest.mark.isaacsim_ci def test_floating_base_osc_action_term_indexing(): """Regression test for #4999 / PR #5107: verify OperationalSpaceControllerAction uses correct @@ -1634,9 +1700,9 @@ def _update_states( ) ee_pose_b = torch.cat([ee_pos_b, ee_quat_b], dim=-1) - # Compute the current velocity of the end-effector - ee_vel_w = robot.data.body_vel_w.torch[:, ee_frame_idx, :] # Extract end-effector velocity in the world frame - root_vel_w = robot.data.root_vel_w.torch # Extract root velocity in the world frame + # Match the link-origin reference point used by the pose and Jacobian. + ee_vel_w = robot.data.body_link_vel_w.torch[:, ee_frame_idx, :] + root_vel_w = robot.data.root_link_vel_w.torch relative_vel_w = ee_vel_w - root_vel_w # Compute the relative velocity in the world frame ee_lin_vel_b = quat_apply_inverse(robot.data.root_quat_w.torch, relative_vel_w[:, 0:3]) # From world to root frame ee_ang_vel_b = quat_apply_inverse(robot.data.root_quat_w.torch, relative_vel_w[:, 3:6]) @@ -1753,6 +1819,8 @@ def _convert_to_task_frame( # Convert target commands from base to the task frame command = command.clone() task_frame_pose_b = ee_target_pose_b.clone() + # Rounded goal quaternions must define a unit rotation when used as a reference frame. + task_frame_pose_b[:, 3:] /= torch.linalg.vector_norm(task_frame_pose_b[:, 3:], dim=-1, keepdim=True) cmd_idx = 0 for target_type in osc.cfg.target_types: diff --git a/source/isaaclab/test/envs/test_video_recorder.py b/source/isaaclab/test/envs/test_video_recorder.py index 3b0192f4b70d..83f64ced7948 100644 --- a/source/isaaclab/test/envs/test_video_recorder.py +++ b/source/isaaclab/test/envs/test_video_recorder.py @@ -229,6 +229,31 @@ def test_visualizer_source_auto_picks_first_with_render_rgb_array(): assert viz.render_calls == 1 +def test_visualizer_source_refreshes_physics_before_on_demand_capture(): + """On-demand capture reads a frame after physics transforms are synchronized.""" + synchronized = False + + class _FreshFrameViz(_FakeViz): + def render_rgb_array(self) -> np.ndarray: + return np.full_like(self._frame, 255 if synchronized else 0) + + viz = _FreshFrameViz("kit") + env = _make_env(visualizers=[viz]) + env.sim.is_rendering = False + + def synchronize_physics() -> None: + nonlocal synchronized + synchronized = True + + env.sim.forward.side_effect = synchronize_physics + recorder = VideoRecorder(_cfg(source="visualizer:kit"), env) + + frame = recorder._get_frame() + + assert frame is not None + assert np.all(frame == 255) + + def test_visualizer_source_auto_no_visualizer_logs_and_returns_none(caplog): """source='visualizer' with no visualizers logs an error once and returns None instead of raising.""" import logging diff --git a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py index 59b56d3d1f7b..b931ceccaffc 100644 --- a/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py +++ b/source/isaaclab/test/install_ci/misc/test_wheel_builder_smoke.py @@ -5,12 +5,13 @@ """ Setup: - - bash tools/wheel_builder/build.sh + - uv build --wheel tools/wheel_builder --out-dir tools/wheel_builder/build/dist - ./isaaclab.sh -u - uv pip install [sb3,skrl,rsl-rl] Tests: - import isaaclab -> verify importable - from isaaclab import __version__ -> verify version matches wheel filename + - inspect the wheel and isaaclab.__path__ -> verify the core package has a flat layout - from isaaclab import _deprioritize_prebundle_paths -> verify wheel exports path sanitizer - from isaaclab.app import AppLauncher -> verify importable - from isaaclab.envs import VideoRecorderCfg -> verify importable @@ -27,6 +28,7 @@ import glob import shutil +import zipfile import pytest from utils import UV_Mixin, run_cmd @@ -49,13 +51,20 @@ def _build_and_install_wheel(self, isaaclab_root): """Build the wheel and install it in a uv environment once for all tests.""" cls = self.__class__ - build_script = isaaclab_root / "tools" / "wheel_builder" / "build.sh" - dist_dir = isaaclab_root / "tools" / "wheel_builder" / "build" / "dist" + builder_dir = isaaclab_root / "tools" / "wheel_builder" + dist_dir = builder_dir / "build" / "dist" + shutil.rmtree(dist_dir, ignore_errors=True) + dist_dir.mkdir(parents=True) - # Build the wheel (capture output silently to avoid spamming the test log with 10k+ + # Build through the PEP 517 entry point used by Git-source consumers. Capture output + # silently to avoid spamming the test log with 10k+ # setuptools/pip lines; the captured output is included in the assertion if it fails). - result = run_cmd(["bash", str(build_script)], cwd=isaaclab_root, stream=False) - assert result.returncode == 0, f"build.sh failed:\n{result.stdout}\n{result.stderr}" + result = run_cmd( + ["uv", "build", "--wheel", str(builder_dir), "--out-dir", str(dist_dir)], + cwd=isaaclab_root, + stream=False, + ) + assert result.returncode == 0, f"PEP 517 wheel build failed:\n{result.stdout}\n{result.stderr}" # Find the built wheel wheels = glob.glob(str(dist_dir / "isaaclab-*.whl")) @@ -92,6 +101,27 @@ def test_isaaclab_version_matches_wheel(self): f"isaaclab.__version__ mismatch: expected {expected_version}, got {imported_version}" ) + def test_isaaclab_package_has_flat_layout(self): + """Verify core modules are installed directly under the top-level package.""" + with zipfile.ZipFile(self._wheel) as wheel: + names = set(wheel.namelist()) + + assert "isaaclab/app/__init__.py" in names + assert "isaaclab/apps/isaaclab.python.kit" in names + nested_prefix = "isaaclab/source/isaaclab/isaaclab/" + assert not any(name.startswith(nested_prefix) for name in names) + + result = self.run_in_uv_env( + [ + "python", + "-c", + "import isaaclab; " + "from pathlib import Path; " + "assert list(isaaclab.__path__) == [str(Path(isaaclab.__file__).parent)]", + ] + ) + assert result.returncode == 0, f"isaaclab has multiple package roots:\n{result.stdout}\n{result.stderr}" + # from isaaclab import _deprioritize_prebundle_paths def test_isaaclab_prebundle_path_sanitizer_exported(self): """Verify the wheel exports the prebundle path sanitizer used by AppLauncher.""" diff --git a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py index 705006c35ed9..e821e5d5c53f 100644 --- a/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py +++ b/source/isaaclab/test/install_ci/uv_pip/test_uv_pip_install_isaaclab_all_trains_cartpole.py @@ -15,8 +15,8 @@ Reinstall AFTER the wheel install: unsafe-best-match re-resolves torch from PyPI to CPU.) - (aarch64 only) export LD_PRELOAD=/lib/aarch64-linux-gnu/libgomp.so.1 Tests: - - python -c "import importlib.metadata as m; assert m.version('newton') == '1.5.2'" - -> verify the wheel resolves Newton 1.5 + - python -c "import importlib.metadata as m; assert m.version('newton') == '1.6.0'" + -> verify the wheel resolves the pinned Newton release - uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole-Direct --num_envs 16 presets=newton_mjwarp --max_iterations 5; uv run isaaclab train --rl_library rsl_rl --task Isaac-Cartpole-Camera-Direct --num_envs 16 presets=newton_mjwarp,newton_renderer --max_iterations 2 @@ -57,11 +57,11 @@ def test_uv_pip_install_isaaclab_all_trains_cartpole(self, isaaclab_root, wheel, assert result.returncode == 0, f"uv pip install {wheel}[all] failed:\n{result.stdout}\n{result.stderr}" result = self.run_in_uv_env( - ["python", "-c", "import importlib.metadata as m; assert m.version('newton') == '1.5.2'"], + ["python", "-c", "import importlib.metadata as m; assert m.version('newton') == '1.6.0'"], cwd=isaaclab_root, ) assert result.returncode == 0, ( - f"isaaclab[all] did not resolve Newton 1.5:\n{result.stdout}\n{result.stderr}" + f"isaaclab[all] did not resolve the pinned Newton build:\n{result.stdout}\n{result.stderr}" ) # Restore the CUDA build selected for this architecture. diff --git a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt index c742fe29cabf..a495835d3dd7 100644 --- a/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt +++ b/source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt @@ -1,9 +1,6 @@ numpy>=2 -mujoco~=3.11.0 -mujoco-warp~=3.11.0 -newton[sim]==1.5.2 +mujoco~=3.12.0 +mujoco-warp~=3.12.0 +newton[sim]==1.6.0 newton-usd-schemas>=0.4.1 -torch==2.11.0 -torchvision==0.26.0 -torchaudio==2.11.0 packaging>=20,<27 diff --git a/source/isaaclab/test/performance/test_robot_load_performance.py b/source/isaaclab/test/performance/test_robot_load_performance.py index a1eb8b49c8ea..d436d82e6e3e 100644 --- a/source/isaaclab/test/performance/test_robot_load_performance.py +++ b/source/isaaclab/test/performance/test_robot_load_performance.py @@ -13,8 +13,8 @@ # launch omniverse app simulation_app = AppLauncher(headless=True).app +import numpy as np import pytest -import torch from isaaclab_physx.cloner import physx_replicate import isaaclab.sim.utils as sim_utils @@ -49,7 +49,7 @@ def test_robot_load_performance(test_config, device): stage = sim_utils.get_current_stage() # Generate grid positions for environments - positions, _ = cloner.grid_transforms(NUM_ENVS, SPACING, device=device) + positions, _ = cloner.grid_transforms(NUM_ENVS, SPACING) # Create environment prims using USD replicate env_paths = [f"/World/Robots_{i}" for i in range(NUM_ENVS)] @@ -58,7 +58,7 @@ def test_robot_load_performance(test_config, device): stage=stage, sources=[env_paths[0]], destinations=["/World/Robots_{}"], - env_ids=torch.arange(NUM_ENVS), + env_ids=np.arange(NUM_ENVS, dtype=np.int64), positions=positions, ) @@ -67,9 +67,8 @@ def test_robot_load_performance(test_config, device): stage=stage, sources=[env_paths[0]], destinations=["/World/Robots_{}"], - env_ids=torch.arange(NUM_ENVS), - mapping=torch.ones(1, NUM_ENVS, dtype=torch.bool), # 1 source -> all envs - device=device, + env_ids=np.arange(NUM_ENVS, dtype=np.int64), + mapping=np.ones((1, NUM_ENVS), dtype=np.bool_), # 1 source -> all envs ) with Timer(f"{test_config['name']} load time for device {device}") as timer: diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 7aff3c4c9099..eed7bd67dd1f 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -7,10 +7,12 @@ from __future__ import annotations +import re from typing import Any, cast import pytest +from isaaclab.renderers import render_context from isaaclab.renderers.base_renderer import BaseRenderer from isaaclab.renderers.output_contract import RenderBufferKind, RenderBufferSpec from isaaclab.renderers.render_context import RenderContext @@ -207,6 +209,38 @@ def test_render_into_camera_calls_update_render_read_order(): assert events == ["ut", "geo", "render", "read", "render", "read"] +def test_render_into_camera_call_order_unaffected_by_render_profile_flag(monkeypatch): + """Enabling ``ISAACLAB_RENDER_PROFILE`` only wraps the render call in a printed timer. + + The wrapping must not change the call order or drop the render call itself. + """ + monkeypatch.setattr(render_context, "_RENDER_PROFILE_ENABLED", True) + + ctx = RenderContext() + events: list[str] = [] + cfg = IsaacRtxRendererCfg() + fake = _FakeBackend(event_log=events) + _set_entries(ctx, (cfg, fake)) + + ctx.render_into_camera(cast(BaseRenderer, fake), object(), CameraData(), physics_step_count=1) + + assert events == ["ut", "geo", "render", "read"] + + +def test_render_into_camera_prints_timing_line_when_render_profile_enabled(monkeypatch, capsys): + """The printed line must match the format ``scripts/benchmarks/benchmark_renderer.py`` parses.""" + monkeypatch.setattr(render_context, "_RENDER_PROFILE_ENABLED", True) + + ctx = RenderContext() + cfg = IsaacRtxRendererCfg() + fake = _FakeBackend() + _set_entries(ctx, (cfg, fake)) + + ctx.render_into_camera(cast(BaseRenderer, fake), object(), CameraData(), physics_step_count=1) + + assert re.search(rf"{re.escape(render_context.RENDER_PROFILE_SCOPE)} took [\d.]+ ms", capsys.readouterr().out) + + def test_reset_stage_prepare_flag_allows_second_prepare_stage(): """After reset_stage_prepare_flag, ensure_prepare_stage invokes prepare_stage again.""" ctx = RenderContext() diff --git a/source/isaaclab/test/renderers/test_visual_material_pipeline.py b/source/isaaclab/test/renderers/test_visual_material_pipeline.py index 48ff07f6703b..ec65c4cb47ec 100644 --- a/source/isaaclab/test/renderers/test_visual_material_pipeline.py +++ b/source/isaaclab/test/renderers/test_visual_material_pipeline.py @@ -12,6 +12,7 @@ from pathlib import Path from types import SimpleNamespace +import numpy as np import pytest import torch import warp as wp @@ -243,8 +244,8 @@ def test_material_initialization_orders_paths_by_plan_column(monkeypatch: pytest plan = ClonePlan( sources=("/World/envs/env_42/Robot", "/World/envs/env_7/Robot"), destinations=("/World/envs/env_{}/Robot",) * 2, - env_ids=torch.tensor([19, 42, 7]), - clone_mask=torch.tensor([[True, True, False], [False, False, True]]), + env_ids=np.asarray([19, 42, 7], dtype=np.int64), + clone_mask=np.asarray([[True, True, False], [False, False, True]], dtype=np.bool_), ) simulation = SimpleNamespace(get_clone_plan=lambda: plan) monkeypatch.setattr( @@ -286,8 +287,8 @@ def test_material_initialization_rejects_partial_owner_row(monkeypatch: pytest.M plan = ClonePlan( sources=("/World/envs/env_0/Robot",), destinations=("/World/envs/env_{}/Robot",), - env_ids=torch.arange(4), - clone_mask=torch.tensor([[True, True, False, False]]), + env_ids=np.arange(4, dtype=np.int64), + clone_mask=np.asarray([[True, True, False, False]], dtype=np.bool_), ) simulation = SimpleNamespace(get_clone_plan=lambda: plan) monkeypatch.setattr( diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index ce1bfcc419dd..e95643c8b0f4 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -14,10 +14,12 @@ from types import SimpleNamespace +import numpy as np import pytest import torch import isaaclab.sim as sim_utils +from isaaclab import cloner from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.cloner import CloneCfg @@ -182,8 +184,8 @@ def test_reset_to_env_ids_input_types(device, setup_scene): assert_state_equal(prev_state, scene.get_state()) -def test_scene_publishes_plan_via_replicate(monkeypatch: pytest.MonkeyPatch): - """A cfg-driven scene forwards the right plan and stage to cloner.replicate. +def test_scene_publishes_plan_before_replicate(monkeypatch: pytest.MonkeyPatch): + """A cfg-driven scene publishes the exact plan it forwards to replication. Uses a test-seam fake to isolate this unit test from real backend dispatch; queue lifecycle is owned by :func:`replicate` itself (snapshot-and-clear) and does not @@ -193,24 +195,53 @@ def test_scene_publishes_plan_via_replicate(monkeypatch: pytest.MonkeyPatch): captured: list = [] - def fake_replicate(plan, *, stage, replicate_physics=True): - captured.append((plan, stage, replicate_physics)) + def fake_replicate(plan, *, replicate_physics=True): + captured.append((plan, replicate_physics, sim_utils.SimulationContext.instance().get_clone_plan())) monkeypatch.setattr(replicate_session_module, "replicate", fake_replicate) with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: sim._app_control_on_stop_handle = None - scene = InteractiveScene(MySceneCfg(num_envs=4, env_spacing=1.0)) + InteractiveScene(MySceneCfg(num_envs=4, env_spacing=1.0)) assert len(captured) == 1 - plan, stage, replicate_physics = captured[0] + plan, replicate_physics, published = captured[0] + assert published is plan assert plan.sources == ("/World/envs/env_0",) assert plan.destinations == ("/World/envs/env_{}",) assert plan.clone_mask.shape == (1, 4) - assert stage is scene.stage assert replicate_physics is True +def test_empty_scene_leaves_clone_lifecycle_to_caller(): + """An empty scene authors one prototype and leaves its replication to the direct task.""" + with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene = InteractiveScene(InteractiveSceneCfg(num_envs=4, env_spacing=1.0)) + + assert sim.get_clone_plan() is None + env_template = scene.cfg.clone_cfg.clone_template + grid_positions = cloner.grid_transforms(4, 1.0)[0] + torch.testing.assert_close(scene.env_origins, torch.from_numpy(grid_positions)) + env_0 = scene.stage.GetPrimAtPath(env_template.format(0)) + np.testing.assert_allclose(sim_utils.resolve_prim_pose(env_0)[0], grid_positions[0]) + assert all(not scene.stage.GetPrimAtPath(env_template.format(i)).IsValid() for i in range(1, 4)) + + cube_cfg = RigidObjectCfg( + prim_path=f"{env_template.format('[^/]+')}/Cube", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + cloning_contexts=(cloner.UsdReplicateContext,), + ) + cube_cfg.class_type(cube_cfg) + positions = grid_positions + np.asarray((0.25, 0.5, 0.75), dtype=np.float32) + plan = cloner.clone_plan_from_env_0(env_template.format(0), env_template, 4, positions) + cloner.replicate(plan) + + assert sim.get_clone_plan() is plan + assert all(scene.stage.GetPrimAtPath(f"{env_template.format(i)}/Cube").IsValid() for i in range(4)) + torch.testing.assert_close(scene.env_origins, torch.from_numpy(positions)) + + @pytest.mark.parametrize("device", ["cuda:0"]) @pytest.mark.parametrize("replicate_physics", [True, False]) def test_replicate_physics_flag_controls_physx_replicator(device, replicate_physics, setup_scene, monkeypatch): @@ -261,31 +292,6 @@ def __getattr__(self, name): assert torch.isfinite(scene["robot"].data.joint_pos.torch).all() -def test_cfg_cloning_contexts_override_backend_default(monkeypatch: pytest.MonkeyPatch): - """AssetBaseCfg.cloning_contexts replaces the backend default stack for that asset.""" - import isaaclab.cloner.replicate_session as replicate_session_module - from isaaclab.cloner import REPLICATION_QUEUE - - # keep the queue for inspection: the fake drain does not clear it - monkeypatch.setattr(replicate_session_module, "replicate", lambda plan, *, stage, replicate_physics=True: None) - - with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: - sim._app_control_on_stop_handle = None - scene_cfg = MySceneCfg(num_envs=2, env_spacing=1.0) - scene_cfg.rigid_obj.cloning_contexts = ("isaaclab.cloner:UsdReplicateContext",) - try: - InteractiveScene(scene_cfg) - queued_by_path = {cfg.prim_path: cfg for cfg in REPLICATION_QUEUE} - # the override rides the queued cfg; resolution happens at replicate() - assert queued_by_path["/World/envs/env_[^/]+/RigidObj"].cloning_contexts == ( - "isaaclab.cloner:UsdReplicateContext", - ) - # untouched asset resolves to the backend default stack at replicate() - assert queued_by_path["/World/envs/env_[^/]+/Robot"].cloning_contexts is None - finally: - REPLICATION_QUEUE.clear() - - def test_collect_asset_cfgs_resolves_env_regex_macros_and_declares_globals(): """The composition root separates cloneable configs from shared prim roots.""" scene = object.__new__(InteractiveScene) @@ -307,7 +313,7 @@ def test_collect_asset_cfgs_resolves_env_regex_macros_and_declares_globals(): scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs, global_paths = scene._collect_asset_cfgs() + cfgs, global_paths, _ = scene._collect_asset_cfgs() prim_paths = sorted(c.prim_path for c in cfgs) assert prim_paths == ["/World/envs/env_[^/]+/Cube", "/World/envs/env_[^/]+/Shape"] @@ -323,7 +329,7 @@ def test_collect_asset_cfgs_excludes_entities_without_spawners(): scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs, global_paths = scene._collect_asset_cfgs() + cfgs, global_paths, _ = scene._collect_asset_cfgs() assert cfgs == [] assert global_paths == () diff --git a/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py b/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py index 68da4d7d0702..66220b2d87df 100644 --- a/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py @@ -44,6 +44,7 @@ import random +import numpy as np import torch import isaaclab.sim as sim_utils @@ -64,8 +65,8 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048): # Create interface to clone the scene # Create environment clones using Lab's cloner utilities env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=10.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=10.0) # Everything under the namespace "/World/envs/env_0" will be cloned sim.stage.DefinePrim("/World/envs/env_0", "Xform") # Define the scene @@ -109,7 +110,7 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048): lab_cloner.ClonePlan( sources=(env_fmt.format(0),), destinations=(env_fmt,), - clone_mask=torch.ones((1, num_envs), dtype=torch.bool, device=sim.device), + clone_mask=np.ones((1, num_envs), dtype=np.bool_), ) ) # PhysX-only optimization: filter collisions across env clones. Skip on Newton — diff --git a/source/isaaclab/test/sensors/check_ray_caster.py b/source/isaaclab/test/sensors/check_ray_caster.py index bfde5c1d0dcb..c50f98932d16 100644 --- a/source/isaaclab/test/sensors/check_ray_caster.py +++ b/source/isaaclab/test/sensors/check_ray_caster.py @@ -39,6 +39,7 @@ """Rest everything follows.""" +import numpy as np import torch import isaaclab.sim as sim_utils @@ -58,8 +59,8 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048): # Create interface to clone the scene # Create environment clones using Lab's cloner utilities env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0) # Everything under the namespace "/World/envs/env_0" will be cloned sim.stage.DefinePrim("/World/envs/env_0", "Xform") # Define the scene diff --git a/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py b/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py index 4f062271efe3..d6ebc6c5f95e 100644 --- a/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py +++ b/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py @@ -29,7 +29,6 @@ from pxr import Gf, Sdf, Usd, UsdGeom, Vt import isaaclab.sim as sim_utils -from isaaclab import cloner from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.camera import Camera, CameraCfg @@ -734,14 +733,6 @@ def render_synthetic_gaussian_scene( renderer_cfg=renderer_cfg, ) camera = Camera(cfg) - # Camera is constructed after the scene's ReplicateSession has exited, so its - # queued USD replication needs an explicit drain (Path B). Reuse the scene's - # env positions so env_origins stays consistent. - published = sim.get_clone_plan() - positions = published.positions if published is not None else None - src, dst = "/World/envs/env_0", "/World/envs/env_{}" - camera_plan = cloner.clone_plan_from_env_0(src, dst, num_envs, str(sim.device), positions) - cloner.replicate(camera_plan, stage=sim.stage) sim.reset() for _ in range(stabilisation_steps): sim.step() diff --git a/source/isaaclab/test/sensors/test_camera.py b/source/isaaclab/test/sensors/test_camera.py index 541f9e879083..de0fb922d4e0 100644 --- a/source/isaaclab/test/sensors/test_camera.py +++ b/source/isaaclab/test/sensors/test_camera.py @@ -106,10 +106,12 @@ def test_camera_init(setup_sim_camera): """Test camera initialization.""" # Create camera configuration sim, camera_cfg, dt = setup_sim_camera + sim.set_setting("/physics/fabricUpdateTransformations", False) # Create camera camera = Camera(camera_cfg) # Check simulation parameter is set correctly assert sim.get_setting("/isaaclab/render/rtx_sensors") + assert sim.get_setting("/physics/fabricUpdateTransformations") # Play sim sim.reset() # Check if camera is initialized diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index 5b7ee1906f1e..a0da81c198f6 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -461,18 +461,18 @@ def _create_heterogeneous_clone_scene(sim: sim_utils.SimulationContext, num_envs """Create alternating Spot/ANYmal and cube/sphere cloned environments.""" stage = sim_utils.get_current_stage() env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=4.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=4.0) sim_utils.create_prim("/World/envs", "Xform", stage=stage) - for env_id, origin in enumerate(env_origins.cpu().tolist()): + for env_id, origin in enumerate(env_origins): sim_utils.create_prim(env_fmt.format(env_id), "Xform", translation=tuple(origin), stage=stage) sim_utils.create_prim(env_fmt.format(env_id) + "/RayCasterCamera", "Xform", stage=stage) - robot_mask = torch.zeros((2, num_envs), dtype=torch.bool, device=sim.device) + robot_mask = np.zeros((2, num_envs), dtype=np.bool_) robot_mask[0, 0::2] = True robot_mask[1, 1::2] = True - object_mask = robot_mask.clone() + object_mask = robot_mask.copy() spot_spawn = copy.deepcopy(SPOT_CFG.spawn) anymal_spawn = copy.deepcopy(ANYMAL_C_CFG.spawn) @@ -499,7 +499,7 @@ def _create_heterogeneous_clone_scene(sim: sim_utils.SimulationContext, num_envs [env_fmt.format(i) + f"/{asset_name}" for asset_name in ("Robot", "Object") for i in range(2)], [env_fmt + "/Robot", env_fmt + "/Robot", env_fmt + "/Object", env_fmt + "/Object"], env_ids, - mask=torch.cat([robot_mask, object_mask], dim=0), + mask=np.concatenate((robot_mask, object_mask), axis=0), ) sim.set_clone_plan( @@ -516,14 +516,14 @@ def _create_heterogeneous_clone_scene(sim: sim_utils.SimulationContext, num_envs env_fmt + "/Object", env_fmt + "/Object", ), - clone_mask=torch.cat([robot_mask, object_mask], dim=0), + clone_mask=np.concatenate((robot_mask, object_mask), axis=0), env_ids=env_ids, positions=None, cfg_rows={}, ) ) sim_utils.update_stage() - return env_origins + return torch.as_tensor(env_origins, device=sim.device) @pytest.mark.isaacsim_ci diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py index a3fa80b906da..a7a6e6ddb9e8 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_integration.py +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -359,8 +359,8 @@ def _create_object_body(path: str) -> None: ClonePlan( sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object"), destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), - clone_mask=torch.tensor([[True, False, True], [False, True, False]], dtype=torch.bool, device=sim.device), - env_ids=torch.arange(3, dtype=torch.long, device=sim.device), + clone_mask=np.asarray([[True, False, True], [False, True, False]], dtype=np.bool_), + env_ids=np.arange(3, dtype=np.int64), positions=None, cfg_rows={}, ) diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 69f97aaf73fc..e674a8611110 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -15,12 +15,12 @@ """Rest everything follows.""" from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch +import numpy as np import pytest -import torch -from pxr import Usd, UsdGeom +from pxr import Sdf, Usd, UsdGeom import isaaclab.sim as sim_utils from isaaclab import cloner @@ -31,7 +31,6 @@ grid_transforms, make_clone_plan, queue_replication, - replicate, sequential, usd_replicate, ) @@ -65,13 +64,13 @@ def test_usd_replicate_with_positions_and_mask(sim): # Prepare destination env namespaces num_envs = 3 - env_ids = torch.arange(num_envs, dtype=torch.long) + env_ids = np.arange(num_envs, dtype=np.int64) sim_utils.create_prim("/World/envs", "Xform") for i in range(num_envs): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform") # Map A -> env 0 and 2; B -> env 1 only - mask = torch.zeros((2, num_envs), dtype=torch.bool) + mask = np.zeros((2, num_envs), dtype=np.bool_) mask[0, [0, 2]] = True mask[1, [1]] = True @@ -98,34 +97,35 @@ def test_usd_replicate_with_positions_and_mask(sim): assert any(op.GetOpType() == UsdGeom.XformOp.TypeTranslate for op in ops) -def test_usd_replicate_context_queue_and_replicate(sim): - """UsdReplicateContext queues copy specs and applies them on replicate.""" - sim_utils.create_prim("/World/template", "Xform") +def test_usd_replicate_context_consumes_plan(sim): + """UsdReplicateContext consumes the same plan used by every clone backend.""" sim_utils.create_prim("/World/template/A", "Xform") sim_utils.create_prim("/World/envs", "Xform") - sim_utils.create_prim("/World/envs/env_0", "Xform") - sim_utils.create_prim("/World/envs/env_1", "Xform") stage = sim_utils.get_current_stage() ctx = UsdReplicateContext(stage) - ctx.queue_mapping( - sources=["/World/template/A"], - destinations=["/World/envs/env_{}/A"], - env_ids=torch.tensor([0, 1], dtype=torch.long), + plan = ClonePlan( + sources=("/World/template/A",), + destinations=("/World/envs/env_{}",), + clone_mask=np.asarray([[False, True]], dtype=np.bool_), + env_ids=np.asarray([10, 20], dtype=np.int64), + positions=np.asarray([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32), + context_rows={UsdReplicateContext: (0,)}, ) - assert not stage.GetPrimAtPath("/World/envs/env_1/A").IsValid() - ctx.replicate() + ctx.replicate(plan) - assert stage.GetPrimAtPath("/World/envs/env_0/A").IsValid() - assert stage.GetPrimAtPath("/World/envs/env_1/A").IsValid() + assert not stage.GetPrimAtPath("/World/envs/env_10").IsValid() + prim = stage.GetPrimAtPath("/World/envs/env_20") + assert prim.IsValid() + assert tuple(UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(0).ExtractTranslation()) == (4.0, 5.0, 6.0) def test_usd_replicate_nested_asset_preserves_local_offset_with_positions(sim): """Grid positions are authored on env roots but not on nested replicated assets.""" camera_offset = (0.57, -0.8, 0.5) num_envs = 2 - env_ids = torch.arange(num_envs, dtype=torch.long) - positions, _ = grid_transforms(num_envs, 3.0, device=sim.cfg.device) + env_ids = np.arange(num_envs, dtype=np.int64) + positions, _ = grid_transforms(num_envs, 3.0) sim_utils.create_prim("/World/envs", "Xform") sim_utils.create_prim("/World/envs/env_0", "Xform") @@ -195,7 +195,7 @@ def test_usd_replicate_depth_order_parent_child(sim): sim_utils.create_prim("/World/template/Parent/Child", "Xform") # Destinations (single env) - env_ids = torch.tensor([0, 1], dtype=torch.long) + env_ids = np.asarray([0, 1], dtype=np.int64) sim_utils.create_prim("/World/envs", "Xform") sim_utils.create_prim("/World/envs/env_0", "Xform") sim_utils.create_prim("/World/envs/env_1", "Xform") @@ -216,10 +216,6 @@ def test_usd_replicate_depth_order_parent_child(sim): def test_usd_replicate_self_copy_skips_copy_spec(sim): """usd_replicate must not call Sdf.CopySpec when source and destination paths are identical.""" - from unittest.mock import patch - - import isaaclab.cloner.usd as _cloner_mod - stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/envs", "Xform") sim_utils.create_prim("/World/envs/env_0", "Xform") @@ -228,19 +224,19 @@ def test_usd_replicate_self_copy_skips_copy_spec(sim): sim_utils.create_prim("/World/envs/env_1", "Xform") copy_calls: list[tuple[str, str]] = [] - real_copy_spec = _cloner_mod.Sdf.CopySpec + real_copy_spec = Sdf.CopySpec def capturing_copy_spec(src_layer, src_path, dst_layer, dst_path, *args): copy_calls.append((str(src_path), str(dst_path))) return real_copy_spec(src_layer, src_path, dst_layer, dst_path, *args) - with patch.object(_cloner_mod.Sdf, "CopySpec", capturing_copy_spec): + with patch.object(Sdf, "CopySpec", capturing_copy_spec): usd_replicate( stage, sources=["/World/envs/env_0"], destinations=["/World/envs/env_{}"], - env_ids=torch.tensor([0, 1], dtype=torch.long), - mask=torch.ones((1, 2), dtype=torch.bool), + env_ids=np.asarray([0, 1], dtype=np.int64), + mask=np.ones((1, 2), dtype=np.bool_), ) assert all(src != dst for src, dst in copy_calls), f"Self-copy detected in CopySpec calls: {copy_calls}" @@ -328,12 +324,12 @@ def test_make_clone_plan_homogeneous_returns_env_root_plan(sim): cube = SimpleNamespace( prim_path="/World/envs/env_[^/]+/Robot", spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + cloning_contexts=None, ) plan = make_clone_plan( cfgs=[cube], num_clones=4, env_spacing=1.0, - device=sim.cfg.device, global_paths=("/World/Ground",), ) @@ -360,9 +356,9 @@ def test_resolve_matching_prims_from_source_searches_only_plan_source(sim, monke plan = ClonePlan( sources=("/World/envs/env_0/Robot",), destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones((1, 2), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device), - positions=torch.zeros((2, 3), device=sim.cfg.device), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), ) sim.set_clone_plan(plan) @@ -397,6 +393,7 @@ def test_make_clone_plan_heterogeneous_mutates_spawn_paths(sim): """Multi-variant spawners get per-variant spawn_paths and contribute multiple plan rows.""" multi_cfg = SimpleNamespace( prim_path="/World/envs/env_[^/]+/Object", + cloning_contexts=None, spawn=sim_utils.MultiAssetSpawnerCfg( assets_cfg=[ sim_utils.ConeCfg(radius=0.1, height=0.2), @@ -407,12 +404,12 @@ def test_make_clone_plan_heterogeneous_mutates_spawn_paths(sim): plain_cfg = SimpleNamespace( prim_path="/World/envs/env_[^/]+/Robot", spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + cloning_contexts=None, ) plan = make_clone_plan( cfgs=[multi_cfg, plain_cfg], num_clones=4, env_spacing=1.0, - device=sim.cfg.device, global_paths=("/World/Ground",), clone_strategy=sequential, ) @@ -435,7 +432,6 @@ def test_make_clone_plan_records_globals_outside_replication_rows(sim): cfgs=[], num_clones=3, env_spacing=1.0, - device=sim.cfg.device, global_paths=("/World/global/Robot", "/World/ground"), ) @@ -448,266 +444,24 @@ def test_make_clone_plan_records_globals_outside_replication_rows(sim): def test_clone_plan_from_env_0_populates_cfg_rows_and_global_paths(sim): """The direct-env constructor separates replicated cfg rows from shared asset paths.""" - env_cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot") - env_cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object") - global_cfg = SimpleNamespace(prim_path="/World/global/Light") + env_cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) + env_cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=None) + global_cfg = SimpleNamespace(prim_path="/World/global/Light", spawn=None) for cfg in (env_cfg_a, env_cfg_b, global_cfg): cfg.cloning_contexts = (UsdReplicateContext,) queue_replication(cfg) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = grid_transforms(4, 1.0, device=sim.cfg.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, 4, sim.cfg.device, pos, global_paths=("/World/global/Light",)) + pos = grid_transforms(4, 1.0)[0] + plan = cloner.clone_plan_from_env_0(src, dest, 4, pos, global_paths=("/World/global/Light",)) assert plan.sources == ("/World/envs/env_0",) assert plan.destinations == ("/World/envs/env_{}",) assert plan.cfg_rows == {id(env_cfg_a): (0,), id(env_cfg_b): (0,)} assert plan.global_paths == ("/World/global/Light",) assert plan.clone_mask.all() and plan.clone_mask.shape == (1, 4) - assert torch.equal(plan.env_ids, torch.arange(4, dtype=torch.long, device=sim.cfg.device)) - - -def test_replicate_physics_false_keeps_usd_only(sim): - """replicate(replicate_physics=False) drops every context except UsdReplicateContext.""" - - class FakePhysicsCtx: - replicate_priority = 0 - instances: list["FakePhysicsCtx"] = [] - - def __init__(self, stage, *, global_paths): - FakePhysicsCtx.instances.append(self) - - def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): - pass - - def replicate(self): - pass - - stage = sim_utils.get_current_stage() - stage.DefinePrim("/World/envs/env_0/Robot", "Xform") - cfg = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Robot", - cloning_contexts=(UsdReplicateContext, FakePhysicsCtx), - spawn=None, - ) - REPLICATION_QUEUE.append(cfg) - - plan = ClonePlan( - sources=("/World/envs/env_0/Robot",), - destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones((1, 2), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device), - positions=grid_transforms(2, 1.0, device=sim.cfg.device)[0], - cfg_rows={id(cfg): (0,)}, - ) - replicate(plan, stage=stage, replicate_physics=False) - - assert FakePhysicsCtx.instances == [] - assert stage.GetPrimAtPath("/World/envs/env_1/Robot").IsValid() - - -def test_replicate_drains_queue_dispatches_and_publishes(sim): - """replicate(plan) drains REPLICATION_QUEUE, calls each backend once, publishes, clears.""" - - class FakeCtx: - replicate_priority = 0 - instances: list["FakeCtx"] = [] - - def __init__(self, stage, *, global_paths): - self.stage = stage - self.queue_calls: list[tuple] = [] - self.replicate_calls = 0 - FakeCtx.instances.append(self) - - def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): - self.queue_calls.append((tuple(sources), tuple(destinations), mask.clone())) - - def replicate(self): - self.replicate_calls += 1 - - cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) - cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=None) - cfg_a.cloning_contexts = (FakeCtx,) - cfg_b.cloning_contexts = (FakeCtx,) - REPLICATION_QUEUE.append(cfg_a) - REPLICATION_QUEUE.append(cfg_b) - - plan = ClonePlan( - sources=("/World/envs/env_0/Robot", "/World/envs/env_0/Object"), - destinations=("/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"), - clone_mask=torch.ones((2, 4), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(4, dtype=torch.long, device=sim.cfg.device), - positions=grid_transforms(4, 1.0, device=sim.cfg.device)[0], - cfg_rows={id(cfg_a): (0,), id(cfg_b): (1,)}, - ) - sim.set_clone_plan(None) - - replicate(plan, stage=sim_utils.get_current_stage()) - - # Exactly one FakeCtx instance is shared across both cfgs, dispatched once per backend - # with the union of rows the cfgs own. - assert len(FakeCtx.instances) == 1 - ctx = FakeCtx.instances[0] - assert len(ctx.queue_calls) == 1 - sources, _destinations, mask = ctx.queue_calls[0] - assert sources == ("/World/envs/env_0/Robot", "/World/envs/env_0/Object") - assert mask.shape == (2, 4) - assert ctx.replicate_calls == 1 - assert sim.get_clone_plan() is plan - assert REPLICATION_QUEUE == [] - - -def test_replicate_dedupes_shared_rows_across_cfgs(sim): - """Regression: multiple cfgs sharing the same row dispatch one mapping row, not N. - - In a homogeneous plan every cfg under the env root maps to row 0; without dedup, each - cfg would tell the backend to re-instantiate row 0 once more, multiplying the count - of articulations/rigids per world by the number of cfgs. - """ - - class FakeCtx: - replicate_priority = 0 - instances: list["FakeCtx"] = [] - - def __init__(self, stage, *, global_paths): - self.queue_calls: list[tuple] = [] - self.replicate_calls = 0 - FakeCtx.instances.append(self) - - def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): - self.queue_calls.append((tuple(sources), tuple(destinations), mask.clone())) - - def replicate(self): - self.replicate_calls += 1 - - cfgs = [SimpleNamespace(prim_path=f"/World/envs/env_[^/]+/asset_{i}", spawn=None) for i in range(5)] - for cfg in cfgs: - cfg.cloning_contexts = (FakeCtx,) - REPLICATION_QUEUE.append(cfg) - - plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 3), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(3, dtype=torch.long, device=sim.cfg.device), - positions=grid_transforms(3, 1.0, device=sim.cfg.device)[0], - cfg_rows={id(cfg): (0,) for cfg in cfgs}, - ) - - replicate(plan, stage=sim_utils.get_current_stage()) - - assert len(FakeCtx.instances) == 1 - ctx = FakeCtx.instances[0] - assert len(ctx.queue_calls) == 1, "shared rows should collapse to one queue_mapping per backend" - sources, _destinations, mask = ctx.queue_calls[0] - assert sources == ("/World/envs/env_0",) - assert mask.shape == (1, 3) - assert ctx.replicate_calls == 1 - - -def test_replicate_runs_lower_priority_backends_first(sim): - """Sort order: lower replicate_priority runs first (physics before USD).""" - - call_order: list[str] = [] - - class LowPriority: - replicate_priority = 0 - - def __init__(self, stage, *, global_paths): - pass - - def queue_mapping(self, *args, **kwargs): - pass - - def replicate(self): - call_order.append("low") - - class HighPriority: - replicate_priority = 100 - - def __init__(self, stage, *, global_paths): - pass - - def queue_mapping(self, *args, **kwargs): - pass - - def replicate(self): - call_order.append("high") - - cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) - cfg.cloning_contexts = (HighPriority, LowPriority) - REPLICATION_QUEUE.append(cfg) - - plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device), - positions=None, - cfg_rows={id(cfg): (0,)}, - ) - replicate(plan, stage=sim_utils.get_current_stage()) - - assert call_order == ["low", "high"] - - -def test_replicate_skips_cfgs_not_in_plan(sim): - """Cfgs absent from plan.cfg_rows are silently skipped.""" - sentinel = MagicMock() - sentinel.replicate_priority = 0 - sentinel.replicate.side_effect = lambda: None - sentinel_cls = MagicMock(return_value=sentinel) - - excluded_cfg = SimpleNamespace(prim_path="/World/global/Skip") - excluded_cfg.cloning_contexts = (sentinel_cls,) - REPLICATION_QUEUE.append(excluded_cfg) - - plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device), - positions=None, - cfg_rows={}, - ) - replicate(plan, stage=sim_utils.get_current_stage()) - - sentinel_cls.assert_not_called() - - -def test_replicate_clears_queue_on_backend_failure(sim): - """REPLICATION_QUEUE is drained even when a backend ctx raises mid-dispatch.""" - - class ExplodingCtx: - replicate_priority = 0 - - def __init__(self, stage, *, global_paths): - pass - - def queue_mapping(self, *args, **kwargs): - pass - - def replicate(self): - raise RuntimeError("backend boom") - - cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) - cfg.cloning_contexts = (ExplodingCtx,) - REPLICATION_QUEUE.append(cfg) - - plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool, device=sim.cfg.device), - env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device), - positions=None, - cfg_rows={id(cfg): (0,)}, - ) - - with pytest.raises(RuntimeError, match="backend boom"): - replicate(plan, stage=sim_utils.get_current_stage()) - - assert REPLICATION_QUEUE == [] + np.testing.assert_array_equal(plan.env_ids, np.arange(4, dtype=np.int64)) def test_replicate_session_clears_queue_when_asset_init_fails(sim): @@ -724,12 +478,12 @@ def test_replicate_session_clears_queue_when_asset_init_fails(sim): cfgs=[], num_clones=2, env_spacing=1.0, - device=sim.cfg.device, - stage=sim_utils.get_current_stage(), - ): + ) as session: + assert sim.get_clone_plan() is session.plan leaked_cfg.cloning_contexts = (sentinel_cls,) REPLICATION_QUEUE.append(leaked_cfg) raise RuntimeError("asset boom") assert REPLICATION_QUEUE == [] + assert sim.get_clone_plan() is None sentinel_cls.assert_not_called() diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 0d1c9120bdd9..e786c677f03a 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -17,6 +17,7 @@ from types import SimpleNamespace +import numpy as np import pytest pytestmark = pytest.mark.integration @@ -51,7 +52,7 @@ def _make_standalone_stage(): def _set_sim_context(monkeypatch, nm, clone_plan=_DEFAULT, scene_data_provider=_DEFAULT): - clone_plan = SimpleNamespace() if clone_plan is _DEFAULT else clone_plan + clone_plan = SimpleNamespace(clone_mask=np.ones((1, 1), dtype=np.bool_)) if clone_plan is _DEFAULT else clone_plan scene_data_provider = SimpleNamespace() if scene_data_provider is _DEFAULT else scene_data_provider sim = SimpleNamespace( get_clone_plan=lambda: clone_plan, @@ -419,9 +420,9 @@ def test_ensure_visualization_model_populates_num_envs_when_backend_is_physx(mon _reset_newton_manager_state() monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) - monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: _make_env_stage(num_envs=4)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: _make_env_stage()) monkeypatch.setattr(nm.PhysicsManager, "_sim", None, raising=False) - _set_sim_context(monkeypatch, nm) + _set_sim_context(monkeypatch, nm, clone_plan=SimpleNamespace(clone_mask=np.ones((1, 4), dtype=np.bool_))) monkeypatch.setattr(nm.PhysicsManager, "_device", "cpu", raising=False) builder = _make_finalize_builder(body_count=3) @@ -861,7 +862,6 @@ def test_shadow_deformable_placement_uses_parent_pose_not_root(monkeypatch): def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import(monkeypatch): """Clone-path world ``add_usd`` must ignore deformables outside ``/World/envs``.""" - import torch from isaaclab_newton.physics import visualization_builder as vb from pxr import UsdGeom @@ -878,15 +878,15 @@ def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import clone_plan = SimpleNamespace( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - env_ids=torch.tensor([0, 1], dtype=torch.int32), - clone_mask=torch.tensor([[False, False]], dtype=torch.bool), + env_ids=np.asarray([0, 1], dtype=np.int64), + clone_mask=np.asarray([[False, False]], dtype=np.bool_), + positions=np.zeros((2, 3), dtype=np.float32), ) monkeypatch.setattr(vb, "ModelBuilder", lambda up_axis="Z": fake_builder) monkeypatch.setattr(vb, "_restore_visible_colliders_without_visual_shapes", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "import_builder_visual_material_paths", lambda *args, **kwargs: None) monkeypatch.setattr(vb, "build_source_builders", lambda *args, **kwargs: {}) - monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: None) - monkeypatch.setattr(vb, "rename_builder_labels", lambda *args, **kwargs: None) + monkeypatch.setattr(vb, "replicate_builder_mapping", lambda *args, **kwargs: ({}, [], [])) _builder, (shadow_entities, registry_groups) = vb.build_visualization_builder_from_stage_envs( stage, diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 955e23047b1e..adc8480c50fe 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -927,6 +927,19 @@ def test_is_rendering_true_when_only_cfg_visualizer_is_set(): assert ctx.is_rendering is True +def test_is_rendering_false_when_only_cfg_visualizer_is_headless(): + """A capture-only headless visualizer must not trigger continuous rendering.""" + cfg_visualizer = type("CfgVisualizer", (), {"visualizer_type": "kit", "headless": True})() + settings = { + "/isaaclab/render/rtx_sensors": False, + "/isaaclab/visualizer/types": "", + "/isaaclab/visualizer/explicit": False, + "/isaaclab/visualizer/disable_all": False, + } + ctx = _make_context_with_settings(settings, visualizer_cfgs=[cfg_visualizer]) + assert ctx.is_rendering is False + + def test_is_rendering_false_when_cli_disable_all_even_with_cfg_visualizer(): cfg_visualizer = type("CfgVisualizer", (), {"visualizer_type": "newton_gl"})() settings = { diff --git a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py index 081496376092..050aa910964c 100644 --- a/source/isaaclab/test/sim/test_simulation_stage_in_memory.py +++ b/source/isaaclab/test/sim/test_simulation_stage_in_memory.py @@ -16,8 +16,8 @@ """Rest everything follows.""" +import numpy as np import pytest -import torch import omni.physx import usdrt @@ -197,8 +197,8 @@ def test_stage_in_memory_with_clone_in_fabric(sim): source_prim_path = f"{base_env_path}/env_0" # create environment clones using Isaac Lab's cloner utilities - env_ids = torch.arange(num_clones, dtype=torch.long, device="cpu") - env_origins, _ = cloner.grid_transforms(num_clones, spacing=3.0, device="cpu") + env_ids = np.arange(num_clones, dtype=np.int64) + env_origins, _ = cloner.grid_transforms(num_clones, spacing=3.0) # create source prim stage_in_memory.DefinePrim(source_prim_path, "Xform") diff --git a/source/isaaclab/test/terrains/check_terrain_importer.py b/source/isaaclab/test/terrains/check_terrain_importer.py index 4d04bab0a4d6..dbd5275eb813 100644 --- a/source/isaaclab/test/terrains/check_terrain_importer.py +++ b/source/isaaclab/test/terrains/check_terrain_importer.py @@ -62,6 +62,7 @@ """Rest everything follows.""" +import numpy as np import torch import isaaclab.sim as sim_utils @@ -87,8 +88,8 @@ def main(): # Create interface to clone the scene # Create environment clones using Lab's cloner utilities env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_balls, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_balls, spacing=2.0, device=sim.device) + env_ids = np.arange(num_balls, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_balls, spacing=2.0) # Everything under the namespace "/World/envs/env_0" will be cloned sim_utils.define_prim("/World/envs/env_0") diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index 8b0e52b15580..714b432095b5 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -53,10 +53,10 @@ def test_terrain_importer_env_origins(device, env_spacing, num_envs): terrain_importer_origins = terrain_importer.env_origins # obtain env origins using Lab's grid_transforms - lab_grid_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=env_spacing, device=sim.device) + lab_grid_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=env_spacing) # check if the env origins are the same - torch.testing.assert_close(terrain_importer_origins, lab_grid_origins, rtol=1e-5, atol=1e-5) + np.testing.assert_allclose(terrain_importer_origins.cpu().numpy(), lab_grid_origins, rtol=1e-5, atol=1e-5) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @@ -300,8 +300,8 @@ def _populate_scene(sim: SimulationContext, num_balls: int = 2048, geom_sphere: # Create environment clones using Lab's cloner utilities env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_balls, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_balls, spacing=2.0, device=sim.device) + env_ids = np.arange(num_balls, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_balls, spacing=2.0) # Everything under the namespace "/World/envs/env_0" will be cloned sim.stage.DefinePrim("/World/envs/env_0", "Xform") diff --git a/source/isaaclab/test/test_scripts_warp_backward_ordering.py b/source/isaaclab/test/test_scripts_warp_backward_ordering.py index 8fa750bcc9f0..249e35f34ce2 100644 --- a/source/isaaclab/test/test_scripts_warp_backward_ordering.py +++ b/source/isaaclab/test/test_scripts_warp_backward_ordering.py @@ -17,8 +17,6 @@ import pytest -_REPO_ROOT = Path(__file__).resolve().parents[3] - _ENTRY_SCRIPTS = [ "scripts/reinforcement_learning/train.py", "scripts/reinforcement_learning/play.py", @@ -29,10 +27,12 @@ _SETTING = "wp.config.enable_backward = False" -@pytest.mark.unit +pytestmark = pytest.mark.unit + + @pytest.mark.parametrize("script", _ENTRY_SCRIPTS) -def test_entry_script_disables_warp_backward_before_isaaclab_imports(script: str): - lines = (_REPO_ROOT / script).read_text().splitlines() +def test_entry_script_disables_warp_backward_before_isaaclab_imports(source_checkout_root: Path, script: str): + lines = (source_checkout_root / script).read_text().splitlines() setting_at = next((i for i, line in enumerate(lines) if line.strip() == _SETTING), None) assert setting_at is not None, f"{script} does not set '{_SETTING}'" diff --git a/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst b/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst new file mode 100644 index 000000000000..fb2e3c7b3922 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed :obj:`~isaaclab_assets.robots.agility.DIGIT_V4_CFG` to disable colliders on the + RealSense camera mount's decoration meshes and to split actuators into a stable-armature and a + low-armature group, matching what the Digit velocity tasks previously configured per-task. diff --git a/source/isaaclab_assets/changelog.d/shadow-hand-collider-variant.rst b/source/isaaclab_assets/changelog.d/shadow-hand-collider-variant.rst new file mode 100644 index 000000000000..d256302fccaf --- /dev/null +++ b/source/isaaclab_assets/changelog.d/shadow-hand-collider-variant.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed :attr:`SHADOW_HAND_PHYSX_CFG` and :attr:`SHADOW_HAND_NEWTON_CFG` to select the + Shadow Hand asset's ``Colliders="simplified"`` variant. Set ``spawn.variants["Colliders"]`` + to ``"full"`` to use the previous collision model. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/agility.py b/source/isaaclab_assets/isaaclab_assets/robots/agility.py index 2c85a42ec681..ed67e16dfba0 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/agility.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/agility.py @@ -6,6 +6,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.sim.schemas import UsdPhysicsCollisionCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR LEG_JOINT_NAMES = [ @@ -19,21 +20,50 @@ ARM_JOINT_NAMES = [".*_arm_.*"] +# On MJWarp these ten joints diverge at the armature the USD authors. With the damping the asset +# carries (c = 57.3) and the velocity tasks' substep (h = sim.dt / num_substeps = 0.0025 s), the +# observed threshold is c*h/I > 2: wrist_yaw at I = 0.01822 [kg m^2] gives 7.86 and grew 6.9x per +# substep, the other eight at 0.05228 give 2.74, and every joint at or above I = 0.0716 was stable. +# 0.10 leaves margin. Re-measure if sim.dt, num_substeps or the asset's damping change. +# Together these cover exactly ``LEG_JOINT_NAMES + ARM_JOINT_NAMES``. +_LOW_ARMATURE_JOINT_NAMES = [".*_arm_wrist_.*", ".*_toe_a", ".*_toe_b"] +_STABLE_ARMATURE_JOINT_NAMES = [ + ".*_hip_roll", + ".*_hip_yaw", + ".*_hip_pitch", + ".*_knee", + ".*_arm_shoulder_.*", + ".*_arm_elbow", +] +_MIN_STABLE_ARMATURE = 0.10 + DIGIT_V4_CFG = ArticulationCfg( spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/Agility/Digit/digit_v4.usd", activate_contact_sensors=True, + # digit_v4.usd applies CollisionAPI to 32 prims, every one a decoration mesh on a + # RealSense camera mount -- glass, USB-C, case halves. They are 32 of the robot's 55 + # collision shapes while the arms, hips and rods carry none, and produced 3e7 N contact + # forces on bodies 1.4 m above the ground. Colliders on a camera's glass are an authoring + # error on either backend. + collision_props={"/.*camera_mount/.*/Visual/.*": [UsdPhysicsCollisionCfg(collision_enabled=False)]}, ), init_state=ArticulationCfg.InitialStateCfg( pos=(0.0, 0.0, 1.05), ), soft_joint_pos_limit_factor=0.9, actuators={ - "all": ImplicitActuatorCfg( - joint_names_expr=".*", + "legs_arms": ImplicitActuatorCfg( + joint_names_expr=_STABLE_ARMATURE_JOINT_NAMES, + stiffness=None, + damping=None, + ), + "low_armature": ImplicitActuatorCfg( + joint_names_expr=_LOW_ARMATURE_JOINT_NAMES, stiffness=None, damping=None, + armature=_MIN_STABLE_ARMATURE, ), }, ) diff --git a/source/isaaclab_contrib/changelog.d/ooctipus-clone-plan-contract.skip b/source/isaaclab_contrib/changelog.d/ooctipus-clone-plan-contract.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py index c71dec35be40..05f6725e344b 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py +++ b/source/isaaclab_contrib/isaaclab_contrib/deformable/deformable_object.py @@ -40,7 +40,7 @@ class DeformableRegistryEntry: """Entry in the deformable body registry. Registered by :class:`DeformableObject` during ``__init__``, consumed by - ``newton_physics_replicate`` inside the per-world ``begin_world``/``end_world`` loop. + the Newton clone context inside the per-world ``begin_world``/``end_world`` loop. After replication, ``particle_offsets`` and ``particles_per_body`` are filled in so the asset can bind to the correct particle ranges. """ @@ -65,7 +65,7 @@ class DeformableRegistryEntry: k_mu: float = 1e5 k_lambda: float = 1e5 k_damp: float = 0.0 - # Filled by newton_physics_replicate: + # Filled by the Newton clone context: particle_offsets: list[int] = field(default_factory=list) particles_per_body: int = 0 @@ -80,8 +80,8 @@ def add_deformable_entry_to_builder( builder, entry: DeformableRegistryEntry, env_idx: int, - env_position: list[float], - env_rotation: list[float] | tuple[float, float, float, float], + env_position: np.ndarray, + env_rotation: np.ndarray, ) -> None: """Add a deformable registry entry to a Newton ``ModelBuilder`` for one environment. @@ -173,8 +173,8 @@ def add_deformable_entry_to_builder( def add_registered_deformables_to_builder( builder, world_idx: int, - env_position: list[float], - env_rotation: list[float] | tuple[float, float, float, float], + env_position: np.ndarray, + env_rotation: np.ndarray, ) -> None: """Add all registered deformable entries to one Newton builder world.""" for entry in SimulationManager._deformable_registry: @@ -869,7 +869,7 @@ def _initialize_impl(self): if self._num_instances == 0: raise RuntimeError( f"No deformable body instances found for '{self.cfg.prim_path}'. " - "Ensure newton_physics_replicate or MODEL_INIT processed the registry." + "Ensure clone-plan replication or MODEL_INIT processed the registry." ) logger.info("Newton deformable object initialized at: %s", self.cfg.prim_path) diff --git a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py index 124c9881f0bc..a0d4800c95af 100644 --- a/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py +++ b/source/isaaclab_contrib/test/deformable/test_deformable_builder_hooks.py @@ -9,7 +9,6 @@ import pytest import warp as wp -from isaaclab_newton.cloner.replicate import NewtonReplicateContext from isaaclab_newton.physics import NewtonManager from isaaclab_newton.sim.spawners.materials import NewtonDeformableMaterialCfg @@ -132,18 +131,6 @@ def test_builder_hook_resets_entry_offsets_on_first_environment(): assert entry.particles_per_body == 3 -def test_newton_physics_context_is_replicate_context(): - """Test that Newton registers its replicate context as the backend physics context. - - USD clones are no longer part of a backend stack: :func:`isaaclab.cloner.replicate` - adds ``UsdReplicateContext`` per spawned cfg only when Kit is available, which is - covered by the replicate-session tests in ``test_cloner.py``. - """ - from isaaclab_newton.cloner import PHYSICS_CONTEXT - - assert PHYSICS_CONTEXT is NewtonReplicateContext - - def test_fabric_particle_sync_skips_missing_fabric_prim(monkeypatch): """Test that missing Fabric prims are skipped before attributes are authored.""" entry = _make_surface_entry() diff --git a/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst b/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst new file mode 100644 index 000000000000..5c13727e221a --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed locomanipulation SDG generation with NuRec backgrounds by enabling camera capture, applying Isaac RTX + Gaussian renderer settings, syncing randomized fixture poses, and recording the projected scene state after + placement. ``--high_res_video`` now records RGB observations at 512x320 instead of 960x540; update MP4 + conversion dimensions and model input shapes accordingly. diff --git a/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst b/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst new file mode 100644 index 000000000000..bffb2ae2ed24 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed the Mimic generation loop never bounding a run by failure count. ``env_loop`` now stops when + ``datagen_config.max_num_failures`` failed attempts have accumulated, alongside the existing stop + on enough successes or attempts. + +Removed +^^^^^^^ + +* Removed the ``datagen_config.max_num_failures = 25`` assignment from the shipped Mimic environment + configs. The field was never read when those lines were written, so honouring it now would newly + cap every shipped task at 25 failed attempts and cut short any run asking for a large number of + demos. Set the field explicitly to opt into a cap. diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py index 5001726e50c3..687a74f0cc4f 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py @@ -140,6 +140,18 @@ def env_loop( print(f"Reached {generation_num_trials} successes/attempts. Exiting.") break + # with the success guarantee on, nothing else bounds the run: a task that rarely + # succeeds retries forever. max_num_failures is the opt-in bound on that. Without the + # guarantee the check above already stops on generation_num_trials attempts, so the + # cap has nothing to add and must not cut a fixed-attempt run short. + max_num_failures = env.cfg.datagen_config.max_num_failures + if generation_guarantee and max_num_failures is not None and num_failures >= max_num_failures: + print( + f"Reached {num_failures} failures (max_num_failures={max_num_failures}) after" + f" {num_success}/{generation_num_trials} successes. Exiting." + ) + break + # check that simulation is stopped or not if env.sim.is_stopped(): break diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py index 43085361f950..0df362689ec5 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py @@ -32,7 +32,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py index 31bf8fe81f3a..b645c4fcf2ab 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py @@ -29,7 +29,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py index 78df5903d761..4c7192a6307f 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 10 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py index ef79f102f330..3e35749f9c33 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py index 974e0895b984..537541ffae01 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py @@ -27,7 +27,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py index cda843307acf..2773e8139013 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py @@ -29,7 +29,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py index a3ce6fc310a2..6f84e90bf97a 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py index cd7e3e472c6e..9b1cb235b6c6 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py @@ -33,7 +33,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py index 83a727ee6022..f188a9ecffe9 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py index e6ca8a12cd02..050128cf9a93 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py index 1b3d544444cb..ef94781bc961 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. @@ -157,7 +156,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py index be87d9080743..8def41266475 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. @@ -155,7 +154,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py index 5ddcf3d26c3f..71ad54c594ab 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py index e145257e2fbe..dc12d03f49e5 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 10 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py index 9a987a3b0f21..b220625bdb46 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py index 6ed48a7590ca..bfae20234e67 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py index 72be2a63c075..a981caefb9a7 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py @@ -5,6 +5,7 @@ import numpy as np import torch +from isaaclab_physx.renderers import IsaacRtxRendererCfg, IsaacRtxRendererGlobalSettingsCfg import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg @@ -35,6 +36,11 @@ NUM_FORKLIFTS = 0 NUM_BOXES = 0 +ISAAC_RTX_GAUSSIAN_CAMERA_RENDERER_CARB_SETTINGS = { + "/rtx/rtpt/gaussian/accumulatedDepth/allHits/enabled": True, + "/rtx/rtpt/gaussian/accumulatedAlbedo/enabled": True, + "/rtx/rtpt/gaussian/maxGaussiansToAccumulate": 360, +} @configclass @@ -52,7 +58,17 @@ class G1LocomanipulationSDGSceneCfg(LocomanipulationG1SceneCfg): ), ) - def add_robot_pov_cam(self, height, width): + def add_robot_pov_cam(self, height, width, use_nurec_renderer_settings: bool = False): + camera_kwargs = {} + if use_nurec_renderer_settings: + camera_kwargs["renderer_cfg"] = IsaacRtxRendererCfg( + global_settings=IsaacRtxRendererGlobalSettingsCfg( + enable_dl_denoiser=True, + antialiasing_mode="DLSS", + carb_settings=ISAAC_RTX_GAUSSIAN_CAMERA_RENDERER_CARB_SETTINGS, + ) + ) + robot_pov_cam = CameraCfg( prim_path="{ENV_REGEX_NS}/Robot/torso_link/d435_link/camera", update_period=0.0, @@ -61,6 +77,7 @@ def add_robot_pov_cam(self, height, width): data_types=["rgb"], spawn=sim_utils.PinholeCameraCfg(focal_length=8.0, clipping_range=(0.1, 20.0)), offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, -0.1736482, 0.0, 0.9848078), convention="world"), + **camera_kwargs, ) setattr(self, "robot_pov_cam", robot_pov_cam) @@ -176,10 +193,10 @@ def __init__(self, cfg: G1LocomanipulationSDGEnvCfg, **kwargs): self._num_boxes = NUM_BOXES set_ground_invisible = False - if cfg.high_res_video: - cfg.scene.add_robot_pov_cam(540, 960) - else: - cfg.scene.add_robot_pov_cam(160, 256) + camera_height, camera_width = (320, 512) if cfg.high_res_video else (160, 256) + cfg.scene.add_robot_pov_cam( + camera_height, camera_width, use_nurec_renderer_settings=cfg.background_usd_path is not None + ) cfg.scene.add_forklifts(self._num_forklifts) cfg.scene.add_boxes(self._num_boxes) diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py index 0b2506f48b3b..d6a4780f2513 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -11,7 +11,7 @@ import isaaclab.utils.math as math_utils from isaaclab.assets import AssetBaseCfg -from isaaclab.sim.views import FrameView +from isaaclab.sim.views import UsdFrameView from .occupancy_map_utils import OccupancyMap, intersect_occupancy_maps from .transform_utils import transform_mul @@ -101,10 +101,11 @@ class SceneAsset(HasPose): def __init__(self, scene, entity_name: str): self.scene = scene self.entity_name = entity_name - self._xform_view: FrameView | None = None + self._xform_view: UsdFrameView | None = None + self._cached_pose: torch.Tensor | None = None - def _get_xform_view(self) -> FrameView: - """Return the FrameView for this asset, building it on demand. + def _get_xform_view(self) -> UsdFrameView: + """Return the USD transform view for this asset, building it on demand. Static scene assets carry no runtime view (``scene[name]`` returns the spawned configuration), so the pose view is created lazily here and cached on this @@ -114,23 +115,27 @@ def _get_xform_view(self) -> FrameView: if self._xform_view is None or self._xform_view.count == 0: if self._xform_view is not None: self._xform_view.close() + self._cached_pose = None entity = self.scene[self.entity_name] prim_path = ( entity.prim_path if isinstance(entity, AssetBaseCfg) else getattr(entity, "_usd_view", entity)._prim_path ) - self._xform_view = FrameView(prim_path, device=self.scene.device) + self._xform_view = UsdFrameView(prim_path, device=self.scene.device) return self._xform_view def get_pose(self): """Get the 3D pose of the entity.""" + if self._cached_pose is not None: + return self._cached_pose.clone() xform_prim = self._get_xform_view() pos_w, quat_w = xform_prim.get_world_poses() position = pos_w.torch orientation = quat_w.torch pose = torch.cat([position, orientation], dim=-1) - return pose + self._cached_pose = pose.detach().clone() + return self._cached_pose.clone() def set_pose(self, pose: torch.Tensor): """Set the 3D pose of the entity.""" @@ -139,6 +144,16 @@ def set_pose(self, pose: torch.Tensor): orientation = pose[..., 3:] with xform_prim.xform_world_space_writer() as writer: writer.set_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) + self._cached_pose = pose.detach().clone() + entity = self.scene[self.entity_name] + if isinstance(entity, AssetBaseCfg): + # Static assets have no runtime data object, so keep their config pose aligned. + pose_cfg = pose[0] if pose.ndim > 1 else pose + env_origin = self.scene.env_origins[0].to(pose_cfg.device) + pos = (pose_cfg[:3] - env_origin).detach().cpu().tolist() + rot = pose_cfg[3:].detach().cpu().tolist() + entity.init_state.pos = tuple(float(value) for value in pos) + entity.init_state.rot = tuple(float(value) for value in rot) class RelativePose(HasPose): diff --git a/source/isaaclab_mimic/test/test_generation_failure_cap.py b/source/isaaclab_mimic/test/test_generation_failure_cap.py new file mode 100644 index 000000000000..c029c1addf8b --- /dev/null +++ b/source/isaaclab_mimic/test/test_generation_failure_cap.py @@ -0,0 +1,150 @@ +# Copyright (c) 2024-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the ``max_num_failures`` bound in the Mimic generation loop. + +``env_loop`` is driven with a fake environment whose ``step`` consumes one scripted attempt outcome +per call and feeds the action queue back, so the loop's own termination logic runs unmodified +without a simulator, data generator, or dataset. A step-count fuse turns an unbounded loop into a +test failure instead of a hang. +""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +import asyncio +from types import SimpleNamespace + +import pytest +import torch + +from isaaclab_mimic.datagen import generation + +FUSE_STEPS = 40 + + +class _Fuse(Exception): + """Raised by the fake environment once the loop has run for longer than any test expects.""" + + +def _run( + outcomes, + *, + max_num_failures, + generation_num_trials=3, + generation_guarantee=True, + num_envs=1, + attempts_per_step=1, +): + """Run ``env_loop`` over scripted attempt outcomes; return how it ended and the final counters. + + ``attempts_per_step`` is how many of the ``num_envs`` generators finish an attempt on the same + step, which is what decides whether the bound is read before or after the extra attempts land. + + Returns: + A tuple ``(how, num_success, num_failures, num_attempts)`` where ``how`` is ``"exited"`` if + the loop returned on its own and ``"fuse"`` if it was still running after ``FUSE_STEPS``. + """ + generation.num_success = generation.num_failures = generation.num_attempts = 0 + loop = asyncio.get_event_loop() + action_queue: asyncio.Queue = asyncio.Queue() + reset_queue: asyncio.Queue = asyncio.Queue() + for env_id in range(num_envs): + action_queue.put_nowait((env_id, torch.zeros(7))) + scripted = iter(outcomes) + steps = {"n": 0} + + def step(actions): + steps["n"] += 1 + if steps["n"] > FUSE_STEPS: + raise _Fuse(f"env_loop still running after {FUSE_STEPS} steps") + for _ in range(attempts_per_step): + if next(scripted): + generation.num_success += 1 + else: + generation.num_failures += 1 + generation.num_attempts += 1 + for env_id in range(num_envs): + action_queue.put_nowait((env_id, torch.zeros(7))) + + env = SimpleNamespace( + num_envs=num_envs, + device="cpu", + action_space=SimpleNamespace(shape=(num_envs, 7)), + step=step, + reset=lambda env_ids=None: None, + close=lambda: None, + sim=SimpleNamespace(is_stopped=lambda: False), + cfg=SimpleNamespace( + datagen_config=SimpleNamespace( + generation_guarantee=generation_guarantee, + generation_num_trials=generation_num_trials, + max_num_failures=max_num_failures, + ) + ), + ) + try: + generation.env_loop(env, reset_queue, action_queue, None, loop) + how = "exited" + except _Fuse: + how = "fuse" + return how, generation.num_success, generation.num_failures, generation.num_attempts + + +def test_failure_cap_stops_a_run_that_never_succeeds(): + how, succ, fail, attempts = _run([False] * 100, max_num_failures=5) + assert how == "exited" + assert (succ, fail, attempts) == (0, 5, 5) + + +def test_no_cap_by_default_keeps_the_success_guarantee(): + """``None`` means what the guarantee always meant: keep going until enough demos succeed.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=None) + assert how == "fuse" + assert fail == FUSE_STEPS + + +def test_enough_successes_still_end_the_run_first(): + outcomes = [False, True, False, True, True] + [False] * 50 + how, succ, fail, _ = _run(outcomes, max_num_failures=100, generation_num_trials=3) + assert how == "exited" + assert (succ, fail) == (3, 2) + + +def test_cap_reached_before_enough_successes_ends_the_run(): + outcomes = [False, True, False, True, False, False] + [True] * 50 + how, succ, fail, _ = _run(outcomes, max_num_failures=4, generation_num_trials=3) + assert how == "exited" + assert (succ, fail) == (2, 4) + + +@pytest.mark.parametrize("max_num_failures", [None, 3, 100]) +def test_attempt_based_termination_is_unchanged(max_num_failures): + """With the guarantee off the run stops on ``generation_num_trials`` attempts, cap or no cap. + + A cap of 3 against 10 requested attempts is the case that matters: the fixed-attempt contract + says the run delivers the attempts it was asked for, so the cap must not end it at 3. + """ + how, _, _, attempts = _run( + [False] * 100, max_num_failures=max_num_failures, generation_num_trials=10, generation_guarantee=False + ) + assert how == "exited" + assert attempts == 10 + + +def test_bound_is_exact_when_attempts_end_on_separate_steps(): + """Four environments, one attempt landing per step: the bound is read between every attempt.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=1) + assert how == "exited" + assert fail == 5 + + +def test_attempts_ending_together_overshoot_the_bound_by_at_most_num_envs_minus_one(): + """Attempts that end on the step that crosses the bound are already complete and still count.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=4) + assert how == "exited" + assert 5 < fail <= 5 + 4 - 1 diff --git a/source/isaaclab_newton/changelog.d/fix-newton-graph-coloring-hang.rst b/source/isaaclab_newton/changelog.d/fix-newton-graph-coloring-hang.rst new file mode 100644 index 000000000000..efabc29b7e99 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/fix-newton-graph-coloring-hang.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Prevented Newton VBD initialization from hanging in Warp's graph-color balancing pass. diff --git a/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst b/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst new file mode 100644 index 000000000000..c9190865df83 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst @@ -0,0 +1,18 @@ +Fixed +^^^^^ + +* Fixed :attr:`~isaaclab_newton.assets.articulation.articulation_data.ArticulationData.joint_pos` + exposing Newton's ``joint_q`` -- joint *coordinate* space -- rather than DOF positions. A ball + joint occupies 4 quaternion components against 3 DOFs, so on an articulation containing one the + array was wider than ``num_joints`` while ``joint_names``, ``joint_vel``, ``default_joint_pos`` + and the joint gains stayed in DOF space, and every consumer indexing them with the same joint ids + read a different joint past the first ball joint. Added + :class:`~isaaclab_newton.assets.articulation.joint_coordinates.JointCoordinateMap`, which converts + between the two spaces on read and on write. Articulations whose joints all have one coordinate + per DOF keep the existing zero-copy view and are unaffected. + +* Fixed joint position targets being written in DOF space into Newton's coordinate-layout + ``joint_target_q``. That array follows ``newton.use_coord_layout_targets``, which defaults to + ``True`` from Newton 1.6, so on a ball-jointed articulation the actuators were writing 50 DOF + targets into a 56-wide coordinate array. Targets now go through a DOF-shaped staging buffer and + the same coordinate map. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-drop-newton15-tendon-shim.minor.rst b/source/isaaclab_newton/changelog.d/jichuanh-drop-newton15-tendon-shim.minor.rst new file mode 100644 index 000000000000..de3e27e7e560 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-drop-newton15-tendon-shim.minor.rst @@ -0,0 +1,8 @@ +Removed +^^^^^^^ + +* Removed the Newton 1.5 compatibility shim for the MuJoCo tendon adapter. Newton 1.6.0rc1 + provides the ``mujoco:actuator`` custom-frequency view API (newton-physics/newton#4017) + directly, so the adapter now reads it from the articulation view and model rather than + through a wrapper. No migration is needed: the shim was internal and became a no-op once + the Newton pin moved to 1.6.0rc1. diff --git a/source/isaaclab_newton/changelog.d/jichuanh-newton-actuator-rename-test.skip b/source/isaaclab_newton/changelog.d/jichuanh-newton-actuator-rename-test.skip new file mode 100644 index 000000000000..57b0ced36002 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/jichuanh-newton-actuator-rename-test.skip @@ -0,0 +1,3 @@ +Test-only change: the actuator tests now assert Newton's new drive class names +(``DrivePD``, ``DriveNeuralMLP``, ``DriveNeuralLSTM``). Newton 1.6.0rc1 still exports the +old ``Controller*`` aliases, so there is no user-visible behavior change. diff --git a/source/isaaclab_newton/changelog.d/mpm-surface-extraction.rst b/source/isaaclab_newton/changelog.d/mpm-surface-extraction.rst new file mode 100644 index 000000000000..105d713c641b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/mpm-surface-extraction.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Newton visualizers showing nested static collision geometry, including generated proxy-collider visual meshes, + when the rigid-body root had a separate visual subtree. diff --git a/source/isaaclab_newton/changelog.d/ooctipus-clone-plan-contract.major.rst b/source/isaaclab_newton/changelog.d/ooctipus-clone-plan-contract.major.rst new file mode 100644 index 000000000000..6bc8a25efb43 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ooctipus-clone-plan-contract.major.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* **Breaking:** Routed production Newton cloning through the simulation-owned + ``NewtonReplicateContext.replicate(plan)`` contract and removed ``PHYSICS_CONTEXT`` and + ``queue_mapping(...)``. Standalone tooling may continue to use ``newton_physics_replicate(...)`` + with NumPy arrays; its unused ``device`` argument was removed. Changed world-builder hooks to + receive independent NumPy arrays for the environment position and orientation instead of Python lists. diff --git a/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst b/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst new file mode 100644 index 000000000000..28f10cea43bf --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Built Newton shadow visualization layouts from clone-plan positions when destination USD environment prims are absent. diff --git a/source/isaaclab_newton/changelog.d/replication-names-copies.rst b/source/isaaclab_newton/changelog.d/replication-names-copies.rst new file mode 100644 index 000000000000..4d409dd5d33b --- /dev/null +++ b/source/isaaclab_newton/changelog.d/replication-names-copies.rst @@ -0,0 +1,4 @@ +Changed +^^^^^^^ + +* Changed homogeneous Newton cloning to assign labels during replication, avoiding a second full-model pass. diff --git a/source/isaaclab_newton/changelog.d/root-joint-names.rst b/source/isaaclab_newton/changelog.d/root-joint-names.rst new file mode 100644 index 000000000000..5f532697b71c --- /dev/null +++ b/source/isaaclab_newton/changelog.d/root-joint-names.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Changed importer-generated floating-base root joints to use ``{body}_free_joint`` instead of + generated ``joint_`` labels, giving replicated environments stable body-derived joint paths. diff --git a/source/isaaclab_newton/changelog.d/shadow-cloned-tendon-labels.rst b/source/isaaclab_newton/changelog.d/shadow-cloned-tendon-labels.rst new file mode 100644 index 000000000000..7b0a36dd0ccf --- /dev/null +++ b/source/isaaclab_newton/changelog.d/shadow-cloned-tendon-labels.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Fixed batched scene cloning duplicating custom-frequency label prefixes, which prevented MuJoCo tendon actuators from resolving their targets. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py index 9921ca150915..f69b6ad35068 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py @@ -22,6 +22,7 @@ from isaaclab.assets.articulation import ordering_kernels from isaaclab.sim.schemas.schemas_actuators import _validate_newton_native_actuator_cfgs +from isaaclab_newton.assets.articulation.joint_coordinates import scatter_joint_coordinates from isaaclab_newton.physics import NewtonManager as SimulationManager if TYPE_CHECKING: @@ -93,6 +94,9 @@ def finalize_native_actuators(self, collection: ActuatorCollection) -> NewtonAct ) def _post_actuator() -> None: + # Telemetry reads _sim_bind_joint_pos inside the decimation loop, ahead of the + # post-step gather, so the DOF-space view is re-derived here. + articulation._data._gather_joint_coordinates() wp.launch( actuator_kernels.sync_torque_telemetry, dim=(self.num_instances, self.num_joints), @@ -134,7 +138,9 @@ def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> return self._native_actuator_path_active def submit_commands(self, collection: ActuatorCollection) -> None: + """Publish the collection's targets to the backend arrays.""" articulation = self._articulation + data = articulation.data if self._native_actuator_path_active: # Newton consumes raw explicit-actuator targets through joint_act. user_effort = collection._joint_effort_target @@ -143,12 +149,6 @@ def submit_commands(self, collection: ActuatorCollection) -> None: write_pos_target = True write_vel_target = True write_joint_act = True - if not articulation.data.has_joint_ordering: - articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target) - articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target) - articulation.data._sim_bind_joint_act.assign(collection._joint_effort_target) - articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target) - return else: # Lab executors publish processed targets; only implicit joints use # the backend position and velocity drives. @@ -158,28 +158,43 @@ def submit_commands(self, collection: ActuatorCollection) -> None: write_pos_target = collection.has_implicit_actuators write_vel_target = collection.has_implicit_actuators write_joint_act = False - if not articulation.data.has_joint_ordering: - articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target_sim) - if collection.has_implicit_actuators: - articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target_sim) - articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target_sim) - return - - ordering_kernels.launch_reorder_joint_targets_user_to_backend( - user_effort=user_effort, - user_pos_target=user_pos_target, - user_vel_target=user_vel_target, - backend_to_user=articulation._joint_backend_to_user_map(), - write_effort=True, - write_pos_target=write_pos_target, - write_vel_target=write_vel_target, - write_joint_act=write_joint_act, - backend_effort=articulation.data._sim_bind_joint_effort, - backend_pos_target=articulation.data._sim_bind_joint_position_target, - backend_vel_target=articulation.data._sim_bind_joint_velocity_target, - backend_joint_act=articulation.data._sim_bind_joint_act, - device=self.device, - ) + + if data.has_joint_ordering: + ordering_kernels.launch_reorder_joint_targets_user_to_backend( + user_effort=user_effort, + user_pos_target=user_pos_target, + user_vel_target=user_vel_target, + backend_to_user=articulation._joint_backend_to_user_map(), + write_effort=True, + write_pos_target=write_pos_target, + write_vel_target=write_vel_target, + write_joint_act=write_joint_act, + backend_effort=data._sim_bind_joint_effort, + backend_pos_target=data._sim_bind_joint_position_target, + backend_vel_target=data._sim_bind_joint_velocity_target, + backend_joint_act=data._sim_bind_joint_act, + device=self.device, + ) + else: + data._sim_bind_joint_effort.assign(user_effort) + if write_pos_target: + data._sim_bind_joint_position_target.assign(user_pos_target) + if write_vel_target: + data._sim_bind_joint_velocity_target.assign(user_vel_target) + if write_joint_act: + data._sim_bind_joint_act.assign(user_effort) + + # Newton takes coordinate-layout position targets from 1.6 on; every branch above writes + # DOF-indexed targets, so the staging buffer has to be scattered across unconditionally. + # Sequenced after the writes above (not a try/finally) so an exception mid-write leaves the + # staging buffer unflushed rather than scattering a half-written buffer into joint_target_q. + if articulation.data._joint_targets_need_conversion: + scatter_joint_coordinates( + articulation.data._joint_coord_map, + articulation.data._sim_bind_joint_position_target, + articulation.data._sim_bind_joint_target_coords, + articulation._ALL_ENV_MASK, + ) def reset_native_actuators(self, env_ids: Sequence[int] | slice) -> None: if self._native_actuator_path_active and SimulationManager._adapter is not None: diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 53b23d09ee06..84d107b7e347 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -12,7 +12,7 @@ import re import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np import torch @@ -36,6 +36,7 @@ from isaaclab_newton.assets import kernels as shared_kernels from isaaclab_newton.assets.articulation import kernels as articulation_kernels +from isaaclab_newton.assets.articulation.joint_coordinates import scatter_joint_coordinates from isaaclab_newton.physics import NewtonManager as SimulationManager from .actuator_control import NewtonActuatorControl @@ -1200,6 +1201,14 @@ def write_joint_state_to_sim_index( ], device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + self._env_ids_to_mask(env_ids), + ) # Let the data class handle the invalidation of the pose and velocity related properties. if not skip_forward: self.data._reset_pose(env_ids=env_ids) @@ -1266,6 +1275,14 @@ def write_joint_state_to_sim_mask( ], device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + env_mask, + ) # Let the data class handle the invalidation of the pose and velocity related properties. if not skip_forward: self.data._reset_pose(env_mask=env_mask) @@ -1319,6 +1336,14 @@ def write_joint_position_to_sim_index( self.data._sim_bind_joint_pos, device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + self._env_ids_to_mask(env_ids), + ) # Let the data class handle the invalidation of pose- and velocity-dependent properties. if not skip_forward: self.data._reset_pose(env_ids=env_ids) @@ -1369,6 +1394,14 @@ def write_joint_position_to_sim_mask( self.data._sim_bind_joint_pos, device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + env_mask, + ) # Let the data class handle the invalidation of pose- and velocity-dependent properties. if not skip_forward: self.data._reset_pose(env_mask=env_mask) @@ -2249,14 +2282,24 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( @staticmethod @wp.kernel(enable_backward=False) - def _build_env_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=wp.int32)): + def _build_env_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=Any)): i = wp.tid() - mask[indices[i]] = True + mask[wp.int32(indices[i])] = True - def _env_ids_to_mask(self, env_ids: wp.array) -> wp.array: - """Convert warp env_ids to a boolean Warp mask.""" + def _env_ids_to_mask(self, env_ids: wp.array | torch.Tensor) -> wp.array: + """Convert env_ids to a boolean Warp mask. + + Args: + env_ids: Environment indices as returned by :meth:`_resolve_env_ids`, which may be a + warp array or a torch tensor of any integer width. + + Returns: + A per-environment boolean mask. + """ if env_ids is self._ALL_INDICES: return self._ALL_ENV_MASK + if isinstance(env_ids, torch.Tensor): + env_ids = wp.from_torch(env_ids) mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) wp.launch(self._build_env_mask_kernel, dim=env_ids.shape[0], inputs=[mask, env_ids], device=self.device) return mask @@ -3444,13 +3487,16 @@ def _create_buffers(self): ) # Republish the Tier-1 backend->user state shadows inside the stepped # (and captured) region after the last solver substep. Registering only - # when ordering is non-identity keeps identity-ordering scenes at zero + # when ordering is non-identity or a ball joint needs the coordinate + # gather keeps a plain identity-ordering, non-ball-joint scene at zero # overhead (empty callback list). The reorders are then recorded into # every captured graph, so passthrough state getters never replay stale. # The stored handle is the exact bound method ``_clear_callbacks`` later # deregisters. + # A ball-jointed articulation also needs the slot: its DOF-space joint_pos is derived, not + # sim-bound, so it has to be republished after every step just like the ordering shadows. self._post_step_callback = None - if self.data.has_joint_ordering or self.data.has_body_ordering: + if self.data.has_joint_ordering or self.data.has_body_ordering or self.data._joint_coord_map.required: self._post_step_callback = self._data._refresh_user_order_state SimulationManager.register_post_step_callback(self._post_step_callback) # tendon names are set in _process_tendons function diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 04a29718fc3e..b17e37f3c782 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -22,6 +22,10 @@ from isaaclab_newton.assets import kernels as shared_kernels from isaaclab_newton.assets.articulation import kernels as articulation_kernels +from isaaclab_newton.assets.articulation.joint_coordinates import ( + build_ball_joint_coordinate_map, + gather_joint_coordinates, +) from isaaclab_newton.physics import NewtonManager as SimulationManager if TYPE_CHECKING: @@ -1591,7 +1595,23 @@ def _create_simulation_bindings(self) -> None: "joint_effort_limit", SimulationManager.get_model() )[:, 0] # -- joint states - self._sim_bind_joint_pos = self._root_view.get_dof_positions(SimulationManager.get_state_0())[:, 0] + # ``get_dof_positions`` returns Newton's ``joint_q``, which is *coordinate* space: a ball + # joint occupies 4 quaternion components against 3 DOFs, so the array is wider than + # ``num_joints`` whenever the articulation has one. IsaacLab addresses joints by DOF + # index everywhere, so keep the coordinate array separate and publish a DOF-space view. + self._sim_bind_joint_coords = self._root_view.get_dof_positions(SimulationManager.get_state_0())[:, 0] + # The view's per-joint counts are already in the column order of the array above and + # already exclude the free root, fixed joints and loop-closing joints. + self._joint_coord_map = build_ball_joint_coordinate_map( + self._root_view.joint_coord_counts, self._root_view.joint_dof_counts, self.device + ) + if self._joint_coord_map.required: + self._sim_bind_joint_pos = wp.zeros( + (self._num_instances, self._num_joints), dtype=wp.float32, device=self.device + ) + gather_joint_coordinates(self._joint_coord_map, self._sim_bind_joint_coords, self._sim_bind_joint_pos) + else: + self._sim_bind_joint_pos = self._sim_bind_joint_coords self._sim_bind_joint_vel = self._root_view.get_dof_velocities(SimulationManager.get_state_0())[:, 0] # -- joint commands (sent to the simulation) self._sim_bind_joint_effort = self._root_view.get_attribute("joint_f", SimulationManager.get_control())[ @@ -1601,6 +1621,19 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_position_target = self._root_view.get_attribute( "joint_target_q", SimulationManager.get_control() )[:, 0] + # ``joint_target_q`` follows ``newton.use_coord_layout_targets``, which defaults to + # True from Newton 1.6. Under that layout the array is coordinate-shaped, exactly like + # ``joint_q``, so actuators keep writing DOF-indexed targets into a staging buffer and + # the same map scatters them across. + self._sim_bind_joint_target_coords = self._sim_bind_joint_position_target + self._joint_targets_need_conversion = self._sim_bind_joint_target_coords.shape[1] != self._num_joints + if self._joint_targets_need_conversion: + self._sim_bind_joint_position_target = wp.zeros( + (self._num_instances, self._num_joints), dtype=wp.float32, device=self.device + ) + gather_joint_coordinates( + self._joint_coord_map, self._sim_bind_joint_target_coords, self._sim_bind_joint_position_target + ) self._sim_bind_joint_velocity_target = self._root_view.get_attribute( "joint_target_qd", SimulationManager.get_control() )[:, 0] @@ -1629,13 +1662,20 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_effort_limits_sim = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) - self._sim_bind_joint_pos = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) + # No joints: the view reports no counts, so the map has nothing to convert. + self._sim_bind_joint_coords = self._sim_bind_joint_pos = wp.zeros( + (self._num_instances, 0), dtype=wp.float32, device=self.device + ) + self._joint_coord_map = build_ball_joint_coordinate_map( + self._root_view.joint_coord_counts, self._root_view.joint_dof_counts, self.device + ) self._sim_bind_joint_vel = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_effort = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_act = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_position_target = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) + self._joint_targets_need_conversion = False self._sim_bind_joint_velocity_target = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) @@ -2157,8 +2197,18 @@ def _refresh_user_order_body_state(self) -> None: outputs=[self._body_link_pose_w_user, self._body_com_vel_w_user], ) + def _gather_joint_coordinates(self) -> None: + """Re-derive the DOF-space joint positions from Newton's coordinate array. + + A no-op unless the articulation has a ball joint; without one ``_sim_bind_joint_pos`` is a + zero-copy view onto ``joint_q`` and is always current. + """ + if self._joint_coord_map.required: + gather_joint_coordinates(self._joint_coord_map, self._sim_bind_joint_coords, self._sim_bind_joint_pos) + def _refresh_user_order_state(self) -> None: - """Republish all Tier-1 user-order state shadows from live backend state. + """Republish all Tier-1 user-order state shadows from live backend state, and gather any + ball-joint DOF positions ahead of them. Registered as a post-step callback (see :meth:`isaaclab_newton.physics.NewtonManager.register_post_step_callback`) @@ -2166,8 +2216,12 @@ def _refresh_user_order_state(self) -> None: the last solver substep. With no Python freshness guard the launches are recorded into every captured graph and replayed on each tick, so the passthrough ``joint_pos`` / ``joint_vel`` / ``body_link_pose_w`` / - ``body_com_vel_w`` shadows behave exactly like sim-bound memory. + ``body_com_vel_w`` shadows behave exactly like sim-bound memory. For an + identity-ordered ball-joint articulation the coordinate gather is the only + work this method does -- the two reorder calls below are no-ops. """ + # Ahead of the reorder: the user-order shadows have to gather from a current DOF buffer. + self._gather_joint_coordinates() self._refresh_user_order_joint_state() self._refresh_user_order_body_state() diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py new file mode 100644 index 000000000000..cb7f041217bb --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py @@ -0,0 +1,232 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Conversion between Newton's joint coordinate space and Isaac Lab's DOF space. + +Newton stores a ball joint as a 4-component unit quaternion against 3 DOFs, so an articulation +containing one has more joint coordinates than DOFs. Free and distance joints have the same kind +of mismatch, but Isaac Lab's view excludes free joints and this module rejects distance joints, so +ball is the only mismatched layout this map converts. Every other joint type has one coordinate +per DOF, so the two spaces coincide for most assets and :func:`build_ball_joint_coordinate_map` +reports ``required = False`` for them. + +Isaac Lab addresses joints by DOF index throughout -- ``joint_names``, ``find_joints``, +``SceneEntityCfg.joint_ids`` -- so joint positions have to be exposed in DOF space to stay +consistent with ``joint_vel``, ``default_joint_pos`` and the joint gains, all of which already are. + +The rotation vector is the representation consistent with ``joint_qd``, which holds angular velocity +in the joint frame. PhysX also reports spherical-joint positions as an axis-angle vector projected +onto the DOF axes; sign and frame parity with PhysX has not been verified numerically. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import warp as wp + +_BALL_LAYOUT = (4, 3) +"""``(coordinates, DOFs)`` of a ball joint -- the only mismatched layout this map converts; free +joints are excluded from Lab's view, and distance joints (7 against 6) are rejected.""" + + +@wp.kernel(enable_backward=False) +def gather_single_coord_dofs( + coords: wp.array2d(dtype=wp.float32), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + dofs: wp.array2d(dtype=wp.float32), +): + """Copy joints whose coordinate count equals their DOF count.""" + env, i = wp.tid() + dofs[env, dof_index[i]] = coords[env, coord_index[i]] + + +@wp.kernel(enable_backward=False) +def gather_ball_dofs( + coords: wp.array2d(dtype=wp.float32), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + dofs: wp.array2d(dtype=wp.float32), +): + """Quaternion -> rotation vector for each ball joint. + + ``wp.quat_to_axis_angle`` resolves the double cover itself -- it flips the axis with the sign of + ``w`` and returns an angle in ``[0, pi]`` -- so a fixed pose always decodes to the same vector. + """ + env, i = wp.tid() + c = coord_index[i] + d = dof_index[i] + axis, angle = wp.quat_to_axis_angle( + wp.quat(coords[env, c + 0], coords[env, c + 1], coords[env, c + 2], coords[env, c + 3]) + ) + rotvec = axis * angle + dofs[env, d + 0] = rotvec[0] + dofs[env, d + 1] = rotvec[1] + dofs[env, d + 2] = rotvec[2] + + +@wp.kernel(enable_backward=False) +def scatter_single_coord_dofs( + dofs: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + coords: wp.array2d(dtype=wp.float32), +): + """Inverse of :func:`gather_single_coord_dofs`, for the selected environments.""" + env, i = wp.tid() + if env_mask[env]: + coords[env, coord_index[i]] = dofs[env, dof_index[i]] + + +@wp.kernel(enable_backward=False) +def scatter_ball_dofs( + dofs: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + coords: wp.array2d(dtype=wp.float32), +): + """Rotation vector -> quaternion for each ball joint, for the selected environments.""" + env, i = wp.tid() + if not env_mask[env]: + return + c = coord_index[i] + d = dof_index[i] + rotvec = wp.vec3(dofs[env, d + 0], dofs[env, d + 1], dofs[env, d + 2]) + angle = wp.length(rotvec) + q = wp.quat_identity() + if angle > 1.0e-9: + q = wp.quat_from_axis_angle(rotvec / angle, angle) + coords[env, c + 0] = q[0] + coords[env, c + 1] = q[1] + coords[env, c + 2] = q[2] + coords[env, c + 3] = q[3] + + +@dataclass(frozen=True) +class BallJointCoordinateMap: + """Index tables mapping an articulation view's ball-joint coordinates to their DOFs and back. + + A plain data record, not an object with behavior -- :func:`build_ball_joint_coordinate_map` + produces one, and :func:`gather_joint_coordinates` / :func:`scatter_joint_coordinates` consume + one. Fields are only meaningful when ``required`` is True; the ``as_wp`` conversion in + :func:`build_ball_joint_coordinate_map` is skipped otherwise, so on a required=False map the + remaining fields are placeholder empty arrays. The frozen dataclass prevents field + reassignment, but the Warp arrays remain mutable objects. + """ + + required: bool + """Whether the articulation has a ball joint and the remaining fields are populated.""" + + single_dof: wp.array + """DOF indices of joints whose coordinate count equals their DOF count, dtype ``wp.int32``.""" + + single_coord: wp.array + """Matching coordinate indices for :attr:`single_dof`, dtype ``wp.int32``.""" + + ball_dof: wp.array + """First DOF index of each ball joint, dtype ``wp.int32``.""" + + ball_coord: wp.array + """First coordinate index of each ball joint, dtype ``wp.int32``.""" + + +def build_ball_joint_coordinate_map(coord_counts: list[int], dof_counts: list[int], device) -> BallJointCoordinateMap: + """Build the index tables mapping an articulation view's joint coordinates to its DOFs and back. + + Built from the view's own per-joint counts, which are in the column order of + :meth:`~newton.ArticulationView.get_dof_positions` and already exclude the free root joint, + fixed joints and loop-closing joints. + + Args: + coord_counts: Coordinates per selected joint (``ArticulationView.joint_coord_counts``). + dof_counts: DOFs per selected joint (``ArticulationView.joint_dof_counts``). + device: Device to allocate the index tables on. + + Returns: + The index tables, with ``required`` set when the articulation has a ball joint. + + Raises: + NotImplementedError: If a joint's coordinate and DOF counts differ in any way other than the + ball-joint layout, which would otherwise be silently decoded as a quaternion. + """ + single_dof: list[int] = [] + single_coord: list[int] = [] + ball_dof: list[int] = [] + ball_coord: list[int] = [] + + coord, dof = 0, 0 + for n_coords, n_dofs in zip(coord_counts, dof_counts, strict=True): + if n_coords == n_dofs: + single_dof.extend(range(dof, dof + n_dofs)) + single_coord.extend(range(coord, coord + n_coords)) + elif (n_coords, n_dofs) == _BALL_LAYOUT: + ball_dof.append(dof) + ball_coord.append(coord) + else: + raise NotImplementedError( + f"Joint with {n_coords} coordinates against {n_dofs} DOFs has no coordinate" + " conversion; only ball joints (4 against 3) are supported." + ) + coord += n_coords + dof += n_dofs + + required = bool(ball_dof) + if not required: + empty = wp.array([], dtype=wp.int32, device=device) + return BallJointCoordinateMap(False, empty, empty, empty, empty) + as_wp = lambda values: wp.array(values, dtype=wp.int32, device=device) # noqa: E731 + return BallJointCoordinateMap(True, as_wp(single_dof), as_wp(single_coord), as_wp(ball_dof), as_wp(ball_coord)) + + +def gather_joint_coordinates(coord_map: BallJointCoordinateMap, coords: wp.array, dofs: wp.array) -> None: + """Write the DOF-space view of ``coords`` into ``dofs``. + + Args: + coord_map: Index tables from :func:`build_ball_joint_coordinate_map`. + coords: Newton's joint coordinate array for this view. + dofs: DOF-space destination [rad or m, depending on joint type]. + """ + num_envs = coords.shape[0] + for kernel, dof_index, coord_index in ( + (gather_single_coord_dofs, coord_map.single_dof, coord_map.single_coord), + (gather_ball_dofs, coord_map.ball_dof, coord_map.ball_coord), + ): + wp.launch( + kernel, + dim=(num_envs, dof_index.shape[0]), + inputs=[coords, dof_index, coord_index, dofs], + device=coords.device, + ) + + +def scatter_joint_coordinates( + coord_map: BallJointCoordinateMap, dofs: wp.array, coords: wp.array, env_mask: wp.array +) -> None: + """Write ``dofs`` back into ``coords`` for the selected environments. + + Scoping this to the written environments matters: resets are staggered, and ``dofs`` for + the other environments only holds the last post-step gather. An all-environment scatter + would overwrite their live ``joint_q`` with an exp(log(q)) round trip of itself, perturbing + state that was never written. + + Args: + coord_map: Index tables from :func:`build_ball_joint_coordinate_map`. + dofs: DOF-space joint positions [rad or m, depending on joint type]. + coords: Newton's joint coordinate array to write into. + env_mask: Per-environment boolean selection of the environments that were written. + """ + for kernel, dof_index, coord_index in ( + (scatter_single_coord_dofs, coord_map.single_dof, coord_map.single_coord), + (scatter_ball_dofs, coord_map.ball_dof, coord_map.ball_coord), + ): + wp.launch( + kernel, + dim=(env_mask.shape[0], dof_index.shape[0]), + inputs=[dofs, env_mask, dof_index, coord_index, coords], + device=coords.device, + ) diff --git a/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py b/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py index e4fd7b265f3f..cc780dd33f04 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/mpm_object/mpm_object.py @@ -54,8 +54,8 @@ def add_mpm_entry_to_builder( builder, entry: MPMObjectRegistryEntry, env_idx: int, - env_position: list[float], - env_rotation: list[float] | tuple[float, float, float, float], + env_position: np.ndarray, + env_rotation: np.ndarray, ) -> None: """Emit one registered MPM object into one Newton builder world.""" if env_idx == 0: @@ -80,8 +80,8 @@ def add_mpm_entry_to_builder( def add_registered_mpm_objects_to_builder( builder, world_idx: int, - env_position: list[float], - env_rotation: list[float] | tuple[float, float, float, float], + env_position: np.ndarray, + env_rotation: np.ndarray, ) -> None: """Emit all registered MPM objects into one Newton builder world.""" for entry in SimulationManager._mpm_object_registry: @@ -432,8 +432,8 @@ def _clear_callbacks(self) -> None: def _compose_env_asset_pose( cfg: MPMObjectCfg, - env_position: list[float], - env_rotation: list[float] | tuple[float, float, float, float], + env_position: np.ndarray, + env_rotation: np.ndarray, ) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]: """Compose the environment transform with the asset's initial pose (both ``xyzw``).""" env_pos = wp.vec3(*env_position) @@ -457,7 +457,7 @@ def _resolve_particle_asset_paths(prim_path: str, num_instances: int) -> list[st matched = cloner_path.match(prim_path, template) if matched is not None: paths_by_env_id.update((env_id, f"{template.format(env_id)}{matched.suffix}") for env_id in env_ids) - env_ids = [int(env_id) for env_id in clone_plan.env_ids.tolist()] + env_ids = [int(env_id) for env_id in clone_plan.env_ids] if len(env_ids) == num_instances and all(env_id in paths_by_env_id for env_id in env_ids): return [paths_by_env_id[env_id] for env_id in env_ids] diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/cloner/__init__.pyi index b3bda092bcb3..5e0105c313d7 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/cloner/__init__.pyi @@ -5,7 +5,6 @@ __all__ = [ "NewtonReplicateContext", - "PHYSICS_CONTEXT", "copy_newton_clone_source", "newton_builder_world_hook", "newton_physics_replicate", @@ -13,7 +12,6 @@ __all__ = [ from .replicate import ( NewtonReplicateContext, - PHYSICS_CONTEXT, copy_newton_clone_source, newton_builder_world_hook, newton_physics_replicate, diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 279ed11fbeeb..31ccd501cd16 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -9,9 +9,8 @@ from typing import Any import numpy as np -import torch import warp as wp -from newton import GeoType, ModelBuilder, ShapeFlags +from newton import GeoType, JointType, ModelBuilder, ShapeFlags from pxr import Usd, UsdGeom, UsdPhysics @@ -38,6 +37,17 @@ def _has_visible_non_collision_geometry(stage: Usd.Stage, prim_path: str) -> boo return False +def _static_collider_owner_path(stage: Usd.Stage, collider_path: str) -> str: + """Return the nearest rigid-body ancestor or the collider's immediate parent.""" + collider_prim = stage.GetPrimAtPath(collider_path) + prim = collider_prim.GetParent() if collider_prim else None + while prim and not prim.IsPseudoRoot(): + if prim.HasAPI(UsdPhysics.RigidBodyAPI): + return str(prim.GetPath()) + prim = prim.GetParent() + return collider_path.rpartition("/")[0] + + def _restore_visible_colliders_without_visual_shapes( builder: ModelBuilder, stage: Usd.Stage, @@ -59,6 +69,14 @@ def _restore_visible_colliders_without_visual_shapes( """ if not path_shape_map or not load_visual_shapes: return + # Newton may synthesize a visible ``*_visual`` mesh for a proxy-purpose collider. + # It is not an authored USD prim and must remain hidden alongside its source collider. + for index, path in enumerate(builder.shape_label): + if not path.endswith("_visual") or stage.GetPrimAtPath(path): + continue + collider_prim = stage.GetPrimAtPath(path.removesuffix("_visual")) + if collider_prim and collider_prim.HasAPI(UsdPhysics.CollisionAPI): + builder.shape_flags[index] &= ~ShapeFlags.VISIBLE bodies_with_visual_shapes = { builder.shape_body[index] for index, flags in enumerate(builder.shape_flags) @@ -66,7 +84,7 @@ def _restore_visible_colliders_without_visual_shapes( } # Resolved on first use: a static parent whose colliders are all filtered out below is # never traversed at all. - static_parents_with_visual_shapes: dict[str, bool] = {} + static_owners_with_visual_shapes: dict[str, bool] = {} for path, index in path_shape_map.items(): flags = builder.shape_flags[index] body_index = builder.shape_body[index] @@ -77,10 +95,10 @@ def _restore_visible_colliders_without_visual_shapes( ): continue if body_index < 0: - parent_path = path.rpartition("/")[0] - if parent_path not in static_parents_with_visual_shapes: - static_parents_with_visual_shapes[parent_path] = _has_visible_non_collision_geometry(stage, parent_path) - if static_parents_with_visual_shapes[parent_path]: + owner_path = _static_collider_owner_path(stage, path) + if owner_path not in static_owners_with_visual_shapes: + static_owners_with_visual_shapes[owner_path] = _has_visible_non_collision_geometry(stage, owner_path) + if static_owners_with_visual_shapes[owner_path]: continue imageable = UsdGeom.Imageable(stage.GetPrimAtPath(path)) if ( @@ -158,9 +176,22 @@ def _build_source_builder( replace_newton_builder_shape_colors(builder, stage) if load_visual_shapes: import_builder_visual_material_paths(builder, stage) + _name_root_joints_after_their_body(builder) return builder +def _name_root_joints_after_their_body(builder: ModelBuilder) -> None: + """Name importer-generated free root joints after their child bodies, in place.""" + for index, label in enumerate(builder.joint_label): + if not isinstance(label, str) or not label.startswith("joint_") or not label[6:].isdigit(): + continue + if builder.joint_type[index] != JointType.FREE or builder.joint_parent[index] != -1: + continue + body_label = builder.body_label[builder.joint_child[index]] + if isinstance(body_label, str) and body_label.startswith("/"): + builder.joint_label[index] = f"{body_label}_free_joint" + + def _quat_multiply(a: np.ndarray, b: np.ndarray) -> np.ndarray: """Hamilton product of xyzw quaternion arrays, broadcast over the leading axes.""" ax, ay, az, aw = a[..., 0], a[..., 1], a[..., 2], a[..., 3] @@ -196,35 +227,72 @@ def _invert_xform(xform: Sequence[float] | np.ndarray) -> np.ndarray: return np.concatenate([-_quat_rotate(quat_inv, xform[:3]), quat_inv]) +def _label_groups(builder: ModelBuilder) -> dict[str, list]: + """Return every entity-label container owned by a Newton builder.""" + groups = { + name: value for name, value in vars(builder).items() if name.endswith("_label") and isinstance(value, list) + } + for frequency in builder.custom_frequencies.values(): + if frequency.label_attribute is not None: + groups[frequency.label_attribute] = builder.custom_attributes[frequency.label_attribute].values + groups["mujoco:equality_constraint_label"] = builder.custom_attributes["mujoco:equality_constraint_label"].values + return groups + + +def _rebase_labels(builder: ModelBuilder, source: str, destination: str) -> str: + """Make entity labels relative to the nearest templated destination ancestor.""" + source = source.rstrip("/") or "/" + destination = destination.rstrip("/") or "/" + prefix, _, destination_name = destination.rpartition("/") + if "{}" in destination_name: + prefix, destination_name = destination, "" + for labels in _label_groups(builder).values(): + for index, label in enumerate(labels): + if not isinstance(label, str) or not label or not label.startswith("/"): + continue + suffix = clone_path.relative_to(label, source) + if suffix is None: + suffix = label[len(source) :] if label.startswith(source + "_") else None + if suffix is None: + raise ValueError(f"Newton label {label!r} is outside clone source {source!r}.") + labels[index] = (destination_name + suffix).lstrip("/") + if not labels[index]: + raise ValueError(f"Newton label {label!r} cannot be prefixed by destination {destination!r}.") + return prefix + + def replicate_builder_mapping( builder: ModelBuilder, sources: Sequence[str], - mapping: torch.Tensor, - positions: torch.Tensor, - quaternions: torch.Tensor, + mapping: np.ndarray, + positions: np.ndarray, + quaternions: np.ndarray, source_builders: dict[str, ModelBuilder], + destinations: Sequence[str] | None = None, + env_ids: np.ndarray | None = None, *, source_site_indices: dict[int, dict[str, list[int]]] | None = None, env_root_sites: dict[str, wp.transform] | None = None, - per_world_builder_hooks: Sequence[Callable[[ModelBuilder, int, list[float], list[float]], None]] = (), -) -> tuple[dict[str, list[list[int]]], list[wp.transform]]: - """Replicate source builders into per-env Newton worlds.""" + per_world_builder_hooks: Sequence[Callable[[ModelBuilder, int, np.ndarray, np.ndarray], None]] = (), +) -> tuple[dict[str, list[list[int]]], list[wp.transform], list[tuple[str, int]]]: + """Replicate source builders, naming homogeneous copies at their destinations.""" source_site_indices = source_site_indices or {} env_root_sites = env_root_sites or {} - num_worlds = mapping.size(1) + num_worlds = mapping.shape[1] local_site_map: dict[str, list[list[int]]] = {} - positions_np = positions.detach().cpu().numpy().astype(np.float32, copy=False) - quaternions_np = quaternions.detach().cpu().numpy().astype(np.float32, copy=False) - xforms_np = np.concatenate((positions_np, quaternions_np), axis=1) - xform_rows = xforms_np.tolist() - world_xforms = [wp.transform(*row) for row in xform_rows] + positions = positions.astype(np.float32, copy=False) + quaternions = quaternions.astype(np.float32, copy=False) + xforms_np = np.concatenate((positions, quaternions), axis=1) + world_xforms = [wp.transform(*row) for row in xforms_np] can_batch = ( len(sources) == 1 - and mapping.size(0) == 1 + and mapping.shape[0] == 1 and num_worlds > 0 - and bool(mapping.all().item()) + and bool(mapping.all()) and not per_world_builder_hooks + and bool(destinations) + and env_ids is not None ) if can_batch: source_builder = source_builders[sources[0]] @@ -243,98 +311,94 @@ def replicate_builder_mapping( base_shape = builder.shape_count stride = source_builder.shape_count source_xform_inv = _invert_xform(xforms_np[0]) - xforms = _compose_world_xforms(positions_np, quaternions_np, source_xform_inv) - builder.replicate(source_builder, num_worlds, xforms=xforms) + xforms = _compose_world_xforms(positions, quaternions, source_xform_inv) + + # Resolve label-based ownership before names become relative but target paths do not. + source_builder._resolve_custom_frequency_articulation_owners() + label_groups = _label_groups(source_builder) + original_labels = {name: list(labels) for name, labels in label_groups.items()} + try: + prefix = _rebase_labels(source_builder, sources[0], destinations[0]) + prefixes = [prefix.format(int(env_id)) for env_id in env_ids] + builder.replicate(source_builder, num_worlds, xforms=xforms, label_prefixes=prefixes) + finally: + for name, labels in original_labels.items(): + label_groups[name][:] = labels for label, local_indices in site_local_indices.items(): local_site_map[label] = [ [base_shape + world * stride + local for local in local_indices] for world in range(num_worlds) ] - return local_site_map, world_xforms + bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping, skip_entity_labels=True) + return local_site_map, world_xforms, bindings - source_world_indices = mapping.to(dtype=torch.int64).argmax(dim=1).tolist() + source_world_indices = mapping.argmax(axis=1) # Per-world placements for every env-root site, composed up front so the per-world loop # below only indexes rows. root_site_xforms = { - label: _compose_world_xforms(positions_np, quaternions_np, xform) for label, xform in env_root_sites.items() + label: _compose_world_xforms(positions, quaternions, xform) for label, xform in env_root_sites.items() } # Same for the source placements, but only for the occupied ``(row, col)`` pairs of the # mapping: composing a dense ``num_rows x num_worlds`` table would blow up on heterogeneous # plans where each row is present in a handful of worlds. # One scan of the transposed mapping yields the occupied pairs in world-major order, which - # is the order both indices want; scanning per world instead costs a torch call and a - # device sync per world. + # is the order both indices want. rows_per_world: list[list[int]] = [[] for _ in range(num_worlds)] worlds_per_row: dict[int, list[int]] = {} - for col, row in mapping.t().nonzero(as_tuple=False).tolist(): + for col_value, row_value in np.argwhere(mapping.T): + col, row = int(col_value), int(row_value) rows_per_world[col].append(row) worlds_per_row.setdefault(row, []).append(col) source_xforms: dict[tuple[int, int], np.ndarray] = {} for row, cols in worlds_per_row.items(): - source_col = source_world_indices[row] + source_col = int(source_world_indices[row]) row_xforms = _compose_world_xforms( - positions_np[cols], - quaternions_np[cols], + positions[cols], + quaternions[cols], _invert_xform(xforms_np[source_col]), ) source_xforms.update(((row, col), row_xforms[index]) for index, col in enumerate(cols)) for col in range(num_worlds): builder.begin_world() - for label, world_site_xforms in root_site_xforms.items(): site_idx = builder.add_site(body=-1, xform=world_site_xforms[col], label=label) local_site_map.setdefault(label, [[] for _ in range(num_worlds)])[col].append(site_idx) - for row in rows_per_world[col]: source_builder = source_builders[sources[row]] offset = builder.shape_count builder.add_builder(source_builder, xform=source_xforms[row, col]) - for label, source_shape_indices in source_site_indices.get(id(source_builder), {}).items(): local_indices = local_site_map.setdefault(label, [[] for _ in range(num_worlds)])[col] local_indices.extend(offset + shape_idx for shape_idx in source_shape_indices) - for hook in per_world_builder_hooks: - hook(builder, col, xform_rows[col][:3], xform_rows[col][3:]) + hook(builder, col, xforms_np[col, :3].copy(), xforms_np[col, 3:].copy()) builder.end_world() - return local_site_map, world_xforms - - -_BUILTIN_LABEL_TYPES: tuple[str, ...] = ( - "body", - "joint", - "shape", - "articulation", - "constraint_mimic", - "equality_constraint", -) + bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) if destinations else [] + return local_site_map, world_xforms, bindings def rename_builder_labels( builder: ModelBuilder, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, + env_ids: np.ndarray, + mapping: np.ndarray, + *, + skip_entity_labels: bool = False, ) -> list[tuple[str, int]]: """Rewrite source-root labels to per-env destination roots and return Fabric body bindings.""" fabric_body_bindings: list[tuple[str, int]] = [] bound_body_indices: set[int] = set() - env_ids_list = env_ids.tolist() - for source_index, source in enumerate(sources): source_root = source.rstrip("/") or "/" - world_cols = torch.nonzero(mapping[source_index], as_tuple=True)[0].tolist() + world_cols = np.flatnonzero(mapping[source_index]) # Pre-normalize the destination roots destination = destinations[source_index] - world_roots = { - env_id: (destination.format(env_id).rstrip("/") or "/") - for env_id in (env_ids_list[col] for col in world_cols) - } + world_roots = {int(col): (destination.format(int(env_ids[col])).rstrip("/") or "/") for col in world_cols} def _rename_pair(values, worlds, src_root=source_root, roots=world_roots, *, collect_body_bindings=False): rows = ( @@ -356,14 +420,11 @@ def _rename_pair(values, worlds, src_root=source_root, roots=world_roots, *, col fabric_body_bindings.append((renamed_value, index)) bound_body_indices.add(index) - for labels, worlds, collect_body_bindings in ( - (builder.body_label, builder.body_world, True), - (builder.joint_label, builder.joint_world, False), - (builder.shape_label, builder.shape_world, False), - (builder.articulation_label, builder.articulation_world, False), - (builder.constraint_mimic_label, builder.constraint_mimic_world, False), - ): - _rename_pair(labels, worlds, collect_body_bindings=collect_body_bindings) + if not skip_entity_labels: + for name, labels in vars(builder).items(): + worlds = getattr(builder, f"{name[:-6]}_world", None) if name.endswith("_label") else None + if isinstance(labels, list) and worlds is not None: + _rename_pair(labels, worlds, collect_body_bindings=name == "body_label") custom_attrs = builder.custom_attributes.values() worlds_by_freq = {attr.frequency: attr.values for attr in custom_attrs if attr.references == "world"} diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index e76f20aef209..b7366ee3fe1d 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -9,9 +9,9 @@ import copy import re from collections.abc import Callable, Iterator, Sequence -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Any -import torch +import numpy as np import warp as wp from newton import ModelBuilder @@ -23,18 +23,14 @@ from isaaclab_newton.cloner.newton_clone_utils import ( _restore_visible_colliders_without_visual_shapes, build_source_builders, - rename_builder_labels, replicate_builder_mapping, ) -from isaaclab_newton.physics import NewtonManager +from isaaclab_newton.physics import NewtonCfg, NewtonManager from isaaclab_newton.renderers.visual_material import import_builder_visual_material_paths if TYPE_CHECKING: - _MappingBatch: TypeAlias = tuple[ - tuple[str, ...], tuple[str, ...], torch.Tensor, torch.Tensor, torch.Tensor | None, torch.Tensor | None - ] -else: - _MappingBatch = tuple + from isaaclab.cloner import ClonePlan + from isaaclab.sim import SimulationContext def copy_newton_clone_source(source_path: str, xform: wp.transform | None = None) -> ModelBuilder: @@ -66,7 +62,7 @@ def copy_newton_clone_source(source_path: str, xform: wp.transform | None = None @contextlib.contextmanager def newton_builder_world_hook( - hook: Callable[[ModelBuilder, int, list[float], list[float]], None], + hook: Callable[[ModelBuilder, int, np.ndarray, np.ndarray], None], ) -> Iterator[None]: """Temporarily extend every world built by Newton replication. @@ -74,8 +70,8 @@ def newton_builder_world_hook( only its callback and preserves hooks owned by other callers. Args: - hook: Callback receiving the builder, world index, world position [m], - and world orientation quaternion in xyzw order during replication. + hook: Callback receiving the builder, world index, world position [m] as a NumPy array, + and world orientation quaternion in xyzw order as a NumPy array during replication. Yields: Control while the callback is registered. @@ -98,24 +94,19 @@ def _build_newton_builder_from_mapping( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None = None, + quaternions: np.ndarray | None = None, up_axis: str = "Z", load_visual_shapes: bool = True, global_paths: tuple[str, ...] = (), -) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder]]: - """Build a Newton model builder from clone mapping inputs. - - Also returns the per-source builders (``{source_path: ModelBuilder}``) so the - committing path can retain them for single-model consumers such as the - batched Newton IK action. - """ +) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder], list[tuple[str, int]]]: + """Build a Newton model builder from clone mapping inputs and retain its source builders.""" if positions is None: - positions = torch.zeros((mapping.size(1), 3), device=mapping.device, dtype=torch.float32) + positions = np.zeros((mapping.shape[1], 3), dtype=np.float32) if quaternions is None: - quaternions = torch.zeros((mapping.size(1), 4), device=mapping.device, dtype=torch.float32) + quaternions = np.zeros((mapping.shape[1], 4), dtype=np.float32) quaternions[:, 3] = 1.0 manager_cls = PhysicsManager._sim.physics_manager @@ -168,9 +159,15 @@ def _build_newton_builder_from_mapping( # Inject registered sites into source builders (and global sites into main builder). global_sites, source_sites, root_sites = NewtonManager._cl_inject_sites(builder, source_builders) - replicate_args = (builder, sources, mapping, positions, quaternions, source_builders) - local_site_map, world_xforms = replicate_builder_mapping( - *replicate_args, + local_site_map, world_xforms, fabric_body_bindings = replicate_builder_mapping( + builder=builder, + sources=sources, + mapping=mapping, + positions=positions, + quaternions=quaternions, + source_builders=source_builders, + destinations=destinations, + env_ids=env_ids, source_site_indices=source_sites, env_root_sites=root_sites, per_world_builder_hooks=NewtonManager._per_world_builder_hooks, @@ -178,7 +175,7 @@ def _build_newton_builder_from_mapping( site_index_map = {label: (idx, None) for label, idx in global_sites.items()} site_index_map.update((label, (None, per_world)) for label, per_world in local_site_map.items()) - return builder, stage_info, site_index_map, world_xforms, source_builders + return builder, stage_info, site_index_map, world_xforms, source_builders, fabric_body_bindings def _renderer_wants_visual_shapes() -> bool: @@ -188,167 +185,90 @@ def _renderer_wants_visual_shapes() -> bool: sensors on any renderer backend. A headless training run without cameras draws none of them, so importing them only costs USD parse time and memory. """ - from isaaclab.sim import SimulationContext - - sim = SimulationContext.instance() + sim = PhysicsManager._sim if sim is None: return True return bool(sim.is_rendering or sim.can_render_rgb_array() or sim.visual_shapes_required) -class NewtonReplicateContext: - """Queue and run Newton replication work for one stage.""" - - replicate_priority = 0 - - def __init__( - self, - stage: Usd.Stage, - global_paths: tuple[str, ...] = (), - device: str = "cpu", - up_axis: str = "Z", - load_visual_shapes: bool | None = None, - commit_to_manager: bool = True, - ): - """Initialize the context. - - Args: - stage: USD stage containing source assets. - global_paths: Shared scene-asset roots imported once outside replicated worlds. - device: Device used by the finalized Newton model builder. - up_axis: Up axis for the Newton model builder. - load_visual_shapes: Whether to import visual-only geometry. If ``None``, - read from the active :class:`NewtonCfg`, which itself defaults to - importing them only when a renderer or visualizer is active. - commit_to_manager: Whether :meth:`replicate` should publish the builder to - :class:`NewtonManager`. - """ - self.stage = stage - self._global_paths = global_paths - self.device = device - self.up_axis = up_axis - if load_visual_shapes is None: - from isaaclab_newton.physics import NewtonCfg - - cfg = PhysicsManager._cfg - load_visual_shapes = cfg.load_visual_shapes if isinstance(cfg, NewtonCfg) else None - self.load_visual_shapes = _renderer_wants_visual_shapes() if load_visual_shapes is None else load_visual_shapes - self.commit_to_manager = commit_to_manager - self._queue: list[_MappingBatch] = [] - - def queue_mapping( - self, - sources: Sequence[str], - destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - *, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - ) -> None: - """Queue replication rows from the current flat clone mapping. - - Args: - sources: Source prim paths used for cloning. - destinations: Destination prim path templates. - env_ids: Environment ids for destination worlds. - mapping: Boolean source-to-environment mapping matrix. - positions: Optional per-environment world positions [m]. - quaternions: Optional per-environment orientations in xyzw order. - """ - self._queue.append((tuple(sources), tuple(destinations), env_ids, mapping, positions, quaternions)) - - @staticmethod - def _merge_optional_tensor( - name: str, current: torch.Tensor | None, incoming: torch.Tensor | None - ) -> torch.Tensor | None: - """Merge optional tensors, requiring equal values when both are present.""" - if current is None: - return incoming - if incoming is None: - return current - if current.device != incoming.device or current.shape != incoming.shape or not torch.equal(current, incoming): - raise ValueError(f"Queued Newton mappings must use the same {name} tensor.") - return current - - def _merged_mapping(self) -> _MappingBatch: - """Merge queued mapping batches into the legacy flat mapping shape.""" - if not self._queue: - raise RuntimeError("Cannot replicate without queued Newton mappings.") - - sources: list[str] = [] - destinations: list[str] = [] - mappings: list[torch.Tensor] = [] - env_ids = self._queue[0][2] - positions = self._queue[0][4] - quaternions = self._queue[0][5] - - for ( - queued_sources, - queued_destinations, - queued_env_ids, - mapping, - queued_positions, - queued_quaternions, - ) in self._queue: - if ( - env_ids.device != queued_env_ids.device - or env_ids.shape != queued_env_ids.shape - or not torch.equal(env_ids, queued_env_ids) - ): - raise ValueError("Queued Newton mappings must use the same env_ids tensor.") - sources.extend(queued_sources) - destinations.extend(queued_destinations) - mappings.append(mapping) - positions = self._merge_optional_tensor("positions", positions, queued_positions) - quaternions = self._merge_optional_tensor("quaternions", quaternions, queued_quaternions) - - return tuple(sources), tuple(destinations), env_ids, torch.cat(mappings, dim=0), positions, quaternions - - def replicate(self) -> tuple[ModelBuilder, object, dict]: - """Build the Newton model builder from queued mappings and optionally publish it.""" - sources, destinations, env_ids, mapping, positions, quaternions = self._merged_mapping() - builder, stage_info, site_index_map, world_xforms, source_builders = _build_newton_builder_from_mapping( - stage=self.stage, +def _replicate_newton( + stage: Usd.Stage, + sources: Sequence[str], + destinations: Sequence[str], + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None, + quaternions: np.ndarray | None, + up_axis: str, + load_visual_shapes: bool, + global_paths: tuple[str, ...], +) -> tuple[ModelBuilder, object, dict]: + """Build one Newton model and publish its plan-derived lookup data.""" + builder, stage_info, site_index_map, world_xforms, source_builders, fabric_body_bindings = ( + _build_newton_builder_from_mapping( + stage=stage, sources=sources, destinations=destinations, env_ids=env_ids, mapping=mapping, positions=positions, quaternions=quaternions, - up_axis=self.up_axis, - load_visual_shapes=self.load_visual_shapes, - global_paths=self._global_paths, + up_axis=up_axis, + load_visual_shapes=load_visual_shapes, + global_paths=global_paths, ) - fabric_body_bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) - if self.commit_to_manager: - NewtonManager._cl_site_index_map = site_index_map - NewtonManager._cl_fabric_body_bindings = fabric_body_bindings - NewtonManager._world_xforms = world_xforms - NewtonManager._cl_protos = source_builders - NewtonManager.set_builder(builder) - NewtonManager._num_envs = mapping.size(1) - self._queue.clear() - return builder, stage_info, site_index_map + ) + NewtonManager._cl_site_index_map = site_index_map + NewtonManager._cl_fabric_body_bindings = fabric_body_bindings + NewtonManager._world_xforms = world_xforms + NewtonManager._cl_protos = source_builders + NewtonManager.set_builder(builder) + NewtonManager._num_envs = mapping.shape[1] + return builder, stage_info, site_index_map -PHYSICS_CONTEXT = NewtonReplicateContext -"""Physics-only replication context for Newton assets. USD replication is added automatically -by :func:`~isaaclab.cloner.replicate` when the asset has a spawner and Kit is available.""" +class NewtonReplicateContext: + """Build one Newton model from the rows routed to it in a clone plan.""" + + replicate_priority = 0 + + def __init__(self, sim_context: SimulationContext, *, up_axis: str = "Z"): + """Initialize the context from its owning simulation.""" + self._sim = sim_context + self.up_axis = up_axis + + def replicate(self, plan: ClonePlan) -> tuple[ModelBuilder, object, dict]: + """Build and publish a Newton model from this context's plan rows.""" + if plan.env_ids is None: + raise ValueError("ClonePlan.env_ids is required for replication.") + rows = plan.context_rows[type(self)] + cfg = self._sim.cfg.physics + load_visual_shapes = cfg.load_visual_shapes if isinstance(cfg, NewtonCfg) else None + return _replicate_newton( + stage=self._sim.stage, + sources=tuple(plan.sources[row] for row in rows), + destinations=tuple(plan.destinations[row] for row in rows), + env_ids=plan.env_ids, + mapping=plan.clone_mask[list(rows)], + positions=plan.positions, + quaternions=None, + up_axis=self.up_axis, + load_visual_shapes=_renderer_wants_visual_shapes() if load_visual_shapes is None else load_visual_shapes, + global_paths=plan.global_paths, + ) def newton_physics_replicate( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - device: str = "cpu", + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None = None, + quaternions: np.ndarray | None = None, up_axis: str = "Z", global_paths: tuple[str, ...] = (), -): +) -> tuple[ModelBuilder, dict[str, Any]]: """Replicate prims into a Newton ``ModelBuilder`` using a per-source mapping. Args: @@ -359,14 +279,24 @@ def newton_physics_replicate( mapping: Boolean source-to-environment mapping matrix. positions: Optional per-environment world positions. quaternions: Optional per-environment orientations in xyzw order. - device: Device used by the finalized Newton model builder. up_axis: Up axis for the Newton model builder. global_paths: Shared scene-asset roots imported once. Defaults to none. Returns: Tuple of the populated Newton model builder and stage metadata. """ - ctx = NewtonReplicateContext(stage, global_paths=global_paths, device=device, up_axis=up_axis) - ctx.queue_mapping(sources, destinations, env_ids, mapping, positions=positions, quaternions=quaternions) - builder, stage_info, _site_index_map = ctx.replicate() + cfg = PhysicsManager._cfg + load_visual_shapes = cfg.load_visual_shapes if isinstance(cfg, NewtonCfg) else None + builder, stage_info, _ = _replicate_newton( + stage=stage, + sources=sources, + destinations=destinations, + env_ids=env_ids, + mapping=mapping, + positions=positions, + quaternions=quaternions, + up_axis=up_axis, + load_visual_shapes=_renderer_wants_visual_shapes() if load_visual_shapes is None else load_visual_shapes, + global_paths=global_paths, + ) return builder, stage_info diff --git a/source/isaaclab_newton/isaaclab_newton/physics/_mjwarp_view_compat.py b/source/isaaclab_newton/isaaclab_newton/physics/_mjwarp_view_compat.py deleted file mode 100644 index 39872fa54bf1..000000000000 --- a/source/isaaclab_newton/isaaclab_newton/physics/_mjwarp_view_compat.py +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Back-fill the custom-frequency view API that Newton gained in newton-physics/newton#4017. - -Newton 1.6 exposes ``mujoco:actuator`` through :class:`~newton.selection.ArticulationView`: -``custom_frequency_counts`` reports the per-articulation count, and ``get_attribute`` / -``set_attribute`` reach the actuator rows. On 1.5 only ``mujoco:tendon`` is admitted -- the -custom-frequency branch rejects every other namespace -- so the same calls raise. - -#4017 landed on Newton's main but was deliberately not backported to the 1.5 patch series, so it -arrives with 1.6. :func:`ensure_newton_custom_frequency_api` supplies the missing surface in the meantime -- on the -view (``custom_frequency_counts``/``custom_frequency_labels`` and actuator ``get_attribute`` / -``set_attribute``) and on the model (``custom_frequency_articulation``). Because it fills the same -names Newton 1.6 fills, :mod:`~isaaclab_newton.physics.mjwarp_tendon_control` is written against -1.6 only and carries no version branch. - -Delete this module once the pinned Newton is 1.6 or newer; the only caller reverts to reading -``articulation.root_view`` and the model straight through. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import numpy as np -import warp as wp - -if TYPE_CHECKING: - from newton import Control, Model, State - from newton.selection import ArticulationView - -__all__ = ["ensure_newton_custom_frequency_api"] - - -@wp.kernel -def _scatter_into_flat_rows( - values: wp.array3d(dtype=wp.float32), - rows: wp.array3d(dtype=wp.int32), - flat: wp.array(dtype=wp.float32), -) -> None: - """Write view-shaped values into the model-global rows they belong to.""" - world, instance, column = wp.tid() - flat[rows[world, instance, column]] = values[world, instance, column] - - -def ensure_newton_custom_frequency_api(view: ArticulationView, model: Model): - """Return a ``(view, model)`` pair exposing Newton 1.6's custom-frequency API. - - Args: - view: Articulation view to adapt. - model: Newton model the view selects from. - - Returns: - The pair unchanged when Newton already provides the API, otherwise wrappers adding it. - """ - if hasattr(view, "custom_frequency_counts"): - return view, model - adapted = _CustomFrequencyView(view, model) - return adapted, _OwnershipModel(model, adapted.actuator_owner_rows) - - -class _CustomFrequencyView: - """Adds ``mujoco:actuator`` support to a Newton 1.5 :class:`ArticulationView`. - - Everything except the custom-frequency surface is delegated to the wrapped view, so the - layout, masks and every other attribute stay Newton's own. - """ - - def __init__(self, view: ArticulationView, model: Model): - self._view = view - self._model = model - self._actuator_rows: wp.array | None = None - - counts = dict(getattr(model, "custom_frequency_counts", {}) or {}) - tendon_total = int(counts.get("mujoco:tendon", 0)) - - # Which model-global actuator rows belong to each articulation this view holds. Ownership - # is stated in the data -- ``actuator_target_label`` is the driven joint's prim path and - # ``articulation_label`` is the articulation's -- so it is read, never derived from - # strides. MEASURED: a 2-env handover model reports 80 actuators over 6 articulations, - # 20 per hand and none for the object, so no division recovers the per-hand count. - instances = max(int(view.count), 1) - self._tendon_count = int(getattr(view, "tendon_count", 0)) or (tendon_total // instances if tendon_total else 0) - self._rows_per_instance = _actuator_rows_per_instance(view, model, self._tendon_count) - self._total_actuator_rows = int(_to_numpy(model.mujoco.actuator_world).size) - self._actuator_count = int(self._rows_per_instance.shape[1]) - - self.custom_frequency_counts: dict[str, int] = {} - if self._actuator_count: - self.custom_frequency_counts["mujoco:actuator"] = self._actuator_count - if self._tendon_count: - self.custom_frequency_counts["mujoco:tendon"] = self._tendon_count - - self.custom_frequency_labels: dict[str, list[str]] = {} - labels = getattr(getattr(model, "mujoco", None), "actuator_label", None) - if labels is not None and self._actuator_count: - self.custom_frequency_labels["mujoco:actuator"] = [str(labels[row]) for row in self._rows_per_instance[0]] - tendon_names = getattr(view, "tendon_names", None) - if tendon_names: - self.custom_frequency_labels["mujoco:tendon"] = list(tendon_names) - - @property - def actuator_owner_rows(self) -> np.ndarray: - """Owning articulation id per model-global actuator row, as Newton 1.6 states it. - - Newton 1.6 publishes this as ``Model.custom_frequency_articulation["mujoco:actuator"]``; - here it is projected from the inferred per-instance rows so the caller reads one shape. - """ - owners = np.full(self._total_actuator_rows, -1, dtype=np.int64) - articulation_ids = _to_numpy(self._view.articulation_ids) - for instance, rows in enumerate(self._rows_per_instance): - owners[rows] = articulation_ids[instance] - return owners - - def __getattr__(self, name: str) -> Any: - """Delegate everything this wrapper does not define to the wrapped view.""" - return getattr(self._view, name) - - def get_attribute(self, name: str, source: Model | State | Control): - """Read *name*, handling ``mujoco:actuator`` attributes Newton 1.5 rejects. - - The gather runs on the host, which synchronizes the device. That is affordable because - the tendon adapter reads actuator attributes once while resolving its mapping; the - per-step path is :meth:`set_attribute`, which stays on the device. - """ - if not self._is_actuator_attribute(name): - return self._view.get_attribute(name, source) - rows = self._actuator_row_table().numpy() - # Gather through numpy: Warp arrays do not accept an index array. - flat = _to_numpy(self._flat_attribute(name, source)) - return wp.array(flat[rows], device=self._view.device) - - def set_attribute(self, name: str, source: Model | State | Control, values) -> None: - """Write *values*, handling ``mujoco:actuator`` attributes Newton 1.5 rejects.""" - if not self._is_actuator_attribute(name): - self._view.set_attribute(name, source, values) - return - rows = self._actuator_row_table() - if tuple(values.shape) != tuple(rows.shape): - raise ValueError(f"Expected values shaped {tuple(rows.shape)} for '{name}', got {tuple(values.shape)}.") - wp.launch( - _scatter_into_flat_rows, - dim=rows.shape, - inputs=[values, rows], - outputs=[self._flat_attribute(name, source)], - device=values.device, - ) - - def _is_actuator_attribute(self, name: str) -> bool: - """Whether *name* addresses the ``mujoco:actuator`` frequency Newton 1.5 will not resolve.""" - return name.startswith("mujoco.actuator") or name == "mujoco.ctrl" - - def _flat_attribute(self, name: str, source: Model | State | Control): - """Resolve a dotted ``mujoco.*`` name to the flat array on *source*.""" - obj: Any = source - for part in name.split("."): - obj = getattr(obj, part) - return obj - - def _actuator_row_table(self) -> wp.array: - """Model-global actuator row per ``(world, instance, actuator)``, built once.""" - if self._actuator_rows is not None: - return self._actuator_rows - view = self._view - rows = self._rows_per_instance.reshape(view.world_count, view.count_per_world, -1) - _assert_rows_usable(rows, self._total_actuator_rows, self._actuator_count) - self._actuator_rows = wp.array(rows.astype(np.int32), dtype=wp.int32, device=view.device) - return self._actuator_rows - - -def _actuator_rows_per_instance(view: ArticulationView, model: Model, tendon_count: int) -> np.ndarray: - """Model-global actuator rows owned by each articulation the view selects. - - Newton 1.5 states no actuator-to-articulation mapping, and the two candidate signals are both - unusable: ``actuator_trnid`` is left at -1 for USD imports, and ``articulation_label`` is the - template path, so every clone reports the same string -- MEASURED, a 2-clone Shadow Hand scene - reports ``['/World/Env_0/Robot', '/World/Env_0/Robot']`` with all 40 actuator labels under - ``Env_0``. - - What is reliable is which world a row belongs to. Partition the actuators by - ``actuator_world``, count the articulations in that world from its tendons, and take this - articulation's block by its position among them. Both counts come from the same MuJoCo - ordering, so the blocks line up. - - Args: - view: Articulation view selecting the articulations. - model: Newton model carrying the world assignments. - tendon_count: Fixed tendons per articulation. - - Returns: - Rows per instance, shape ``(view.count, actuators_per_articulation)``. - - Raises: - ValueError: If a world does not divide evenly into equal actuator blocks. - """ - actuator_worlds = _to_numpy(model.mujoco.actuator_world).astype(np.int64, copy=False) - tendon_worlds = _to_numpy(model.mujoco.tendon_world).astype(np.int64, copy=False) - ordinal = _ordinal_within_world(view, tendon_count) - - # An articulation spawned outside a cloned scene keeps ``actuator_world`` at the -1 the - # importer writes, so partitioning by world would select nothing and silently report the - # articulation as having no actuators. Every row belongs to this view's single world in that - # case -- MEASURED on the hands demo: 20 actuators all at -1 against tendons at world 0. - if actuator_worlds.size and int(actuator_worlds.max()) < 0: - if view.count != 1: - raise ValueError( - f"MuJoCo actuator rows carry no world assignment, so the {view.count} articulations" - " this view selects cannot be told apart." - ) - return np.arange(actuator_worlds.size, dtype=np.int64).reshape(1, -1) - - per_instance = [] - for world in range(view.world_count): - rows = np.flatnonzero(actuator_worlds == world) - # Tendons and actuators are emitted per articulation in the same order, so the tendon - # count reveals how many articulations share this world. - articulations = int(np.count_nonzero(tendon_worlds == world)) // max(tendon_count, 1) - if articulations <= 0 or rows.size % articulations: - raise ValueError( - f"World {world} holds {rows.size} MuJoCo actuator rows across {articulations}" - " articulation(s), which does not divide into equal blocks." - ) - width = rows.size // articulations - for instance in range(view.count_per_world): - start = (ordinal + instance) * width - per_instance.append(rows[start : start + width].tolist()) - return np.array(per_instance, dtype=np.int64).reshape(view.count, -1) - - -def _ordinal_within_world(view: ArticulationView, tendon_count: int) -> int: - """Position of this view's articulation among those sharing a world. - - The view's tendon layout offset is this articulation's first tendon row, and tendons are - emitted per articulation, so dividing by the per-articulation tendon count gives its index. - """ - layout = getattr(view, "frequency_layouts", {}).get("mujoco:tendon") - if layout is None or not tendon_count: - return 0 - return int(layout.offset) // tendon_count - - -def _assert_rows_usable(rows: np.ndarray, total_rows: int, actuator_count: int) -> None: - """Reject a row table that would silently drive the wrong actuators. - - A join that matched the wrong articulation still yields rows inside the model, so being in - range is not enough -- every row has to be distinct, or two instances share actuators. - - Raises: - ValueError: If any row is out of range or two slots share one. - """ - if rows.size and (rows.min() < 0 or rows.max() >= total_rows): - raise ValueError( - f"MuJoCo actuator rows fall outside the model's {total_rows} rows" - f" (got {rows.min()}..{rows.max()}); the articulation labels did not join cleanly." - ) - if np.unique(rows).size != rows.size: - raise ValueError( - "MuJoCo actuator rows repeat, so two articulations would share actuators." - f" Expected {rows.size} distinct rows across {actuator_count} actuators per instance." - ) - - -def _to_numpy(value) -> np.ndarray: - """Return *value* as a numpy array, whether it is a Warp array already or a sequence.""" - return value.numpy() if hasattr(value, "numpy") else np.asarray(value) - - -class _OwnershipModel: - """Adds Newton 1.6's ``custom_frequency_articulation`` to a Newton 1.5 :class:`Model`. - - Only the actuator frequency is supplied; everything else is delegated, so the model behaves - exactly as Newton's own apart from the one relation 1.5 does not state. - """ - - def __init__(self, model: Model, actuator_owner_rows: np.ndarray): - self._model = model - self.custom_frequency_articulation = {"mujoco:actuator": actuator_owner_rows} - - def __getattr__(self, name: str) -> Any: - """Delegate everything this wrapper does not define to the wrapped model.""" - return getattr(self._model, name) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_tendon_control.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_tendon_control.py index a8a17b849370..0d8245b5e158 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_tendon_control.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_tendon_control.py @@ -34,8 +34,6 @@ from isaaclab_newton.assets import kernels as shared_kernels from isaaclab_newton.assets.articulation import kernels as articulation_kernels -from ._mjwarp_view_compat import ensure_newton_custom_frequency_api - if TYPE_CHECKING: from newton import Control, Model from newton.selection import ArticulationView @@ -66,9 +64,7 @@ def create(cls, articulation: Articulation, model: Model) -> MjWarpTendonControl Returns: The adapter, or None when the articulation's tendons are all passive. """ - # Single compatibility seam: on Newton 1.6 this returns both unchanged. Everything below, - # and every other module, is written against 1.6 only. - view, model = ensure_newton_custom_frequency_api(articulation.root_view, model) + view = articulation.root_view mapping = resolve_fixed_tendon_actuator_columns(view, model) if mapping is None or mapping[0].size == 0: return None diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index d9a24af1ab69..cc22d7878140 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -558,7 +558,7 @@ class NewtonManager(PhysicsManager): # model from these and resolve it via ``query.path_to_source``. _cl_protos: dict[str, ModelBuilder] = {} _deformable_registry: list = [] - _per_world_builder_hooks: list[Callable[[ModelBuilder, int, list[float], list[float]], None]] = [] + _per_world_builder_hooks: list[Callable[[ModelBuilder, int, np.ndarray, np.ndarray], None]] = [] @classmethod def initialize(cls, sim_context: SimulationContext) -> None: @@ -569,6 +569,12 @@ def initialize(cls, sim_context: SimulationContext) -> None: """ super().initialize(sim_context) + # This context imports NewtonManager, so it can only be imported after this module initializes. + from isaaclab_newton.cloner import NewtonReplicateContext # noqa: PLC0415 + + cls.clone_context_type = NewtonReplicateContext + sim_context.get_or_create_backend(NewtonReplicateContext, sim_context) + # Newton-specific setup: get gravity from SimulationCfg (not physics manager cfg) sim = PhysicsManager._sim if sim is not None: @@ -1985,7 +1991,12 @@ def instantiate_builder_from_stage(cls): import_builder_visual_material_paths(builder, stage) NewtonManager._world_xforms = [wp.transform()] for hook in cls._per_world_builder_hooks: - hook(builder, 0, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]) + hook( + builder, + 0, + np.zeros(3, dtype=np.float32), + np.asarray((0.0, 0.0, 0.0, 1.0), dtype=np.float32), + ) else: # Load everything except the env subtrees (ground plane, lights, etc.) # and any terrain colliders already added as heightfields above. @@ -2025,12 +2036,16 @@ def instantiate_builder_from_stage(cls): ) ) - positions = torch.tensor([pos for pos, _ in poses], dtype=torch.float32) - quaternions = torch.tensor([quat for _, quat in poses], dtype=torch.float32) - mapping = torch.ones((1, len(env_paths)), dtype=torch.bool) - replicate_args = (builder, (proto_path,), mapping, positions, quaternions, source_builders) - local_site_map, world_xforms = replicate_builder_mapping( - *replicate_args, + positions = np.asarray([pos for pos, _ in poses], dtype=np.float32) + quaternions = np.asarray([quat for _, quat in poses], dtype=np.float32) + mapping = np.ones((1, len(env_paths)), dtype=np.bool_) + local_site_map, world_xforms, _ = replicate_builder_mapping( + builder=builder, + sources=(proto_path,), + mapping=mapping, + positions=positions, + quaternions=quaternions, + source_builders=source_builders, source_site_indices=source_site_indices, env_root_sites=env_root_sites, per_world_builder_hooks=cls._per_world_builder_hooks, @@ -2923,7 +2938,7 @@ def _ensure_visualization_model(cls) -> None: "deferring visualization model creation." ) return - NewtonManager._num_envs = len(env_paths) if clone_plan is not None else 1 + NewtonManager._num_envs = clone_plan.clone_mask.shape[1] if clone_plan is not None else 1 builder, (shadow_entities, registry_groups) = build_visualization_builder_from_stage_envs( stage, env_paths, clone_plan, up_axis=up_axis, device=str(PhysicsManager._device or "cpu") ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py index e20100d709e7..b2a7451be395 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/vbd_manager.py @@ -38,7 +38,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: def start_simulation(cls) -> None: """Start simulation and bind registered deformables to Fabric.""" if cls._builder is not None: - cls._builder.color() + cls._builder.color(balance_colors=False) super().start_simulation() try: from isaaclab_contrib.deformable.deformable_object import setup_registered_deformable_fabric_sync @@ -54,7 +54,9 @@ def instantiate_builder_from_stage(cls) -> None: super().instantiate_builder_from_stage() if cls._builder is None: raise RuntimeError("Newton stage import did not create a builder.") - cls._builder.color() + # Warp's optional balancing pass can cycle indefinitely for valid graph colorings. + # The initial assignment is sufficient for VBD correctness. + cls._builder.color(balance_colors=False) @classmethod def _get_usd_import_ignore_paths(cls) -> list[str]: diff --git a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py index f11b5b57df27..bd9f775d90fd 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py @@ -8,19 +8,17 @@ from collections.abc import Sequence from typing import Any -import torch +import numpy as np from newton import ModelBuilder from newton._src.usd.schemas import SchemaResolverNewton, SchemaResolverPhysx from pxr import Usd from isaaclab.scene_data.deformable_discovery import DeformableStageEntry, discover_deformables_on_stage -from isaaclab.sim.utils.transforms import resolve_prim_pose from isaaclab_newton.cloner.newton_clone_utils import ( _restore_visible_colliders_without_visual_shapes, build_source_builders, - rename_builder_labels, replicate_builder_mapping, ) from isaaclab_newton.physics.visualization_deformables import add_shadow_deformables_to_builder @@ -122,16 +120,15 @@ def build_visualization_builder_from_stage_envs( if not env_paths: raise ValueError("clone plan requires at least one environment path") - env_path_by_id = dict(env_paths) - sources = tuple(clone_plan.sources) destinations = tuple(clone_plan.destinations) - env_ids = clone_plan.env_ids.detach().cpu() - mapping = clone_plan.clone_mask.detach().cpu() - - poses = [resolve_prim_pose(stage.GetPrimAtPath(env_path_by_id[int(env_id)])) for env_id in env_ids.tolist()] - positions = torch.tensor([pos for pos, _ in poses], dtype=torch.float32) - quaternions = torch.tensor([quat for _, quat in poses], dtype=torch.float32) + env_ids = clone_plan.env_ids + mapping = clone_plan.clone_mask + if env_ids is None or clone_plan.positions is None: + raise ValueError("clone plan requires environment ids and positions for visualization") + positions = clone_plan.positions.astype(np.float32, copy=False) + quaternions = np.zeros((len(env_ids), 4), dtype=np.float32) + quaternions[:, 3] = 1.0 # Ignore every deformable on the stage for the world import — not only those under # clone sources. Otherwise a non-env deformable (e.g. ``/World/Assets/Cloth``) is # imported here and added again by ``add_shadow_deformables_to_builder``. @@ -159,8 +156,16 @@ def build_visualization_builder_from_stage_envs( visual_builder.shape_collision_filter_pairs = [] visual_builder.shape_collision_group[:] = [0] * visual_builder.shape_count builder.add_builder(global_builder) - replicate_builder_mapping(builder, sources, mapping, positions, quaternions, source_builders) - rename_builder_labels(builder, sources, destinations, env_ids, mapping) + replicate_builder_mapping( + builder=builder, + sources=sources, + mapping=mapping, + positions=positions, + quaternions=quaternions, + source_builders=source_builders, + destinations=destinations, + env_ids=env_ids, + ) shadow_entities, registry_groups = add_shadow_deformables_to_builder( builder, stage, env_paths, device=device, entries=deformable_entries, clone_plan=clone_plan ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py b/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py index 0c7a09643361..f5544b63f8c4 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/visualization_deformables.py @@ -132,8 +132,6 @@ def _expand_clone_plan_deformable_entries( env_ids = clone_plan.env_ids if env_ids is None: env_ids = range(clone_plan.clone_mask.shape[1]) - else: - env_ids = env_ids.detach().cpu().tolist() expanded: dict[str, DeformableStageEntry] = {entry.root_path: entry for entry in entries} for entry in entries: @@ -144,7 +142,7 @@ def _expand_clone_plan_deformable_entries( if entry.root_path != source and not entry.root_path.startswith(f"{source}/"): continue - selected_env_ids = clone_plan.clone_mask[source_idx].detach().cpu().tolist() + selected_env_ids = clone_plan.clone_mask[source_idx] for env_id, selected in zip(env_ids, selected_env_ids, strict=True): if not selected: continue diff --git a/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py b/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py index 3820642a0422..7300f3f2175b 100644 --- a/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py +++ b/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py @@ -240,6 +240,7 @@ def __init__( joint_names: list[str] | None = None, body_names: list[str] | None = None, tendon_names: list[str] | None = None, + joint_coord_counts: list[int] | None = None, ): """Initialize the mock Newton articulation view. @@ -253,6 +254,10 @@ def __init__( joint_names: Names of joints. Defaults to ["joint_0", ...]. body_names: Names of bodies. Defaults to ["body_0", ...]. tendon_names: Names of fixed tendons. Defaults to ["tendon_0", ...]. + joint_coord_counts: Coordinate count per selected joint, e.g. ``[1, 4, 1]`` for a + three-joint articulation with a ball joint in the middle. Must sum with its + matching per-joint DOF counts (inferred as 3 in place of any ``4``, 1 otherwise) + to ``num_joints`` DOFs. Defaults to one coordinate per DOF -- no ball joints. """ self._count = num_instances self._link_count = num_bodies @@ -261,6 +266,10 @@ def __init__( self._device = device self._is_fixed_base = is_fixed_base self._noop_setters = False + self._joint_coord_counts_override = joint_coord_counts + # Real Newton's ``get_dof_positions`` returns ``joint_q`` (coordinate space), wider than the + # DOF count whenever a ball joint is present; fake that width here too. + self._joint_coord_count_total = sum(joint_coord_counts) if joint_coord_counts is not None else num_joints # Set joint and body names self._joint_dof_names = joint_names if joint_names is not None else [f"joint_{i}" for i in range(num_joints)] @@ -320,6 +329,20 @@ def joint_dof_count(self) -> int: """Number of DOFs (joints) per instance.""" return self._joint_dof_count + @property + def joint_dof_counts(self) -> list[int]: + """DOF count per selected joint. One DOF per joint unless a ball layout was requested.""" + if self._joint_coord_counts_override is None: + return [1] * self._joint_dof_count + return [3 if n_coords == 4 else 1 for n_coords in self._joint_coord_counts_override] + + @property + def joint_coord_counts(self) -> list[int]: + """Coordinate count per selected joint. Equal to :attr:`joint_dof_counts` unless a ball layout was requested.""" + if self._joint_coord_counts_override is None: + return [1] * self._joint_dof_count + return list(self._joint_coord_counts_override) + @property def is_fixed_base(self) -> bool: """Whether the articulation has a fixed base.""" @@ -396,10 +419,10 @@ def _ensure_link_velocities(self) -> wp.array | None: return self._link_velocities def _ensure_dof_positions(self) -> wp.array: - """Lazily create DOF positions.""" + """Lazily create DOF positions, in coordinate space (see ``_joint_coord_count_total``).""" if self._dof_positions is None: self._dof_positions = wp.zeros( - (self._count, 1, self._joint_dof_count), dtype=wp.float32, device=self._device + (self._count, 1, self._joint_coord_count_total), dtype=wp.float32, device=self._device ) return self._dof_positions @@ -719,8 +742,10 @@ def set_random_mock_data(self) -> None: link_vel_np = np.random.randn(N, 1, L, 6).astype(np.float32) self._link_velocities = wp.array(link_vel_np, dtype=wp.spatial_vectorf, device=dev) - # DOF state - self._dof_positions = wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) + # DOF state -- positions are coordinate-space width (see ``_joint_coord_count_total``), + # velocities are always DOF-space width. + C = self._joint_coord_count_total + self._dof_positions = wp.array(np.random.randn(N, 1, C).astype(np.float32), dtype=wp.float32, device=dev) self._dof_velocities = wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) # Body properties diff --git a/source/isaaclab_newton/test/assets/test_joint_coordinates.py b/source/isaaclab_newton/test/assets/test_joint_coordinates.py new file mode 100644 index 000000000000..216ca4b7c5ee --- /dev/null +++ b/source/isaaclab_newton/test/assets/test_joint_coordinates.py @@ -0,0 +1,210 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Kitless tests for the Newton joint coordinate/DOF conversion.""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest +import warp as wp +from isaaclab_newton.assets.articulation.joint_coordinates import ( + BallJointCoordinateMap, + build_ball_joint_coordinate_map, + gather_joint_coordinates, + scatter_joint_coordinates, +) +from scipy.spatial.transform import Rotation + +# One revolute, one ball, one revolute -- the layout that hides an off-by-one when the tables are +# built by walking the model instead of the view's own per-joint counts. +COORD_COUNTS = [1, 4, 1] +DOF_COUNTS = [1, 3, 1] + +# Two balls, straddling a revolute on each side -- the second ball's offsets (ball_coord[1] = 5, +# ball_dof[1] = 4) are not exercised by the single-ball layout above. +TWO_BALL_COORD_COUNTS = [1, 4, 4, 1] +TWO_BALL_DOF_COUNTS = [1, 3, 3, 1] + + +def _map() -> BallJointCoordinateMap: + return build_ball_joint_coordinate_map(COORD_COUNTS, DOF_COUNTS, "cpu") + + +def test_tables_cover_every_dof() -> None: + """Every DOF of every joint must be tabulated exactly once.""" + m = _map() + covered = list(m.single_dof.numpy()) + [b + k for b in m.ball_dof.numpy() for k in range(3)] + assert sorted(covered) == list(range(sum(DOF_COUNTS))) + assert sorted(list(m.single_coord.numpy()) + [b + k for b in m.ball_coord.numpy() for k in range(4)]) == list( + range(sum(COORD_COUNTS)) + ) + + +def test_two_ball_tables_cover_every_dof() -> None: + """A second ball joint's offsets are not a simple repeat of the first's.""" + m = build_ball_joint_coordinate_map(TWO_BALL_COORD_COUNTS, TWO_BALL_DOF_COUNTS, "cpu") + assert list(m.ball_dof.numpy()) == [1, 4] + assert list(m.ball_coord.numpy()) == [1, 5] + covered = list(m.single_dof.numpy()) + [b + k for b in m.ball_dof.numpy() for k in range(3)] + assert sorted(covered) == list(range(sum(TWO_BALL_DOF_COUNTS))) + assert sorted(list(m.single_coord.numpy()) + [b + k for b in m.ball_coord.numpy() for k in range(4)]) == list( + range(sum(TWO_BALL_COORD_COUNTS)) + ) + + +def test_zero_rotation_vector_scatters_to_identity_quaternion() -> None: + """The ``angle > 1e-9`` guard's else-branch -- taken on every reset of a passive ball joint at + ``default_joint_pos = 0`` -- must produce the identity quaternion, not ``0/0``.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + dofs = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + coords = wp.zeros((1, n_coords), dtype=wp.float32, device="cpu") + mask = wp.array(np.array([True]), dtype=wp.bool, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, mask) + + ball_coord = int(m.ball_coord.numpy()[0]) + np.testing.assert_array_equal(coords.numpy()[0, ball_coord : ball_coord + 4], [0.0, 0.0, 0.0, 1.0]) + + +@pytest.mark.parametrize("sign", [1.0, -1.0]) +def test_identity_quaternion_gathers_to_zero_rotation_vector(sign: float) -> None: + """The inverse of the guard above: the identity quaternion (either hemisphere) decodes to the + zero rotation vector, not a NaN from a degenerate axis normalization.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + coords_np = np.zeros((1, n_coords), dtype=np.float32) + ball_coord = int(m.ball_coord.numpy()[0]) + coords_np[0, ball_coord : ball_coord + 4] = np.array([0.0, 0.0, 0.0, 1.0]) * sign + out = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + + gather_joint_coordinates(m, wp.array(coords_np, dtype=wp.float32, device="cpu"), out) + + ball_dof = int(m.ball_dof.numpy()[0]) + np.testing.assert_array_equal(out.numpy()[0, ball_dof : ball_dof + 3], [0.0, 0.0, 0.0]) + + +def test_joint_pos_is_dof_shaped_on_a_ball_jointed_mock() -> None: + """Regression for the original bug: :attr:`ArticulationData.joint_pos` must be DOF-shaped + even when the underlying view's ``joint_q`` is wider (a ball joint). This exercises + ``_create_simulation_bindings`` end to end, unlike the coordinate-table-only tests + above -- it fails on the pre-fix binding, which read ``get_dof_positions()`` (coordinate + space) straight into ``joint_pos`` with no conversion, so ``joint_pos.shape[1]`` would be + ``sum(TWO_BALL_COORD_COUNTS) == 10`` instead of ``sum(TWO_BALL_DOF_COUNTS) == 8``. + """ + import isaaclab_newton.assets.articulation.articulation_data as newton_data_module + from isaaclab_newton.assets.articulation.articulation_data import ArticulationData + from isaaclab_newton.test.fixtures.views import MockNewtonArticulationView + + num_instances = 2 + num_dofs = sum(TWO_BALL_DOF_COUNTS) + num_bodies = len(TWO_BALL_DOF_COUNTS) + 1 + + mock_view = MockNewtonArticulationView( + num_instances=num_instances, + num_bodies=num_bodies, + num_joints=num_dofs, + device="cpu", + is_fixed_base=True, + joint_coord_counts=TWO_BALL_COORD_COUNTS, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + mock_model = MagicMock() + mock_model.world_count = num_instances + mock_model.gravity = wp.array( + np.tile(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), (num_instances + 1, 1)), + dtype=wp.vec3f, + device="cpu", + ) + mock_model.articulation_count = num_instances + mock_model.max_joints_per_articulation = num_bodies + mock_model.max_dofs_per_articulation = num_dofs + mock_model.joint_dof_count = num_instances * num_dofs + mock_model.body_count = num_instances * num_bodies + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = MagicMock() + mock_manager.get_state_1.return_value = MagicMock() + mock_manager.get_control.return_value = MagicMock() + + original_sim_manager = newton_data_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + try: + data = ArticulationData(mock_view, "cpu") + finally: + newton_data_module.SimulationManager = original_sim_manager + + assert data.joint_pos.torch.shape[1] == num_dofs + + +def test_map_is_inert_without_ball_joints() -> None: + """An articulation whose joints all have one coordinate per DOF needs no conversion.""" + assert not build_ball_joint_coordinate_map([1, 1, 1], [1, 1, 1], "cpu").required + assert not build_ball_joint_coordinate_map([], [], "cpu").required + + +def test_unsupported_layout_is_rejected() -> None: + """A distance joint (7 coordinates, 6 DOFs) must not be decoded as a quaternion.""" + with pytest.raises(NotImplementedError, match="7 coordinates against 6 DOFs"): + build_ball_joint_coordinate_map([7], [6], "cpu") + + +@pytest.mark.parametrize("num_envs", [1, 2]) +def test_scatter_then_gather_round_trips(num_envs: int) -> None: + """DOF values survive a trip through coordinate space, at one environment and at two.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + rng = np.random.default_rng(0) + dofs_np = rng.uniform(-0.7, 0.7, size=(num_envs, n_dofs)).astype(np.float32) + dofs = wp.array(dofs_np, dtype=wp.float32, device="cpu") + coords = wp.zeros((num_envs, n_coords), dtype=wp.float32, device="cpu") + mask = wp.array(np.ones(num_envs, dtype=bool), dtype=wp.bool, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, mask) + # The quaternion the scatter wrote must match an independent exp map. + ball_coord = int(m.ball_coord.numpy()[0]) + ball_dof = int(m.ball_dof.numpy()[0]) + for env in range(num_envs): + expected = Rotation.from_rotvec(dofs_np[env, ball_dof : ball_dof + 3]).as_quat() + np.testing.assert_allclose(coords.numpy()[env, ball_coord : ball_coord + 4], expected, atol=1e-6) + + out = wp.zeros((num_envs, n_dofs), dtype=wp.float32, device="cpu") + gather_joint_coordinates(m, coords, out) + np.testing.assert_allclose(out.numpy(), dofs_np, atol=1e-5) + + +def test_gather_is_invariant_to_quaternion_sign() -> None: + """``q`` and ``-q`` are the same rotation and must decode to the same rotation vector.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + c = int(m.ball_coord.numpy()[0]) + base = np.zeros((1, n_coords), dtype=np.float32) + base[0, c : c + 4] = Rotation.from_rotvec([0.2, -0.5, 0.1]).as_quat() + + decoded = [] + for sign in (1.0, -1.0): + coords = base.copy() + coords[0, c : c + 4] *= sign + out = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + gather_joint_coordinates(m, wp.array(coords, dtype=wp.float32, device="cpu"), out) + decoded.append(out.numpy().copy()) + np.testing.assert_allclose(decoded[0], decoded[1], atol=1e-6) + + +def test_scatter_only_touches_masked_environments() -> None: + """Resets are staggered, so an unmasked environment's coordinates must not move.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + coords_np = np.full((2, n_coords), 0.25, dtype=np.float32) + coords = wp.array(coords_np, dtype=wp.float32, device="cpu") + dofs = wp.array(np.full((2, n_dofs), 0.3, dtype=np.float32), dtype=wp.float32, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, wp.array(np.array([True, False]), dtype=wp.bool, device="cpu")) + assert not np.allclose(coords.numpy()[0], coords_np[0]) + np.testing.assert_array_equal(coords.numpy()[1], coords_np[1]) diff --git a/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py index 1e99e95edd88..debdd0103ae8 100644 --- a/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py +++ b/source/isaaclab_newton/test/assets/test_newton_actuators_newton.py @@ -598,7 +598,7 @@ def test_has_delay(self): def test_controller_is_pd(self): for a in self.result["actuator_info"]: - self.assertEqual(a["controller_type"], "ControllerPD") + self.assertEqual(a["controller_type"], "DrivePD") # --------------------------------------------------------------------------- @@ -772,7 +772,7 @@ def test_kfe_controller_is_pd(self): kfe_acts = [a for a in self.result["actuator_info"] if "ClampingPositionBased" in a["clamping_types"]] self.assertTrue(len(kfe_acts) > 0, "No actuator with position-based clamping found") for a in kfe_acts: - self.assertEqual(a["controller_type"], "ControllerPD") + self.assertEqual(a["controller_type"], "DrivePD") def test_kfe_has_position_based_clamping(self): kfe_acts = [a for a in self.result["actuator_info"] if "ClampingPositionBased" in a["clamping_types"]] @@ -845,11 +845,11 @@ def test_num_actuators(self): self.assertGreaterEqual(self.result["num_actuators"], 2) def test_has_neural_mlp_controller(self): - mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralMLP"] + mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "DriveNeuralMLP"] self.assertTrue(len(mlp_acts) > 0, "No NeuralMLP controller found") def test_mlp_has_dc_motor_clamping(self): - mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralMLP"] + mlp_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "DriveNeuralMLP"] for a in mlp_acts: self.assertIn("ClampingDCMotor", a["clamping_types"]) @@ -890,11 +890,11 @@ def test_num_actuators(self): self.assertGreaterEqual(self.result["num_actuators"], 2) def test_has_neural_lstm_controller(self): - lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralLSTM"] + lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "DriveNeuralLSTM"] self.assertTrue(len(lstm_acts) > 0, "No NeuralLSTM controller found") def test_lstm_has_dc_motor_clamping(self): - lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "ControllerNeuralLSTM"] + lstm_acts = [a for a in self.result["actuator_info"] if a["controller_type"] == "DriveNeuralLSTM"] for a in lstm_acts: self.assertIn("ClampingDCMotor", a["clamping_types"]) diff --git a/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py b/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py index 0f0338268778..846585da0dce 100644 --- a/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py +++ b/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py @@ -10,8 +10,8 @@ from unittest import mock import newton +import numpy as np import pytest -import torch from isaaclab_newton.cloner import copy_newton_clone_source, newton_builder_world_hook from isaaclab_newton.physics import NewtonManager @@ -98,8 +98,8 @@ def test_explicit_global_import_uses_global_world(monkeypatch): stage, (), (), - torch.arange(2), - torch.empty((0, 2), dtype=torch.bool), + np.arange(2, dtype=np.int64), + np.empty((0, 2), dtype=np.bool_), global_paths=global_paths, load_visual_shapes=False, ) diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 0fe1a2da2fad..fcd8db94044d 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -9,17 +9,12 @@ from unittest import mock import newton -import torch +import numpy as np import warp as wp from isaaclab_newton.cloner import newton_clone_utils as newton_clone_utils_module -from isaaclab_newton.cloner.newton_clone_utils import ( - _BUILTIN_LABEL_TYPES, - rename_builder_labels, - replicate_builder_mapping, -) +from isaaclab_newton.cloner.newton_clone_utils import rename_builder_labels, replicate_builder_mapping from isaaclab_newton.physics import visualization_builder as visualization_builder_module from isaaclab_newton.physics import visualization_deformables as visualization_deformables_module -from newton.solvers import SolverMuJoCo from pxr import Sdf, Usd, UsdGeom, UsdPhysics @@ -38,9 +33,8 @@ _VIS_BUILTIN_LABEL_ATTRS = tuple(attr for attr in _VIS_LABEL_SUFFIXES if attr != "equality_constraint_label") _VIS_EQ_FREQ = "mujoco:equality_constraint" -_TENDON_FREQ = "mujoco:tendon" -_SRC = "/Sources/protoA" -_DST = "/World/envs/env_{}" +_SRC = "/World/envs/env_0/protoA" +_DST = "/World/envs/env_{}/protoA" class _FakeVisualizationModelBuilder: @@ -64,6 +58,7 @@ def __init__(self, up_axis=None): references="world", ), } + self.custom_frequencies = {} self.geometry_sources = [] self.world_slices = [] self._current_world = None @@ -129,35 +124,10 @@ def _record_world_slice(self, label_start, label_end, geometry_start, geometry_e self.world_slices[self._current_world].append((label_start, label_end, geometry_start, geometry_end)) -def _inject_builtins(builder: newton.ModelBuilder, types: tuple[str, ...], src_path: str, worlds: list[int]) -> None: - for kind in types: - for world in worlds: - if kind == "equality_constraint": - builder.add_custom_values( - **{ - "mujoco:equality_constraint_label": f"{src_path}/{kind}_{world}", - "mujoco:equality_constraint_world": world, - } - ) - else: - getattr(builder, f"{kind}_label").append(f"{src_path}/{kind}_{world}") - getattr(builder, f"{kind}_world").append(world) - - -def _inject_tendons(builder: newton.ModelBuilder, src_path: str, worlds: list[int]) -> None: - labels = builder.custom_attributes["mujoco:tendon_label"].values = [] - world_ids = builder.custom_attributes["mujoco:tendon_world"].values = [] - for world in worlds: - labels.append(f"{src_path}/Tendon_{world}") - world_ids.append(world) - builder._custom_frequency_counts[_TENDON_FREQ] = len(worlds) - - def _make_builder(worlds: list[int]) -> newton.ModelBuilder: builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - _inject_builtins(builder, _BUILTIN_LABEL_TYPES, _SRC, worlds) - _inject_tendons(builder, _SRC, worlds) + builder.shape_label.extend(f"{_SRC}/shape_{world}" for world in worlds) + builder.shape_world.extend(worlds) return builder @@ -182,74 +152,11 @@ def _populate_custom_frequency(builder, freq_name, string_columns, worlds): builder._custom_frequency_counts[f"syn:{freq_name}"] = len(worlds) -class TestRenameBuilderLabels(unittest.TestCase): - def setUp(self): - self.worlds = [0, 1, 2] - self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) - self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) - - def _rename(self, builder): - rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - - def _assert_builtins(self, builder, types=_BUILTIN_LABEL_TYPES): - for kind in types: - if kind == "equality_constraint": - labels = builder.custom_attributes["mujoco:equality_constraint_label"].values - worlds = builder.custom_attributes["mujoco:equality_constraint_world"].values - else: - labels = getattr(builder, f"{kind}_label") - worlds = getattr(builder, f"{kind}_world") - self.assertEqual( - labels, - [f"{_DST.format(int(w))}/{kind}_{int(w)}" for w in worlds], - ) - - def test_builtin_and_tendon_labels_rewritten_per_world(self): - builder = _make_builder(self.worlds) - self._rename(builder) - self._assert_builtins(builder) - tendon_worlds = builder.custom_attributes["mujoco:tendon_world"].values - self.assertEqual( - builder.custom_attributes["mujoco:tendon_label"].values, - [f"{_DST.format(int(w))}/Tendon_{int(w)}" for w in tendon_worlds], - ) - - def test_source_root_boundary_cases(self): - builder = _make_builder(self.worlds) - builder.body_label.append(_SRC) - builder.body_world.append(self.worlds[0]) - self._rename(builder) - self.assertEqual(builder.body_label[-1], _DST.format(self.worlds[0])) - - builder = _make_builder(self.worlds) - rename_builder_labels(builder, [f"{_SRC}/"], [_DST], self.env_ids, self.mapping) - self._assert_builtins(builder) - - def test_unmatched_rows_left_untouched(self): - builder = _make_builder(self.worlds) - builder.body_label.append(f"{_SRC}/body_99") - builder.body_world.append(99) - builder.custom_attributes["mujoco:tendon_label"].values.append("named_tendon") - builder.custom_attributes["mujoco:tendon_world"].values.append(self.worlds[0]) - self._rename(builder) - self.assertEqual(builder.body_label[-1], f"{_SRC}/body_99") - self.assertEqual(builder.custom_attributes["mujoco:tendon_label"].values[-1], "named_tendon") - - def test_sparse_env_ids(self): - for worlds in ([10, 20, 30], [0, 1_000_000, 2_147_000_000]): - builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - _inject_builtins(builder, ("body",), _SRC, worlds) - env_ids = torch.tensor(worlds, dtype=torch.int32) - rename_builder_labels(builder, [_SRC], [_DST], env_ids, torch.ones(1, len(worlds), dtype=torch.bool)) - self._assert_builtins(builder, ("body",)) - - class TestRenameCustomAttributes(unittest.TestCase): def setUp(self): self.worlds = [0, 1] - self.env_ids = torch.tensor(self.worlds, dtype=torch.int32) - self.mapping = torch.ones(1, len(self.worlds), dtype=torch.bool) + self.env_ids = np.array([10, 20], dtype=np.int64) + self.mapping = np.ones((1, len(self.worlds)), dtype=np.bool_) def test_custom_string_columns_follow_frequency_worlds(self): builder = newton.ModelBuilder() @@ -264,16 +171,9 @@ def test_custom_string_columns_follow_frequency_worlds(self): for column in columns: self.assertEqual( builder.custom_attributes[f"syn:{column}"].values, - [f"{_DST.format(int(w))}/{column}_{int(w)}" for w in worlds], + [f"{_DST.format(int(self.env_ids[w]))}/{column}_{int(w)}" for w in worlds], ) - def test_empty_custom_string_column_passes_through(self): - builder = newton.ModelBuilder() - _add_custom_frequency(builder, "freqA", ["freqA_label"]) - rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - _populate_custom_frequency(builder, "freqA", ["freqA_label"], self.worlds) - self.assertEqual(len(builder.custom_attributes["syn:freqA_label"].values), len(self.worlds)) - def test_custom_string_columns_ignore_unset_world_rows(self): builder = newton.ModelBuilder() _add_custom_frequency(builder, "freqA", ["freqA_label"]) @@ -285,7 +185,7 @@ def test_custom_string_columns_ignore_unset_world_rows(self): self.assertEqual( builder.custom_attributes["syn:freqA_label"].values, - ["unassigned", f"{_DST.format(self.worlds[0])}/freqA_label_{self.worlds[0]}"], + ["unassigned", f"{_DST.format(int(self.env_ids[0]))}/freqA_label_{self.worlds[0]}"], ) def test_shape_material_paths_follow_shape_worlds(self): @@ -304,7 +204,9 @@ def test_shape_material_paths_follow_shape_worlds(self): rename_builder_labels(builder, [_SRC], [_DST], self.env_ids, self.mapping) - self.assertEqual(paths, {index: f"{_DST.format(index)}/Looks/material" for index in range(len(self.worlds))}) + self.assertEqual( + paths, {index: f"{_DST.format(int(self.env_ids[index]))}/Looks/material" for index in self.worlds} + ) def test_other_shape_attributes_without_world_references_pass_through(self): builder = _make_builder(self.worlds) @@ -325,26 +227,6 @@ def test_other_shape_attributes_without_world_references_pass_through(self): self.assertEqual(notes, {index: f"{_SRC}/note" for index in range(len(self.worlds))}) -class TestRenameMultiSource(unittest.TestCase): - def test_prefix_overlap_does_not_cross_contaminate(self): - sources = ["/Sources/protoA", "/Sources/protoAB"] - builder = newton.ModelBuilder() - SolverMuJoCo.register_custom_attributes(builder) - builder.body_label.extend([f"{sources[0]}/body", f"{sources[1]}/body"] * 2) - builder.body_world.extend([0, 0, 1, 1]) - rename_builder_labels( - builder, - sources, - ["/World/envs/env_{}", "/World/envs/env_{}"], - torch.tensor([0, 1], dtype=torch.int32), - torch.tensor([[1, 1], [1, 1]], dtype=torch.bool), - ) - self.assertEqual( - builder.body_label, - ["/World/envs/env_0/body", "/World/envs/env_0/body", "/World/envs/env_1/body", "/World/envs/env_1/body"], - ) - - class TestReplicateBuilderMapping(unittest.TestCase): @staticmethod def _source_builder(root_path: str): @@ -353,6 +235,7 @@ def _source_builder(root_path: str): return builder def test_source_local_sites_batched_with_correct_indices(self): + source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" source = newton.ModelBuilder() source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) site_idx = source.add_site(body=0, xform=wp.transform(), label="ee") @@ -364,17 +247,19 @@ def test_source_local_sites_batched_with_correct_indices(self): base_shape = builder.shape_count stride = source.shape_count - positions = torch.tensor([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]]) - quaternions = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * 3) + positions = np.array([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]], dtype=np.float32) + quaternions = np.array([[0.0, 0.0, 0.0, 1.0]] * 3, dtype=np.float32) with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: - local_site_map, _ = replicate_builder_mapping( + local_site_map, _, _ = replicate_builder_mapping( builder, - (_SRC,), - torch.ones((1, 3), dtype=torch.bool), + (source_path,), + np.ones((1, 3), dtype=np.bool_), positions, quaternions, - {_SRC: source}, + {source_path: source}, + destinations=(destination,), + env_ids=np.arange(3, dtype=np.int64), source_site_indices={id(source): {"ee": [site_idx]}}, ) @@ -383,27 +268,30 @@ def test_source_local_sites_batched_with_correct_indices(self): local_site_map["ee"], [[base_shape + world * stride + site_idx] for world in range(3)], ) - for world_indices in local_site_map["ee"]: - self.assertEqual(builder.shape_label[world_indices[0]], "ee") + for world, world_indices in enumerate(local_site_map["ee"]): + self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/ee") def test_env_root_sites_batched_at_correct_world_positions(self): + source_path, destination = "/World/envs/env_0", "/World/envs/env_{}" source = newton.ModelBuilder() source.add_body(xform=wp.transform((2.0, 0.0, 0.0), wp.quat_identity())) builder = newton.ModelBuilder() base_shape = builder.shape_count - positions = torch.tensor([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]]) - quaternions = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * 3) + positions = np.array([[2.0, 0.0, 0.0], [5.0, 0.0, 0.0], [8.0, 0.0, 0.0]], dtype=np.float32) + quaternions = np.array([[0.0, 0.0, 0.0, 1.0]] * 3, dtype=np.float32) env_root_offset = wp.transform((0.1, 0.0, 0.0), wp.quat_identity()) with mock.patch.object(builder, "replicate", wraps=builder.replicate) as replicate: - local_site_map, _ = replicate_builder_mapping( + local_site_map, _, _ = replicate_builder_mapping( builder, - (_SRC,), - torch.ones((1, 3), dtype=torch.bool), + (source_path,), + np.ones((1, 3), dtype=np.bool_), positions, quaternions, - {_SRC: source}, + {source_path: source}, + destinations=(destination,), + env_ids=np.arange(3, dtype=np.int64), env_root_sites={"origin": env_root_offset}, ) @@ -418,23 +306,26 @@ def test_env_root_sites_batched_at_correct_world_positions(self): site_pos = builder.shape_transform[world_indices[0]].p self.assertAlmostEqual(float(site_pos[0]), float(positions[world][0]) + 0.1, places=5) self.assertAlmostEqual(float(site_pos[1]), 0.0, places=5) - self.assertEqual(builder.shape_label[world_indices[0]], "origin") + self.assertEqual(builder.shape_label[world_indices[0]], f"/World/envs/env_{world}/origin") def test_inactive_source_rows_are_ignored(self): - sources = ("/Sources/inactive", "/Sources/active") + sources = ("/World/envs/env_0/inactive", "/World/envs/env_0/active") source_builders = {source: self._source_builder(source) for source in sources} + source_builders[sources[0]].body_label.append("/outside/the/plan") builder = _FakeVisualizationModelBuilder() replicate_builder_mapping( builder, sources, - torch.tensor([[False, False], [True, False]], dtype=torch.bool), - torch.tensor([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), - torch.tensor([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]]), + np.array([[False, False], [True, False]], dtype=np.bool_), + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32), + np.array([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]], dtype=np.float32), source_builders, + destinations=("/World/envs/env_{}/inactive", "/World/envs/env_{}/active"), + env_ids=np.arange(2, dtype=np.int64), ) - self.assertEqual(builder.geometry_sources_for_world(0), ["/Sources/active"]) + self.assertEqual(builder.geometry_sources_for_world(0), ["/World/envs/env_0/active"]) self.assertEqual(builder.geometry_sources_for_world(1), []) @@ -451,8 +342,8 @@ def test_clone_plan_expands_prototype_deformables_to_selected_environments(self) clone_plan = ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 4), dtype=torch.bool), - env_ids=torch.arange(4), + clone_mask=np.ones((1, 4), dtype=np.bool_), + env_ids=np.arange(4, dtype=np.int64), ) entries = visualization_deformables_module._expand_clone_plan_deformable_entries([entry], clone_plan) @@ -527,8 +418,9 @@ def test_visualization_builder_disables_collision_pairs(self): clone_plan = ClonePlan( sources=(robot_path,), destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.asarray(((0.0, 0.0, 0.0), (2.0, 0.0, 0.0)), dtype=np.float32), ) for env_paths, plan, expected_shape_count in ( ([], None, 2), @@ -550,8 +442,8 @@ def test_visualization_builder_rejects_clone_plan_without_environment_paths(self clone_plan = ClonePlan( sources=(), destinations=(), - clone_mask=torch.empty((0, 0), dtype=torch.bool), - env_ids=torch.empty(0, dtype=torch.long), + clone_mask=np.empty((0, 0), dtype=np.bool_), + env_ids=np.empty(0, dtype=np.int64), ) with ( @@ -566,7 +458,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) self._define_xform(stage, "/World") self._define_xform(stage, "/World/envs") - env_paths = [(env_id, f"/World/envs/env_{env_id}") for env_id in (0, 1, 2)] + env_paths = [(env_id, f"/World/envs/env_{env_id}") for env_id in (0, 1)] for env_id, env_path in env_paths: self._define_xform(stage, env_path, (float(env_id) * 3.0, 0.0, 0.0)) self._define_xform(stage, f"{env_path}/Object") @@ -576,8 +468,9 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) clone_plan = ClonePlan( sources=("/World/envs/env_0/Object", "/World/envs/env_1/Object"), destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), - clone_mask=torch.tensor([[True, False, True], [False, True, False]], dtype=torch.bool), - env_ids=torch.tensor([0, 1, 2], dtype=torch.long), + clone_mask=np.array([[True, False, True], [False, True, False]], dtype=np.bool_), + env_ids=np.array([0, 1, 2], dtype=np.int64), + positions=np.asarray(((0.0, 0.0, 0.0), (3.0, 0.0, 0.0), (6.0, 0.0, 0.0)), dtype=np.float32), ) with ( @@ -608,5 +501,151 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) ) +class TestReplicationNamesItsCopies(unittest.TestCase): + _SRC = "/World/envs/env_0/Robot" + _ENV = "/World/envs/env_{}" + + def test_batched_prefixes_name_each_world_and_preserve_the_prototype(self): + source = newton.ModelBuilder() + body = source.add_body(xform=wp.transform(), label=self._SRC) + source.add_shape_box(body=body, label=f"{self._SRC}/shape") + child = source.add_link(xform=wp.transform(), label=f"{self._SRC}/link") + source.add_joint_revolute(parent=body, child=child, axis=(0.0, 0.0, 1.0), label=f"{self._SRC}/hinge") + + def resolve_motor_owners(builder): + labels = builder.custom_attributes["syn:motor_label"].values + targets = builder.custom_attributes["syn:motor_target"].values + return [0 if label == target else -1 for label, target in zip(labels, targets, strict=True)] + + source.add_custom_frequency( + newton.ModelBuilder.CustomFrequency( + name="motor", + namespace="syn", + label_attribute="syn:motor_label", + articulation_owner_attribute="syn:motor_articulation", + articulation_owner_resolver=resolve_motor_owners, + ) + ) + for name, dtype, default, references in ( + ("motor_label", str, "", None), + ("motor_target", str, "", None), + ("motor_world", int, -1, "world"), + ("motor_articulation", int, -1, "articulation"), + ): + source.add_custom_attribute( + newton.ModelBuilder.CustomAttribute( + name=name, + namespace="syn", + frequency="syn:motor", + dtype=dtype, + default=default, + references=references, + ) + ) + source.custom_attributes["syn:motor_label"].values = [f"{self._SRC}/motor"] + source.custom_attributes["syn:motor_target"].values = [f"{self._SRC}/motor"] + source.custom_attributes["syn:motor_world"].values = [-1] + source._custom_frequency_counts["syn:motor"] = 1 + original = { + name: list(getattr(source, name)) + for name in ("body_label", "joint_label", "shape_label", "articulation_label") + } + builder = newton.ModelBuilder() + env_ids = np.array([10, 20], dtype=np.int64) + mapping = np.ones((1, len(env_ids)), dtype=np.bool_) + positions = np.zeros((len(env_ids), 3), dtype=np.float32) + quaternions = np.zeros((len(env_ids), 4), dtype=np.float32) + quaternions[:, 3] = 1.0 + replicate_builder_mapping( + builder, + [self._SRC], + mapping, + positions, + quaternions, + {self._SRC: source}, + destinations=["/World/envs/env_{}/Robot"], + env_ids=env_ids, + ) + for name, source_labels in original.items(): + expected = [ + label.replace(self._SRC, f"{self._ENV.format(i)}/Robot", 1) for i in env_ids for label in source_labels + ] + self.assertEqual(getattr(builder, name), expected) + self.assertEqual(getattr(source, name), source_labels) + + expected_labels = [f"{self._ENV.format(i)}/Robot/motor" for i in env_ids] + self.assertEqual(builder.custom_attributes["syn:motor_label"].values, expected_labels) + self.assertEqual(builder.custom_attributes["syn:motor_articulation"].values, [0, source.articulation_count]) + self.assertEqual(builder.custom_attributes["syn:motor_target"].values, expected_labels) + self.assertEqual(source.custom_attributes["syn:motor_label"].values, [f"{self._SRC}/motor"]) + + def test_hook_labels_are_rewritten_after_the_slow_path(self): + source = newton.ModelBuilder() + source.add_body(label=f"{self._SRC}/base") + builder = newton.ModelBuilder() + env_ids = np.array([10, 20], dtype=np.int64) + mapping = np.ones((1, 2), dtype=np.bool_) + + def hook(builder, *_): + builder.add_body(label=f"{self._SRC}/hook") + + replicate_builder_mapping( + builder, + (self._SRC,), + mapping, + np.zeros((2, 3), dtype=np.float32), + np.array([[0.0, 0.0, 0.0, 1.0]] * 2, dtype=np.float32), + {self._SRC: source}, + destinations=("/World/envs/env_{}/Robot",), + env_ids=env_ids, + per_world_builder_hooks=(hook,), + ) + self.assertEqual( + builder.body_label, + [f"/World/envs/env_{env_id}/Robot/{label}" for env_id in env_ids for label in ("base", "hook")], + ) + + +class TestRootJointNaming(unittest.TestCase): + """The importer leaves a floating base's root joint unnamed; every other entity is named.""" + + _SOURCE = "/World/envs/env_0/Robot" + _BODY = "/World/envs/env_0/Robot/pelvis" + + @staticmethod + def _builder_with_free_root(body_label: str) -> newton.ModelBuilder: + builder = newton.ModelBuilder() + body = builder.add_link(xform=wp.transform(), label=body_label) + builder.add_joint_free(child=body) + return builder + + def test_a_generated_root_joint_name_becomes_its_body_path(self): + builder = self._builder_with_free_root(self._BODY) + self.assertFalse(builder.joint_label[0].startswith("/")) + + newton_clone_utils_module._name_root_joints_after_their_body(builder) + + self.assertEqual(builder.joint_label[0], f"{self._BODY}_free_joint") + + def test_other_joint_labels_are_left_alone(self): + named = self._builder_with_free_root(self._BODY) + named.joint_label[0] = "authored" + + non_free = newton.ModelBuilder() + parent = non_free.add_link(xform=wp.transform(), label=self._BODY) + child = non_free.add_link(xform=wp.transform(), label=f"{self._BODY}/link") + non_free.add_joint_revolute(parent=parent, child=child, axis=(0.0, 0.0, 1.0)) + + non_root = newton.ModelBuilder() + parent = non_root.add_link(xform=wp.transform(), label=self._BODY) + child = non_root.add_link(xform=wp.transform(), label=f"{self._BODY}/link") + non_root.add_joint_free(parent=parent, child=child) + + for builder in (named, non_free, non_root): + original = list(builder.joint_label) + newton_clone_utils_module._name_root_joints_after_their_body(builder) + self.assertEqual(builder.joint_label, original) + + if __name__ == "__main__": unittest.main() diff --git a/source/isaaclab_newton/test/cloner/test_visual_shape_import.py b/source/isaaclab_newton/test/cloner/test_visual_shape_import.py index 98989544e5b2..1cbda87afe3a 100644 --- a/source/isaaclab_newton/test/cloner/test_visual_shape_import.py +++ b/source/isaaclab_newton/test/cloner/test_visual_shape_import.py @@ -5,6 +5,8 @@ """Unit tests: the Newton cloner imports visual-only geometry only when it is drawn.""" +from types import SimpleNamespace + import newton from isaaclab_newton.cloner import newton_clone_utils from isaaclab_newton.cloner import replicate as replicate_module @@ -101,6 +103,62 @@ def test_skipping_visual_shapes_skips_collider_visibility_resolution(self): # ...and the collider was visible either way, so skipping it changed nothing. assert list(builder.shape_flags) == flags_before + def test_nested_static_collider_uses_rigid_body_root_for_visual_lookup(self): + """A collision subtree stays hidden when its rigid-body root has visuals.""" + stage = Usd.Stage.CreateInMemory() + root = UsdGeom.Xform.Define(stage, _SOURCE) + UsdPhysics.RigidBodyAPI.Apply(root.GetPrim()) + collider = UsdGeom.Cube.Define(stage, f"{_SOURCE}/Collisions/collision") + UsdPhysics.CollisionAPI.Apply(collider.GetPrim()) + UsdGeom.Cube.Define(stage, f"{_SOURCE}/Visuals/visual") + builder = SimpleNamespace( + shape_body=[-1], + shape_flags=[ShapeFlags.COLLIDE_SHAPES], + shape_label=[str(collider.GetPrim().GetPath())], + shape_type=[newton.GeoType.BOX], + ) + + newton_clone_utils._restore_visible_colliders_without_visual_shapes( + builder, stage, {str(collider.GetPrim().GetPath()): 0} + ) + + assert not builder.shape_flags[0] & ShapeFlags.VISIBLE + + def test_static_collider_without_rigid_body_or_visual_remains_visible(self): + """A standalone static collider retains its authored viewport visibility.""" + stage = Usd.Stage.CreateInMemory() + collider = UsdGeom.Cube.Define(stage, f"{_SOURCE}/collision") + UsdPhysics.CollisionAPI.Apply(collider.GetPrim()) + builder = SimpleNamespace( + shape_body=[-1], + shape_flags=[ShapeFlags.COLLIDE_SHAPES], + shape_label=[str(collider.GetPrim().GetPath())], + shape_type=[newton.GeoType.BOX], + ) + + newton_clone_utils._restore_visible_colliders_without_visual_shapes( + builder, stage, {str(collider.GetPrim().GetPath()): 0} + ) + + assert builder.shape_flags[0] & ShapeFlags.VISIBLE + + def test_generated_proxy_collider_visual_remains_hidden(self): + """Newton's unauthored visual companion must not expose a proxy collider.""" + stage = Usd.Stage.CreateInMemory() + collider = UsdGeom.Cube.Define(stage, f"{_SOURCE}/collision") + UsdPhysics.CollisionAPI.Apply(collider.GetPrim()) + collider_path = str(collider.GetPrim().GetPath()) + builder = SimpleNamespace( + shape_body=[-1, -1], + shape_flags=[ShapeFlags.COLLIDE_SHAPES, ShapeFlags.VISIBLE], + shape_label=[collider_path, f"{collider_path}_visual"], + shape_type=[newton.GeoType.BOX, newton.GeoType.MESH], + ) + + newton_clone_utils._restore_visible_colliders_without_visual_shapes(builder, stage, {collider_path: 0}) + + assert not builder.shape_flags[1] & ShapeFlags.VISIBLE + class _StubSim: def __init__(self, is_rendering: bool, can_render_rgb_array: bool, visual_shapes_required: bool = False): @@ -116,9 +174,7 @@ class TestRendererWantsVisualShapes: """The auto mode must follow whatever will actually draw the shapes.""" def _patch_sim(self, monkeypatch, sim): - import isaaclab.sim as sim_module - - monkeypatch.setattr(sim_module.SimulationContext, "instance", staticmethod(lambda: sim)) + monkeypatch.setattr(replicate_module.PhysicsManager, "_sim", sim) def test_headless_run_skips_visual_shapes(self, monkeypatch): """Nothing rendering means nothing needs the visual-only shapes.""" diff --git a/source/isaaclab_newton/test/physics/test_mjwarp_view_compat.py b/source/isaaclab_newton/test/physics/test_mjwarp_view_compat.py deleted file mode 100644 index 407c950ba169..000000000000 --- a/source/isaaclab_newton/test/physics/test_mjwarp_view_compat.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from types import SimpleNamespace - -import numpy as np -import pytest -import warp as wp -from isaaclab_newton.physics._mjwarp_view_compat import ensure_newton_custom_frequency_api - -pytestmark = pytest.mark.unit - - -def _model(hands_per_world=2, worlds=2, actuators_per_hand=2, tendons_per_hand=1): - """Build a model shaped the way Newton reports one. - - Ownership comes from the per-row world assignments, not the labels: MEASURED, a 2-clone - Shadow Hand scene reports ``articulation_label`` as the template path for every clone. - """ - total_hands = hands_per_world * worlds - actuator_world, tendon_world = [], [] - for world in range(worlds): - actuator_world += [world] * (hands_per_world * actuators_per_hand) - tendon_world += [world] * (hands_per_world * tendons_per_hand) - return SimpleNamespace( - articulation_count=total_hands, - # Template path, identical for every clone -- deliberately useless for ownership. - articulation_label=["/World/Env_0/Robot"] * total_hands, - custom_frequency_counts={ - "mujoco:actuator": len(actuator_world), - "mujoco:tendon": len(tendon_world), - }, - mujoco=SimpleNamespace( - actuator_world=np.array(actuator_world), - tendon_world=np.array(tendon_world), - actuator_target_label=[f"/World/Env_0/Robot/joint_{i}" for i in range(len(actuator_world))], - ctrl=wp.zeros(len(actuator_world), dtype=wp.float32, device="cpu"), - ), - ) - - -def _view(articulation_ids, *, world_count=2, count_per_world=1, tendon_offset=0, tendon_count=1): - return SimpleNamespace( - device=wp.get_device("cpu"), - world_count=world_count, - count_per_world=count_per_world, - count=world_count * count_per_world, - articulation_ids=np.array(articulation_ids).reshape(world_count, count_per_world), - tendon_count=tendon_count, - tendon_names=[f"t{i}" for i in range(tendon_count)], - # The offset is this articulation's first tendon row, which is what places it among the - # articulations sharing its world. - frequency_layouts={"mujoco:tendon": SimpleNamespace(offset=tendon_offset)}, - ) - - -def _right_hand(model=None): - """The first hand in each world: tendon offset 0.""" - return ensure_newton_custom_frequency_api(_view([0, 2], tendon_offset=0), model or _model())[0] - - -def test_a_view_that_already_has_the_api_is_returned_unchanged(): - """On Newton 1.6 the wrapper must not interpose at all.""" - view = _view([0, 2]) - view.custom_frequency_counts = {"mujoco:actuator": 2} - - assert ensure_newton_custom_frequency_api(view, _model())[0] is view - - -def test_counts_come_from_the_world_partition_not_the_totals(): - """8 actuators over 4 articulations happens to divide, but the world partition is what rules.""" - model = _model() - - assert model.custom_frequency_counts["mujoco:actuator"] == 8 - assert _right_hand(model).custom_frequency_counts["mujoco:actuator"] == 2 - - -def test_cloned_articulations_take_their_own_world_block(): - """The CI failure: one articulation cloned across worlds, whose labels are all identical.""" - model = _model(hands_per_world=1, worlds=2, actuators_per_hand=20, tendons_per_hand=4) - adapted = ensure_newton_custom_frequency_api(_view([0, 1], tendon_offset=0, tendon_count=4), model)[0] - - assert adapted.custom_frequency_counts["mujoco:actuator"] == 20 - rows = adapted._rows_per_instance - # World 0 owns rows 0-19, world 1 owns 20-39; the clones must not share. - assert rows[0].tolist() == list(range(20)) - assert rows[1].tolist() == list(range(20, 40)) - - -def test_unrelated_attributes_are_delegated_to_the_wrapped_view(): - view = _view([0, 2]) - view.some_newton_attribute = "delegated" - adapted = ensure_newton_custom_frequency_api(view, _model())[0] - - assert adapted.some_newton_attribute == "delegated" - assert adapted.world_count == 2 - - -def test_two_articulations_in_one_world_do_not_claim_each_others_actuators(): - """The handover scene puts two hands in each world, and each view must take only its own.""" - model = _model() - right = ensure_newton_custom_frequency_api(_view([0, 2], tendon_offset=0), model)[0] - left = ensure_newton_custom_frequency_api(_view([1, 3], tendon_offset=1), model)[0] - - right.set_attribute( - "mujoco.ctrl", model, wp.array(np.array([[[1.0, 2.0]], [[5.0, 6.0]]], dtype=np.float32), device="cpu") - ) - left.set_attribute( - "mujoco.ctrl", model, wp.array(np.array([[[3.0, 4.0]], [[7.0, 8.0]]], dtype=np.float32), device="cpu") - ) - - # Right owns rows 0-1 and 4-5, left owns 2-3 and 6-7; neither overwrote the other. - np.testing.assert_array_equal(model.mujoco.ctrl.numpy(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) - - -def test_actuator_rows_are_read_back_in_view_shape(): - model = _model() - model.mujoco.actuator_trntype = np.arange(8, dtype=np.int32) - - got = _right_hand(model).get_attribute("mujoco.actuator_trntype", model).numpy() - - assert got.shape == (2, 1, 2) - # The right hand owns rows 0-1 in world 0 and 4-5 in world 1. - np.testing.assert_array_equal(got[:, 0, :], [[0, 1], [4, 5]]) - - -def test_a_mismatched_command_shape_is_refused(): - """Silently broadcasting the wrong shape would drive arbitrary actuators.""" - model = _model() - - with pytest.raises(ValueError, match="Expected values shaped"): - _right_hand(model).set_attribute("mujoco.ctrl", model, wp.zeros((2, 1, 3), dtype=wp.float32, device="cpu")) diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 6ded442b4c6b..2edd2f7f2d4f 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -32,7 +32,6 @@ import isaaclab_newton.physics.newton_manager as newton_manager_module import numpy as np import pytest -import torch import warp as wp from isaaclab_newton.assets.articulation import articulation as articulation_module from isaaclab_newton.physics import ( @@ -589,7 +588,8 @@ def test_mpm_solver_cfg_forwards_every_solver_field(field_name, value): @pytest.mark.parametrize("field_name, value", _KAMINO_PADMM_FIELD_VALUES) def test_kamino_solver_cfg_forwards_padmm_fields(field_name, value): """Every tunable P-ADMM cfg field round-trips into ``PADMMSolverConfig``.""" - solver_cfg = KaminoPADMMSolverCfg(dynamics_solver_cfg=KaminoPADMMCfg(**{field_name: value})) + sparse_kwargs = {"sparse_jacobian": True, "sparse_dynamics": True} if field_name == "penalty_update_method" else {} + solver_cfg = KaminoPADMMSolverCfg(**sparse_kwargs, dynamics_solver_cfg=KaminoPADMMCfg(**{field_name: value})) newton_cfg = solver_cfg.to_solver_config() assert hasattr(newton_cfg.padmm, field_name), ( f"{field_name!r} disappeared from PADMMSolverConfig — KaminoPADMMCfg needs to drop or rename it." @@ -597,6 +597,13 @@ def test_kamino_solver_cfg_forwards_padmm_fields(field_name, value): assert getattr(newton_cfg.padmm, field_name) == value +def test_kamino_padmm_rejects_adaptive_penalties_with_dense_dynamics(): + """Kamino's adaptive P-ADMM penalties require the sparse solver path.""" + solver_cfg = KaminoPADMMSolverCfg(dynamics_solver_cfg=KaminoPADMMCfg(penalty_update_method="balanced")) + with pytest.raises(ValueError, match="sparse_dynamics=True"): + solver_cfg.to_solver_config() + + @pytest.mark.parametrize("field_name, value", _KAMINO_DVI_FIELD_VALUES) def test_kamino_solver_cfg_forwards_dvi_fields(field_name, value): """Every tunable DVI cfg field round-trips into ``DVISolverConfig``.""" @@ -845,8 +852,8 @@ def test_production_imports_scope_mujoco_joint_properties( stage=stage, sources=(root_path,), destinations=("/World/envs/env_{}/robot",), - env_ids=torch.tensor([0], dtype=torch.int64), - mapping=torch.ones((1, 1), dtype=torch.bool), + env_ids=np.array([0], dtype=np.int64), + mapping=np.ones((1, 1), dtype=np.bool_), load_visual_shapes=False, ) else: diff --git a/source/isaaclab_newton/test/physics/test_vbd_core.py b/source/isaaclab_newton/test/physics/test_vbd_core.py index 3604fa2d8fc7..0a946d1eca89 100644 --- a/source/isaaclab_newton/test/physics/test_vbd_core.py +++ b/source/isaaclab_newton/test/physics/test_vbd_core.py @@ -100,14 +100,14 @@ def test_vbd_excludes_registered_deformable_meshes(monkeypatch, env_paths): class Builder: def __init__(self): self.imports = [] - self.color_calls = 0 + self.color_calls = [] def add_usd(self, stage, *, root_path=None, ignore_paths=(), schema_resolvers=()): self.imports.append((root_path, list(ignore_paths))) return {"path_shape_map": {}} - def color(self): - self.color_calls += 1 + def color(self, *, balance_colors): + self.color_calls.append(balance_colors) children = [ SimpleNamespace( @@ -132,7 +132,7 @@ def create_builder(cls, *, up_axis): def replicate(*args, **kwargs): replicate_calls.append(kwargs) - return {}, [object() for _ in env_paths] + return {}, [object() for _ in env_paths], [] monkeypatch.setattr(newton_module, "get_current_stage", lambda: stage) monkeypatch.setattr(pxr, "UsdGeom", usd_geom) @@ -179,7 +179,7 @@ def hook(builder, world_idx, position, rotation): else: assert builders[0].imports == [(None, ["/World/terrain", *deformable_paths])] assert hook_calls == [0] - assert builders[0].color_calls == 1 + assert builders[0].color_calls == [False] def test_vbd_colors_prebuilt_builder_before_start(monkeypatch): @@ -189,8 +189,8 @@ def test_vbd_colors_prebuilt_builder_before_start(monkeypatch): events = [] class Builder: - def color(self): - events.append("color") + def color(self, *, balance_colors): + events.append(("color", balance_colors)) monkeypatch.setattr(physics.NewtonVBDManager, "_builder", Builder()) monkeypatch.setattr(NewtonManager, "start_simulation", classmethod(lambda cls: events.append("start"))) @@ -198,7 +198,7 @@ def color(self): physics.NewtonVBDManager.start_simulation() - assert events == ["color", "start"] + assert events == [("color", False), "start"] @pytest.mark.parametrize("external_rigid_solver", [False, True]) diff --git a/source/isaaclab_newton/test/renderers/test_segmentation.py b/source/isaaclab_newton/test/renderers/test_segmentation.py index 929e03c252e6..af5368ba48b6 100644 --- a/source/isaaclab_newton/test/renderers/test_segmentation.py +++ b/source/isaaclab_newton/test/renderers/test_segmentation.py @@ -9,14 +9,13 @@ from types import SimpleNamespace +import numpy as np import pytest -pytest.importorskip("numpy") pytest.importorskip("torch") pytest.importorskip("warp") pytest.importorskip("pxr") -import torch from isaaclab_newton.renderers.segmentation import NewtonSegmentationMapper from pxr import Usd @@ -31,7 +30,7 @@ def _empty_clone_plan() -> ClonePlan: """A clone plan owning nothing, standing in for scenes with no replicated shapes to fall back to.""" - return ClonePlan(sources=(), destinations=(), clone_mask=torch.zeros(0, 0, dtype=torch.bool)) + return ClonePlan(sources=(), destinations=(), clone_mask=np.zeros((0, 0), dtype=np.bool_)) def _cfg(**overrides): @@ -191,10 +190,6 @@ def _prototype_only_scene(): env, so the clone plan spreads the asset to env_1 while the stage never gains env_1 prims. Returns the stage, the per-shape prim-path list, and the matching clone plan. """ - import torch - - from isaaclab.cloner import ClonePlan - stage = Usd.Stage.CreateInMemory() # Only the prototype env is authored; env_1 exists in the Newton model alone. for path in ("/World/envs/env_0/Robot/pole/geom", "/World/envs/env_0/Robot/cart/geom", "/World/ground/geom"): @@ -212,8 +207,8 @@ def _prototype_only_scene(): plan = ClonePlan( sources=("/World/envs/env_0/Robot",), destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones(1, 2, dtype=torch.bool), - env_ids=torch.tensor([0, 1]), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.array([0, 1], dtype=np.int64), ) return stage, shape_paths, plan diff --git a/source/isaaclab_ov/changelog.d/antoiner-physx-lazy-contact-loss.rst b/source/isaaclab_ov/changelog.d/antoiner-physx-lazy-contact-loss.rst new file mode 100644 index 000000000000..4025a93d7cde --- /dev/null +++ b/source/isaaclab_ov/changelog.d/antoiner-physx-lazy-contact-loss.rst @@ -0,0 +1,10 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_ov.sensors.ContactSensor` reporting the last in-contact force forever after a body + left contact on GPU (issue #7613), when the sensor was configured with ``history_length=0`` and its data + was read less often than every physics step (for example once per policy step with + ``lazy_sensor_update=True``). PhysX zeroes the net contact force of a body only on the exact physics step + where its contact is lost, so a lazily refreshed sensor skipped that step. The ovphysx contact bindings + are now read on every physics step regardless of the history length, while the warp kernels that consume + the fetched buffers stay lazy. diff --git a/source/isaaclab_ov/changelog.d/fix-implicit-effort-submission.rst b/source/isaaclab_ov/changelog.d/fix-implicit-effort-submission.rst new file mode 100644 index 000000000000..3c4cf9c96238 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-implicit-effort-submission.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed implicit actuator PD estimates being submitted as additional joint forces + alongside the native ovphysx joint drives. Implicit actuators now submit only + their feedforward effort commands while retaining PD estimates as telemetry. diff --git a/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst new file mode 100644 index 000000000000..89726882fc2b --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-legacy-ovrtx-instance-cloning.rst @@ -0,0 +1,6 @@ +Fixed +^^^^^ + +* Fixed missing cloned visuals in the legacy OVRTX renderer by expanding nested USD instances + in its exported clone sources. Simulation stages and the ovstage renderer path retained + their original instancing. diff --git a/source/isaaclab_ov/changelog.d/fix-ovphysx-static-property-rereads.rst b/source/isaaclab_ov/changelog.d/fix-ovphysx-static-property-rereads.rst new file mode 100644 index 000000000000..632fd9866b27 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/fix-ovphysx-static-property-rereads.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Fixed OVPhysX articulation joint properties (stiffness, damping, armature, position/velocity/effort + limits, friction) and body mass/inertia being re-read from their CPU-only bindings on every simulation + step. These static properties are now read once after invalidation and kept current by the + ``write_*`` / ``set_*`` setters. This removes three blocking host round-trips per physics step from the + native Newton actuator path, which made it several times slower than the Isaac Lab actuator path on + OVPhysX at large environment counts. diff --git a/source/isaaclab_ov/changelog.d/fix-ovrtx-clone-plan-test-import.skip b/source/isaaclab_ov/changelog.d/fix-ovrtx-clone-plan-test-import.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/changelog.d/ooctipus-clone-plan-contract.major.rst b/source/isaaclab_ov/changelog.d/ooctipus-clone-plan-contract.major.rst new file mode 100644 index 000000000000..186a0f2b2e4d --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ooctipus-clone-plan-contract.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **Breaking:** Routed production OvPhysX cloning through the simulation-owned + ``OvPhysxReplicateContext.replicate(plan)`` contract and removed ``PHYSICS_CONTEXT``, ``queue(...)``, + and ``queue_mapping(...)``. Standalone tooling may continue to use ``ovphysx_replicate(...)`` with + NumPy arrays; its unused ``device`` argument was removed. diff --git a/source/isaaclab_ov/changelog.d/ooctipus-plan-owned-world-frames.rst b/source/isaaclab_ov/changelog.d/ooctipus-plan-owned-world-frames.rst new file mode 100644 index 000000000000..17f8c382ae5d --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ooctipus-plan-owned-world-frames.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Projected source-only world frames from clone-plan positions when destination USD environment prims are absent. diff --git a/source/isaaclab_ov/changelog.d/ovphysx-0-6-lifecycle.rst b/source/isaaclab_ov/changelog.d/ovphysx-0-6-lifecycle.rst new file mode 100644 index 000000000000..f5deabe4c94f --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovphysx-0-6-lifecycle.rst @@ -0,0 +1,6 @@ +Added +^^^^^ + +* Added version-selected support for the OVPhysX 0.6 ``warmup()`` and ``destroy()`` + lifecycle APIs while retaining the released 0.5.11 ``warmup_gpu()`` and + ``release()`` path. The public extras remain pinned to ``ovphysx==0.5.11``. diff --git a/source/isaaclab_ov/changelog.d/ovphysx-06.rst b/source/isaaclab_ov/changelog.d/ovphysx-06.rst new file mode 100644 index 000000000000..057362f3099c --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovphysx-06.rst @@ -0,0 +1,9 @@ +Fixed +^^^^^ + +* Disabled the reversed-joint sign correction for OvPhysX 0.6 and newer, which already returned Jacobians and mass matrices + in the public joint basis. Preserved the correction for the default OvPhysX 0.5.11 + runtime and custom joint and body ordering on both versions. +* Removed an unnecessary reversed-joint sign correction from gravity compensation forces on OvPhysX 0.5.11. +* Registered the OvPhysX codeless physics schemas with OVStage before scene population when its registration API was + available, including when the host USD registry already provided those schemas. diff --git a/source/isaaclab_ov/changelog.d/ovrtx-05-render-var-keys.rst b/source/isaaclab_ov/changelog.d/ovrtx-05-render-var-keys.rst new file mode 100644 index 000000000000..085f4e9e5785 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovrtx-05-render-var-keys.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added compatibility with the OVRTX 0.5 ``frame.render_vars`` API, which keys render vars by the + authored RenderVar prim path (for example ``/Render/Vars/LdrColor``) instead of the source name. + The key form is resolved from the installed ``ovrtx`` version when + :mod:`isaaclab_ov.renderers.ovrtx_compat` is imported; OVRTX 0.4 keeps source-name keys and the + public extras stay pinned to ``ovrtx==0.4.1.364340``. diff --git a/source/isaaclab_ov/changelog.d/ovstage-gpu-hierarchy-computation.rst b/source/isaaclab_ov/changelog.d/ovstage-gpu-hierarchy-computation.rst new file mode 100644 index 000000000000..36cd364c82d3 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ovstage-gpu-hierarchy-computation.rst @@ -0,0 +1,11 @@ +Changed +^^^^^^^ + +* Changed :func:`~isaaclab_ov.stage.create_ovstage` to select the ovstage hierarchy computation + model from the installed ovstage version instead of always requesting + ``HierarchyComputationModel.CPU_INCREMENTAL``. ovstage 0.2 and later place objects correctly + under ``GPU_INCREMENTAL``, which moves world-transform computation off the host; ovstage 0.1 + keeps the host model. The version is resolved once at import by the new + :mod:`isaaclab_ov.ovstage_compat` module. No migration is required: the public extras stay + pinned to ``ovstage==0.1.1.355824``, so the host model remains in force until that pin moves, + and a missing or unparsable install also keeps the host model. diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/actuator_control.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/actuator_control.py index 3d2b475c66e8..b3d53f4ea2ae 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/actuator_control.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/actuator_control.py @@ -128,13 +128,13 @@ def stage_user_command( def submit_commands(self, collection: ActuatorCollection) -> None: articulation = self._articulation - # Native telemetry contains the local implicit-drive shadow. Submit raw runtime effort - # instead, so OVPhysX evaluates each implicit PD drive exactly once. + # Submit the processed effort command, not telemetry that includes the implicit PD estimate. + # The native runtime similarly provides effort without the implicit-drive estimate. write_effort = articulation._can_write_effort # position and velocity targets only for implicit actuators. write_pos = articulation._has_implicit_actuators and articulation._can_write_pos_target write_vel = articulation._has_implicit_actuators and articulation._can_write_vel_target - user_effort = collection._applied_effort + user_effort = collection._joint_effort_target_sim if self._actuator_runtime is not None: user_effort = self._actuator_runtime.wrapper.joint_f_2d if articulation.data.has_joint_ordering: diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index 6ef0b4ee0a2d..98efca433794 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -39,6 +39,7 @@ from isaaclab_ov import tensor_types as TT from isaaclab_ov.assets import kernels as shared_kernels from isaaclab_ov.physics import OvPhysxManager +from isaaclab_ov.physics.ovphysx_compat import OVPHYSX_VERSION, requires_legacy_joint_sign_correction from isaaclab_ov.sim.views.ovphysx_view import OvPhysxView from .actuator_control import OvPhysxActuatorControl @@ -3958,11 +3959,13 @@ def has_articulation_root_api(prim) -> bool: self._root_view.try_binding_for(tt) # construct the data container; counts come from the view's bindings - joint_dof_signs = self._resolve_joint_dof_signs(stage) self._data = ArticulationData(self._root_view, self._device) - if -1 in joint_dof_signs: - self._data._joint_dof_signs = wp.array(joint_dof_signs, dtype=wp.int32, device=self.device) - self._data._has_reversed_joints = True + # OvPhysX 0.6 already corrects reversed-joint dynamics in the runtime. + if requires_legacy_joint_sign_correction(OVPHYSX_VERSION): + joint_dof_signs = self._resolve_joint_dof_signs(stage) + if -1 in joint_dof_signs: + self._data._joint_dof_signs = wp.array(joint_dof_signs, dtype=wp.int32, device=self.device) + self._data._has_reversed_joints = True self._resolve_and_install_ordering_maps() self._data.fixed_tendon_names = self._fixed_tendon_names self._data.spatial_tendon_names = self._spatial_tendon_names diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py index 222aec52ae92..ae87c4fdefab 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py @@ -314,6 +314,8 @@ def _reset_body_com_pose_b_dependents(self) -> None: self._body_state_w_buf, self._body_link_state_w_buf, self._body_com_state_w_buf, + self._body_inertia, + self._body_inertia_backend, ] ) self._reset_dynamics(body_com_jacobian=True, mass_matrix=True, gravity_compensation=True) @@ -872,12 +874,15 @@ def _refresh_reordered_body_buffer( tensor_type: int, *, component_count: int | None = None, + static: bool = False, ) -> None: """Refresh a body buffer from its binding, gathering into public order under body ordering. Under identity body ordering the binding is read straight into the public buffer; otherwise it is staged in backend order and gathered into public order when the - public buffer is stale for the current step. + public buffer is stale. State buffers go stale every simulation step; static + property buffers (mass, inertia) only after explicit invalidation, since stepping + never changes them and their setters update the buffers in place. Args: buf: Owned public-order buffer to refresh in place. @@ -885,13 +890,16 @@ def _refresh_reordered_body_buffer( tensor_type: ``TensorType`` key of the source binding. component_count: Trailing components per body for a three-dimensional buffer, or ``None`` for a two-dimensional buffer. + static: Whether the binding is a static property that is read once after + invalidation instead of once per simulation step. """ + read_binding = self._read_static_binding_into_buf if static else self._read_binding_into_buf if self.body_ordering is None: - self._read_binding_into_buf(tensor_type, buf) + read_binding(tensor_type, buf) return - if buf.timestamp >= self._sim_timestamp: + if buf.timestamp >= (0.0 if static else self._sim_timestamp): return - self._read_binding_into_buf(tensor_type, backend_buffer) + read_binding(tensor_type, backend_buffer) if component_count is None: self._read_launch_cache.launch( (id(buf), "body_2d"), @@ -919,7 +927,7 @@ def body_mass(self) -> ProxyArray: Routed through pinned-host staging because the underlying OVPhysX binding is CPU-only (``ARTICULATION_BODY_MASS``). """ - self._refresh_reordered_body_buffer(self._body_mass, self._body_mass_backend, TT.BODY_MASS) + self._refresh_reordered_body_buffer(self._body_mass, self._body_mass_backend, TT.BODY_MASS, static=True) if self._body_mass_ta is None: self._body_mass_ta = ProxyArray(self._body_mass.data) return self._body_mass_ta @@ -935,7 +943,7 @@ def body_inertia(self) -> ProxyArray: a CPU-only binding). """ self._refresh_reordered_body_buffer( - self._body_inertia, self._body_inertia_backend, TT.BODY_INERTIA, component_count=9 + self._body_inertia, self._body_inertia_backend, TT.BODY_INERTIA, component_count=9, static=True ) if self._body_inertia_ta is None: self._body_inertia_ta = ProxyArray(self._body_inertia.data) @@ -1107,12 +1115,18 @@ def body_link_jacobian_w(self) -> ProxyArray: return self._body_link_jacobian_w_ta def _refresh_generalized_dynamics_buffer( - self, buffer: TimestampedBuffer, backend_buffer: wp.array, tensor_type: int, reorder_kernel: wp.Kernel + self, + buffer: TimestampedBuffer, + backend_buffer: wp.array, + tensor_type: int, + reorder_kernel: wp.Kernel, + *, + correct_joint_signs: bool = True, ) -> None: """Refresh a generalized dynamics buffer and gather its joint axes when needed.""" if buffer.timestamp >= self._sim_timestamp: return - if self.has_joint_ordering or self._has_reversed_joints: + if self.has_joint_ordering or (correct_joint_signs and self._has_reversed_joints): self._binding_read(tensor_type, backend_buffer) self._read_launch_cache.launch( (id(buffer), "generalized_dynamics"), @@ -1121,7 +1135,7 @@ def _refresh_generalized_dynamics_buffer( inputs=[ backend_buffer, self._jacobian_joint_user_to_backend, - self._joint_dof_signs, + self._joint_dof_signs if correct_joint_signs else None, self._num_base_dofs, self.has_joint_ordering, ], @@ -1145,11 +1159,13 @@ def mass_matrix(self) -> ProxyArray: @property def gravity_compensation_forces(self) -> ProxyArray: """See :attr:`isaaclab.assets.BaseArticulationData.gravity_compensation_forces`.""" + # Gravity forces already use the public joint basis on both 0.5.11 and 0.6. self._refresh_generalized_dynamics_buffer( self._gravity_compensation_forces, self._gravity_compensation_forces_backend, TT.GRAVITY_FORCE, ordering_kernels.reorder_generalized_vector_backend_to_user, + correct_joint_signs=False, ) return self._gravity_compensation_forces_ta @@ -2386,14 +2402,23 @@ def _read_joint_property_binding( backend_buffer: TimestampedBuffer | None, component_count: int | None = None, ) -> None: - """Refresh a joint property binding into a public user-order buffer.""" + """Refresh a joint property binding into a public user-order buffer. + + Joint properties (gains, limits, armature, friction) are static bindings: stepping + the simulation never changes them, and the ``write_*`` setters update these buffers + in place before pushing to the wheel. They are therefore read once after explicit + invalidation rather than once per simulation step. On OVPhysX these bindings are + CPU-only, so every read is a blocking host round-trip whose cost scales with the + number of environments; re-reading them per physics step dominated the step time + of any hot path touching them (for example the native actuator telemetry sync). + """ if not self.has_joint_ordering: - self._read_scalar_binding(tensor_type, user_buffer) + self._read_static_binding_into_buf(tensor_type, user_buffer) return - if user_buffer.timestamp >= self._sim_timestamp: + if user_buffer.timestamp >= 0.0: return - self._read_scalar_binding(tensor_type, backend_buffer) + self._read_static_binding_into_buf(tensor_type, backend_buffer) if component_count is None: self._read_launch_cache.launch( (id(user_buffer), "joint_property_2d"), diff --git a/source/isaaclab_ov/isaaclab_ov/cloner/__init__.py b/source/isaaclab_ov/isaaclab_ov/cloner/__init__.py index 29e7196c1134..405efacb03c7 100644 --- a/source/isaaclab_ov/isaaclab_ov/cloner/__init__.py +++ b/source/isaaclab_ov/isaaclab_ov/cloner/__init__.py @@ -3,6 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause -from .replicate import OvPhysxReplicateContext, PHYSICS_CONTEXT, ovphysx_replicate +from .replicate import OvPhysxReplicateContext, ovphysx_replicate -__all__ = ["OvPhysxReplicateContext", "PHYSICS_CONTEXT", "ovphysx_replicate"] +__all__ = ["OvPhysxReplicateContext", "ovphysx_replicate"] diff --git a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py index 1a6f43bd2396..c5d93298ad3b 100644 --- a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py +++ b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py @@ -3,41 +3,25 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""OvPhysX replication hook for IsaacLab's cloning pipeline. - -Called from the scene cloning path in place of immediate PhysX or Newton -replication. Unlike those replicators, ovphysx.PhysX does not exist yet at -this point in the scene setup — it is created lazily on the first -:meth:`~isaaclab_ov.physics.OvPhysxManager.reset` call. - -This function records an active clone recipe on :class:`OvPhysxManager`. When -:meth:`~isaaclab_ov.physics.OvPhysxManager._warmup_and_load` eventually -creates the ``PhysX`` instance, env-0-only loads replay each recipe via -``physx.clone(source, targets, transforms)`` after loading. Full-stage loads instead -materialize or overlay every recipe in serialized USDA before attaching OVStage. -Recipes remain active for the current simulation context so a forced re-warmup -rebuilds the same topology without modifying the live USD stage. -""" +"""OvPhysX clone-context dispatch from the active clone plan.""" from __future__ import annotations from collections.abc import Sequence +from typing import TYPE_CHECKING -import torch +import numpy as np -from pxr import Gf, Sdf, Usd, UsdGeom +from pxr import Gf, Usd, UsdGeom from isaaclab import cloner +from isaaclab.physics import PhysicsManager -from isaaclab_ov._clone import CloneTransform, clone_transforms_from_positions +from isaaclab_ov._clone import CloneTransform - -def _select_env_ids(env_ids: torch.Tensor, mapping: torch.Tensor, row: int) -> torch.Tensor: - """Return the environment ids selected by a replication row.""" - row_mask = mapping[row] - if row_mask.dtype != torch.bool: - row_mask = row_mask.to(dtype=torch.bool) - return env_ids[row_mask] +if TYPE_CHECKING: + from isaaclab.cloner import ClonePlan + from isaaclab.sim import SimulationContext def _matrix_to_clone_transform(matrix: Gf.Matrix4d) -> CloneTransform: @@ -57,202 +41,142 @@ def _matrix_to_clone_transform(matrix: Gf.Matrix4d) -> CloneTransform: ) -def _pose_tensor_rows(tensor: torch.Tensor | None, name: str, component_count: int) -> list[list[float]] | None: - """Validate and copy an optional per-environment pose tensor to CPU rows.""" - if tensor is None: - return None - if tensor.ndim != 2 or tensor.shape[1] != component_count: - raise ValueError(f"{name} must have shape [num_envs, {component_count}], got {list(tensor.shape)}.") - return tensor.detach().cpu().tolist() - - -def _validate_pose_rows(name: str, rows: list[list[float]] | None, env_ids: Sequence[int]) -> None: - """Validate that optional pose rows contain every selected environment.""" - if rows is None: - return - for env_id in env_ids: - if env_id < 0 or env_id >= len(rows): - raise ValueError(f"{name} does not contain selected environment id {env_id}; it has {len(rows)} rows.") +def _clone_recipes( + stage: Usd.Stage, + sources: Sequence[str], + destinations: Sequence[str], + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None, + quaternions: np.ndarray | None, +) -> list[tuple[str, list[str], list[CloneTransform]]]: + """Build OvPhysX clone recipes from one flat mapping.""" + if positions is not None and positions.shape != (len(env_ids), 3): + raise ValueError(f"positions must have shape [num_envs, 3], got {list(positions.shape)}.") + if quaternions is not None and quaternions.shape != (len(env_ids), 4): + raise ValueError(f"quaternions must have shape [num_envs, 4], got {list(quaternions.shape)}.") + + columns_by_row = [[] for _ in range(mapping.shape[0])] + for row, column in np.argwhere(mapping): + columns_by_row[row].append(column) + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + recipes = [] + for row, source in enumerate(sources): + columns = columns_by_row[row] + if not columns: + continue + active_env_ids = env_ids[columns] + matched = cloner.path.match(source, destinations[row]) + self_env_id = int(matched.instance) if matched is not None and matched.instance.isdigit() else None + + source_prim = stage.GetPrimAtPath(source) + if not source_prim.IsValid(): + raise ValueError(f"OvPhysX clone source prim is not valid on the stage: {source}") + source_world = xform_cache.GetLocalToWorldTransform(source_prim).RemoveScaleShear() + if self_env_id is None: + source_anchor_world = Gf.Matrix4d(1.0) + else: + prefix, _ = cloner.path.split(destinations[row]) + source_anchor_path = f"{prefix}{self_env_id}" + source_anchor = stage.GetPrimAtPath(source_anchor_path) + if not source_anchor.IsValid(): + raise ValueError(f"OvPhysX clone source anchor prim is not valid on the stage: {source_anchor_path}") + source_anchor_world = xform_cache.GetLocalToWorldTransform(source_anchor).RemoveScaleShear() + source_relative = source_world * source_anchor_world.GetInverse() + + targets = [] + target_transforms = [] + for env_id, column in zip(active_env_ids, columns): + env_id = int(env_id) + if env_id == self_env_id: + continue + targets.append(destinations[row].format(env_id)) + target_env_world = Gf.Matrix4d(1.0) + if positions is not None: + target_env_world.SetTranslateOnly(Gf.Vec3d(*map(float, positions[column]))) + if quaternions is not None: + q = quaternions[column] + target_env_world.SetRotateOnly(Gf.Quatd(float(q[3]), Gf.Vec3d(*map(float, q[:3])))) + target_transforms.append(_matrix_to_clone_transform(source_relative * target_env_world)) + if targets: + recipes.append((source, targets, target_transforms)) + return recipes class OvPhysxReplicateContext: - """Queue and run OvPhysX clone operations for one stage.""" + """Apply one clone plan to an OvPhysX simulation.""" replicate_priority = 0 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): + def __init__(self, sim_context: SimulationContext): """Initialize the context. Args: - stage: USD stage associated with the pending clone operations. + sim_context: Simulation context that owns this clone backend. """ - self.stage = stage - physics_scene_prim = self.stage.GetPrimAtPath("/physicsScene") - if physics_scene_prim.IsValid(): - physics_scene_prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) - self._queue: list[tuple[str, list[str], list[CloneTransform]]] = [] - - def queue( - self, source: str, targets: Sequence[str], parent_positions: Sequence[tuple[float, float, float]] - ) -> None: - """Queue one pending OvPhysX clone operation. - - Args: - source: Source prim path. - targets: Destination prim paths. - parent_positions: Final world positions [m] for each target root; - orientations are identity. - """ - target_transforms = clone_transforms_from_positions(parent_positions) - self._queue_transforms(source, targets, target_transforms) - - def _queue_transforms( - self, source: str, targets: Sequence[str], target_transforms: Sequence[CloneTransform] - ) -> None: - """Queue final target-root world poses for one OvPhysX clone operation.""" - self._queue.append((source, list(targets), list(target_transforms))) + self._sim = sim_context + self.stage = sim_context.stage - def queue_mapping( - self, - sources: Sequence[str], - destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - *, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - ) -> None: - """Queue clone operations from the current flat clone mapping. + def replicate(self, plan: ClonePlan) -> None: + """Publish clone operations from this context's plan rows. Args: - sources: Source prim paths. - destinations: Destination path templates with ``"{}"`` for env id. - env_ids: Environment indices. - mapping: Bool/int mask selecting envs per source. - positions: Optional per-environment world positions [m], shape - ``[num_envs, 3]``. - quaternions: Optional per-environment orientations in xyzw order, - shape ``[num_envs, 4]``. + plan: Replication layout shared by every clone backend. Raises: - ValueError: If a provided pose tensor is malformed or lacks a selected - environment, or if an active source or source anchor prim is invalid. + ValueError: If positions are malformed or an active source or source anchor prim is invalid. """ - positions_list = _pose_tensor_rows(positions, "positions", 3) - quaternions_list = _pose_tensor_rows(quaternions, "quaternions", 4) - xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - - for i, src in enumerate(sources): - active_env_ids = [int(env_id) for env_id in _select_env_ids(env_ids, mapping, i).tolist()] - if not active_env_ids: - continue - _validate_pose_rows("positions", positions_list, active_env_ids) - _validate_pose_rows("quaternions", quaternions_list, active_env_ids) - - self_env_id: int | None = None - matched = cloner.path.match(src, destinations[i]) - if matched is not None and matched.instance.isdigit(): - self_env_id = int(matched.instance) - - source_prim = self.stage.GetPrimAtPath(src) - if not source_prim.IsValid(): - raise ValueError(f"OvPhysX clone source prim is not valid on the stage: {src}") - source_world = xform_cache.GetLocalToWorldTransform(source_prim).RemoveScaleShear() - if self_env_id is None: - source_anchor_world = Gf.Matrix4d(1.0) - else: - prefix, _ = cloner.path.split(destinations[i]) - source_anchor_path = f"{prefix}{self_env_id}" - source_anchor = self.stage.GetPrimAtPath(source_anchor_path) - if not source_anchor.IsValid(): - raise ValueError( - f"OvPhysX clone source anchor prim is not valid on the stage: {source_anchor_path}" - ) - source_anchor_world = xform_cache.GetLocalToWorldTransform(source_anchor).RemoveScaleShear() - source_relative = source_world * source_anchor_world.GetInverse() - - targets: list[str] = [] - target_transforms: list[CloneTransform] = [] - for env_id in active_env_ids: - if env_id == self_env_id: - continue - targets.append(destinations[i].format(env_id)) - - target_env_world = Gf.Matrix4d(1.0) - if positions_list is not None: - pos = positions_list[env_id] - target_env_world.SetTranslateOnly(Gf.Vec3d(float(pos[0]), float(pos[1]), float(pos[2]))) - if quaternions_list is not None: - quat = quaternions_list[env_id] - target_env_world.SetRotateOnly( - Gf.Quatd( - float(quat[3]), - Gf.Vec3d(float(quat[0]), float(quat[1]), float(quat[2])), - ) - ) - target_transforms.append(_matrix_to_clone_transform(source_relative * target_env_world)) - - if targets: - self._queue_transforms(src, targets, target_transforms) - - def replicate(self) -> None: - """Publish all queued clones to :class:`OvPhysxManager`.""" - from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager - - for source, targets, target_transforms in self._queue: - OvPhysxManager._register_clone_transforms(source, targets, target_transforms) - self._queue.clear() - - -PHYSICS_CONTEXT = OvPhysxReplicateContext -"""Physics replication context for OvPhysX assets. OvPhysxReplicateContext authors USD -internally, so USD replication is not separately added. -TODO: decompose into UsdReplicateContext + a pure-physics OvPhysxReplicateContext to match -the physx/newton split.""" + if plan.env_ids is None: + raise ValueError("ClonePlan.env_ids is required for replication.") + rows = plan.context_rows[type(self)] + recipes = _clone_recipes( + stage=self.stage, + sources=tuple(plan.sources[row] for row in rows), + destinations=tuple(plan.destinations[row] for row in rows), + env_ids=plan.env_ids, + mapping=plan.clone_mask[list(rows)], + positions=plan.positions, + quaternions=None, + ) + for source, targets, transforms in recipes: + self._sim.physics_manager._register_clone_transforms(source, targets, transforms) def ovphysx_replicate( stage: Usd.Stage, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - device: str = "cpu", + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None = None, + quaternions: np.ndarray | None = None, ) -> None: - """Queue OvPhysX clone operations from a flat clone mapping. - - The ``positions`` and ``quaternions`` parameters describe each environment's - world pose. Source-relative rows are composed with the target environment - poses and queued as final target-root world transforms. + """Publish OvPhysX clone recipes from one raw source-to-environment mapping. Args: - stage: USD stage associated with the clone operations. - sources: Source prim paths (one per prototype). - destinations: Destination path templates with ``"{}"`` for env index. - env_ids: Environment indices tensor. - mapping: ``(num_sources, num_envs)`` bool tensor; True selects which - environments receive each source. - positions: World (x, y, z) positions [m] for every environment, shape - ``[num_envs, 3]``. - quaternions: Optional environment orientations in xyzw order, shape - ``[num_envs, 4]``. - device: Torch device (unused; kept for API compatibility). + stage: USD stage containing the source prims. + sources: Source prim paths, one per mapping row. + destinations: Destination templates containing ``"{}"``, one per mapping row. + env_ids: Integer environment identifiers, shape ``[num_envs]``. + mapping: Boolean source-to-environment selection, shape ``[len(sources), num_envs]``. + positions: Optional environment positions [m], shape ``[num_envs, 3]``. + quaternions: Optional environment orientations in xyzw order, shape ``[num_envs, 4]``. Raises: - ValueError: If a provided pose tensor is malformed or lacks a selected - environment, or if an active source or source anchor prim is invalid. + RuntimeError: If no simulation context is active. + ValueError: If transforms are malformed or a source or source anchor is invalid. """ - del device - - ctx = OvPhysxReplicateContext(stage) - ctx.queue_mapping( - sources, - destinations, - env_ids, - mapping, + recipes = _clone_recipes( + stage=stage, + sources=sources, + destinations=destinations, + env_ids=env_ids, + mapping=mapping, positions=positions, quaternions=quaternions, ) - ctx.replicate() + sim = PhysicsManager._sim + if sim is None: + raise RuntimeError("OvPhysX replication requires an active SimulationContext.") + for source, targets, transforms in recipes: + sim.physics_manager._register_clone_transforms(source, targets, transforms) diff --git a/source/isaaclab_ov/isaaclab_ov/ovstage_compat.py b/source/isaaclab_ov/isaaclab_ov/ovstage_compat.py new file mode 100644 index 000000000000..187f064a5b07 --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/ovstage_compat.py @@ -0,0 +1,92 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Version compatibility between the public OVStage 0.1 API and OVStage 0.2 and later. + +OVStage computes world transforms from the prim hierarchy either on the host or on the +device. The host model costs CPU work proportional to the number of prims, which the +barrier in the OVRTX ovstage write path then waits on, so the device model is preferred +wherever it is available. ``GPU_INCREMENTAL`` leaves objects out of place on OVStage 0.1 +and is corrected in 0.2, so the model is chosen from the installed version rather than +hard-coded. + +The installed version cannot change while the process runs, so the model is resolved once +at import and published as :data:`HIERARCHY_COMPUTATION_MODEL`. + +The published name is resolved against :class:`ovstage.HierarchyComputationModel` by the +caller, which keeps this module free of an ``ovstage`` import and therefore importable +wherever the version policy needs to be inspected or tested. + +The public extras stay pinned to ``ovstage==0.1.1.355824``; a missing or unparsable +install keeps the OVStage 0.1 host model. +""" + +from __future__ import annotations + +import importlib.metadata +import logging + +from packaging.version import InvalidVersion, Version + +logger = logging.getLogger(__name__) + +# First OVStage version whose GPU_INCREMENTAL hierarchy model places objects correctly. +_GPU_HIERARCHY_VERSION = Version("0.2") + + +def detect_ovstage_version() -> Version | None: + """Return the installed ``ovstage`` version. + + Read from distribution metadata rather than importing ``ovstage`` so the version + policy can be inspected without loading the runtime. An unparsable version is logged + and reported as missing, which keeps the OVStage 0.1 behavior. + + Returns: + The installed version, or ``None`` when ``ovstage`` is absent or its version + string cannot be parsed. + """ + try: + raw = importlib.metadata.version("ovstage") + except importlib.metadata.PackageNotFoundError: + return None + try: + return Version(raw) + except InvalidVersion: + logger.warning("Could not parse ovstage version %r; assuming the OVStage 0.1 hierarchy model.", raw) + return None + + +def supports_gpu_hierarchy_computation(version: Version | None) -> bool: + """Return whether ``version`` computes the prim hierarchy correctly on the device. + + Args: + version: OVStage version to classify, or ``None`` when OVStage is unavailable. + + Returns: + Whether ``version`` is OVStage 0.2 or newer. + """ + return version is not None and version >= _GPU_HIERARCHY_VERSION + + +def resolve_hierarchy_computation_model(version: Version | None) -> str: + """Return the :class:`ovstage.HierarchyComputationModel` member name for ``version``. + + Args: + version: OVStage version the model is chosen for, or ``None`` when OVStage is + unavailable. + + Returns: + ``"GPU_INCREMENTAL"`` on OVStage 0.2 and later, otherwise ``"CPU_INCREMENTAL"``. + """ + if supports_gpu_hierarchy_computation(version): + return "GPU_INCREMENTAL" + return "CPU_INCREMENTAL" + + +OVSTAGE_VERSION: Version | None = detect_ovstage_version() +"""Installed OVStage version, or ``None`` when it is unavailable or unparsable.""" + +HIERARCHY_COMPUTATION_MODEL: str = resolve_hierarchy_computation_model(OVSTAGE_VERSION) +"""Name of the hierarchy computation model to request for the installed OVStage.""" diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_compat.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_compat.py new file mode 100644 index 000000000000..df3be95ce000 --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_compat.py @@ -0,0 +1,104 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Version compatibility between the public OVPhysX 0.5.11 API and OVPhysX 0.6 and later. + +OVPhysX 0.5.11 warms GPU state with ``warmup_gpu()`` and tears the runtime down +with ``release()``, while 0.6 replaces those entry points with ``warmup()`` and +``destroy()``. The installed version cannot change while the process runs, so +the entry-point names are resolved once at import and published as +:data:`OVPHYSX_LIFECYCLE_ENTRY_POINTS`. + +The public extras stay pinned to ``ovphysx==0.5.11``; a missing or unparsable +install keeps the 0.5.11 entry points and reversed-joint sign correction. +OvPhysX 0.6 fixes the dynamics tensor signs in the runtime, so that correction +must be disabled on the new release line. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +from collections.abc import Mapping +from types import MappingProxyType + +from packaging.version import InvalidVersion, Version + +logger = logging.getLogger(__name__) + +# First OVPhysX release line that uses the current lifecycle entry points. +_CURRENT_LIFECYCLE_RELEASE = (0, 6) + + +def detect_ovphysx_version() -> Version | None: + """Return the installed ``ovphysx`` version. + + Read from distribution metadata rather than importing ``ovphysx`` so the + runtime is still bootstrapped only by the physics manager. An unparsable + version is logged and reported as missing, which keeps the OVPhysX 0.5.11 + behavior. + + Returns: + The installed version, or ``None`` when ``ovphysx`` is absent or its + version string cannot be parsed. + """ + try: + raw = importlib.metadata.version("ovphysx") + except importlib.metadata.PackageNotFoundError: + return None + try: + return Version(raw) + except InvalidVersion: + logger.warning("Could not parse ovphysx version %r; assuming OVPhysX 0.5.11 compatibility behavior.", raw) + return None + + +def uses_current_lifecycle_api(version: Version | None) -> bool: + """Return whether ``version`` uses the OVPhysX 0.6 lifecycle API. + + Args: + version: OVPhysX version to classify, or ``None`` when OVPhysX is unavailable. + + Returns: + Whether ``version`` is OVPhysX 0.6 or newer. + """ + return version is not None and version.release[:2] >= _CURRENT_LIFECYCLE_RELEASE + + +def requires_legacy_joint_sign_correction(version: Version | None) -> bool: + """Return whether dynamics tensors need the pre-0.6 reversed-joint correction. + + Args: + version: Installed OVPhysX version, or ``None`` when unavailable or unparsable. + + Returns: + Whether to preserve the legacy correction. The 0.6 release line, including + development builds, already returns tensors in the public joint basis. + """ + return version is None or version.release[:2] < (0, 6) + + +def build_lifecycle_entry_points(version: Version | None) -> Mapping[str, str]: + """Return the lifecycle entry-point names for ``version``. + + Args: + version: OVPhysX version the names are built for, or ``None`` when + OVPhysX is unavailable. + + Returns: + Read-only mapping from lifecycle operation to runtime method name. + """ + if uses_current_lifecycle_api(version): + entry_points = {"warmup": "warmup", "destroy": "destroy"} + else: + entry_points = {"warmup": "warmup_gpu", "destroy": "release"} + return MappingProxyType(entry_points) + + +OVPHYSX_VERSION: Version | None = detect_ovphysx_version() +"""Installed OVPhysX version, or ``None`` when it is unavailable or unparsable.""" + +OVPHYSX_LIFECYCLE_ENTRY_POINTS: Mapping[str, str] = build_lifecycle_entry_points(OVPHYSX_VERSION) +"""Maps lifecycle operations to method names for the installed OVPhysX version.""" diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index 425739d79c37..e0cc6370b72e 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -24,7 +24,7 @@ import numpy as np import warp as wp -from pxr import UsdPhysics +from pxr import Sdf, UsdPhysics from isaaclab.physics import PhysicsEvent, PhysicsManager from isaaclab.scene_data import SceneDataBackend, SceneDataFormat @@ -39,8 +39,10 @@ from isaaclab_ov._clone import CloneTransform, clone_transforms_from_positions from isaaclab_ov._runtime import import_ovphysx +from isaaclab_ov.cloner import OvPhysxReplicateContext from isaaclab_ov.stage import create_ovstage +from .ovphysx_compat import OVPHYSX_LIFECYCLE_ENTRY_POINTS from .ovphysx_manager_cfg import DEFAULT_COOKED_COLLIDER_CACHE_DIR if TYPE_CHECKING: @@ -400,6 +402,8 @@ class OvPhysxManager(PhysicsManager): Lifecycle: initialize() -> reset() -> step() (repeated) -> close() """ + clone_context_type = OvPhysxReplicateContext + _cfg: ClassVar[OvPhysxCfg | None] = None _physx: ClassVar[Any] = None # ovphysx.PhysX (lazy import) _ovstage: ClassVar[Any] = None @@ -485,12 +489,12 @@ def _prepare_stage_creation(cls) -> None: @classmethod def _ensure_physx_schemas_registered(cls) -> None: - """Register the codeless USD plugins published by the OVPhysX wheel. + """Register the codeless USD schemas published by the OVPhysX wheel. - The wheel's public paths include both ``PhysxSchema`` and - ``OmniUsdPhysicsDeformableSchema``. A host may already provide one of - those plugins from a compiled library, so only missing plugin names are - registered from the wheel's codeless resource paths. + OVStage maintains its own USD schema registry, so register the wheel's + schema root there even when the host USD runtime already provides the + same plugins. For the host USD registry, only register providers that + are not already available from a compiled plugin. """ if cls._physx_schemas_registered: return @@ -500,6 +504,15 @@ def _ensure_physx_schemas_registered(cls) -> None: from pxr import Plug # noqa: PLC0415 except ImportError: return + try: + import ovstage # noqa: PLC0415 + except ImportError: + pass # Host USD schemas can still be registered without OVStage. + else: + schema_root = getattr(ovphysx, "codeless_schema_root", None) + register_ovstage_schemas = getattr(getattr(ovstage, "population", None), "register_usd_schemas", None) + if callable(schema_root) and callable(register_ovstage_schemas): + register_ovstage_schemas(str(schema_root())) registry = Plug.Registry() registered_names = {plugin.name.casefold() for plugin in registry.GetAllPlugins()} # The wheel documents ``/resources`` as its stable layout and its @@ -525,6 +538,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: IsaacLab's conservative first-device policy for this process. """ super().initialize(sim_context) + sim_context.get_or_create_backend(cls.clone_context_type, sim_context) cls._ensure_physx_schemas_registered() cls._gravity = tuple(sim_context.cfg.gravity) cls._warmup_done = False @@ -588,6 +602,24 @@ def _reset_physx_stage(physx: Any) -> None: operation = physx.reset_stage() physx.wait_op(operation) + @staticmethod + def _warmup_physx(physx: Any) -> None: + """Warm a runtime through its version-selected API.""" + entry_point = OVPHYSX_LIFECYCLE_ENTRY_POINTS["warmup"] + warmup = getattr(physx, entry_point, None) + if warmup is None: + raise AttributeError(f"OVPhysX does not expose the selected {entry_point}() lifecycle entry point") + warmup() + + @staticmethod + def _destroy_physx(physx: Any) -> None: + """Tear a runtime down through its version-selected API.""" + entry_point = OVPHYSX_LIFECYCLE_ENTRY_POINTS["destroy"] + destroy = getattr(physx, entry_point, None) + if destroy is None: + raise AttributeError(f"OVPhysX does not expose the selected {entry_point}() lifecycle entry point") + destroy() + @classmethod def close(cls) -> None: """Release ovphysx resources and clean up.""" @@ -620,18 +652,44 @@ def _release_physx(cls) -> None: GPU-first processes. """ physx = cls._physx - cls._physx = None + if physx is None: + cls._destroy_ovstage() + return + + # Preserve the legacy 0.5.11 behavior: release both owners even when + # cleanup raises. Only OVPhysX 0.6 destroy failures can remain retryable. + destroy_entry_point = OVPHYSX_LIFECYCLE_ENTRY_POINTS["destroy"] + release_owners = destroy_entry_point == "release" try: - if physx is not None: + try: + cls._close_physx_views(physx) + finally: try: - cls._close_physx_views(physx) + cls._reset_physx_stage(physx) finally: try: - cls._reset_physx_stage(physx) - finally: - physx.release() + cls._destroy_physx(physx) + except Exception: + if destroy_entry_point == "destroy": + # OVPhysX 0.6 keeps ``handle`` valid when destroy raises + # before native teardown. Preserve both owners so a later + # close can retry. A RuntimeError from ``handle`` means + # destruction reached its terminal state. + try: + physx.handle + except RuntimeError: + release_owners = True + except Exception: + # An unfamiliar handle probe must not replace the + # original destroy error or release either owner. + release_owners = False + raise + else: + release_owners = True finally: - cls._destroy_ovstage() + if release_owners: + cls._physx = None + cls._destroy_ovstage() @classmethod def _attach_ovstage(cls, stage_usda: str) -> None: @@ -922,8 +980,8 @@ def _warmup_and_load(cls) -> None: choice and registers process-exit cleanup. On a forced re-warm before :meth:`close`, it reuses the active instance, attaches the new USD through OVStage, rebuilds active clone recipes through full-stage materialization - or runtime replay, and (on GPU) re-runs ``warmup_gpu`` so the new stage's - bodies are resident. + or runtime replay, and (on GPU) re-runs the supported warmup entry point + so the new stage's bodies are resident. Raises: RuntimeError: If ``SimulationContext`` is not set, or if a device @@ -954,6 +1012,8 @@ def _warmup_and_load(cls) -> None: scene_prim = sim.stage.GetPrimAtPath(sim.cfg.physics_prim_path) if scene_prim.IsValid(): + if cls._active_clone_recipes: + scene_prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) cls._configure_physx_scene_prim(scene_prim, PhysicsManager._cfg, ovphysx_device) # Flatten the current USD stage to USDA text so OVStage can populate it @@ -997,7 +1057,7 @@ def _warmup_and_load(cls) -> None: # GPU bodies must be re-warmed after every OVStage attachment: the cached PhysX # instance carries its old buffer layout from the previous stage. if ovphysx_device == "gpu": - cls._physx.warmup_gpu() + cls._warmup_physx(cls._physx) # Initialize the SceneDataBackend now that the wheel's PhysX is live and # the OVStage is attached. The central @@ -1115,8 +1175,6 @@ def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: values. The GPU buffer-capacity values are only consulted when ``device == "gpu"``. device: Resolved physics device — one of ``"cpu"`` or ``"gpu"``. """ - from pxr import Sdf - schemas = Sdf.TokenListOp() current = scene_prim.GetMetadata("apiSchemas") or Sdf.TokenListOp() items = list(current.prependedItems) if current.prependedItems else [] diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py new file mode 100644 index 000000000000..d46d6c00d1f8 --- /dev/null +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_compat.py @@ -0,0 +1,89 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Version compatibility between the public OVRTX 0.4 API and OVRTX 0.5 and later. + +OVRTX 0.4 keys ``frame.render_vars`` by render-var source name (``"LdrColor"``), while +0.5 keys it by the authored RenderVar prim path (``"/Render/Vars/LdrColor"``). The +installed version cannot change while the process runs, so the key form is resolved once +at import and published as :data:`RENDER_VAR_FRAME_KEYS`; per-frame code indexes that +mapping instead of re-checking the version. + +The public extras stay pinned to ``ovrtx==0.4.1.364340``; a missing or unparsable install +keeps the 0.4 key form. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +from collections.abc import Mapping +from types import MappingProxyType + +from packaging.version import InvalidVersion, Version + +from .ovrtx_usd import render_var_prim_paths_by_source + +logger = logging.getLogger(__name__) + +# First OVRTX version that keys ``frame.render_vars`` by RenderVar prim path. +_PRIM_PATH_RENDER_VARS_VERSION = Version("0.5") + + +def detect_ovrtx_version() -> Version | None: + """Return the installed ``ovrtx`` version. + + Read from distribution metadata rather than ``ovrtx.__version__`` so it does not + require importing the runtime. An unparsable version is logged and reported as + missing, which keeps the OVRTX 0.4 behavior. + + Returns: + The installed version, or ``None`` when ``ovrtx`` is absent or its version string + cannot be parsed. + """ + try: + raw = importlib.metadata.version("ovrtx") + except importlib.metadata.PackageNotFoundError: + return None + try: + return Version(raw) + except InvalidVersion: + logger.warning("Could not parse ovrtx version %r; assuming the OVRTX 0.4 render-var API.", raw) + return None + + +def uses_prim_path_render_vars(version: Version | None) -> bool: + """Return whether ``version`` keys ``frame.render_vars`` by RenderVar prim path. + + Args: + version: OVRTX version to classify, or ``None`` when OVRTX is unavailable. + + Returns: + Whether ``version`` is OVRTX 0.5 or newer. + """ + return version is not None and version >= _PRIM_PATH_RENDER_VARS_VERSION + + +def build_render_var_frame_keys(version: Version | None) -> Mapping[str, str]: + """Return the ``frame.render_vars`` key for every render-var source under ``version``. + + Args: + version: OVRTX version the keys are built for, or ``None`` when OVRTX is unavailable. + + Returns: + Read-only mapping of render-var source name to frame key: the source name itself on + OVRTX 0.4, or the authored RenderVar prim path on OVRTX 0.5 and later. + """ + prim_paths = render_var_prim_paths_by_source() + if uses_prim_path_render_vars(version): + return MappingProxyType(dict(prim_paths)) + return MappingProxyType({source: source for source in prim_paths}) + + +OVRTX_VERSION: Version | None = detect_ovrtx_version() +"""Installed OVRTX version, or ``None`` when it is unavailable or unparsable.""" + +RENDER_VAR_FRAME_KEYS: Mapping[str, str] = build_render_var_frame_keys(OVRTX_VERSION) +"""Maps render-var source name to its ``frame.render_vars`` key for the installed OVRTX.""" diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 34dfc8de0c5f..d7cdd9b28619 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -72,35 +72,35 @@ from isaaclab.sim import SimulationContext from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp -from isaaclab_ov.stage import ( - create_ovstage, - points_tensor_from_warp, - xform_tensor_from_numpy, - xform_tensor_from_warp, -) - -from .ovrtx_annotator_utils import ( +from isaaclab_ov.renderers.ovrtx_annotator_utils import ( build_instance_id_to_labels_and_semantics, build_semantic_id_to_labels, decode_semantic_id_map, decode_stable_id_map, decode_stable_id_semantic_id_map, ) -from .ovrtx_renderer_cfg import OVRTXRendererCfg -from .ovrtx_renderer_kernels import ( +from isaaclab_ov.renderers.ovrtx_compat import RENDER_VAR_FRAME_KEYS +from isaaclab_ov.renderers.ovrtx_renderer_cfg import OVRTXRendererCfg +from isaaclab_ov.renderers.ovrtx_renderer_kernels import ( compute_cable_points_world_kernel, create_camera_transforms_kernel, extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, sync_newton_transforms_kernel, ) -from .ovrtx_shader_cache import redirect_shader_cache -from .ovrtx_usd import ( +from isaaclab_ov.renderers.ovrtx_shader_cache import redirect_shader_cache +from isaaclab_ov.renderers.ovrtx_usd import ( build_render_product_as_string, create_scene_partition_attributes, export_stage_to_string, ) -from .visual_materials import OVRTXVisualMaterialWriter +from isaaclab_ov.renderers.visual_materials import OVRTXVisualMaterialWriter +from isaaclab_ov.stage import ( + create_ovstage, + points_tensor_from_warp, + xform_tensor_from_numpy, + xform_tensor_from_warp, +) if TYPE_CHECKING: from isaaclab_ppisp import PpispPipeline @@ -111,10 +111,26 @@ from isaaclab.renderers.camera_render_spec import CameraRenderSpec -# Maps depth render-var sources to compatible output buffers. +# ``frame.render_vars`` keys of the render vars read below. Baked at import from the installed +# OVRTX version, which decides whether frames are keyed by source name or RenderVar prim path. +_LDR_COLOR_VAR = RENDER_VAR_FRAME_KEYS["LdrColor"] +_HDR_COLOR_VAR = RENDER_VAR_FRAME_KEYS["HdrColor"] +_ALBEDO_VAR = RENDER_VAR_FRAME_KEYS["DiffuseAlbedoSD"] +_NORMALS_VAR = RENDER_VAR_FRAME_KEYS["NormalSD"] +_MOTION_VECTORS_VAR = RENDER_VAR_FRAME_KEYS["TargetMotionSD"] +_SEMANTIC_SEGMENTATION_VAR = RENDER_VAR_FRAME_KEYS["SemanticSegmentation"] +_INSTANCE_SEGMENTATION_VAR = RENDER_VAR_FRAME_KEYS["NonStableInstanceSegmentation"] +_SEMANTIC_ID_MAP_VAR = RENDER_VAR_FRAME_KEYS["SemanticIdMap"] +_STABLE_ID_MAP_VAR = RENDER_VAR_FRAME_KEYS["StableIdMap"] +_STABLE_ID_SEMANTIC_ID_MAP_VAR = RENDER_VAR_FRAME_KEYS["StableIdSemanticIdMap"] + +# Map render vars needed to decode the instance-segmentation info dicts. +_INSTANCE_SEGMENTATION_MAP_VARS = (_STABLE_ID_SEMANTIC_ID_MAP_VAR, _STABLE_ID_MAP_VAR, _SEMANTIC_ID_MAP_VAR) + +# Maps depth render vars to compatible output buffers. _DEPTH_VAR_BUFFER_KEYS: dict[str, tuple[str, ...]] = { - "DistanceToImagePlaneSD": ("depth", "distance_to_image_plane"), - "DistanceToCameraSD": ("distance_to_camera",), + RENDER_VAR_FRAME_KEYS["DistanceToImagePlaneSD"]: ("depth", "distance_to_image_plane"), + RENDER_VAR_FRAME_KEYS["DistanceToCameraSD"]: ("distance_to_camera",), } # The resolved integer value is assigned to the ``omni:rtx:minimal:mode`` attribute of the render product. @@ -283,6 +299,8 @@ def supported_output_types(self) -> dict[RenderBufferKind, RenderBufferSpec]: instance_seg_spec = ( RenderBufferSpec(4, wp.uint8) if self.cfg.colorize_instance_segmentation else RenderBufferSpec(1, wp.int32) ) + # Semantic segmentation: colorized RGBA (uint8), else raw int32 IDs (matches Isaac RTX, whose + # non-colorized per-pixel value is the semantic ID). semantic_seg_spec = ( RenderBufferSpec(4, wp.uint8) if self.cfg.colorize_semantic_segmentation else RenderBufferSpec(1, wp.int32) ) @@ -310,6 +328,9 @@ def __init__(self, cfg: OVRTXRendererCfg): # derives from this one cached device so a bare "cuda" cannot be re-interpreted per call site. self._warp_device: wp.Device | None = None self._render_product_paths = [] + # Shared by both paths. The legacy-only binding handles that pair with these live in + # _init_fields_legacy instead; the ovstage path drives the same offsets and counts + # through its stage queries. self._object_newton_indices: wp.array | None = None self._object_scales: wp.array | None = None self._object_scales_by_path: dict[str, tuple[float, float, float]] = {} @@ -317,6 +338,7 @@ def __init__(self, cfg: OVRTXRendererCfg): self._deformable_particle_counts: list[int] = [] self._particle_visual_offsets: list[int] = [] self._particle_visual_counts: list[int] = [] + # Shared Newton cable curve state used by both legacy and ovstage write paths. self._cable_segment_counts: list[int] = [] self._cable_max_points: int = 0 self._cable_shape_ids: wp.array | None = None @@ -329,11 +351,14 @@ def __init__(self, cfg: OVRTXRendererCfg): self._output_id_color_buffers: dict[str, wp.array] = {} self._clone_plan: ClonePlan | None = None self._visual_material_writer_ref: weakref.ReferenceType[OVRTXVisualMaterialWriter] | None = None + + # Selected once at construction so every dispatch method below sees a stable path for the + # lifetime of the renderer, even if the environment variable changes mid-process. self._use_ovstage = ovrtx_use_ovstage_enabled() self._init_fields() logger.info("Creating OVRTX renderer...") - config = RendererConfig( + OVRTX_CONFIG = RendererConfig( log_file_path=self.cfg.log_file_path, log_level=self.cfg.log_level, read_gpu_transforms=_read_gpu_transforms_enabled(), @@ -345,9 +370,9 @@ def __init__(self, cfg: OVRTXRendererCfg): # Takes the config because the redirect can be what first loads the ovrtx # library, and initialization only happens once, so it has to see the # same config the renderer below is built with. - redirect_shader_cache(config) + redirect_shader_cache(OVRTX_CONFIG) - self._renderer = Renderer(config) + self._renderer = Renderer(OVRTX_CONFIG) if not self._renderer: raise RuntimeError( "Failed to create OVRTX Renderer; the underlying ovrtx.Renderer constructor returned a falsy" @@ -368,12 +393,22 @@ def visual_material_writer(self): return self._create_visual_material_writer def prepare_cameras(self, stage: Any, spec: CameraRenderSpec) -> None: + """Resolve the camera's PPISP cfg and apply OVRTX-specific USD overrides. + + When ``spec.cfg.isp_cfg`` is set, resolves it (sentinel discovery + + normalization) via :func:`isaaclab_ppisp.resolve_and_normalize` so + :mod:`isaaclab` does not need to know about PPISP. Then pins + ``exposure:*`` to neutral and applies ``OmniRtxCameraExposureAPI_1`` so + the RTX exposure model OVRTX embeds does not compound on top of the + ISP. Without an ISP, the camera prim's authored exposure is left alone. + """ if spec.cfg.isp_cfg is None: return try: from isaaclab_ppisp import apply_rtx_exposure_overrides, resolve_and_normalize except ModuleNotFoundError as exc: _raise_missing_ppisp_error(exc) + camera_prim_path = spec.camera_prim_paths[0] if spec.camera_prim_paths else None spec.cfg.isp_cfg = resolve_and_normalize(spec.cfg.isp_cfg, stage, camera_prim_path) if spec.cfg.isp_cfg is None or not spec.camera_prim_paths: @@ -381,33 +416,66 @@ def prepare_cameras(self, stage: Any, spec: CameraRenderSpec) -> None: apply_rtx_exposure_overrides(stage, list(spec.camera_prim_paths)) def prepare_stage(self, stage: Any, num_envs: int) -> None: + """Prepare the USD stage for OVRTX before :meth:`create_render_data`. + + Adds scene partition attributes and exports the stage to a string held on the renderer until + :meth:`create_render_data` is called. + """ if stage is None: return + self._clone_plan = SimulationContext.instance().get_clone_plan() if self._clone_plan is None or self._clone_plan.env_ids is None or self._clone_plan.positions is None: raise RuntimeError("Clone plan with environment ids and positions is required when preparing OVRTX stage") - expected_ids = torch.arange(num_envs, device=self._clone_plan.env_ids.device) - if not torch.equal(self._clone_plan.env_ids, expected_ids): + if not np.array_equal(self._clone_plan.env_ids, np.arange(num_envs)): raise RuntimeError("OVRTX requires ClonePlan environment ids ordered from zero.") + + # If temp_usd_dir is set, write the pre-ovrtx stage to a temporary file. if self.cfg.temp_usd_dir is not None: _write_file(Path(self.cfg.temp_usd_dir), "pre_ovrtx_renderer_stage.usda", stage.ExportToString()) + logger.info("Preparing stage (%d envs)...", num_envs) create_scene_partition_attributes(stage, num_envs) + + # Composed scales must be read while the full stage is still live, before export trims it. self._capture_object_scales(stage, self._clone_plan) + + # Unused variant rows retain placeholder source paths but have no spawned prim. + # Keep active rows independent so dynamic assets retain their compiled binding paths. + active_sources = tuple( + path for path, mask in zip(self._clone_plan.sources, self._clone_plan.clone_mask, strict=True) if mask.any() + ) self._exported_usd_string = export_stage_to_string( stage, num_envs, - source_paths=self._clone_plan.sources, + source_paths=active_sources, keep_env_roots=not self._use_ovstage, ) def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: + """Record composed world scales of scaled environment prims before the stage is exported. + + The per-frame object transform write rebuilds each body's matrix from a Newton + ``transformf``, which carries only translation and rotation, so any scale authored on the + USD prim is lost once that write lands. Capturing the composed scale here, while the full + stage is still live, lets :meth:`_create_object_scale_array` fold it back in. + + Only paths whose scale deviates from unit are stored. Scales found under clone-plan source + paths are projected through the cloner query boundary because OVRTX creates their active + destinations only after the host stage is exported. + + Args: + stage: The live USD stage, before per-environment trimming and export. + plan: Validated plan describing the active prototype-to-clone relation. + """ self._object_scales_by_path.clear() + from pxr import Gf, Usd, UsdGeom envs_prim = stage.GetPrimAtPath("/World/envs") if not envs_prim.IsValid(): return + xform_cache = UsdGeom.XformCache() for prim in Usd.PrimRange(envs_prim): if not prim.IsA(UsdGeom.Xformable): @@ -426,6 +494,14 @@ def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: self._object_scales_by_path.setdefault(clone_path, scale) def _create_object_scale_array(self, object_paths: list[str]) -> wp.array: + """Build the device scale array aligned with the Newton body binding order. + + Args: + object_paths: Bound body prim paths, ordered to match the Newton index array. + + Returns: + Per-body scale factors, shape ``[len(object_paths)]``, unit where no scale was authored. + """ scales = [self._object_scales_by_path.get(path, (1.0, 1.0, 1.0)) for path in object_paths] return wp.array(scales, dtype=wp.vec3f, device=self._device) @@ -444,22 +520,33 @@ def _init_fields_legacy(self) -> None: self._particle_points_binding = None self._particle_workaround_applied = False self._cable_points_binding = None + # Stable Warp views into ``_cable_points`` for ASYNC GPU writes. self._cable_point_slices: list[wp.array] = [] def _initialize_from_spec_legacy(self, spec: CameraRenderSpec): + """Initialize the OVRTX renderer with internal environment cloning. + + Args: + spec: Tiled camera description (resolution, paths, data types). + """ width = spec.cfg.width height = spec.cfg.height num_envs = spec.num_instances data_types = spec.cfg.data_types if spec.cfg.data_types else ["rgb"] if spec.cfg.isp_cfg is not None and "rgb_hdr" not in data_types: data_types = [*data_types, "rgb_hdr"] + env_0_prefix = "/World/envs/env_0/" first_cam_path = spec.camera_prim_paths[0] if not first_cam_path.startswith(env_0_prefix): raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") self._camera_rel_path = spec.camera_path_relative_to_env_0 + + logger.info("Injecting camera definitions...") + if self._exported_usd_string is None: raise RuntimeError("Expected an exported USD string from stage") + render_product_string, render_product_path = build_render_product_as_string( width=width, height=height, @@ -472,58 +559,88 @@ def _initialize_from_spec_legacy(self, spec: CameraRenderSpec): enable_shadows=self.cfg.enable_shadows, ) self._render_product_paths.append(render_product_path) + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string - self._exported_usd_string = None + self._exported_usd_string = None # Free memory + + # If temp_usd_dir is set, write the combined USD stage to a temporary file. if self.cfg.temp_usd_dir is not None: _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) + + logger.info("Loading USD into OvRTX...") self._renderer.open_usd_from_string(combined_usd_string) + logger.info("OVRTX loaded USD from string successfully") + camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] if num_envs > 1: self._clone_sources_in_ovrtx() self._update_scene_partitions_after_clone(num_envs) + # OVRTX 0.4 keeps the initial Fabric camera relationship after clone_usd creates the remaining + # cameras. Rewrite it so the RenderProduct includes every camera in its tiled output. self._renderer.write_array_attribute( - prim_paths=[render_product_path], attribute_name="camera", tensors=[camera_paths] + prim_paths=[render_product_path], + attribute_name="camera", + tensors=[camera_paths], ) + self._initialized_scene = True + self._camera_xform_binding = self._renderer.bind_attribute( prim_paths=camera_paths, attribute_name="omni:xform", semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.EXISTING_ONLY, ) + + # OVRTX requires omni:resetXformStack on cameras for correct world transform binding self._renderer.write_attribute( prim_paths=camera_paths, attribute_name="omni:resetXformStack", tensor=np.full(num_envs, True, dtype=np.bool_), ) - if self._camera_xform_binding is None: + + if self._camera_xform_binding is not None: + logger.info("Camera binding created successfully") + else: raise RuntimeError("Camera binding is None — cannot render without a valid camera binding") + self._setup_xform_bindings() self._setup_deformable_bindings(num_envs) self._setup_particle_bindings() self._setup_cable_bindings() def _clone_sources_in_ovrtx(self): + """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan`.""" clone_plan = self._clone_plan if clone_plan is None or clone_plan.env_ids is None or clone_plan.positions is None: raise RuntimeError("Clone plan with environment ids and positions is required when using OVRTX cloning") - env_ids = clone_plan.env_ids.detach().cpu() - clone_mask = clone_plan.clone_mask.detach().cpu() + + env_ids = clone_plan.env_ids + clone_mask = clone_plan.clone_mask num_envs = len(env_ids) - env_prim_paths = [f"/World/envs/env_{env_id}" for env_id in env_ids.tolist()] + env_prim_paths = [f"/World/envs/env_{int(env_id)}" for env_id in env_ids] + logger.info("Cloning sources in OVRTX...") + + num_cloned_sources = 0 for row_idx, (source, destination) in enumerate(zip(clone_plan.sources, clone_plan.destinations, strict=True)): target_paths = [ destination.format(int(env_id)) - for env_id in env_ids[clone_mask[row_idx]].tolist() + for env_id in env_ids[clone_mask[row_idx]] if destination.format(int(env_id)) != source ] if target_paths: + logger.debug("Cloning row %d: %s -> %d target(s)", row_idx, source, len(target_paths)) try: self._renderer.clone_usd(source, target_paths) - except Exception as exc: - raise RuntimeError(f"Failed to clone row {row_idx} from {source}: {exc}") from exc + num_cloned_sources += 1 + except Exception as e: + error_msg = f"Failed to clone row {row_idx} from {source}: {e}" + logger.error(error_msg) + raise RuntimeError(error_msg) + + logger.info("Cloned %d sources successfully in OVRTX", num_cloned_sources) env_root_xforms = np.tile(np.eye(4, dtype=np.float64), (num_envs, 1, 1)) - env_root_xforms[:, 3, :3] = clone_plan.positions.cpu().numpy() + env_root_xforms[:, 3, :3] = clone_plan.positions self._renderer.write_attribute( prim_paths=env_prim_paths, attribute_name="omni:xform", @@ -533,62 +650,100 @@ def _clone_sources_in_ovrtx(self): ) def _update_scene_partitions_after_clone(self, num_envs: int): + """Update scene partition attributes on cloned environments and cameras in OvRTX.""" + logger.info("Writing scene partitions for %d environments...", num_envs) partition_tokens = [f"env_{i}" for i in range(num_envs)] env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] camera_prim_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + self._renderer.write_attribute( - env_prim_paths, "primvars:omni:scenePartition", partition_tokens, semantic=Semantic.TOKEN_STRING + env_prim_paths, + "primvars:omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, ) + logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) + self._renderer.write_attribute( - camera_prim_paths, "omni:scenePartition", partition_tokens, semantic=Semantic.TOKEN_STRING + camera_prim_paths, + "omni:scenePartition", + partition_tokens, + semantic=Semantic.TOKEN_STRING, ) + logger.info("Written omni:scenePartition to %d cameras", num_envs) def _setup_xform_bindings_legacy(self): + """Setup OVRTX bindings for scene objects to sync with Newton physics.""" try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping object bindings") return + if SimulationContext.instance() is None: + logger.info("No active simulation context, will not set up ovrtx object bindings for newton") return + newton_model = NewtonManager.get_model() if newton_model is None: + logger.debug("Newton model not available, skipping object bindings") return + all_body_paths = getattr(newton_model, "body_label", None) if all_body_paths is None: + logger.info("Newton model has no body_label, skipping object bindings") return + object_paths = [] newton_indices = [] for idx, path in enumerate(all_body_paths): if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: object_paths.append(path) newton_indices.append(idx) - if not object_paths: + + if len(object_paths) == 0: + logger.info("No dynamic objects found for binding") return + self._object_xform_binding = self._renderer.bind_attribute( prim_paths=object_paths, attribute_name="omni:xform", semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.EXISTING_ONLY, ) + self._renderer.write_attribute( prim_paths=object_paths, attribute_name="omni:resetXformStack", tensor=np.full(len(object_paths), True, dtype=np.bool_), ) + if self._object_xform_binding is None: raise RuntimeError("Failed to create OVRTX object bindings") + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) self._object_scales = self._create_object_scale_array(object_paths) self._object_transform_buffer = wp.zeros(len(newton_indices), dtype=wp.mat44d, device=self._device) def _setup_deformable_bindings_legacy(self, num_envs: int): + """Setup OVRTX bindings for Newton deformable bodies. + + Args: + num_envs: Number of environments. + """ try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping deformable body bindings") return + + # Early return if the deformable registry is empty. deformable_registry = NewtonManager._deformable_registry if not deformable_registry: + logger.debug("Deformable registry is empty, skipping deformable body bindings") return + + # Validate the number of particle offsets for each deformable entry upfront. bad_entries = [entry for entry in deformable_registry if len(entry.particle_offsets) != num_envs] if bad_entries: details = "\n".join( @@ -598,19 +753,41 @@ def _setup_deformable_bindings_legacy(self, num_envs: int): f"OVRTX expects one particle offset per environment ({num_envs}), but the following " f"deformable entries have a mismatched offset count:\n{details}" ) + self._deformable_particle_offsets = [] self._deformable_particle_counts = [] - vis_mesh_prim_paths = [] + + vis_mesh_prim_paths: list[str] = [] + + # Each registry entry is one deformable asset registered at spawn time. Its + # ``vis_mesh_prim_path`` uses a regex env wildcard (e.g. ``env_.*``) to denote one + # homogeneous visual mesh replicated into every environment, not a subset of envs. + # During replication, Newton appends one particle block per env in contiguous env order + # and records the start index in ``entry.particle_offsets``; ``particles_per_body`` is + # the block size. The inner loop therefore emits one OVRTX mesh binding per env, + # resolving the env wildcard with ``env_idx`` and pairing it with that env's slice in + # the flat ``particle_q`` array. + # + # This mapping is valid only while deformable registry entries remain homogeneous across + # all envs with dense, contiguous env ids. If deformables later support env subsets or + # non-contiguous env ids, OVRTX must consume explicit per-instance env metadata instead + # of deriving env ids from ``enumerate(entry.particle_offsets)``. for entry in deformable_registry: for idx, particle_offset in enumerate(entry.particle_offsets): self._deformable_particle_offsets.append(particle_offset) self._deformable_particle_counts.append(entry.particles_per_body) + vis_mesh_prim_paths.append( re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(idx), entry.vis_mesh_prim_path) ) + prim_count = len(vis_mesh_prim_paths) - if not prim_count: + if prim_count == 0: + logger.warning("No deformable visual prim paths collected, skipping deformable body bindings") return + + # World-space particle_q is written directly into mesh points. Reset the xform stack + # and pin identity omni:xform so inherited env/asset transforms are not applied twice. self._renderer.write_attribute( prim_paths=vis_mesh_prim_paths, attribute_name="omni:resetXformStack", @@ -624,6 +801,7 @@ def _setup_deformable_bindings_legacy(self, num_envs: int): semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.MUST_EXIST, ) + self._deformable_points_binding = self._renderer.bind_array_attribute( prim_paths=vis_mesh_prim_paths, attribute_name="points", @@ -632,15 +810,24 @@ def _setup_deformable_bindings_legacy(self, num_envs: int): prim_mode=PrimMode.MUST_EXIST, flags=BindingFlag.OPTIMIZE, ) + if self._deformable_points_binding is None: raise RuntimeError("Failed to create OVRTX deformable body bindings") def _setup_cable_bindings_legacy(self) -> None: + """Setup OVRTX ``points`` bindings for Newton cables (UsdGeom.BasisCurves). + + Cables are rigid segment bodies, not particles, so their curve points are derived from + ``body_q`` each frame rather than sliced out of ``particle_q``. + """ discovered = self._discover_cable_segment_bindings() if discovered is None: return + cable_prim_paths, flat_shape_ids, offsets, counts = discovered prim_count = len(cable_prim_paths) + # Points are written in world space, so neutralise the inherited env/asset transform the + # same way the deformable path does; otherwise the transform is applied twice. self._renderer.write_attribute( prim_paths=cable_prim_paths, attribute_name="omni:resetXformStack", @@ -654,6 +841,7 @@ def _setup_cable_bindings_legacy(self) -> None: semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.MUST_EXIST, ) + self._cable_points_binding = self._renderer.bind_array_attribute( prim_paths=cable_prim_paths, attribute_name="points", @@ -664,6 +852,8 @@ def _setup_cable_bindings_legacy(self) -> None: ) if self._cable_points_binding is None: raise RuntimeError("Failed to create OVRTX cable point bindings") + + # Device-resident buffers select OVRTX's GPU-interop update path via DLPack device. self._allocate_cable_device_buffers(flat_shape_ids, offsets, counts) self._cable_point_slices = [ self._cable_points[offset + curve : offset + curve + segment_count + 1] @@ -671,21 +861,31 @@ def _setup_cable_bindings_legacy(self) -> None: ] def _setup_particle_bindings_legacy(self) -> None: + """Setup OVRTX bindings for Newton particle clouds.""" try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping particle point bindings") return + particle_visual_prims = NewtonManager._particle_visual_prims if not particle_visual_prims: + logger.debug("No particle visual prims registered, skipping particle point bindings") return + self._particle_visual_offsets = [] self._particle_visual_counts = [] - points_prim_paths = [] + points_prim_paths: list[str] = [] + for prim_path, record in particle_visual_prims.items(): points_prim_paths.append(prim_path) self._particle_visual_offsets.append(record.offset) self._particle_visual_counts.append(record.count) + prim_count = len(points_prim_paths) + + # World-space particle_q is written directly into points. Reset the xform stack + # and pin identity omni:xform so inherited env/asset transforms are not applied twice. self._renderer.write_attribute( prim_paths=points_prim_paths, attribute_name="omni:resetXformStack", @@ -699,6 +899,7 @@ def _setup_particle_bindings_legacy(self) -> None: semantic=Semantic.XFORM_MAT4x4, prim_mode=PrimMode.MUST_EXIST, ) + self._particle_points_binding = self._renderer.bind_array_attribute( prim_paths=points_prim_paths, attribute_name="points", @@ -709,8 +910,14 @@ def _setup_particle_bindings_legacy(self) -> None: ) def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: + """Create OVRTX-specific RenderData with GPU buffers. + + Performs OVRTX initialization (stage export, USD load, bindings) on first call, + matching the interface of Isaac RTX and Newton Warp which need no separate initialize(). + """ # Resolve the device once through Warp: a bare "cuda" pins to Warp's current CUDA device - # here, and the normalized string keeps every downstream consumer on that same device. + # here, and the normalized string keeps every downstream consumer (kernel launches, + # allocations) on that same device. self._warp_device = wp.get_device(spec.device) self._device = str(self._warp_device) if not self._initialized_scene: @@ -718,9 +925,21 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXRenderData: return OVRTXRenderData(spec, self._device) def set_outputs(self, render_data: OVRTXRenderData, output_data: dict[str, ProxyArray]) -> None: + """Register pre-allocated warp output buffers for rendering. + + Each :class:`~isaaclab.utils.warp.ProxyArray` already carries the correct warp + dtype from :meth:`~isaaclab.sensors.camera.CameraData.allocate`; store + the underlying warp array directly. ``rgb`` is excluded because it is a + non-contiguous strided view into ``rgba`` and is updated automatically. + + See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`. + """ render_data.warp_buffers = { name: proxy.warp for name, proxy in output_data.items() if name != str(RenderBufferKind.RGB) } + # When PPISP is composed but the user did not request the raw HDR AOV, + # allocate an internal HDR scratch buffer under "rgb_hdr" so both the + # HdrColor extractor and PPISP dispatch can use the same buffer map. if render_data.ppisp_pipeline is not None and str(RenderBufferKind.RGB_HDR) not in render_data.warp_buffers: ref_proxy = next(iter(output_data.values())) render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)] = wp.zeros( @@ -728,12 +947,13 @@ def set_outputs(self, render_data: OVRTXRenderData, output_data: dict[str, Proxy dtype=wp.float32, device=ref_proxy.device, ) - if render_data.ppisp_pipeline is not None and str(RenderBufferKind.RGBA) not in render_data.warp_buffers: - raise ValueError( - "OVRTX renderer ISP requires 'rgba' (or 'rgb', which aliases into rgba) as the" - " LDR output destination, but neither was provided. Add 'rgb' or 'rgba' to" - " Camera.cfg.data_types when isp_cfg is set." - ) + if render_data.ppisp_pipeline is not None: + if str(RenderBufferKind.RGBA) not in render_data.warp_buffers: + raise ValueError( + "OVRTX renderer ISP requires 'rgba' (or 'rgb', which aliases into rgba) as the" + " LDR output destination, but neither was provided. Add 'rgb' or 'rgba' to" + " Camera.cfg.data_types when isp_cfg is set." + ) def _update_transforms_legacy(self) -> None: """Sync transforms to OVRTX.""" @@ -744,11 +964,15 @@ def _update_transforms_legacy(self) -> None: or self._object_transform_buffer is None ): return + + # If self._object_newton_indices is not None, then Newton's the current physics backend + from isaaclab_newton.physics import NewtonManager newton_state = NewtonManager.get_state() if newton_state is None: raise RuntimeError("Newton state should not be None") + body_q = getattr(newton_state, "body_q", None) if body_q is None: return @@ -769,50 +993,87 @@ def _update_transforms_legacy(self) -> None: ) def _update_geometries_legacy(self) -> None: + """Sync geometries to OVRTX.""" if self._deformable_points_binding is not None: self._write_particle_q_slices( self._deformable_points_binding, self._deformable_particle_offsets, self._deformable_particle_counts, ) + if self._particle_points_binding is not None: self._write_particle_q_slices( self._particle_points_binding, self._particle_visual_offsets, self._particle_visual_counts, ) + if self._cable_points_binding is not None: self._write_cable_points_legacy() def _write_cable_points_legacy(self) -> None: + """Recompute world-space cable curve points from Newton bodies and write them to OVRTX.""" self._compute_cable_points_world() + + # Slices alias ``_cable_points``. Pass Warp's CUDA stream so OVRTX waits on-GPU instead of + # forcing a host sync. ``DataAccess.ASYNC`` + device tensors select GPU interop; ``SYNC`` or + # a host array silently takes the CPU path. self._cable_points_binding.write( cast(Any, self._cable_point_slices), data_access=DataAccess.ASYNC, cuda_stream=self._warp_device.stream.cuda_stream, ) - def _write_particle_q_slices(self, binding: Any, particle_offsets: list[int], particle_counts: list[int]) -> None: + def _write_particle_q_slices( + self, + binding: Any, + particle_offsets: list[int], + particle_counts: list[int], + ) -> None: + """Write world-space ``particle_q`` slices into one OVRTX array-attribute binding. + + Args: + binding: OVRTX array-attribute binding for the ``points`` attribute. + particle_offsets: Start index of each prim's slice into Newton's ``particle_q``. + particle_counts: Number of particles in each prim's slice. + """ from isaaclab_newton.physics import NewtonManager state = NewtonManager.get_state() if state is None: raise RuntimeError("Newton state should not be None") + particle_q = getattr(state, "particle_q", None) if particle_q is None: raise RuntimeError("Newton state has no particle_q but particle geometry bindings exist") + particle_slices = [ - particle_q[offset : offset + count] for offset, count in zip(particle_offsets, particle_counts, strict=True) + particle_q[particle_offset : particle_offset + particle_count] + for particle_offset, particle_count in zip(particle_offsets, particle_counts, strict=True) ] + + # Array attributes cannot use ``binding.map()`` like rigid-body xforms, and + # ``DataAccess.ASYNC`` lets OVRTX read the slices in place (no copy on ingest). + # Because the slices alias ``particle_q``, OVRTX must not read them until the + # Warp kernels that wrote ``particle_q`` have finished. Passing ``cuda_stream`` + # hands OVRTX the Warp stream those kernels were enqueued on so it can insert a + # GPU-side wait (a cross-stream dependency) before its read, instead of us + # forcing a host-side ``wp.synchronize_device()`` that would stall the CPU. + cuda_stream = self._warp_device.stream.cuda_stream binding.write( cast(Any, particle_slices), data_access=DataAccess.ASYNC, - cuda_stream=self._warp_device.stream.cuda_stream, + cuda_stream=cuda_stream, ) def _update_camera_legacy( - self, render_data: OVRTXRenderData, positions: ProxyArray, orientations: ProxyArray, intrinsics: ProxyArray + self, + render_data: OVRTXRenderData, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ) -> None: + """Update camera transforms in OVRTX binding.""" num_envs = positions.shape[0] converted_wp = wp.empty(num_envs, dtype=wp.quatf, device=self._device) convert_camera_frame_orientation_convention_wp( @@ -836,14 +1097,51 @@ def _update_camera_legacy( cuda_stream=self._warp_device.stream.cuda_stream, ) - def read_output(self, render_data: OVRTXRenderData, camera_data: CameraData) -> None: + def read_output( + self, + render_data: OVRTXRenderData, + camera_data: CameraData, + ) -> None: + """Forward per-output metadata collected during :meth:`render` into ``camera_data.info``. + + This is a *replace*, not a *merge*: every seeded output key is reset to this frame's metadata, + which is ``None`` when its render var was absent. Because :meth:`render` rebuilds ``renderer_info`` + from scratch each frame (see the ``renderer_info.clear()`` in :meth:`_process_render_frame`), a render + var that disappears on a later frame (e.g. a missing ``SemanticIdMap``) must clear the corresponding + ``camera_data.info`` entry too, or downstream consumers would keep reading stale labels. ``renderer_info`` + only ever holds a subset of the outputs, so iterating ``camera_data.info`` both preserves its + ``output``-mirroring key set and resets any dropped metadata to ``None``. + + Present entries are stored by reference (a shallow assignment, not a deep copy): ``camera_data.info`` + shares the same metadata dict objects as ``render_data.renderer_info`` (e.g. the semantic + ``idToLabels`` mapping). Those references stay valid even after ``renderer_info`` is cleared or + rebuilt, and each render builds a fresh metadata dict, so no aliased object is mutated in place. + + Pixel data needs no handling here: :meth:`set_outputs` wraps each ``camera_data.output`` tensor as a + zero-copy warp array stored in ``render_data.warp_buffers``, and :meth:`render` writes the rendered + tiles directly into those warp arrays. + + See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`. + """ assert camera_data.info is not None, "CameraData.info should be created in CameraData.allocate" for output_name in camera_data.info: camera_data.info[output_name] = render_data.renderer_info.get(output_name) def _generate_random_colors_from_ids(self, input_ids: wp.array, output_colors: wp.array | None) -> wp.array: + """Generate pseudo-random RGBA colors from uint32 IDs into a reusable output buffer. + + Args: + input_ids: 3-D uint32 Warp array of shape (H, W, 1). + output_colors: Existing color buffer to reuse, or None to allocate a new one. + + Returns: + Color buffer containing the generated colors. + """ + + # Lazily allocate, and re-allocate if the shape changes. if output_colors is None or output_colors.shape != input_ids.shape: output_colors = wp.zeros(shape=input_ids.shape, dtype=wp.uint32, device=self._device) + wp.launch( kernel=generate_random_colors_from_ids_kernel, dim=input_ids.shape, @@ -854,6 +1152,31 @@ def _generate_random_colors_from_ids(self, input_ids: wp.array, output_colors: w @contextlib.contextmanager def _map_render_var_to_dlpack(self, render_var: Any) -> Iterator[wp.array]: + """Map ``render_var`` for CUDA reads and yield it as a Warp array. + + The render is still in flight when the mapping returns, so reading it has to be ordered + against render completion. Normally that is a ``cudaStreamWaitEvent`` on the Warp stream the + consuming kernels run on, which is the ordering the OVRTX API is designed around. + + On Linux that GPU-side wait measures substantially slower end to end, so the mapping is + instead requested with no GPU-side barrier and the calling thread blocks on the + render-completion event. Setting :data:`_DISABLE_LINUX_CUDA_CPU_SYNC_ENV` to ``1`` puts + Linux back on the GPU-side wait; it is an escape hatch for platforms where that trade-off + no longer holds, and is worth re-measuring before being relied on. + + Note that ``sync_stream=0`` is OVRTX's "no sync" sentinel, *not* the NULL CUDA stream: the + field encodes ``0=no sync, 1=default stream, >1=specific stream``, so omitting the argument + entirely means ``1``, not ``0``. + + The yielded array is a zero-copy view of the mapped memory and is only valid inside the + ``with`` block -- the mapping is released on exit. + + Args: + render_var: OVRTX ``RenderVarOutput`` to map (looked up from ``frame.render_vars``). + + Yields: + The render var's contents as a Warp array, valid for the duration of the context. + """ gpu_side_sync = _gpu_side_render_var_sync_enabled() sync_stream = self._warp_device.stream.cuda_stream if gpu_side_sync else 0 with render_var.map(device=Device.CUDA, sync_stream=sync_stream) as mapping: @@ -866,20 +1189,38 @@ def _process_id_segmentation_render_var( render_data: OVRTXRenderData, frame, output_buffers: dict, - render_var_name: str, + render_var_key: str, buffer_key: str, colorize: bool, ) -> None: - if render_var_name not in frame.render_vars or buffer_key not in output_buffers: + """Extract a uint32 ID-segmentation render var into ``output_buffers[buffer_key]``. + + Shared by ``semantic_segmentation`` (``SemanticSegmentation``) and ``instance_segmentation`` + (``NonStableInstanceSegmentation``), which only differ in the source render var, the destination buffer, + and whether to colorize. + + Args: + render_data: OVRTX render data for the current frame. + frame: OVRTX frame holding the mapped render vars. + output_buffers: Destination warp buffers, keyed by data type. + render_var_key: ``frame.render_vars`` key of the OVRTX render var to read. + buffer_key: Data type key into ``output_buffers``. + colorize: If True, IDs are mapped to RGBA colors; otherwise raw uint32 IDs are copied. + """ + render_var = frame.render_vars.get(render_var_key) + if render_var is None or buffer_key not in output_buffers: return - with self._map_render_var_to_dlpack(frame.render_vars[render_var_name]) as tiled_data: + + with self._map_render_var_to_dlpack(render_var) as tiled_data: if tiled_data.dtype != wp.uint32: return + if colorize: color_buffer = self._generate_random_colors_from_ids( tiled_data, self._output_id_color_buffers.get(buffer_key) ) self._output_id_color_buffers[buffer_key] = color_buffer + colors_torch = wp.to_torch(color_buffer) colors_uint8 = colors_torch.view(torch.uint8) if colors_torch.dim() == 2: @@ -888,15 +1229,32 @@ def _process_id_segmentation_render_var( tiled_data = wp.from_torch(colors_uint8, dtype=wp.uint8) self._extract_rgba_tiles(render_data, tiled_data, output_buffers, buffer_key) else: + # Non-colorized: ensure (TH, TW, 1) shape for the uint32 extraction kernel. Reshape the warp + # array directly instead of round-tripping through torch, which raises on ``torch.uint32`` + # (newer torch exposes the dtype but ``wp.from_torch`` still rejects it). if tiled_data.ndim == 2: tiled_data = tiled_data.reshape((*tiled_data.shape, 1)) self._launch_extract_all_tiles(render_data, tiled_data, output_buffers[buffer_key]) def _process_semantic_id_map(self, render_data: OVRTXRenderData, frame) -> None: - if "SemanticIdMap" not in frame.render_vars: + """Decode the ``SemanticIdMap`` render var into ``render_data.renderer_info["semantic_segmentation"]``. + + Populates an ``"idToLabels"`` mapping compatible with Isaac RTX / Replicator: keys are the raw semantic + IDs (``colorize_semantic_segmentation=False``) or the RGBA color tuples the segmentation buffer uses + (``colorize_semantic_segmentation=True``); values are ``{semantic_type: label}`` dicts. The reserved + BACKGROUND (ID 0) and UNLABELLED (ID 1) entries are always included. + + Args: + render_data: OVRTX render data for the current frame. + frame: OVRTX frame holding the mapped render vars. + """ + semantic_id_map = frame.render_vars.get(_SEMANTIC_ID_MAP_VAR) + if semantic_id_map is None: return - with frame.render_vars["SemanticIdMap"].map(device=Device.CPU) as mapping: + + with semantic_id_map.map(device=Device.CPU) as mapping: labels_by_id = decode_semantic_id_map(np.from_dlpack(mapping)) + render_data.renderer_info["semantic_segmentation"] = { "idToLabels": build_semantic_id_to_labels( labels_by_id, colorize=self.cfg.colorize_semantic_segmentation, device=self._device @@ -904,19 +1262,44 @@ def _process_semantic_id_map(self, render_data: OVRTXRenderData, frame) -> None: } def _process_instance_segmentation_maps(self, render_data: OVRTXRenderData, frame) -> None: - required_vars = ("StableIdSemanticIdMap", "StableIdMap", "SemanticIdMap") - missing = [var for var in required_vars if var not in frame.render_vars] + """Decode the instance-segmentation map render vars into ``renderer_info["instance_segmentation"]``. + + An *instance pixel ID* is a compact integer that the renderer assigns to each visible object instance. + Every pixel in the segmentation buffer holds the ID of the instance rendered at that location; the same + ID maps to the same object across the entire frame. ID 0 is reserved for BACKGROUND (no geometry), and + ID 1 for UNLABELLED (geometry with no semantic annotation). All other IDs are dynamically assigned per + frame. + + Populates ``"idToLabels"`` (instance pixel ID -> USD prim path) and ``"idToSemantics"`` (instance pixel + ID -> ``{semantic_type: label}``) compatible with Isaac RTX / Replicator. Resolving both requires all + three map render vars — ``StableIdSemanticIdMap`` (pixel ID -> stable ID + semantic ID), ``StableIdMap`` + (stable ID -> prim path), and ``SemanticIdMap`` (semantic ID -> label). Keys are the raw pixel IDs + (``colorize_instance_segmentation=False``) or the RGBA color tuples the segmentation buffer uses + (``colorize_instance_segmentation=True``); the reserved BACKGROUND (ID 0) and UNLABELLED (ID 1) entries + are always included. + + Raises: + RuntimeError: If any of the three required render vars is absent from ``frame``. + + Args: + render_data: OVRTX render data for the current frame. + frame: OVRTX frame holding the mapped render vars. + """ + resolved = {key: frame.render_vars.get(key) for key in _INSTANCE_SEGMENTATION_MAP_VARS} + missing = [key for key, render_var in resolved.items() if render_var is None] if missing: raise RuntimeError( f"instance_segmentation was requested but the following render vars are missing from the " f"OVRTX frame: {missing}. Available vars: {list(frame.render_vars.keys())}" ) - with frame.render_vars["StableIdSemanticIdMap"].map(device=Device.CPU) as mapping: + + with resolved[_STABLE_ID_SEMANTIC_ID_MAP_VAR].map(device=Device.CPU) as mapping: stable_id_semantic_id_map = decode_stable_id_semantic_id_map(np.from_dlpack(mapping)) - with frame.render_vars["StableIdMap"].map(device=Device.CPU) as mapping: + with resolved[_STABLE_ID_MAP_VAR].map(device=Device.CPU) as mapping: stable_id_to_path = decode_stable_id_map(np.from_dlpack(mapping)) - with frame.render_vars["SemanticIdMap"].map(device=Device.CPU) as mapping: + with resolved[_SEMANTIC_ID_MAP_VAR].map(device=Device.CPU) as mapping: semantic_id_to_labels = decode_semantic_id_map(np.from_dlpack(mapping)) + id_to_labels, id_to_semantics = build_instance_id_to_labels_and_semantics( stable_id_semantic_id_map, stable_id_to_path, @@ -932,6 +1315,22 @@ def _process_instance_segmentation_maps(self, render_data: OVRTXRenderData, fram def _launch_extract_all_tiles( self, render_data: OVRTXRenderData, tiled_buffer: wp.array, output_buffer: wp.array ) -> None: + """Launch ``extract_all_tiles_kernel`` for one tiled/output buffer pair. + + This is the only place that should launch ``extract_all_tiles_kernel``: it validates that + ``output_buffer`` cannot read past the end of ``tiled_buffer`` (the kernel derives its per-thread + channel loop bound from ``output_buffer``'s last dimension) before every launch, so callers cannot + accidentally skip the check. + + Args: + render_data: OVRTX render data for the current frame. + tiled_buffer: 3D array of shape (H, W, C) holding all tiles packed into one buffer. + output_buffer: 4D array of shape (num_envs, H, W, C) to receive the per-env tiles, with C no + greater than ``tiled_buffer``'s channel count. + + Raises: + ValueError: If ``output_buffer``'s channel count exceeds ``tiled_buffer``'s. + """ tiled_channels = tiled_buffer.shape[-1] output_channels = output_buffer.shape[-1] if output_channels > tiled_channels: @@ -939,10 +1338,17 @@ def _launch_extract_all_tiles( f"Output buffer has {output_channels} channels but the tiled buffer only has {tiled_channels};" " extract_all_tiles_kernel would read out of bounds." ) + wp.launch( kernel=extract_all_tiles_kernel, dim=(render_data.num_envs, render_data.height, render_data.width), - inputs=[tiled_buffer, output_buffer, render_data.num_cols, render_data.width, render_data.height], + inputs=[ + tiled_buffer, + output_buffer, + render_data.num_cols, + render_data.width, + render_data.height, + ], device=self._device, ) @@ -954,9 +1360,12 @@ def _extract_rgba_tiles( buffer_key: str, suffix: str = "", ) -> None: + """Extract per-env RGBA tiles from tiled buffer into output_buffers (single kernel launch).""" output_buffer = output_buffers[buffer_key] - if output_buffer.shape[-1] not in (3, 4): - raise ValueError(f"Expected RGB (3 channels) or RGBA (4 channels), got {output_buffer.shape[-1]}") + num_channels = output_buffer.shape[-1] + if num_channels not in (3, 4): + raise ValueError(f"Expected RGB (3 channels) or RGBA (4 channels), got {num_channels}") + self._launch_extract_all_tiles(render_data, tiled_data, output_buffer) def _extract_depth_tiles( @@ -966,6 +1375,15 @@ def _extract_depth_tiles( output_buffers: dict, buffer_keys: Sequence[str], ) -> None: + """Extract per-env depth tiles into the given output buffers (one kernel launch each). + + Args: + render_data: OVRTX render data for the current frame. + tiled_depth_data: Tiled depth data mapped from one depth render var. + output_buffers: Destination warp buffers, keyed by data type. + buffer_keys: Data types that this depth render var measures. Keys absent from + ``output_buffers`` are skipped. + """ for depth_type in buffer_keys: if depth_type in output_buffers: self._launch_extract_all_tiles(render_data, tiled_depth_data, output_buffers[depth_type]) @@ -973,6 +1391,7 @@ def _extract_depth_tiles( def _extract_hdr_color_tiles( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict ) -> None: + """Extract per-env HdrColor tiles into output_buffers.""" if "rgb_hdr" not in output_buffers: return if tiled_data.dtype not in (wp.float16, wp.float32): @@ -982,11 +1401,14 @@ def _extract_hdr_color_tiles( def _prepare_ppisp_hdr_source( self, render_data: OVRTXRenderData, tiled_data: wp.array, output_buffers: dict ) -> wp.array: + """Return the PPISP HdrColor source on the output buffer device.""" if render_data.ppisp_pipeline is None: return tiled_data + output_device = str(output_buffers[str(RenderBufferKind.RGB_HDR)].device) if str(tiled_data.device) == output_device: return tiled_data + # The render product pins ``deviceIds`` to this renderer's CUDA device, so the mapping # normally lands on the output device already. This stays as a fallback for the case OVRTX # reports as "deviceIds ... not in the active device set" and falls back to automatic @@ -994,80 +1416,121 @@ def _prepare_ppisp_hdr_source( return wp.clone(tiled_data, device=output_device) def _process_render_frame(self, render_data: OVRTXRenderData, frame, output_buffers: dict) -> None: + """Extract RGB, depth, albedo, and semantic from a single render frame into output_buffers.""" + # Reset per-output metadata so it is a snapshot of this frame only. Unlike pixel AOVs (always + # present), metadata like the semantic ``idToLabels`` is only repopulated below when its render var + # is available, so without this a missing SemanticIdMap on a later frame would leave a stale mapping. render_data.renderer_info.clear() - if "LdrColor" in frame.render_vars: + + ldr_color = frame.render_vars.get(_LDR_COLOR_VAR) + if ldr_color is not None: buffer_key = None + if render_data.ppisp_pipeline is None and "rgba" in output_buffers: buffer_key = "rgba" else: - for data_type in _RTX_MINIMAL_MODES: - if data_type in output_buffers: - buffer_key = data_type + # The output buffers must contain only one simple shading data type at most after resolution of the data + # types during creation of the output buffers (OVRTXRenderData._create_warp_buffers). + for dt in _RTX_MINIMAL_MODES: + if dt in output_buffers: + buffer_key = dt break + if buffer_key is not None: - with self._map_render_var_to_dlpack(frame.render_vars["LdrColor"]) as tiled_data: + with self._map_render_var_to_dlpack(ldr_color) as tiled_data: self._extract_rgba_tiles(render_data, tiled_data, output_buffers, buffer_key) + for depth_var, buffer_keys in _DEPTH_VAR_BUFFER_KEYS.items(): - if depth_var not in frame.render_vars or not any(key in output_buffers for key in buffer_keys): + depth_render_var = frame.render_vars.get(depth_var) + if depth_render_var is None: + continue + if not any(buffer_key in output_buffers for buffer_key in buffer_keys): continue - with self._map_render_var_to_dlpack(frame.render_vars[depth_var]) as tiled_depth_data: + with self._map_render_var_to_dlpack(depth_render_var) as tiled_depth_data: if tiled_depth_data.dtype == wp.uint32: tiled_depth_data = wp.from_torch( wp.to_torch(tiled_depth_data).view(torch.float32), dtype=wp.float32 ) self._extract_depth_tiles(render_data, tiled_depth_data, output_buffers, buffer_keys) - if "DiffuseAlbedoSD" in frame.render_vars and "albedo" in output_buffers: - with self._map_render_var_to_dlpack(frame.render_vars["DiffuseAlbedoSD"]) as tiled_data: - self._extract_rgba_tiles(render_data, tiled_data, output_buffers, "albedo", suffix="albedo") - if "HdrColor" in frame.render_vars and "rgb_hdr" in output_buffers: - with self._map_render_var_to_dlpack(frame.render_vars["HdrColor"]) as tiled_data: - tiled_data = self._prepare_ppisp_hdr_source(render_data, tiled_data, output_buffers) - self._extract_hdr_color_tiles(render_data, tiled_data, output_buffers) + + albedo_var = frame.render_vars.get(_ALBEDO_VAR) + if albedo_var is not None and "albedo" in output_buffers: + with self._map_render_var_to_dlpack(albedo_var) as tiled_albedo_data: + self._extract_rgba_tiles(render_data, tiled_albedo_data, output_buffers, "albedo", suffix="albedo") + + hdr_color = frame.render_vars.get(_HDR_COLOR_VAR) + if hdr_color is not None and "rgb_hdr" in output_buffers: + with self._map_render_var_to_dlpack(hdr_color) as tiled_hdr_data: + tiled_hdr_data = self._prepare_ppisp_hdr_source(render_data, tiled_hdr_data, output_buffers) + self._extract_hdr_color_tiles(render_data, tiled_hdr_data, output_buffers) + self._process_id_segmentation_render_var( render_data, frame, output_buffers, - "SemanticSegmentation", + _SEMANTIC_SEGMENTATION_VAR, "semantic_segmentation", self.cfg.colorize_semantic_segmentation, ) + # Decode the SemanticIdMap into camera.data.info["semantic_segmentation"]["idToLabels"]. if "semantic_segmentation" in output_buffers: self._process_semantic_id_map(render_data, frame) + self._process_id_segmentation_render_var( render_data, frame, output_buffers, - "NonStableInstanceSegmentation", + _INSTANCE_SEGMENTATION_VAR, "instance_segmentation", self.cfg.colorize_instance_segmentation, ) + # Decode the StableIdSemanticIdMap/StableIdMap/SemanticIdMap trio into + # camera.data.info["instance_segmentation"]["idToLabels"] and ["idToSemantics"]. if "instance_segmentation" in output_buffers: self._process_instance_segmentation_maps(render_data, frame) - if "NormalSD" in frame.render_vars and "normals" in output_buffers: - with self._map_render_var_to_dlpack(frame.render_vars["NormalSD"]) as tiled_data: - self._launch_extract_all_tiles(render_data, tiled_data, output_buffers["normals"]) - if "TargetMotionSD" in frame.render_vars and "motion_vectors" in output_buffers: - with self._map_render_var_to_dlpack(frame.render_vars["TargetMotionSD"]) as tiled_data: - self._launch_extract_all_tiles(render_data, tiled_data, output_buffers["motion_vectors"]) + + normals_var = frame.render_vars.get(_NORMALS_VAR) + if normals_var is not None and "normals" in output_buffers: + with self._map_render_var_to_dlpack(normals_var) as tiled_normals_data: + self._launch_extract_all_tiles(render_data, tiled_normals_data, output_buffers["normals"]) + + # For motion vectors, extract only the first two (u, v) channels from the tiled buffer. + # Note: mirrors the Isaac RTX renderer's handling of the "TargetMotionSD" AOV + # (check: https://github.com/isaac-sim/IsaacLab/issues/2003). + motion_var = frame.render_vars.get(_MOTION_VECTORS_VAR) + if motion_var is not None and "motion_vectors" in output_buffers: + with self._map_render_var_to_dlpack(motion_var) as tiled_motion_vectors_data: + self._launch_extract_all_tiles(render_data, tiled_motion_vectors_data, output_buffers["motion_vectors"]) def _render_legacy(self, render_data: OVRTXRenderData) -> None: + """Render the scene into the provided RenderData.""" if not self._initialized_scene: raise RuntimeError("Scene not initialized. Call initialize() first.") - if self._renderer is None or not self._render_product_paths: + if self._renderer is None or len(self._render_product_paths) == 0: return material_writer = self._visual_material_writer_ref() if self._visual_material_writer_ref is not None else None try: if material_writer is not None: material_writer.publish() - products = self._renderer.step(render_products=set(self._render_product_paths), delta_time=1.0 / 60.0) + products = self._renderer.step( + render_products=set(self._render_product_paths), + delta_time=1.0 / 60.0, + ) finally: if material_writer is not None: drain_errors = contextlib.nullcontext() if sys.exc_info()[0] is None else contextlib.suppress(Exception) with drain_errors: material_writer.drain() product_path = self._render_product_paths[0] - if product_path in products and products[product_path].frames: - self._process_render_frame(render_data, products[product_path].frames[0], render_data.warp_buffers) + if product_path in products and len(products[product_path].frames) > 0: + self._process_render_frame( + render_data, + products[product_path].frames[0], + render_data.warp_buffers, + ) + + # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination + # buffers are the same warp buffer map used by extraction. if render_data.ppisp_pipeline is not None: render_data.ppisp_pipeline.apply( render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], @@ -1075,25 +1538,30 @@ def _render_legacy(self, render_data: OVRTXRenderData) -> None: ) def _close_legacy(self) -> None: + """Release the renderer's tensor bindings and stage. See :meth:`close`.""" + + # Unbind before tearing down renderer def _safe_unbind(binding, name: str) -> None: if binding is None: return try: binding.unbind() - except Exception as exc: - if "destroyed" not in str(exc).lower(): - logger.warning("Error unbinding %s: %s", name, exc) - - for attr, name in ( - ("_camera_xform_binding", "camera transforms"), - ("_object_xform_binding", "object transforms"), - ("_deformable_points_binding", "deformable points"), - ("_particle_points_binding", "particle points"), - ("_cable_points_binding", "cable points"), - ): - _safe_unbind(getattr(self, attr), name) - setattr(self, attr, None) + except Exception as e: + if "destroyed" not in str(e).lower(): + logger.warning("Error unbinding %s: %s", name, e) + + _safe_unbind(self._camera_xform_binding, "camera transforms") + self._camera_xform_binding = None + _safe_unbind(self._object_xform_binding, "object transforms") + self._object_xform_binding = None self._object_transform_buffer = None + _safe_unbind(self._deformable_points_binding, "deformable points") + self._deformable_points_binding = None + _safe_unbind(self._particle_points_binding, "particle points") + self._particle_points_binding = None + _safe_unbind(self._cable_points_binding, "cable points") + self._cable_points_binding = None + self._deformable_particle_offsets = [] self._deformable_particle_counts = [] self._particle_visual_offsets = [] @@ -1106,68 +1574,114 @@ def _safe_unbind(binding, name: str) -> None: self._cable_shape_ids = None self._cable_offsets = None self._cable_counts = None + if self._renderer: try: self._renderer.reset_stage() - except Exception as exc: - logger.warning("Error resetting stage: %s", exc) + except Exception as e: + logger.warning("Error resetting stage: %s", e) + self._renderer = None + self._render_product_paths.clear() self._output_id_color_buffers.clear() self._initialized_scene = False + # --------------------------------------------------------------------------- + # Dispatch methods — route to ovstage or legacy implementation + # --------------------------------------------------------------------------- + def _init_fields(self) -> None: - self._init_fields_ovstage() if self._use_ovstage else self._init_fields_legacy() + if self._use_ovstage: + self._init_fields_ovstage() + else: + self._init_fields_legacy() def _initialize_from_spec(self, spec: CameraRenderSpec) -> None: - self._initialize_from_spec_ovstage(spec) if self._use_ovstage else self._initialize_from_spec_legacy(spec) + if self._use_ovstage: + self._initialize_from_spec_ovstage(spec) + else: + self._initialize_from_spec_legacy(spec) def _setup_xform_bindings(self) -> None: - self._setup_xform_bindings_ovstage() if self._use_ovstage else self._setup_xform_bindings_legacy() + if self._use_ovstage: + self._setup_xform_bindings_ovstage() + else: + self._setup_xform_bindings_legacy() def _setup_deformable_bindings(self, num_envs: int) -> None: - self._setup_deformable_bindings_ovstage( - num_envs - ) if self._use_ovstage else self._setup_deformable_bindings_legacy(num_envs) + if self._use_ovstage: + self._setup_deformable_bindings_ovstage(num_envs) + else: + self._setup_deformable_bindings_legacy(num_envs) def _setup_particle_bindings(self) -> None: - self._setup_particle_bindings_ovstage() if self._use_ovstage else self._setup_particle_bindings_legacy() + if self._use_ovstage: + self._setup_particle_bindings_ovstage() + else: + self._setup_particle_bindings_legacy() def _setup_cable_bindings(self) -> None: - self._setup_cable_bindings_ovstage() if self._use_ovstage else self._setup_cable_bindings_legacy() + if self._use_ovstage: + self._setup_cable_bindings_ovstage() + else: + self._setup_cable_bindings_legacy() @staticmethod def _discover_cable_segment_bindings() -> tuple[list[str], list[int], list[int], list[int]] | None: + """Collect cable prim paths and packed Newton segment shape ids, or ``None`` to skip. + + Returns: + ``None`` when Newton is unavailable or no renderable cables exist. Otherwise a tuple + ``(cable_prim_paths, flat_shape_ids, offsets, counts)`` where: + + * ``cable_prim_paths``: concrete ``BasisCurves`` prim paths to bind, one per curve. + * ``flat_shape_ids``: Newton shape ids for all curves, packed contiguously in segment + order (indices into ``model.shape_body`` / ``shape_transform`` / ``shape_scale``). + * ``offsets``: start index into ``flat_shape_ids`` for each curve. + * ``counts``: number of segment shapes (capsules) for each curve. + """ try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping cable point bindings") return None + cable_segment_shape_ids = NewtonManager.collect_cable_segment_shape_ids() if not cable_segment_shape_ids: + logger.debug("No renderable Newton cables found, skipping cable point bindings") return None - paths, flat_ids, offsets, counts = [], [], [], [] + + cable_prim_paths: list[str] = [] + flat_shape_ids: list[int] = [] + offsets: list[int] = [] + counts: list[int] = [] for prim_path, segment_shape_ids in cable_segment_shape_ids.items(): - paths.append(prim_path) - offsets.append(len(flat_ids)) + cable_prim_paths.append(prim_path) + offsets.append(len(flat_shape_ids)) counts.append(len(segment_shape_ids)) - flat_ids.extend(segment_shape_ids) - return paths, flat_ids, offsets, counts + flat_shape_ids.extend(segment_shape_ids) + return cable_prim_paths, flat_shape_ids, offsets, counts def _allocate_cable_device_buffers(self, flat_shape_ids: list[int], offsets: list[int], counts: list[int]) -> None: - self._cable_shape_ids = wp.array(flat_shape_ids, dtype=wp.int32, device=self._device) - self._cable_offsets = wp.array(offsets, dtype=wp.int32, device=self._device) - self._cable_counts = wp.array(counts, dtype=wp.int32, device=self._device) + """Allocate shared Warp arrays used by the cable point kernel.""" + device = self._device + self._cable_shape_ids = wp.array(flat_shape_ids, dtype=wp.int32, device=device) + self._cable_offsets = wp.array(offsets, dtype=wp.int32, device=device) + self._cable_counts = wp.array(counts, dtype=wp.int32, device=device) self._cable_segment_counts = counts self._cable_max_points = max(counts) + 1 if counts else 0 - self._cable_points = wp.zeros(sum(count + 1 for count in counts), dtype=wp.vec3f, device=self._device) + self._cable_points = wp.zeros(sum(count + 1 for count in counts), dtype=wp.vec3f, device=device) def _compute_cable_points_world(self) -> None: + """Launch the cable endpoint kernel into ``_cable_points``.""" from isaaclab_newton.physics import NewtonManager model = NewtonManager.get_model() state = NewtonManager.get_state() if state is None: raise RuntimeError("Newton state should not be None") + wp.launch( compute_cable_points_world_kernel, dim=(len(self._cable_segment_counts), self._cable_max_points), @@ -1185,23 +1699,46 @@ def _compute_cable_points_world(self) -> None: ) def update_transforms(self) -> None: - self._update_transforms_ovstage() if self._use_ovstage else self._update_transforms_legacy() + """Sync transforms to OVRTX.""" + if self._use_ovstage: + self._update_transforms_ovstage() + else: + self._update_transforms_legacy() def update_geometries(self) -> None: - self._update_geometries_ovstage() if self._use_ovstage else self._update_geometries_legacy() + """Sync geometries to OVRTX.""" + if self._use_ovstage: + self._update_geometries_ovstage() + else: + self._update_geometries_legacy() def update_camera( - self, render_data: OVRTXRenderData, positions: ProxyArray, orientations: ProxyArray, intrinsics: ProxyArray + self, + render_data: OVRTXRenderData, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ) -> None: + """Update camera transforms in OVRTX.""" if self._use_ovstage: self._update_camera_ovstage(render_data, positions, orientations, intrinsics) else: self._update_camera_legacy(render_data, positions, orientations, intrinsics) def render(self, render_data: OVRTXRenderData) -> None: - self._render_ovstage(render_data) if self._use_ovstage else self._render_legacy(render_data) + """Render the scene into the provided RenderData.""" + if self._use_ovstage: + self._render_ovstage(render_data) + else: + self._render_legacy(render_data) def cleanup(self, render_data: OVRTXRenderData | None) -> None: + """Release the render data's buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.cleanup`. + + The stage queries, tensor bindings and render products this renderer holds are shared by + every camera that resolves to it, so releasing them here would tear the scene down while + the other cameras are still rendering. :meth:`close` releases them instead. + """ if render_data is None: return render_data.warp_buffers.clear() @@ -1209,32 +1746,65 @@ def cleanup(self, render_data: OVRTXRenderData | None) -> None: render_data.ppisp_pipeline = None def close(self) -> None: - self._close_ovstage() if self._use_ovstage else self._close_legacy() + """Release the shared stage state. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.close`.""" + if self._use_ovstage: + self._close_ovstage() + else: + self._close_legacy() self._visual_material_writer_ref = None + # --------------------------------------------------------------------------- + # ovstage implementation + # + # Follow-up: + # - Experiment with dropping the per-frame ``.wait()`` calls. ``advance_write_floor(N).wait()`` in + # :meth:`_render_ovstage` already bars all writes at ordinals <= N, so accumulate the + # ``Operation`` objects and ``stage.release_op(op.op_id)`` after it. They must outlive the + # barrier — an ``Operation`` is its buffer's only keepalive. Saves caller-side blocking only. + # --------------------------------------------------------------------------- + def _init_fields_ovstage(self) -> None: self._stage = None self._stage_paths = None self._ovstage_exit_stack: contextlib.ExitStack | None = None - self._current_ordinal = 0 - self._camera_xform_query = self._camera_paths_list = None - self._object_xform_query = self._object_paths_list = None - self._deformable_points_query = self._deformable_paths_list = None - self._particle_points_query = self._particle_paths_list = None - self._cable_points_query = self._cable_paths_list = None - self._cable_point_tensors = [] + self._current_ordinal: int = 0 + self._camera_xform_query = None + self._camera_paths_list = None + self._object_xform_query = None + self._object_paths_list = None + self._deformable_points_query = None + self._deformable_paths_list = None + self._particle_points_query = None + self._particle_paths_list = None + self._cable_points_query = None + self._cable_paths_list = None + # DLTensor descriptors aliasing ``_cable_point_slices``; rebuilt only when cables rebind. + self._cable_point_tensors: list = [] def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None: - width, height, num_envs = spec.cfg.width, spec.cfg.height, spec.num_instances + """Initialize the OVRTX renderer with internal environment cloning (ovstage path). + + Args: + spec: Tiled camera description (resolution, paths, data types). + """ + width = spec.cfg.width + height = spec.cfg.height + num_envs = spec.num_instances data_types = spec.cfg.data_types if spec.cfg.data_types else ["rgb"] if spec.cfg.isp_cfg is not None and "rgb_hdr" not in data_types: data_types = [*data_types, "rgb_hdr"] + + env_0_prefix = "/World/envs/env_0/" first_cam_path = spec.camera_prim_paths[0] - if not first_cam_path.startswith("/World/envs/env_0/"): - raise RuntimeError(f"Expected camera prim under '/World/envs/env_0/', got '{first_cam_path}'") + if not first_cam_path.startswith(env_0_prefix): + raise RuntimeError(f"Expected camera prim under '{env_0_prefix}', got '{first_cam_path}'") self._camera_rel_path = spec.camera_path_relative_to_env_0 + + logger.info("Injecting camera definitions...") + if self._exported_usd_string is None: raise RuntimeError("Expected an exported USD string from stage") + render_product_string, render_product_path = build_render_product_as_string( width=width, height=height, @@ -1246,13 +1816,19 @@ def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None: enable_shadows=self.cfg.enable_shadows, ) self._render_product_paths.append(render_product_path) + combined_usd_string = self._exported_usd_string + "\n\n" + render_product_string - self._exported_usd_string = None + self._exported_usd_string = None # Free memory + + # If temp_usd_dir is set, write the combined USD stage to a temporary file. if self.cfg.temp_usd_dir is not None: _write_file(Path(self.cfg.temp_usd_dir), "ovrtx_renderer_stage.usda", combined_usd_string) + + logger.info("Loading USD into OvRTX via ovstage...") self._ovstage_exit_stack = contextlib.ExitStack() self._stage = self._ovstage_exit_stack.enter_context(create_ovstage("isaaclab.ovrtx")) self._stage_paths = self._ovstage_exit_stack.enter_context(ovstage.PathDictionary(self._stage)) + # Ordinal 0 is the empty/unwritten state in ovstage; the first write must use >= 1. self._current_ordinal += 1 ovstage.population.open_usd_from_string( self._stage, @@ -1260,27 +1836,43 @@ def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None: ordinal=self._current_ordinal, domains=ovstage.PopulationDomain.RENDERING, ) + if num_envs > 1: self._clone_sources_ovstage() self._update_scene_partitions_after_clone_ovstage(num_envs) + self._initialized_scene = True + camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + + # Re-author the RenderProduct's camera relationship after clone. ``stage.clone`` recreates the per-env + # cameras, so the RenderProduct must be pointed at the freshly-interned camera path ids to discover every + # camera for tiled rendering. render_product_paths = self._stage_paths.create_path_list_from_strings([render_product_path]) - with self._stage.query_from_path_list(render_product_paths) as query: - target_ids = np.array([self._stage_paths.intern_path(path) for path in camera_paths], dtype=np.uint64) + with self._stage.query_from_path_list(render_product_paths) as render_product_query: + camera_attribute = self._stage_paths.intern_token("camera") + camera_target_ids = np.array( + [self._stage_paths.intern_path(path) for path in camera_paths], dtype=np.uint64 + ) self._stage.write_attribute( - query, - self._stage_paths.intern_token("camera"), + render_product_query, + camera_attribute, ordinal=self._current_ordinal, - tensors=target_ids, + tensors=camera_target_ids, is_array=True, semantic=ovstage.AttributeSemantic.RELATIONSHIP_PATH_ID, ).wait() self._stage_paths.destroy_path_list(render_product_paths) + self._camera_paths_list = self._stage_paths.create_path_list_from_strings(camera_paths) self._camera_xform_query = self._stage.query_from_path_list(self._camera_paths_list) + if self._camera_xform_query is None: raise RuntimeError("Camera query is None — cannot render without a valid camera query") + logger.info("Camera query created successfully") + + # Resetting the xform stack makes omni:xform the absolute world transform, preventing + # ancestor transforms (env root, asset root) from compounding on top of the camera pose. self._stage.write_attribute( self._camera_xform_query, "omni:resetXformStack", @@ -1288,80 +1880,139 @@ def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None: tensors=np.full(num_envs, True, dtype=np.bool_), is_array=False, ).wait() + self._setup_xform_bindings_ovstage() self._setup_deformable_bindings_ovstage(num_envs) self._setup_particle_bindings_ovstage() self._setup_cable_bindings_ovstage() + + # Commit all init-time writes then attach. attach_ovstage happens last so the renderer + # immediately sees the fully-configured scene on its first step. self._stage.advance_write_floor(ordinal=self._current_ordinal).wait() self._renderer.attach_ovstage(self._stage) + logger.info("OVRTX loaded USD from string successfully via ovstage") self._current_ordinal += 1 def _clone_sources_ovstage(self): + """Clone sources in OVRTX using the scene :class:`~isaaclab.cloner.ClonePlan` (ovstage path).""" clone_plan = self._clone_plan if clone_plan is None or clone_plan.env_ids is None or clone_plan.positions is None: raise RuntimeError("Clone plan with environment ids and positions is required when using OVRTX cloning") - env_ids = clone_plan.env_ids.detach().cpu() - clone_mask = clone_plan.clone_mask.detach().cpu() - paths = [f"/World/envs/env_{env_id}" for env_id in env_ids.tolist()] + + env_ids = clone_plan.env_ids + clone_mask = clone_plan.clone_mask + num_envs = len(env_ids) + env_prim_paths = [f"/World/envs/env_{int(env_id)}" for env_id in env_ids] + + logger.info("Cloning sources in OVRTX...") + + num_cloned_sources = 0 for row_idx, (source, destination) in enumerate(zip(clone_plan.sources, clone_plan.destinations, strict=True)): - targets = [ + target_paths = [ destination.format(int(env_id)) - for env_id in env_ids[clone_mask[row_idx]].tolist() + for env_id in env_ids[clone_mask[row_idx]] if destination.format(int(env_id)) != source ] - if targets: - self._stage.clone(source, targets, ordinal=self._current_ordinal) - xforms = np.tile(np.eye(4, dtype=np.float64), (len(env_ids), 1, 1)) - xforms[:, 3, :3] = clone_plan.positions.cpu().numpy() - path_list = self._stage_paths.create_path_list_from_strings(paths) - query = self._stage.query_from_path_list(path_list) + if target_paths: + logger.debug("Cloning row %d: %s -> %d target(s)", row_idx, source, len(target_paths)) + try: + self._stage.clone(source, target_paths, ordinal=self._current_ordinal) + num_cloned_sources += 1 + except Exception as e: + error_msg = f"Failed to clone row {row_idx} from {source}: {e}" + logger.error(error_msg) + raise RuntimeError(error_msg) + + logger.info("Cloned %d sources successfully in OVRTX", num_cloned_sources) + env_root_xforms = np.tile(np.eye(4, dtype=np.float64), (num_envs, 1, 1)) + env_root_xforms[:, 3, :3] = clone_plan.positions + env_paths_list = self._stage_paths.create_path_list_from_strings(env_prim_paths) + env_query = self._stage.query_from_path_list(env_paths_list) self._stage.write_attribute( - query, + env_query, "omni:xform", ordinal=self._current_ordinal, - tensors=xform_tensor_from_numpy(xforms), + tensors=xform_tensor_from_numpy(env_root_xforms), is_array=False, semantic=ovstage.AttributeSemantic.MATRIX, ).wait() - self._stage.release_query(query).wait() - self._stage_paths.destroy_path_list(path_list) + + self._stage.release_query(env_query).wait() + self._stage_paths.destroy_path_list(env_paths_list) def _update_scene_partitions_after_clone_ovstage(self, num_envs: int): - tokens = np.array([self._stage_paths.intern_token(f"env_{i}") for i in range(num_envs)], dtype=np.uint64) - for paths, attribute in ( - ([f"/World/envs/env_{i}" for i in range(num_envs)], "primvars:omni:scenePartition"), - ([f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)], "omni:scenePartition"), - ): - path_list = self._stage_paths.create_path_list_from_strings(paths) - query = self._stage.query_from_path_list(path_list) - self._stage.write_attribute( - query, - attribute, - ordinal=self._current_ordinal, - tensors=tokens, - is_array=False, - semantic=ovstage.AttributeSemantic.TOKEN_ID, - ).wait() - self._stage.release_query(query).wait() - self._stage_paths.destroy_path_list(path_list) + """Update scene partition attributes on cloned environments and cameras (ovstage path).""" + logger.info("Writing scene partitions for %d environments...", num_envs) + env_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] + camera_prim_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] + # TOKEN_ID semantic tells ovstage the uint64 values are interned string tokens, not raw integers; + # the renderer resolves them back to the original "env_N" strings for scene-partition lookup. + token_ids = np.array([self._stage_paths.intern_token(f"env_{i}") for i in range(num_envs)], dtype=np.uint64) + + env_paths_list = self._stage_paths.create_path_list_from_strings(env_prim_paths) + env_query = self._stage.query_from_path_list(env_paths_list) + self._stage.write_attribute( + env_query, + "primvars:omni:scenePartition", + ordinal=self._current_ordinal, + tensors=token_ids, + is_array=False, + semantic=ovstage.AttributeSemantic.TOKEN_ID, + ).wait() + self._stage.release_query(env_query).wait() + self._stage_paths.destroy_path_list(env_paths_list) + logger.info("Written primvars:omni:scenePartition to %d environments", num_envs) + + cam_paths_list = self._stage_paths.create_path_list_from_strings(camera_prim_paths) + cam_query = self._stage.query_from_path_list(cam_paths_list) + self._stage.write_attribute( + cam_query, + "omni:scenePartition", + ordinal=self._current_ordinal, + tensors=token_ids, + is_array=False, + semantic=ovstage.AttributeSemantic.TOKEN_ID, + ).wait() + self._stage.release_query(cam_query).wait() + self._stage_paths.destroy_path_list(cam_paths_list) + logger.info("Written omni:scenePartition to %d cameras", num_envs) def _setup_xform_bindings_ovstage(self) -> None: + """Setup OVRTX bindings for scene objects to sync with Newton physics (ovstage path).""" try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping object bindings") return - model = NewtonManager.get_model() - if SimulationContext.instance() is None or model is None or getattr(model, "body_label", None) is None: + + if SimulationContext.instance() is None: + logger.info("No active simulation context, will not set up ovrtx object bindings for newton") + return + + newton_model = NewtonManager.get_model() + if newton_model is None: + logger.debug("Newton model not available, skipping object bindings") return - object_paths, indices = [], [] - for idx, path in enumerate(model.body_label): + + all_body_paths = getattr(newton_model, "body_label", None) + if all_body_paths is None: + logger.info("Newton model has no body_label, skipping object bindings") + return + + object_paths = [] + newton_indices = [] + for idx, path in enumerate(all_body_paths): if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: object_paths.append(path) - indices.append(idx) - if not object_paths: + newton_indices.append(idx) + + if len(object_paths) == 0: + logger.info("No dynamic objects found for binding") return + self._object_paths_list = self._stage_paths.create_path_list_from_strings(object_paths) self._object_xform_query = self._stage.query_from_path_list(self._object_paths_list) + self._stage.write_attribute( self._object_xform_query, "omni:resetXformStack", @@ -1369,103 +2020,237 @@ def _setup_xform_bindings_ovstage(self) -> None: tensors=np.full(len(object_paths), True, dtype=np.bool_), is_array=False, ).wait() - self._object_newton_indices = wp.array(indices, dtype=wp.int32, device=self._device) + + if self._object_xform_query is None: + raise RuntimeError("Failed to create OVRTX object bindings") + + self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) self._object_scales = self._create_object_scale_array(object_paths) def _setup_deformable_bindings_ovstage(self, num_envs: int) -> None: + """Setup OVRTX bindings for Newton deformable bodies (ovstage path). + + Args: + num_envs: Number of environments. + """ try: from isaaclab_newton.physics import NewtonManager except ImportError: + logger.debug("NewtonManager not available, skipping deformable body bindings") return - registry = NewtonManager._deformable_registry - if not registry: + + # Early return if the deformable registry is empty. + deformable_registry = NewtonManager._deformable_registry + if not deformable_registry: + logger.debug("Deformable registry is empty, skipping deformable body bindings") return - bad = [entry for entry in registry if len(entry.particle_offsets) != num_envs] - if bad: - raise RuntimeError(f"OVRTX expects one particle offset per environment ({num_envs})") - paths = [] + + # Validate the number of particle offsets for each deformable entry upfront. + bad_entries = [entry for entry in deformable_registry if len(entry.particle_offsets) != num_envs] + if bad_entries: + details = "\n".join( + f"- '{entry.prim_path}' has {len(entry.particle_offsets)} particle offsets" for entry in bad_entries + ) + raise RuntimeError( + f"OVRTX expects one particle offset per environment ({num_envs}), but the following " + f"deformable entries have a mismatched offset count:\n{details}" + ) + self._deformable_particle_offsets = [] self._deformable_particle_counts = [] - for entry in registry: - for idx, offset in enumerate(entry.particle_offsets): - paths.append(re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(idx), entry.vis_mesh_prim_path)) - self._deformable_particle_offsets.append(offset) + + vis_mesh_prim_paths: list[str] = [] + + # Each registry entry is one deformable asset registered at spawn time. Its + # ``vis_mesh_prim_path`` uses a regex env wildcard (e.g. ``env_.*``) to denote one + # homogeneous visual mesh replicated into every environment, not a subset of envs. + # During replication, Newton appends one particle block per env in contiguous env order + # and records the start index in ``entry.particle_offsets``; ``particles_per_body`` is + # the block size. The inner loop therefore emits one OVRTX mesh binding per env, + # resolving the env wildcard with ``env_idx`` and pairing it with that env's slice in + # the flat ``particle_q`` array. + # + # This mapping is valid only while deformable registry entries remain homogeneous across + # all envs with dense, contiguous env ids. If deformables later support env subsets or + # non-contiguous env ids, OVRTX must consume explicit per-instance env metadata instead + # of deriving env ids from ``enumerate(entry.particle_offsets)``. + for entry in deformable_registry: + for idx, particle_offset in enumerate(entry.particle_offsets): + self._deformable_particle_offsets.append(particle_offset) self._deformable_particle_counts.append(entry.particles_per_body) - if not paths: + + vis_mesh_prim_paths.append( + re.sub(r"(?<=[Ee]nv_)(?:\[\^/\][*+]|\.\*)", str(idx), entry.vis_mesh_prim_path) + ) + + prim_count = len(vis_mesh_prim_paths) + if prim_count == 0: + logger.warning("No deformable visual prim paths collected, skipping deformable body bindings") return - self._deformable_paths_list = self._stage_paths.create_path_list_from_strings(paths) + + self._deformable_paths_list = self._stage_paths.create_path_list_from_strings(vis_mesh_prim_paths) self._deformable_points_query = self._stage.query_from_path_list(self._deformable_paths_list) - self._write_identity_xforms(self._deformable_points_query, len(paths)) - def _setup_particle_bindings_ovstage(self) -> None: - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - return - records = NewtonManager._particle_visual_prims - if not records: - return - paths = list(records) - self._particle_visual_offsets = [record.offset for record in records.values()] - self._particle_visual_counts = [record.count for record in records.values()] - self._particle_paths_list = self._stage_paths.create_path_list_from_strings(paths) - self._particle_points_query = self._stage.query_from_path_list(self._particle_paths_list) - self._write_identity_xforms(self._particle_points_query, len(paths)) + # particle_q is already in world space, so resetting the xform stack and pinning an identity + # omni:xform prevents the env-root and asset-root ancestor transforms from being applied on top. + self._stage.write_attribute( + self._deformable_points_query, + "omni:resetXformStack", + ordinal=self._current_ordinal, + tensors=np.full(prim_count, True, dtype=np.bool_), + is_array=False, + ).wait() + + identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) + self._stage.write_attribute( + self._deformable_points_query, + "omni:xform", + ordinal=self._current_ordinal, + tensors=xform_tensor_from_numpy(identity_xforms), + is_array=False, + semantic=ovstage.AttributeSemantic.MATRIX, + ).wait() + + if self._deformable_points_query is None: + raise RuntimeError("Failed to create OVRTX deformable body bindings") def _setup_cable_bindings_ovstage(self) -> None: + """Setup ovstage ``points`` bindings for Newton cables (``UsdGeom.BasisCurves``). + + Mirrors :meth:`_setup_cable_bindings_legacy`: the endpoint kernel writes device memory and + the per-frame handover is zero-copy. The per-curve slices and their DLTensor descriptors are + built once here rather than per frame, because the layout is fixed for the lifetime of the + binding — only the contents of ``_cable_points`` change each step. + """ discovered = self._discover_cable_segment_bindings() if discovered is None: return - paths, ids, offsets, counts = discovered - self._cable_paths_list = self._stage_paths.create_path_list_from_strings(paths) + + cable_prim_paths, flat_shape_ids, offsets, counts = discovered + prim_count = len(cable_prim_paths) + self._cable_paths_list = self._stage_paths.create_path_list_from_strings(cable_prim_paths) self._cable_points_query = self._stage.query_from_path_list(self._cable_paths_list) - self._write_identity_xforms(self._cable_points_query, len(paths)) - self._allocate_cable_device_buffers(ids, offsets, counts) + + # The kernel emits world space, so reset the xform stack and pin an identity omni:xform to + # stop the env-root and asset-root ancestor transforms being applied on top. + self._stage.write_attribute( + self._cable_points_query, + "omni:resetXformStack", + ordinal=self._current_ordinal, + tensors=np.full(prim_count, True, dtype=np.bool_), + is_array=False, + ).wait() + + identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) + self._stage.write_attribute( + self._cable_points_query, + "omni:xform", + ordinal=self._current_ordinal, + tensors=xform_tensor_from_numpy(identity_xforms), + is_array=False, + semantic=ovstage.AttributeSemantic.MATRIX, + ).wait() + + self._allocate_cable_device_buffers(flat_shape_ids, offsets, counts) + # The descriptors alias these slices, so both must outlive every write that uses them. self._cable_point_slices = [ - self._cable_points[offset + curve : offset + curve + count + 1] - for curve, (offset, count) in enumerate(zip(offsets, counts, strict=True)) + self._cable_points[offset + curve : offset + curve + segment_count + 1] + for curve, (offset, segment_count) in enumerate(zip(offsets, counts, strict=True)) ] self._cable_point_tensors = [points_tensor_from_warp(points) for points in self._cable_point_slices] - def _write_identity_xforms(self, query, count: int) -> None: + def _setup_particle_bindings_ovstage(self) -> None: + """Setup OVRTX bindings for Newton particle clouds (ovstage path).""" + try: + from isaaclab_newton.physics import NewtonManager + except ImportError: + logger.debug("NewtonManager not available, skipping particle point bindings") + return + + particle_visual_prims = NewtonManager._particle_visual_prims + if not particle_visual_prims: + logger.debug("No particle visual prims registered, skipping particle point bindings") + return + + self._particle_visual_offsets = [] + self._particle_visual_counts = [] + points_prim_paths: list[str] = [] + + for prim_path, record in particle_visual_prims.items(): + points_prim_paths.append(prim_path) + self._particle_visual_offsets.append(record.offset) + self._particle_visual_counts.append(record.count) + + prim_count = len(points_prim_paths) + + self._particle_paths_list = self._stage_paths.create_path_list_from_strings(points_prim_paths) + self._particle_points_query = self._stage.query_from_path_list(self._particle_paths_list) + + # Divergence from the legacy path: ovstage's PrimMode has only UPSERT and INSERT, with no + # MUST_EXIST equivalent, so the writes below cannot assert that every registered particle + # path resolves to a real prim the way _setup_particle_bindings_legacy does. A stale or + # mistyped path silently upserts a new row instead of raising, surfacing as invisible + # particles rather than an error. Same caveat applies to _setup_deformable_bindings_ovstage. + # + # particle_q is already in world space, so resetting the xform stack and pinning an identity + # omni:xform prevents the env-root and asset-root ancestor transforms from being applied on top. self._stage.write_attribute( - query, + self._particle_points_query, "omni:resetXformStack", ordinal=self._current_ordinal, - tensors=np.full(count, True, dtype=np.bool_), + tensors=np.full(prim_count, True, dtype=np.bool_), is_array=False, ).wait() + + identity_xforms = np.tile(np.eye(4, dtype=np.float64), (prim_count, 1, 1)) self._stage.write_attribute( - query, + self._particle_points_query, "omni:xform", ordinal=self._current_ordinal, - tensors=xform_tensor_from_numpy(np.tile(np.eye(4, dtype=np.float64), (count, 1, 1))), + tensors=xform_tensor_from_numpy(identity_xforms), is_array=False, semantic=ovstage.AttributeSemantic.MATRIX, ).wait() + if self._particle_points_query is None: + raise RuntimeError("Failed to create OVRTX particle point bindings") + def _update_transforms_ovstage(self) -> None: if self._object_xform_query is None or self._object_newton_indices is None or self._object_scales is None: return + + # If self._object_newton_indices is not None, then Newton's the current physics backend + from isaaclab_newton.physics import NewtonManager - state = NewtonManager.get_state() - if state is None: + newton_state = NewtonManager.get_state() + if newton_state is None: raise RuntimeError("Newton state should not be None") - if getattr(state, "body_q", None) is None: + + body_q = getattr(newton_state, "body_q", None) + if body_q is None: return - transforms = wp.empty(len(self._object_newton_indices), dtype=wp.mat44d, device=self._device) + + num_objects = len(self._object_newton_indices) + object_transforms = wp.empty(num_objects, dtype=wp.mat44d, device=self._device) wp.launch( - sync_newton_transforms_kernel, - len(self._object_newton_indices), - inputs=[transforms, self._object_newton_indices, state.body_q, self._object_scales], + kernel=sync_newton_transforms_kernel, + dim=num_objects, + inputs=[object_transforms, self._object_newton_indices, body_q, self._object_scales], device=self._device, ) + # The tensor is handed over zero-copy, so ovstage reads ``object_transforms`` in place and + # must not do so until the kernel above has landed. Passing the producing Warp stream as + # ``cuda_stream`` gives producer ordering: ovstage drains the work already queued on that + # stream before it touches the tensor. That replaces the device-wide + # ``wp.synchronize_device()`` with stream-scoped ordering and removes the host copy; it is + # not a nonblocking handoff, and the ``.wait()`` below can still block the calling thread. + # A GPU-side wait would need the event-based API instead. self._stage.write_attribute( self._object_xform_query, "omni:xform", ordinal=self._current_ordinal, - tensors=xform_tensor_from_warp(transforms), + tensors=xform_tensor_from_warp(object_transforms), is_array=False, semantic=ovstage.AttributeSemantic.MATRIX, cuda_stream=self._warp_device.stream.cuda_stream, @@ -1473,45 +2258,85 @@ def _update_transforms_ovstage(self) -> None: def _update_geometries_ovstage(self) -> None: if self._deformable_points_query is not None or self._particle_points_query is not None: + # If either geometry query is not None, then Newton's the current physics backend from isaaclab_newton.physics import NewtonManager - state = NewtonManager.get_state() - if state is None or getattr(state, "particle_q", None) is None: + newton_state = NewtonManager.get_state() + if newton_state is None: + raise RuntimeError("Newton state should not be None") + + # particle_q is the world-space particle positions for all deformable bodies and particle + # clouds. A non-None geometry query means entries were registered, so Newton must + # expose particle state; a missing particle_q here is an inconsistent state. + particle_q = getattr(newton_state, "particle_q", None) + if particle_q is None: raise RuntimeError("Newton state has no particle_q but particle geometry queries exist") + if self._deformable_points_query is not None: self._write_particle_q_slices_ovstage( self._deformable_points_query, - state.particle_q, + particle_q, self._deformable_particle_offsets, self._deformable_particle_counts, ) + if self._particle_points_query is not None: self._write_particle_q_slices_ovstage( self._particle_points_query, - state.particle_q, + particle_q, self._particle_visual_offsets, self._particle_visual_counts, ) + if self._cable_points_query is not None: self._write_cable_points_ovstage() - def _write_particle_q_slices_ovstage(self, query, particle_q, offsets, counts) -> None: - tensors = [ - points_tensor_from_warp(particle_q[offset : offset + count]) - for offset, count in zip(offsets, counts, strict=True) + def _write_particle_q_slices_ovstage( + self, + query: Any, + particle_q: wp.array, + particle_offsets: list[int], + particle_counts: list[int], + ) -> None: + """Write world-space ``particle_q`` slices into the ``points`` column of one ovstage query. + + Args: + query: ovstage query selecting the prims whose ``points`` attribute is written. + particle_q: Flat world-space particle positions [m], shape ``[total_particles]``, + dtype ``wp.vec3f``. Slices are passed zero-copy as CUDA DLTensors. + particle_offsets: Start index of each prim's slice into :paramref:`particle_q`. + particle_counts: Number of particles in each prim's slice. + """ + particle_slices = [ + points_tensor_from_warp(particle_q[particle_offset : particle_offset + particle_count]) + for particle_offset, particle_count in zip(particle_offsets, particle_counts, strict=True) ] + + # The slices alias ``particle_q`` and are handed over zero-copy, so ovstage must not read + # them until the Warp kernels that wrote ``particle_q`` have finished. Passing the producing + # Warp stream as ``cuda_stream`` gives producer ordering: ovstage drains the work already + # queued on that stream before it touches the slices. That replaces the device-wide + # ``wp.synchronize_device()`` with stream-scoped ordering and removes the host copy; it is + # not a nonblocking handoff, and the ``.wait()`` below can still block the calling thread. self._stage.write_attribute( query, "points", ordinal=self._current_ordinal, - tensors=tensors, + tensors=particle_slices, is_array=True, semantic=ovstage.AttributeSemantic.POINT, cuda_stream=self._warp_device.stream.cuda_stream, ).wait() def _write_cable_points_ovstage(self) -> None: + """Recompute world-space cable curve points on device and write them through ovstage.""" self._compute_cable_points_world() + + # The cached descriptors alias ``_cable_points`` and are handed over zero-copy, so ovstage + # must not read them until the kernel above has landed. Passing the producing Warp stream as + # ``cuda_stream`` gives producer ordering: ovstage drains the work already queued on that + # stream before it touches the slices. That keeps the handover off the host; it is not a + # nonblocking handoff, and the ``.wait()`` below can still block the calling thread. self._stage.write_attribute( self._cable_points_query, "points", @@ -1523,23 +2348,35 @@ def _write_cable_points_ovstage(self) -> None: ).wait() def _update_camera_ovstage( - self, render_data: OVRTXRenderData, positions: ProxyArray, orientations: ProxyArray, intrinsics: ProxyArray + self, + render_data: OVRTXRenderData, + positions: ProxyArray, + orientations: ProxyArray, + intrinsics: ProxyArray, ) -> None: num_envs = positions.shape[0] - converted = wp.empty(num_envs, dtype=wp.quatf, device=self._device) + converted_wp = wp.empty(num_envs, dtype=wp.quatf, device=self._device) convert_camera_frame_orientation_convention_wp( - src=orientations.warp, dst=converted, origin="world", target="opengl", device=self._device + src=orientations.warp, + dst=converted_wp, + origin="world", + target="opengl", + device=self._device, ) - transforms = wp.zeros(num_envs, dtype=wp.mat44d, device=self._device) + camera_transforms = wp.zeros(num_envs, dtype=wp.mat44d, device=self._device) wp.launch( - create_camera_transforms_kernel, num_envs, inputs=[positions, converted, transforms], device=self._device + kernel=create_camera_transforms_kernel, + dim=num_envs, + inputs=[positions, converted_wp, camera_transforms], + device=self._device, ) if self._camera_xform_query is not None: + # Stream-ordered zero-copy handoff, as for the object transforms above. self._stage.write_attribute( self._camera_xform_query, "omni:xform", ordinal=self._current_ordinal, - tensors=xform_tensor_from_warp(transforms), + tensors=xform_tensor_from_warp(camera_transforms), is_array=False, semantic=ovstage.AttributeSemantic.MATRIX, cuda_stream=self._warp_device.stream.cuda_stream, @@ -1548,26 +2385,36 @@ def _update_camera_ovstage( def _render_ovstage(self, render_data: OVRTXRenderData) -> None: if not self._initialized_scene: raise RuntimeError("Scene not initialized. Call initialize() first.") - if self._renderer is None or not self._render_product_paths: + if self._renderer is None or len(self._render_product_paths) == 0: return - writer = self._visual_material_writer_ref() if self._visual_material_writer_ref is not None else None + # Commit all per-frame writes (transforms, geometries, camera, materials) then step. + # advance_write_floor must precede step — the renderer rejects ordinal > write_floor. + material_writer = self._visual_material_writer_ref() if self._visual_material_writer_ref is not None else None try: - if writer is not None: - writer.publish() + if material_writer is not None: + material_writer.publish() self._stage.advance_write_floor(ordinal=self._current_ordinal).wait() finally: - if writer is not None: - with contextlib.nullcontext() if sys.exc_info()[0] is None else contextlib.suppress(Exception): - writer.drain() + if material_writer is not None: + drain_errors = contextlib.nullcontext() if sys.exc_info()[0] is None else contextlib.suppress(Exception) + with drain_errors: + material_writer.drain() products = self._renderer.step( render_products=set(self._render_product_paths), delta_time=1.0 / 60.0, ordinal=self._current_ordinal, ) self._current_ordinal += 1 - path = self._render_product_paths[0] - if path in products and products[path].frames: - self._process_render_frame(render_data, products[path].frames[0], render_data.warp_buffers) + product_path = self._render_product_paths[0] + if product_path in products and len(products[product_path].frames) > 0: + self._process_render_frame( + render_data, + products[product_path].frames[0], + render_data.warp_buffers, + ) + + # Post-render PPISP: HDR scene-linear → LDR RGBA. Source/destination + # buffers are the same warp buffer map used by extraction. if render_data.ppisp_pipeline is not None: render_data.ppisp_pipeline.apply( render_data.warp_buffers[str(RenderBufferKind.RGB_HDR)], @@ -1575,41 +2422,83 @@ def _render_ovstage(self, render_data: OVRTXRenderData) -> None: ) def _close_ovstage(self) -> None: - def release(query, paths): - if query is not None and self._stage is not None: - with contextlib.suppress(Exception): - self._stage.release_query(query).wait() - if paths is not None and self._stage_paths is not None: - with contextlib.suppress(Exception): - self._stage_paths.destroy_path_list(paths) - - for query, paths in ( - (self._camera_xform_query, self._camera_paths_list), - (self._object_xform_query, self._object_paths_list), - (self._deformable_points_query, self._deformable_paths_list), - (self._particle_points_query, self._particle_paths_list), - (self._cable_points_query, self._cable_paths_list), - ): - release(query, paths) - self._camera_xform_query = self._camera_paths_list = None - self._object_xform_query = self._object_paths_list = None - self._deformable_points_query = self._deformable_paths_list = None - self._particle_points_query = self._particle_paths_list = None - self._cable_points_query = self._cable_paths_list = None - self._object_newton_indices = self._object_scales = None + """Release the renderer's stage queries, path lists and ovstage stage. See :meth:`close`.""" + + def _safe_release_query(query, name: str) -> None: + if query is None or self._stage is None: + return + try: + self._stage.release_query(query).wait() + except Exception as e: + if "destroyed" not in str(e).lower(): + logger.warning("Error releasing %s query: %s", name, e) + + def _safe_destroy_path_list(path_list, name: str) -> None: + if path_list is None or self._stage_paths is None: + return + try: + self._stage_paths.destroy_path_list(path_list) + except Exception as e: + if "destroyed" not in str(e).lower(): + logger.warning("Error destroying %s path list: %s", name, e) + + _safe_release_query(self._camera_xform_query, "camera transforms") + self._camera_xform_query = None + _safe_destroy_path_list(self._camera_paths_list, "camera paths") + self._camera_paths_list = None + _safe_release_query(self._object_xform_query, "object transforms") + self._object_xform_query = None + _safe_destroy_path_list(self._object_paths_list, "object paths") + self._object_paths_list = None + _safe_release_query(self._deformable_points_query, "deformable points") + self._deformable_points_query = None + _safe_destroy_path_list(self._deformable_paths_list, "deformable paths") + self._deformable_paths_list = None + _safe_release_query(self._particle_points_query, "particle points") + self._particle_points_query = None + _safe_destroy_path_list(self._particle_paths_list, "particle paths") + self._particle_paths_list = None + + _safe_release_query(self._cable_points_query, "cable points") + self._cable_points_query = None + _safe_destroy_path_list(self._cable_paths_list, "cable paths") + self._cable_paths_list = None + + self._object_newton_indices = None + self._object_scales = None self._object_scales_by_path = {} - self._deformable_particle_offsets = self._deformable_particle_counts = [] - self._particle_visual_offsets = self._particle_visual_counts = [] + self._deformable_particle_offsets = [] + self._deformable_particle_counts = [] + self._particle_visual_offsets = [] + self._particle_visual_counts = [] self._cable_segment_counts = [] self._cable_max_points = 0 - self._cable_point_tensors = self._cable_point_slices = [] - self._cable_points = self._cable_shape_ids = self._cable_offsets = self._cable_counts = None + # Descriptors alias ``_cable_points``; drop them before the buffer so no cached + # DLTensor can outlive the device memory it points at. + self._cable_point_tensors = [] + self._cable_point_slices = [] + self._cable_points = None + self._cable_shape_ids = None + self._cable_offsets = None + self._cable_counts = None + + # Detach before closing ExitStack: the renderer holds a live reference into the stage, + # so detaching first avoids a use-after-free when ExitStack destroys Stage and PathDictionary. + # + # Both are guarded because close() can run before initialization completed — the sim was + # never played, or scene setup raised — and it must stay a no-op when called twice. + # detach_ovstage() raises when nothing is attached, and the ExitStack does not exist until + # _initialize_from_spec_ovstage creates it. if self._renderer is not None and self._stage is not None: self._renderer.detach_ovstage() self._renderer = None + if self._ovstage_exit_stack is not None: self._ovstage_exit_stack.close() - self._ovstage_exit_stack = self._stage = self._stage_paths = None + self._ovstage_exit_stack = None + self._stage = None + self._stage_paths = None + self._render_product_paths.clear() self._output_id_color_buffers.clear() self._initialized_scene = False diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py index e9272a341689..891a9e9c0a7d 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_usd.py @@ -9,14 +9,18 @@ import logging import math +from collections.abc import Mapping +from types import MappingProxyType from pxr import Sdf, Usd, UsdGeom +from isaaclab.sim.utils import make_uninstanceable + logger = logging.getLogger(__name__) -# Maps camera data types to render-var configs. OVRTX frame vars are keyed by source name, -# so shared sources use one config. +# Maps camera data types to (prim path, prim name, source name). Shared sources use one config. +# OVRTX 0.4 keys ``frame.render_vars`` by source name; 0.5+ keys them by the RenderVar prim path. _RENDER_VAR_BY_DATA_TYPE: dict[str, tuple[str, str, str]] = { "rgb": ("/Render/Vars/LdrColor", "LdrColor", "LdrColor"), "rgba": ("/Render/Vars/LdrColor", "LdrColor", "LdrColor"), @@ -53,6 +57,17 @@ _DEFAULT_RENDER_VAR = _RENDER_VAR_BY_DATA_TYPE["rgb"] +# Segmentation ID-map vars are authored alongside the pixel AOVs, not as camera data types. +_SEGMENTATION_MAP_RENDER_VARS: tuple[tuple[str, str, str], ...] = ( + ("/Render/Vars/StableIdSemanticIdMap", "StableIdSemanticIdMap", "StableIdSemanticIdMap"), + ("/Render/Vars/StableIdMap", "StableIdMap", "StableIdMap"), + ("/Render/Vars/SemanticIdMap", "SemanticIdMap", "SemanticIdMap"), +) + +_RENDER_VAR_PRIM_PATH_BY_SOURCE: Mapping[str, str] = MappingProxyType( + {source: path for path, _, source in (*_RENDER_VAR_BY_DATA_TYPE.values(), *_SEGMENTATION_MAP_RENDER_VARS)} +) + def _validate_data_type_combination(data_types: list[str]) -> None: """Reject data type combinations that a single OVRTX render product cannot serve. @@ -131,15 +146,25 @@ def get_render_var_configs(data_types: list[str]) -> list[tuple[str, str, str]]: # Author the ID-to-label map render vars needed to decode the segmentation info dicts. # instance_segmentation needs StableIdSemanticIdMap + StableIdMap to resolve each pixel to a prim path. if "instance_segmentation" in data_types: - render_vars.append(("/Render/Vars/StableIdSemanticIdMap", "StableIdSemanticIdMap", "StableIdSemanticIdMap")) - render_vars.append(("/Render/Vars/StableIdMap", "StableIdMap", "StableIdMap")) + render_vars.append(_SEGMENTATION_MAP_RENDER_VARS[0]) + render_vars.append(_SEGMENTATION_MAP_RENDER_VARS[1]) # SemanticIdMap resolves the semantic-ID-to-label mapping and is shared by both semantic_segmentation and # instance_segmentation, so it is authored once when either output is requested. if "semantic_segmentation" in data_types or "instance_segmentation" in data_types: - render_vars.append(("/Render/Vars/SemanticIdMap", "SemanticIdMap", "SemanticIdMap")) + render_vars.append(_SEGMENTATION_MAP_RENDER_VARS[2]) return render_vars +def render_var_prim_paths_by_source() -> Mapping[str, str]: + """Return the authored RenderVar prim path of every OVRTX render-var source. + + Returns: + Read-only mapping of render-var source name to the absolute path of the ``RenderVar`` + prim this module authors for it. + """ + return _RENDER_VAR_PRIM_PATH_BY_SOURCE + + def build_render_scope_usd( camera_paths: list[str], render_product_name: str, @@ -403,9 +428,10 @@ def export_stage_to_string( anonymous session layer used only for export, so the input stage remains unchanged. When ``keep_env_roots`` is True (the legacy ``renderer.clone_usd`` path) the non-source env root prims stay - active so the exported stage retains a slot for every env. The ovstage ``stage.clone`` path passes False, which - additionally trims the non-source env roots themselves; ``stage.clone`` recreates them and the RenderProduct's - camera relationship is re-authored after clone. + active so the exported stage retains a slot for every env. Nested instances in source subtrees are expanded + on the export session because legacy cloning otherwise drops their geometry. The ovstage ``stage.clone`` + path passes False, retaining source instancing and trimming the non-source env roots themselves; + ``stage.clone`` recreates them and the RenderProduct's camera relationship is re-authored after clone. Args: stage: USD stage to export. @@ -432,6 +458,10 @@ def export_stage_to_string( prim_paths: list[Sdf.Path] = [] if keep_env_roots: + # Native legacy cloning omits instanced visuals; expand only the renderer's copy. + with Usd.EditContext(export_stage, export_session): + for source_path in source_paths: + make_uninstanceable(source_path, stage=export_stage) for child in envs_prim.GetChildren(): # Legacy code path: keep env roots so we can query their xforms after opening stage child_path = child.GetPath() diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py index f8b40b0f40f1..652eca86a35b 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/contact_sensor/contact_sensor.py @@ -336,17 +336,34 @@ def _create_buffers(self) -> None: else: self._poses_flat_buf = None + def _fetch_ovphysx_buffers(self, include_pose: bool = True) -> None: + """Read the contact (and optionally pose) data from ovphysx into the flat buffers. + + The ovphysx bindings write into the tensors allocated once in :meth:`_create_buffers`, + so the buffers are refreshed in place on every call. + + Args: + include_pose: Whether to also read the body poses when ``track_pose`` is enabled. + The per-step refresh in :meth:`update` skips them since only the contact + buffers carry the contact-loss transient. Defaults to True. + """ + # Pull aggregate forces into the pre-allocated flat buffer: + # shape [num_envs * num_sensors, 3] float32. + self._contact_binding.read_net_forces(self._net_forces_flat_buf) + if self._force_matrix_flat_buf is not None: + self._contact_binding.read_force_matrix(self._force_matrix_flat_buf) + if self.cfg.track_pose and include_pose: + # Read pose into [num_envs * num_sensors, 7] float32. + self._root_view.read_into(TT.RIGID_BODY_POSE, self._poses_flat_buf) + def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: """Read contact data from ovphysx and update sensor buffers.""" env_mask = self._resolve_indices_and_mask(None, env_mask) + self._fetch_ovphysx_buffers() - # Pull aggregate forces into the pre-allocated flat buffer: - # shape [num_envs * num_sensors, 3] float32 -> [num_envs * num_sensors] vec3f. - self._contact_binding.read_net_forces(self._net_forces_flat_buf) + # [num_envs * num_sensors, 3] float32 -> [num_envs * num_sensors] vec3f. net_forces_flat = self._net_forces_flat_buf.view(wp.vec3f) - if self._force_matrix_flat_buf is not None: - self._contact_binding.read_force_matrix(self._force_matrix_flat_buf) force_matrix_flat = self._force_matrix_flat_buf.view(wp.vec3f) else: force_matrix_flat = None @@ -380,8 +397,6 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: ) if self.cfg.track_pose: - # Read pose into [num_envs * num_sensors, 7] float32 -> view as transformf. - self._root_view.read_into(TT.RIGID_BODY_POSE, self._poses_flat_buf) poses_flat = self._poses_flat_buf.view(wp.transformf) wp.launch( split_flat_pose_to_pos_quat, @@ -395,6 +410,27 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None) -> None: Operations """ + def update(self, dt: float, force_recompute: bool = False) -> None: + """Advances the sensor timestamp and refreshes the ovphysx contact buffers. + + PhysX zeroes the net contact force of a body only on the physics step where its contact + is lost. A sensor that is refreshed lazily (on data access) would skip that step and keep + reporting the last in-contact force until the next touchdown, so the ovphysx bindings are + read on every physics step. The warp kernels that turn those buffers into sensor data + remain lazy and only run when :attr:`data` is accessed. The body poses have no such + transient and are only read on data access. + + Args: + dt: Time elapsed since the previous sensor update [s]. + force_recompute: Whether to recompute the sensor buffers regardless of their + configured update period. Defaults to False. + """ + super().update(dt, force_recompute=force_recompute) + # Skip the read if the base class already refreshed the buffers on this step, which it + # does when the sensor carries a history buffer. + if self._is_initialized and self._data_generation != self._data_generation_last_update: + self._fetch_ovphysx_buffers(include_pose=False) + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: env_mask = self._resolve_indices_and_mask(env_ids, env_mask) super().reset(None, env_mask) diff --git a/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py b/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py index ce873f3e975f..33c898212a3f 100644 --- a/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py +++ b/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py @@ -315,6 +315,7 @@ def __init__(self, prim_path: str, device: str = "cpu", stage: Usd.Stage | None self._stage = stage sim = sim_utils.SimulationContext.instance() plan = sim.get_clone_plan() if sim is not None else None + self._clone_plan = plan source_matches = tuple(cloner.query.iter_sources(plan, prim_path)) if plan is not None else () self._source_records = [] self._prims: list[Usd.Prim] = [] @@ -516,9 +517,14 @@ def _initialize_impl(self, physx: Any) -> None: def _expand_world_sites_from_clone_plan( self, xform_cache: UsdGeom.XformCache ) -> list[tuple[int, Usd.Prim, list[float], list[float], str]]: - """Return row-ordered source prims and projected poses for source-only world sites.""" + """Return plan-ordered source prims and projected poses for source-only world sites.""" if sum(len(env_ids) for _, _, _, env_ids in self._source_records) <= len(self._prims): return [] + plan = self._clone_plan + if plan is None: + raise RuntimeError("OvPhysxFrameView requires a clone plan for source-only world sites.") + plan_env_ids = range(plan.clone_mask.shape[1]) if plan.env_ids is None else plan.env_ids + column_by_env_id = {int(env_id): column for column, env_id in enumerate(plan_env_ids)} records: list[tuple[int, Usd.Prim, list[float], list[float], str]] = [] for source_root, destination_template, source_prim, env_ids in self._source_records: @@ -528,26 +534,28 @@ def _expand_world_sites_from_clone_plan( raise RuntimeError(f"OvPhysxFrameView source prim {source_prim_path!r} is not under {source_root!r}.") source_world = xform_cache.GetLocalToWorldTransform(source_prim) source_parent_world = xform_cache.GetLocalToWorldTransform(source_prim.GetParent()) + source_match = cloner.path.match(source_root, destination_template) + source_anchor_world = Gf.Matrix4d(1.0) + if source_match is not None: + template_prefix, _ = cloner.path.split(destination_template) + source_anchor_path = template_prefix + source_match.instance + source_anchor = self._stage.GetPrimAtPath(source_anchor_path) + if not source_anchor.IsValid(): + raise RuntimeError(f"OvPhysxFrameView source anchor {source_anchor_path!r} is not on the stage.") + source_anchor_world = xform_cache.GetLocalToWorldTransform(source_anchor) + source_inverse = source_anchor_world.GetInverse() for env_id in env_ids: + env_id = int(env_id) destination_root = destination_template.format(env_id) - source_anchor_path, destination_anchor_path = source_root, destination_root - destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path) - while not destination_anchor.IsValid() and destination_anchor_path != "/": - source_anchor_path = source_anchor_path.rsplit("/", 1)[0] or "/" - destination_anchor_path = destination_anchor_path.rsplit("/", 1)[0] or "/" - destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path) - - source_anchor = self._stage.GetPrimAtPath(source_anchor_path) - if not source_anchor.IsValid() or not destination_anchor.IsValid(): - raise RuntimeError(f"OvPhysxFrameView could not project {source_prim_path!r} into env {env_id}.") - source_inverse = xform_cache.GetLocalToWorldTransform(source_anchor).GetInverse() - destination_world = xform_cache.GetLocalToWorldTransform(destination_anchor) + destination_world = Gf.Matrix4d(1.0) + if plan.positions is not None: + destination_world.SetTranslateOnly(Gf.Vec3d(*map(float, plan.positions[column_by_env_id[env_id]]))) site_world = _gf_matrix_to_xform7(source_world * source_inverse * destination_world) parent_world = _gf_matrix_to_xform7(source_parent_world * source_inverse * destination_world) records.append((env_id, source_prim, site_world, parent_world, destination_root + suffix)) - records.sort(key=lambda record: record[0]) + records.sort(key=lambda record: column_by_env_id[record[0]]) return records def _resolve_rigid_body_ancestor( diff --git a/source/isaaclab_ov/isaaclab_ov/stage.py b/source/isaaclab_ov/isaaclab_ov/stage.py index e054e7f1476e..1a9ec98ad8ea 100644 --- a/source/isaaclab_ov/isaaclab_ov/stage.py +++ b/source/isaaclab_ov/isaaclab_ov/stage.py @@ -10,10 +10,16 @@ from __future__ import annotations +import logging + import numpy as np import ovstage import warp as wp +from isaaclab_ov.ovstage_compat import HIERARCHY_COMPUTATION_MODEL + +logger = logging.getLogger(__name__) + # DLDataType for a 4x4 double matrix (``omni:xform`` column). ovstage stores omni:xform as one # 16-lane float64 element per prim; wp.mat44d maps to the same layout via __dlpack__. OVSTAGE_XFORM_DTYPE = ovstage.DLDataType(code=ovstage.DLDataTypeCode.kDLFloat, bits=64, lanes=16) @@ -32,10 +38,11 @@ def create_ovstage(name: str) -> ovstage.Stage: Every Isaac Lab stage is therefore created through this helper so the whole process agrees on one model. - :attr:`~ovstage.HierarchyComputationModel.CPU_INCREMENTAL` is requested explicitly rather than - left implicit, so the model in force is visible at the call site. - :attr:`~ovstage.HierarchyComputationModel.GPU_INCREMENTAL` is currently not working - objects - are out-of-place. Needs investigation + The model is requested explicitly rather than left implicit, so the model in force is visible + at the call site. Which one is requested depends on the installed ovstage version; see + :mod:`isaaclab_ov.ovstage_compat`. A selected model the installed enum does not carry falls + back to the host model, so a version gate that runs ahead of the runtime degrades rather than + preventing stage creation. Args: name: Instance name used for ovstage diagnostics. @@ -43,9 +50,15 @@ def create_ovstage(name: str) -> ovstage.Stage: Returns: The created :class:`ovstage.Stage`. """ - config = ovstage.StageConfig( - runtime_default_hierarchy_computation_model=ovstage.HierarchyComputationModel.CPU_INCREMENTAL - ) + hierarchy_computation_model = getattr(ovstage.HierarchyComputationModel, HIERARCHY_COMPUTATION_MODEL, None) + if hierarchy_computation_model is None: + logger.warning( + "This ovstage does not expose HierarchyComputationModel.%s; falling back to CPU_INCREMENTAL.", + HIERARCHY_COMPUTATION_MODEL, + ) + # Left unguarded: an ovstage without the host model is broken, and should say so loudly. + hierarchy_computation_model = ovstage.HierarchyComputationModel.CPU_INCREMENTAL + config = ovstage.StageConfig(runtime_default_hierarchy_computation_model=hierarchy_computation_model) return ovstage.Stage(name, config=config) diff --git a/source/isaaclab_ov/test/assets/test_articulation.py b/source/isaaclab_ov/test/assets/test_articulation.py index f60fa1ea7229..ee15662d9019 100644 --- a/source/isaaclab_ov/test/assets/test_articulation.py +++ b/source/isaaclab_ov/test/assets/test_articulation.py @@ -60,7 +60,7 @@ import torch import warp as wp -from pxr import Usd, UsdGeom, UsdPhysics +from pxr import Gf, Usd, UsdGeom, UsdPhysics from isaaclab.test.utils import test_devices from isaaclab.test.utils.articulation_ordering import ( @@ -219,6 +219,65 @@ def read_binding(tensor_type, dst): assert buffer.timestamp == 1.0 +def test_static_property_reads_are_not_invalidated_by_simulation_steps(): + """Joint properties and body mass/inertia should be read once per invalidation, not once per step. + + On OVPhysX these are blocking CPU-only binding reads whose cost scales with the number of + environments, so re-reading them every physics step made per-step consumers (such as the + native actuator telemetry sync) host-bound. State buffers must still refresh every step. + """ + + class Buffer: + def __init__(self, shape): + self.data = wp.zeros(shape, dtype=wp.float32, device="cpu") + self.timestamp = -1.0 + + data = ArticulationData.__new__(ArticulationData) + data.device = "cpu" + data.num_instances = 1 + data.num_joints = 2 + data._sim_timestamp = 1.0 + data.body_ordering = None + data._get_binding = lambda tensor_type: object() + reads: list[int] = [] + data._binding_read = lambda tensor_type, dst: reads.append(tensor_type) + + # Joint properties: one read across several steps, one more after explicit invalidation + # (simulation reinitialization). + data.joint_ordering = None + stiffness = Buffer((1, 2)) + for _ in range(3): + data._sim_timestamp += 1.0 + data._read_joint_property_binding(TT.DOF_STIFFNESS, stiffness, None) + assert reads.count(TT.DOF_STIFFNESS) == 1 + stiffness.timestamp = -1.0 + data._read_joint_property_binding(TT.DOF_STIFFNESS, stiffness, None) + assert reads.count(TT.DOF_STIFFNESS) == 2 + + # Body properties behave the same; body state buffers still refresh every step. + mass, link_pose = Buffer((1, 2)), Buffer((1, 2)) + for _ in range(3): + data._sim_timestamp += 1.0 + data._refresh_reordered_body_buffer(mass, None, TT.BODY_MASS, static=True) + data._refresh_reordered_body_buffer(link_pose, None, TT.LINK_POSE) + assert reads.count(TT.BODY_MASS) == 1 + assert reads.count(TT.LINK_POSE) == 3 + mass.timestamp = -1.0 + data._refresh_reordered_body_buffer(mass, None, TT.BODY_MASS, static=True) + assert reads.count(TT.BODY_MASS) == 2 + + # Under a non-identity joint ordering the property is gathered once, then served from cache. + data.joint_ordering = SimpleNamespace(user_to_backend=wp.array([1, 0], dtype=wp.int32, device="cpu")) + data._read_launch_cache = _WarpLaunchCache("cpu") + user_buffer, backend_buffer = Buffer((1, 2)), Buffer((1, 2)) + data._binding_read = lambda tensor_type, dst: (reads.append(tensor_type), dst.assign([[1.0, 2.0]])) + for _ in range(3): + data._sim_timestamp += 1.0 + data._read_joint_property_binding(TT.DOF_DAMPING, user_buffer, backend_buffer) + assert reads.count(TT.DOF_DAMPING) == 1 + torch.testing.assert_close(wp.to_torch(user_buffer.data), torch.tensor([[2.0, 1.0]])) + + def _read_binding_to_torch(articulation: Articulation, tensor_type: int, device: str | torch.device) -> torch.Tensor: """Read an OVPhysX attribute into a torch tensor on *device*. @@ -502,38 +561,64 @@ def reject_newton_probe(name, *args, **kwargs): @pytest.mark.parametrize("device", ["cuda:0"]) -def test_newton_native_ovphysx_effort_binding_excludes_implicit_pd(device): - """Submit raw native effort so OVPhysX evaluates the implicit joint drive once.""" - with _ovphysx_sim_context(device=device, gravity_enabled=False, use_newton_actuators=True) as sim: +@pytest.mark.parametrize("use_newton_actuators", [False, True]) +def test_ovphysx_effort_binding_excludes_implicit_pd(device, use_newton_actuators): + """Submit implicit feedforward and explicit PD effort without submitting implicit PD telemetry.""" + stiffness, effort_limit = 20.0, 400.0 + with _ovphysx_sim_context(device=device, gravity_enabled=False, use_newton_actuators=use_newton_actuators) as sim: sim._app_control_on_stop_handle = None articulation_cfg = CARTPOLE_CFG.replace( actuators={ "cart": ImplicitActuatorCfg( - joint_names_expr=["slider_to_cart"], joint_effort_limit=400.0, stiffness=20.0, damping=0.0 + joint_names_expr=["slider_to_cart"], + joint_effort_limit=effort_limit, + stiffness=stiffness, + damping=0.0, ), "pole": IdealPDActuatorCfg( joint_names_expr=["cart_to_pole"], - stiffness=20.0, + stiffness=stiffness, damping=0.0, - actuator_effort_limit=400.0, + actuator_effort_limit=effort_limit, ), } ) articulation, _ = generate_articulation(articulation_cfg, 1, device) sim.reset() - articulation.actuators.target_command.set_position_index( - value=articulation.data.joint_pos.torch + torch.tensor([[0.25, 0.5]], device=device) - ) + joint_names = articulation.joint_names + cart_id = joint_names.index("slider_to_cart") + pole_id = joint_names.index("cart_to_pole") + initial_pos = articulation.data.joint_pos.torch.clone() + position_target = initial_pos.clone() + position_target[:, cart_id] += 0.25 + position_target[:, pole_id] += 0.5 + feedforward = torch.zeros_like(initial_pos) + feedforward[:, cart_id] = 1.5 + feedforward[:, pole_id] = -0.75 + articulation.actuators.target_command.set_position_index(value=position_target) + articulation.actuators.target_command.set_effort_index(value=feedforward) articulation.write_data_to_sim() - raw_effort = wp.to_torch(articulation._physx_actuator_wrapper.joint_f_2d) + expected_pd_effort = torch.clamp( + stiffness * (position_target - initial_pos) + feedforward, -effort_limit, effort_limit + ) applied_effort = articulation.actuators.applied_effort.torch - assert torch.any(applied_effort[:, 0] != raw_effort[:, 0]) + torch.testing.assert_close(applied_effort, expected_pd_effort) + assert torch.all(applied_effort[:, cart_id] != feedforward[:, cart_id]) + expected_force = expected_pd_effort.clone() + expected_force[:, cart_id] = feedforward[:, cart_id] + backend_to_user = [joint_names.index(name) for name in articulation.root_view.dof_names] + backend_force = _read_binding_to_torch(articulation, TT.DOF_ACTUATION_FORCE, device) + torch.testing.assert_close(backend_force, expected_force[:, backend_to_user]) + + expected_stiffness = torch.zeros_like(initial_pos) + expected_stiffness[:, cart_id] = stiffness torch.testing.assert_close( - _read_binding_to_torch(articulation, TT.DOF_ACTUATION_FORCE, device), - raw_effort, + _read_binding_to_torch(articulation, TT.DOF_STIFFNESS, device), + expected_stiffness[:, backend_to_user], ) + assert articulation._actuator_control.native_actuator_path_active == use_newton_actuators @pytest.mark.parametrize("device", ["cuda:0"]) @@ -616,8 +701,9 @@ def sim(request): add_ground_plane = request.getfixturevalue("add_ground_plane") else: add_ground_plane = False # default to no ground plane + dt = request.getfixturevalue("dt") if "dt" in request.fixturenames else 1.0 / 60.0 with _ovphysx_sim_context( - device=device, auto_add_lighting=True, gravity_enabled=gravity_enabled, add_ground_plane=add_ground_plane + device=device, auto_add_lighting=True, gravity_enabled=gravity_enabled, add_ground_plane=add_ground_plane, dt=dt ) as sim: sim._app_control_on_stop_handle = None yield sim @@ -649,10 +735,13 @@ def test_write_joint_state_accepts_int64_selector(sim, device, gravity_enabled): torch.testing.assert_close(articulation.data.joint_vel.torch, expected_velocity) -@pytest.mark.parametrize("device", ["cpu"]) -@pytest.mark.parametrize("gravity_enabled", [False]) -def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_enabled): - """Keep dynamics tensors consistent with public joint velocity.""" +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +@pytest.mark.parametrize("gravity_enabled", [False, True]) +@pytest.mark.parametrize("user_ordering", [False, True]) +# Keep integration-time pose/velocity differences below the dynamics tolerance. +@pytest.mark.parametrize("dt", [1e-4]) +def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_enabled, user_ordering, dt): + """Check velocity, kinetic energy and gravity in the public joint basis.""" articulation = Articulation( ArticulationCfg( prim_path="/World/Robot", @@ -660,6 +749,8 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena usd_path=str(Path(__file__).parent / "data" / "articulation_ordering_branching.usda") ), actuators={}, + joint_ordering=BRANCHING_MJWARP_JOINT_NAMES if user_ordering else None, + body_ordering=BRANCHING_MJWARP_BODY_NAMES if user_ordering else None, ) ) UsdPhysics.FixedJoint.Define(sim.stage, "/World/Robot/fixed_root").GetBody1Rel().SetTargets(["/World/Robot/base"]) @@ -667,6 +758,14 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena body0, body1 = joint.GetBody0Rel().GetTargets(), joint.GetBody1Rel().GetTargets() joint.GetBody0Rel().SetTargets(body1) joint.GetBody1Rel().SetTargets(body0) + for prim in Usd.PrimRange(sim.stage.GetPrimAtPath("/World/Robot")): + if prim.IsA(UsdPhysics.RevoluteJoint): + UsdPhysics.RevoluteJoint(prim).GetAxisAttr().Set("Y") + if prim.HasAPI(UsdPhysics.MassAPI): + mass = UsdPhysics.MassAPI(prim) + mass.CreateCenterOfMassAttr(Gf.Vec3f(0.2, 0.0, 0.0)) + # Isotropic inertia makes the energy check independent of body rotation. + mass.CreateDiagonalInertiaAttr(Gf.Vec3f(0.1)) sim.reset() velocity = torch.zeros((1, articulation.num_joints), device=device) @@ -674,7 +773,7 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena velocity[:, articulation.find_joints("left_elbow")[0][0]] = 0.7 articulation.write_joint_velocity_to_sim_index(velocity=velocity) sim.step() - articulation.update(sim.cfg.dt) + articulation.update(dt) joint_velocity = articulation.data.joint_vel.torch predicted_velocity = torch.einsum("nbij,nj->nbi", articulation.data.body_com_jacobian_w.torch, joint_velocity) @@ -691,6 +790,19 @@ def test_reversed_joint_dynamics_use_public_joint_basis(sim, device, gravity_ena ) torch.testing.assert_close(generalized_energy, body_energy, atol=1e-5, rtol=1e-5) + gravity = torch.tensor(sim.cfg.gravity, device=device) + body_weight = articulation.data.body_mass.torch[:, 1:, None] * gravity + expected_compensation = -torch.einsum( + "nbij,nbi->nj", articulation.data.body_com_jacobian_w.torch[:, :, :3], body_weight + ) + if gravity_enabled: + assert torch.all(expected_compensation.abs() > 0.1) + else: + assert torch.count_nonzero(expected_compensation) == 0 + torch.testing.assert_close( + articulation.data.gravity_compensation_forces.torch, expected_compensation, atol=1e-5, rtol=1e-5 + ) + def test_joint_dof_sign_resolution_traverses_instance_proxies(): """Resolve reversed joints inside an instanceable articulation.""" @@ -750,11 +862,12 @@ def test_live_anymal_c_manual_joint_ordering_preserves_unselected_backend_state( @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_live_anymal_c_manual_joint_ordering_reorders_joint_targets(sim, device): """Write nonidentity-ordered joint targets into their intended backend columns.""" + stiffness, damping = 10.0, 2.0 backend_joint_names = ANYMAL_C_PHYSX_JOINT_NAMES joint_ordering = (*backend_joint_names[1:], backend_joint_names[0]) articulation_cfg = generate_articulation_cfg("anymal").replace( joint_ordering=joint_ordering, - actuators={"legs": ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=10.0, damping=2.0)}, + actuators={"legs": ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=stiffness, damping=damping)}, ) articulation, _ = generate_articulation(articulation_cfg, 1, device=device) sim.reset() @@ -768,14 +881,28 @@ def test_live_anymal_c_manual_joint_ordering_reorders_joint_targets(sim, device) joint_index = torch.arange(articulation.num_joints, dtype=torch.float32, device=device).unsqueeze(0) position_target = -0.25 + 0.031 * joint_index velocity_target = 0.07 + 0.017 * joint_index + effort_target = torch.where(joint_index.remainder(2) == 0, 0.0, 0.13 * joint_index) + joint_pos = articulation.data.joint_pos.torch.clone() + joint_vel = articulation.data.joint_vel.torch.clone() articulation.set_joint_position_target_index(target=position_target) articulation.set_joint_velocity_target_index(target=velocity_target) + articulation.actuators.target_command.set_effort_index(value=effort_target) articulation.write_data_to_sim() backend_position_target = _read_binding_to_torch(articulation, TT.DOF_POSITION_TARGET, device) backend_velocity_target = _read_binding_to_torch(articulation, TT.DOF_VELOCITY_TARGET, device) torch.testing.assert_close(backend_position_target, position_target[:, backend_to_user]) torch.testing.assert_close(backend_velocity_target, velocity_target[:, backend_to_user]) + torch.testing.assert_close( + _read_binding_to_torch(articulation, TT.DOF_ACTUATION_FORCE, device), effort_target[:, backend_to_user] + ) + computed_effort = ( + stiffness * (position_target - joint_pos) + damping * (velocity_target - joint_vel) + effort_target + ) + limits = articulation.data.joint_effort_limits.torch + expected_telemetry = torch.clamp(computed_effort, min=-limits, max=limits) + torch.testing.assert_close(articulation.actuators.applied_effort.torch, expected_telemetry) + assert not torch.allclose(expected_telemetry, effort_target) @pytest.mark.parametrize("device", ["cpu"]) @@ -2991,6 +3118,42 @@ def test_body_com_pose_b_cache_and_set_coms_invalidation(sim, device): assert buffer.timestamp < articulation.data._sim_timestamp, name +@pytest.mark.parametrize("device", ["cpu"]) +def test_com_orientation_write_invalidates_static_inertia_cache_with_body_ordering(sim, device): + """A COM rotation refreshes the static inertia cache in non-identity body order.""" + sim._app_control_on_stop_handle = None + articulation_cfg = FRANKA_PANDA_CFG.replace(body_ordering=PANDA_ROOT_PRESERVING_REVERSED_BODY_NAMES) + articulation, _ = generate_articulation(articulation_cfg, 1, device=device) + + sim.reset() + articulation.update(sim.cfg.dt) + assert articulation.body_ordering is not None + + public_body_id = 1 + backend_body_id = articulation.body_ordering.user_to_backend_indices[public_body_id] + assert backend_body_id != public_body_id + + coms = articulation.data.body_com_pose_b.torch[:, public_body_id : public_body_id + 1].clone() + coms[..., 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) + articulation.set_coms_index(coms=wp.from_torch(coms.contiguous(), dtype=wp.transformf), body_ids=[public_body_id]) + + principal_inertia = torch.tensor([[[1.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0]]], device=device) + articulation.set_inertias_index(inertias=principal_inertia, body_ids=[public_body_id]) + torch.testing.assert_close( + articulation.data.body_inertia.torch[:, public_body_id : public_body_id + 1], principal_inertia + ) + + coms[..., 3:7] = torch.tensor([0.0, 0.0, 0.70710677, 0.70710677], device=device) + articulation.set_coms_index(coms=wp.from_torch(coms.contiguous(), dtype=wp.transformf), body_ids=[public_body_id]) + sim.step() + articulation.update(sim.cfg.dt) + expected_rotated_inertia = torch.tensor([[[2.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 3.0]]], device=device) + torch.testing.assert_close( + articulation.data.body_inertia.torch[:, public_body_id : public_body_id + 1], + expected_rotated_inertia, + ) + + @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_root_link_vel_w_refreshes_fk_before_body_com_vel_w_read(sim, device): """Reading ``root_link_vel_w`` must run FK before ``body_com_vel_w`` sees a "fresh" buffer. diff --git a/source/isaaclab_ov/test/assets/test_deformable_object.py b/source/isaaclab_ov/test/assets/test_deformable_object.py index 89d503309fb0..1c1b3fd50602 100644 --- a/source/isaaclab_ov/test/assets/test_deformable_object.py +++ b/source/isaaclab_ov/test/assets/test_deformable_object.py @@ -727,19 +727,8 @@ def test_mixed_deformable_rigid_scene_does_not_duplicate_runtime_clones(): @pytest.mark.skipif(not torch.cuda.is_available(), reason="OVPhysX deformables require CUDA") -def test_heterogeneous_mixed_deformable_rigid_scene_materializes_missing_targets( - monkeypatch: pytest.MonkeyPatch, -): +def test_heterogeneous_mixed_deformable_rigid_scene_materializes_missing_targets(): """Materialize missing rigid targets beside full-stage deformable clones without duplicates.""" - from isaaclab import cloner - - clone_cfg_type = cloner.CloneCfg - monkeypatch.setattr( - cloner, - "CloneCfg", - lambda device: clone_cfg_type(device=device, clone_strategy=cloner.sequential), - ) - with _ovphysx_sim_context(device="cuda:0") as sim: num_envs = 4 scene = InteractiveScene( @@ -753,8 +742,8 @@ def test_heterogeneous_mixed_deformable_rigid_scene_materializes_missing_targets assert plan is not None shape_rows = plan.cfg_rows[id(scene.cfg.shape)] shape_mask = plan.clone_mask[list(shape_rows)] - assert shape_mask.sum(dim=1).tolist() == [2, 2] - assert shape_mask.sum(dim=0).tolist() == [1, 1, 1, 1] + assert shape_mask.sum(axis=1).tolist() == [2, 2] + assert shape_mask.sum(axis=0).tolist() == [1, 1, 1, 1] expected_paths = {f"/World/envs/env_{index}/Shape" for index in range(num_envs)} source_paths = {plan.sources[row] for row in shape_rows} diff --git a/source/isaaclab_ov/test/assets/test_rigid_object.py b/source/isaaclab_ov/test/assets/test_rigid_object.py index d8d330b60256..4a4acc523d27 100644 --- a/source/isaaclab_ov/test/assets/test_rigid_object.py +++ b/source/isaaclab_ov/test/assets/test_rigid_object.py @@ -1253,19 +1253,20 @@ def test_write_state_functions_data_consistency(num_cubes, device, with_offset, torch.testing.assert_close(root_com_vel_w[:, 3:], root_link_vel_w[:, 3:]) -def test_warmup_attach_stage_not_called_for_cpu(): - """Regression test: ``physx.warmup_gpu()`` must not be called for CPU. +def test_warmup_attach_stage_not_called_for_cpu(monkeypatch): + """Keep IsaacLab's explicit warmup on its GPU-only compatibility path. - OVPhysX-equivalent of PhysX's ``test_warmup_attach_stage_not_called_for_cpu``: - PhysX guards :meth:`attach_stage` with ``if is_gpu:`` so the CPU MBP - broadphase is not double-initialised. The OVPhysX manager has the same - structural guard around :meth:`OvPhysxManager._physx.warmup_gpu`: it is - only invoked when ``ovphysx_device == "gpu"``. + OVPhysX 0.5 exposes only ``warmup_gpu()``, while OVPhysX 0.6 also supports + CPU through ``warmup()``. IsaacLab preserves its existing explicit GPU-only + scheduling path for compatibility with both generations. OVPhysX 0.5 needs + no explicit CPU warmup; on 0.6, the first tensor read performs lazy warmup. + The manager therefore does not invoke the compatibility helper on CPU. - We monkey-patch ``OvPhysxManager._physx`` with a :class:`MagicMock` - wrapping the live PhysX object so that ``warmup_gpu`` becomes a spy while - other calls continue to forward, then assert ``warmup_gpu.call_count == 0`` - after a CPU-mode :meth:`sim.reset`. + After the first reset constructs the runtime, we replace + :meth:`OvPhysxManager._warmup_physx` with a :class:`MagicMock`, force the + manager to load the stage again, and assert the helper was not called. + Watching the compatibility helper keeps this regression test independent + of which warmup entry point the installed OVPhysX generation exposes. The test always runs CPU regardless of session parametrization, so it is skipped when the session-locked device is anything other than CPU. The @@ -1283,26 +1284,17 @@ def test_warmup_attach_stage_not_called_for_cpu(): # Allocate a single rigid body so the manager has something to load. generate_cubes_scene(num_cubes=1, height=1.0, device="cpu") - # First reset constructs (or reuses) the real ovphysx.PhysX so we have - # a live instance to wrap. The PhysX object is a C++ binding, so we - # cannot patch attributes directly — replace the class-level reference - # with a MagicMock(wraps=...) that forwards every call. + # First reset constructs (or reuses) the real ovphysx.PhysX instance. sim.reset() - original_physx = OvPhysxManager._physx - assert original_physx is not None, "PhysX should be constructed after sim.reset()" - spy = MagicMock(wraps=original_physx) - OvPhysxManager._physx = spy + assert OvPhysxManager._physx is not None, "PhysX should be constructed after sim.reset()" + + warmup_spy = MagicMock() + monkeypatch.setattr(OvPhysxManager, "_warmup_physx", warmup_spy) # Force _warmup_and_load to run again on the next reset so the spy - # observes the warmup_gpu (or non-call) decision; close() resets + # observes the explicit-warmup (or non-call) decision; close() resets # _warmup_done back to False but we just called sim.reset() above. OvPhysxManager._warmup_done = False - try: - sim.reset() - finally: - OvPhysxManager._physx = original_physx + sim.reset() - assert spy.warmup_gpu.call_count == 0, ( - f"warmup_gpu() was called {spy.warmup_gpu.call_count} time(s) during CPU warmup. " - "OvPhysxManager._warmup_and_load() must guard warmup_gpu() with " - "ovphysx_device == 'gpu' so the CPU pipeline is not mis-initialised." - ) + assert OvPhysxManager._warmup_done is True + warmup_spy.assert_not_called() diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_compat.py b/source/isaaclab_ov/test/physics/test_ovphysx_compat.py new file mode 100644 index 000000000000..b887cd59f09a --- /dev/null +++ b/source/isaaclab_ov/test/physics/test_ovphysx_compat.py @@ -0,0 +1,94 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for OVPhysX 0.5.11 / 0.6 lifecycle and dynamics compatibility.""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util + +import pytest +from packaging.version import Version + +_REQUIRED_MODULES = ("isaaclab_ov",) +_MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] + +pytestmark = [ + pytest.mark.isaacsim_ci, + pytest.mark.skipif( + bool(_MISSING_MODULES), + reason=f"requires optional modules: {', '.join(_MISSING_MODULES)}", + ), +] + +if not _MISSING_MODULES: + from isaaclab_ov.physics.ovphysx_compat import ( # noqa: E402 + OVPHYSX_LIFECYCLE_ENTRY_POINTS, + build_lifecycle_entry_points, + detect_ovphysx_version, + requires_legacy_joint_sign_correction, + ) +else: + OVPHYSX_LIFECYCLE_ENTRY_POINTS = None + build_lifecycle_entry_points = None + detect_ovphysx_version = None + requires_legacy_joint_sign_correction = None + +_LEGACY_ENTRY_POINTS = {"warmup": "warmup_gpu", "destroy": "release"} +_CURRENT_ENTRY_POINTS = {"warmup": "warmup", "destroy": "destroy"} + + +def test_detect_ovphysx_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.5.11") + assert detect_ovphysx_version() == Version("0.5.11") + + +def test_detect_ovphysx_version_returns_none_when_uninstalled(monkeypatch: pytest.MonkeyPatch): + def _missing(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", _missing) + assert detect_ovphysx_version() is None + + +def test_detect_ovphysx_version_returns_none_for_unparseable_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "internal-build") + assert detect_ovphysx_version() is None + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + (None, _LEGACY_ENTRY_POINTS), + (Version("0.5.11"), _LEGACY_ENTRY_POINTS), + (Version("0.6.0.dev1+trunk.e15a64a2"), _CURRENT_ENTRY_POINTS), + (Version("0.6"), _CURRENT_ENTRY_POINTS), + (Version("1.0"), _CURRENT_ENTRY_POINTS), + ], +) +def test_lifecycle_entry_points(version: Version | None, expected: dict[str, str]): + entry_points = build_lifecycle_entry_points(version) + assert dict(entry_points) == expected + + +def test_published_entry_points_are_read_only(): + with pytest.raises(TypeError): + OVPHYSX_LIFECYCLE_ENTRY_POINTS["warmup"] = "mutated" # type: ignore[index] + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + (None, True), + (Version("0.5.11"), True), + (Version("0.6.0.dev1+trunk.e15a64a2"), False), + (Version("0.6"), False), + (Version("0.6.2"), False), + (Version("1.0"), False), + ], +) +def test_reversed_joint_sign_correction_version_boundary(version: Version | None, expected: bool): + assert requires_legacy_joint_sign_correction(version) is expected diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py b/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py index 58c2e712f4d5..abc6ae633f7b 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_manager_lifecycle.py @@ -72,41 +72,61 @@ def _fake_ovphysx_module(bootstrap): @pytest.mark.parametrize( - ("registered_names", "expected_paths"), + ("registered_names", "expected_paths", "schema_root", "has_registration_api"), [ - (["physxSchema"], ["/schemas/OmniUsdPhysicsDeformableSchema/resources"]), - (["PhysxSchema", "OmniUsdPhysicsDeformableSchema"], []), + (["physxSchema"], ["/schemas/OmniUsdPhysicsDeformableSchema/resources"], "/schemas", True), + (["PhysxSchema", "OmniUsdPhysicsDeformableSchema"], [], "/schemas", True), + (["PhysxSchema", "OmniUsdPhysicsDeformableSchema"], [], None, True), + (["physxSchema"], ["/schemas/OmniUsdPhysicsDeformableSchema/resources"], "/schemas", False), + pytest.param( + ["physxSchema"], + ["/schemas/OmniUsdPhysicsDeformableSchema/resources"], + "/schemas", + None, + id="ovstage-import-unavailable", + ), ], ) def test_schema_registration_skips_providers_already_supplied_by_host( - monkeypatch, manager_module, registered_names, expected_paths + monkeypatch, manager_module, registered_names, expected_paths, schema_root, has_registration_api ): manager = manager_module.OvPhysxManager schema_paths = [ Path("/schemas/PhysxSchema/resources"), Path("/schemas/OmniUsdPhysicsDeformableSchema/resources"), ] - registrations = [] + host_registrations = [] + ovstage_registrations = [] fake_ovphysx = ModuleType("ovphysx") fake_ovphysx.codeless_schema_paths = lambda: schema_paths + if schema_root is not None: + fake_ovphysx.codeless_schema_root = lambda: Path(schema_root) + + fake_ovstage = ModuleType("ovstage") + fake_ovstage.population = SimpleNamespace() + if has_registration_api: + fake_ovstage.population.register_usd_schemas = ovstage_registrations.append class FakeRegistry: def GetAllPlugins(self): return [SimpleNamespace(name=name) for name in registered_names] def RegisterPlugins(self, paths): - registrations.append(list(paths)) + host_registrations.append(list(paths)) fake_pxr = ModuleType("pxr") fake_pxr.Plug = type("FakePlug", (), {"Registry": staticmethod(FakeRegistry)}) monkeypatch.setitem(sys.modules, "ovphysx", fake_ovphysx) + # A None entry makes importing OVStage raise ModuleNotFoundError. + monkeypatch.setitem(sys.modules, "ovstage", fake_ovstage if has_registration_api is not None else None) monkeypatch.setitem(sys.modules, "pxr", fake_pxr) manager._ensure_physx_schemas_registered() manager._ensure_physx_schemas_registered() - assert registrations == ([expected_paths] if expected_paths else []) + assert ovstage_registrations == ([schema_root] if schema_root is not None and has_registration_api else []) + assert host_registrations == ([expected_paths] if expected_paths else []) def test_construct_physx_bootstraps_each_runtime_without_replacing_pxr(monkeypatch, manager_module): diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index 2712d41f7bfd..7fdf9ecb4417 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -17,6 +17,9 @@ # CI jobs that need OVPhysX coverage install it explicitly. pytest.importorskip("ovphysx.types", reason="ovphysx wheel not installed") +_LEGACY_LIFECYCLE_ENTRY_POINTS = {"warmup": "warmup_gpu", "destroy": "release"} +_CURRENT_LIFECYCLE_ENTRY_POINTS = {"warmup": "warmup", "destroy": "destroy"} + @pytest.fixture(autouse=True) def _close_test_views(): @@ -467,6 +470,7 @@ def test_manager_attaches_and_releases_owned_ovstage(monkeypatch): from isaaclab_ov.physics import OvPhysxManager events = [] + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", _LEGACY_LIFECYCLE_ENTRY_POINTS) class FakeWriteFloorOp: def __init__(self, ordinal): @@ -544,6 +548,201 @@ def release(self): ] +@pytest.mark.parametrize( + ("entry_points", "expected_calls"), + [ + (_LEGACY_LIFECYCLE_ENTRY_POINTS, ["warmup_gpu", "release"]), + (_CURRENT_LIFECYCLE_ENTRY_POINTS, ["warmup", "destroy"]), + ], +) +def test_manager_uses_version_selected_lifecycle_apis(monkeypatch, entry_points, expected_calls): + """The selected lifecycle generation controls both entry points.""" + from isaaclab_ov.physics import OvPhysxManager + from isaaclab_ov.physics import ovphysx_manager as om_mod + + calls = [] + physx = SimpleNamespace( + warmup=lambda: calls.append("warmup"), + warmup_gpu=lambda: calls.append("warmup_gpu"), + destroy=lambda: calls.append("destroy"), + release=lambda: calls.append("release"), + ) + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", entry_points) + + OvPhysxManager._warmup_physx(physx) + OvPhysxManager._destroy_physx(physx) + + assert calls == expected_calls + + +@pytest.mark.parametrize("operation", ["warmup", "destroy"]) +def test_manager_rejects_missing_lifecycle_api(monkeypatch, operation): + """A runtime that lacks its selected lifecycle entry point reports it.""" + from isaaclab_ov.physics import OvPhysxManager + from isaaclab_ov.physics import ovphysx_manager as om_mod + + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", _CURRENT_LIFECYCLE_ENTRY_POINTS) + entry_point = _CURRENT_LIFECYCLE_ENTRY_POINTS[operation] + lifecycle_method = getattr(OvPhysxManager, f"_{operation}_physx") + with pytest.raises(AttributeError, match=rf"selected {entry_point}\(\) lifecycle entry point"): + lifecycle_method(SimpleNamespace()) + + +def test_manager_releases_legacy_owners_after_release_error(monkeypatch): + """The 0.5.11 path preserves its unconditional owner cleanup on failure.""" + from isaaclab_ov.physics import OvPhysxManager + from isaaclab_ov.physics import ovphysx_manager as om_mod + + events = [] + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", _LEGACY_LIFECYCLE_ENTRY_POINTS) + + class FakePhysX: + def reset_stage(self): + events.append("reset") + return 23 + + def wait_op(self, operation): + events.append(("wait", operation)) + + def release(self): + events.append("release") + raise RuntimeError("legacy release failed") + + class FakeStage: + def destroy(self): + events.append("destroy_stage") + + previous_physx = OvPhysxManager._physx + previous_ovstage = OvPhysxManager._ovstage + OvPhysxManager._physx = FakePhysX() + OvPhysxManager._ovstage = FakeStage() + monkeypatch.setattr(OvPhysxManager, "_close_physx_views", staticmethod(lambda value: events.append("close_views"))) + try: + with pytest.raises(RuntimeError, match="legacy release failed"): + OvPhysxManager._release_physx() + + assert OvPhysxManager._physx is None + assert OvPhysxManager._ovstage is None + assert events == ["close_views", "reset", ("wait", 23), "release", "destroy_stage"] + finally: + OvPhysxManager._physx = previous_physx + OvPhysxManager._ovstage = previous_ovstage + + +def test_manager_retries_current_destroy_before_releasing_owners(monkeypatch): + """A pre-teardown destroy failure preserves the runtime and stage for retry.""" + from isaaclab_ov.physics import OvPhysxManager + from isaaclab_ov.physics import ovphysx_manager as om_mod + + events = [] + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", _CURRENT_LIFECYCLE_ENTRY_POINTS) + + class FakePhysX: + fail_destroy = True + + @property + def handle(self): + return 17 + + def reset_stage(self): + events.append("reset") + return 23 + + def wait_op(self, operation): + events.append(("wait", operation)) + + def destroy(self): + events.append("destroy") + if self.fail_destroy: + raise RuntimeError("destroy did not reach native teardown") + + class FakeStage: + def destroy(self): + events.append("destroy_stage") + + physx = FakePhysX() + stage = FakeStage() + previous_physx = OvPhysxManager._physx + previous_ovstage = OvPhysxManager._ovstage + OvPhysxManager._physx = physx + OvPhysxManager._ovstage = stage + monkeypatch.setattr(OvPhysxManager, "_close_physx_views", staticmethod(lambda value: events.append("close_views"))) + try: + with pytest.raises(RuntimeError, match="did not reach native teardown"): + OvPhysxManager._release_physx() + + assert OvPhysxManager._physx is physx + assert OvPhysxManager._ovstage is stage + + physx.fail_destroy = False + OvPhysxManager._release_physx() + + assert OvPhysxManager._physx is None + assert OvPhysxManager._ovstage is None + assert events == [ + "close_views", + "reset", + ("wait", 23), + "destroy", + "close_views", + "reset", + ("wait", 23), + "destroy", + "destroy_stage", + ] + finally: + OvPhysxManager._physx = previous_physx + OvPhysxManager._ovstage = previous_ovstage + + +def test_manager_releases_owners_after_terminal_destroy_error(monkeypatch): + """A destroy error after terminal teardown does not retain dead owners.""" + from isaaclab_ov.physics import OvPhysxManager + from isaaclab_ov.physics import ovphysx_manager as om_mod + + events = [] + monkeypatch.setattr(om_mod, "OVPHYSX_LIFECYCLE_ENTRY_POINTS", _CURRENT_LIFECYCLE_ENTRY_POINTS) + + class FakePhysX: + terminal = False + + @property + def handle(self): + if self.terminal: + raise RuntimeError("PhysX instance has been destroyed") + return 17 + + def reset_stage(self): + return 23 + + def wait_op(self, operation): + pass + + def destroy(self): + self.terminal = True + raise RuntimeError("native teardown reported a terminal failure") + + class FakeStage: + def destroy(self): + events.append("destroy_stage") + + previous_physx = OvPhysxManager._physx + previous_ovstage = OvPhysxManager._ovstage + OvPhysxManager._physx = FakePhysX() + OvPhysxManager._ovstage = FakeStage() + monkeypatch.setattr(OvPhysxManager, "_close_physx_views", staticmethod(lambda value: None)) + try: + with pytest.raises(RuntimeError, match="terminal failure"): + OvPhysxManager._release_physx() + + assert OvPhysxManager._physx is None + assert OvPhysxManager._ovstage is None + assert events == ["destroy_stage"] + finally: + OvPhysxManager._physx = previous_physx + OvPhysxManager._ovstage = previous_ovstage + + def test_manager_destroys_ovstage_when_population_fails(monkeypatch): """A failed in-memory population does not leak its OVStage allocation.""" import isaaclab_ov.physics.ovphysx_manager as om_mod diff --git a/source/isaaclab_ov/test/sensors/test_contact_sensor.py b/source/isaaclab_ov/test/sensors/test_contact_sensor.py index 44527434771c..42dcf80437f8 100644 --- a/source/isaaclab_ov/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ov/test/sensors/test_contact_sensor.py @@ -666,13 +666,13 @@ def test_nested_rigid_body_hierarchy(device, num_envs): """ with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: stage = get_current_stage() - env_positions, _ = cloner.grid_transforms(num_envs, spacing=3.0, device=device) + env_positions, _ = cloner.grid_transforms(num_envs, spacing=3.0) env_0 = UsdGeom.Xform.Define(stage, "/World/envs/env_0") env_0.AddTranslateOp().Set(Gf.Vec3d(*env_positions[0].tolist())) _author_nested_chain("/World/envs/env_0/Robot") src, dest = "/World/envs/env_0", "/World/envs/env_{}" - clone_plan = cloner.clone_plan_from_env_0(src, dest, num_envs, device, env_positions) + clone_plan = cloner.clone_plan_from_env_0(src, dest, num_envs, env_positions) assert clone_plan.env_ids is not None ovphysx_replicate( stage, @@ -765,6 +765,56 @@ def test_contact_sensor_threshold(device): ) +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_lazy_sensor_reports_contact_loss(device): + """Regression for issue #7613: a lazily read sensor must report the loss of contact. + + Mirrors the PhysX regression test: with ``history_length=0`` and ``lazy_sensor_update=True`` + the sensor is only refreshed when :attr:`ContactSensor.data` is accessed, which a policy-rate + reader does once per four physics steps. The reported force must still drop to zero once the + shape is held in the air. + """ + with _ovphysx_sim_context(device=device, dt=_SIM_DT, add_lighting=False) as sim: + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=True) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = CUBE_CFG + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=CUBE_CFG.prim_path, + track_pose=True, + update_period=0.0, + track_air_time=True, + history_length=0, + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + + sensor: ContactSensor = scene["contact_sensor"] + shape: RigidObject = scene["shape"] + contact_pose = CUBE_CFG.contact_pose.to(device=shape.device).unsqueeze(0) + non_contact_pose = CUBE_CFG.non_contact_pose.to(device=shape.device).unsqueeze(0) + + # Mimic a policy stepping the environment with a decimation of 4: the sensor data is + # read once per policy step and left untouched in between. + decimation = 4 + num_policy_steps = 6 + + def run_policy_step(root_pose: torch.Tensor) -> float: + """Holds the shape at ``root_pose`` for one policy step and reads the sensor once.""" + for _ in range(decimation): + shape.write_root_pose_to_sim_index(root_pose=root_pose) + _perform_sim_step(sim, scene, _SIM_DT) + return torch.linalg.norm(sensor.data.net_normal_forces_w.torch, dim=-1).max().item() + + contact_forces = [run_policy_step(contact_pose) for _ in range(num_policy_steps)] + air_forces = [run_policy_step(non_contact_pose) for _ in range(num_policy_steps)] + + # Guard against a vacuous test: the sensor must have reported contact while on the ground. + assert contact_forces[-1] > 0.1, f"Expected a contact force on the ground; got {contact_forces}" + # The first read in the air may still carry the impulse of the last contact step, so only + # the subsequent reads are required to be free of contact. + assert max(air_forces[1:]) < 0.1, f"Stale contact force reported in the air: {air_forces}" + + @pytest.mark.skip( reason=( "ovphysx ContactSensor v1 does not support track_friction_forces; " diff --git a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py index fda59da5a553..84e1855788a2 100644 --- a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py +++ b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py @@ -10,6 +10,8 @@ from __future__ import annotations +from dataclasses import replace + import pytest # The OVPhysX runtime wheel is optional. Skip gracefully when it is not installed; @@ -19,6 +21,7 @@ from isaaclab_ov.physics import OvPhysxCfg # noqa: E402 import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab import cloner # noqa: E402 from isaaclab.sim import SimulationCfg, build_simulation_context # noqa: E402 from isaaclab.sim.views import FrameView # noqa: E402 @@ -66,21 +69,28 @@ def test_world_attached_source_prim_expands_from_clone_plan(): device=device, sim_cfg=OVPHYSX_SIM_CFG, auto_add_lighting=False, add_ground_plane=False ) as sim: sim._app_control_on_stop_handle = None - scene = InteractiveScene(_OvPhysxFrameViewSceneCfg(num_envs=4, env_spacing=2.0)) - sim.reset() - + scene = InteractiveScene(InteractiveSceneCfg(num_envs=4, env_spacing=2.0)) stage = sim_utils.get_current_stage() prim = stage.DefinePrim("/World/envs/env_0/WorldCamera", "Xform") sim_utils.standardize_xform_ops(prim) prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(0.25, -0.5, 1.0)) + positions = cloner.grid_transforms(scene.num_envs, scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0("/World/envs/env_0", "/World/envs/env_{}", scene.num_envs, positions) + target_env_ids = (0, 5, 2, 9) + env_ids = plan.env_ids.copy() + env_ids[:] = target_env_ids + plan = replace(plan, env_ids=env_ids) + cloner.replicate(plan) + sim.reset() view = FrameView("/World/envs/env_[^/]+/WorldCamera", device=device) - assert not stage.GetPrimAtPath("/World/envs/env_1/WorldCamera").IsValid() + assert not stage.GetPrimAtPath(f"/World/envs/env_{target_env_ids[1]}").IsValid() + assert not stage.GetPrimAtPath(f"/World/envs/env_{target_env_ids[1]}/WorldCamera").IsValid() assert view.count == scene.num_envs assert len(view.prims) == scene.num_envs assert {prim.GetPath().pathString for prim in view.prims} == {"/World/envs/env_0/WorldCamera"} - assert view.prim_paths == [f"/World/envs/env_{i}/WorldCamera" for i in range(scene.num_envs)] + assert view.prim_paths == [f"/World/envs/env_{i}/WorldCamera" for i in target_env_ids] positions, _ = view.get_world_poses() expected_positions = scene.env_origins + torch.tensor([0.25, -0.5, 1.0], device=device) torch.testing.assert_close(positions.torch, expected_positions) diff --git a/source/isaaclab_ov/test/test_cloner.py b/source/isaaclab_ov/test/test_cloner.py index 59a516c52463..9439ec7566e4 100644 --- a/source/isaaclab_ov/test/test_cloner.py +++ b/source/isaaclab_ov/test/test_cloner.py @@ -6,14 +6,18 @@ """Tests for OvPhysX cloning.""" import math +from types import SimpleNamespace +import numpy as np import pytest -import torch -from isaaclab_ov.cloner import OvPhysxReplicateContext +from isaaclab_ov.cloner import OvPhysxReplicateContext, ovphysx_replicate from isaaclab_ov.physics.ovphysx_manager import OvPhysxManager from pxr import Gf, Usd, UsdGeom +from isaaclab.cloner import ClonePlan +from isaaclab.physics import PhysicsManager + def _pose_matrix(position: tuple[float, float, float], quaternion: tuple[float, float, float, float]) -> Gf.Matrix4d: """Build a USD pose matrix from an xyzw quaternion.""" @@ -41,41 +45,66 @@ def test_nested_clone_uses_final_target_pose(monkeypatch): _pose_matrix((0.0, 1.0, 2.0), (source_half_angle_sin, 0.0, 0.0, source_half_angle_cos)) ) - context = OvPhysxReplicateContext(stage) - context.queue_mapping( + monkeypatch.setattr(PhysicsManager, "_sim", SimpleNamespace(physics_manager=OvPhysxManager)) + ovphysx_replicate( + stage, sources=["/World/envs/env_0/Robot", "/World/envs/env_9/Inactive"], destinations=["/World/envs/env_{}/Robot", "/World/envs/env_{}/Inactive"], - env_ids=torch.tensor([0, 1]), - mapping=torch.tensor([[True, True], [False, False]]), - positions=torch.tensor([[4.0, 5.0, 6.0], [10.0, 20.0, 30.0]]), - quaternions=torch.tensor([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, target_half_angle_sin, target_half_angle_cos]]), + env_ids=np.array([0, 1], dtype=np.int64), + mapping=np.array([[True, True], [False, False]], dtype=np.bool_), + positions=np.array([[4.0, 5.0, 6.0], [10.0, 20.0, 30.0]], dtype=np.float32), + quaternions=np.array( + [[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, target_half_angle_sin, target_half_angle_cos]], + dtype=np.float32, + ), ) - expected_orientation = torch.tensor( + expected_orientation = np.array( [ target_half_angle_cos * source_half_angle_sin, target_half_angle_sin * source_half_angle_sin, target_half_angle_sin * source_half_angle_cos, target_half_angle_cos * source_half_angle_cos, - ] + ], + dtype=np.float32, ) expected_transform = (10.0 - half_sqrt_two, 20.0 + half_sqrt_two, 32.0, *expected_orientation.tolist()) - context.replicate() - - assert context._queue == [] assert len(OvPhysxManager._pending_clones) == 1 pending_source, pending_targets, pending_transforms = OvPhysxManager._pending_clones[0] assert pending_source == "/World/envs/env_0/Robot" assert pending_targets == ["/World/envs/env_1/Robot"] assert len(pending_transforms) == 1 assert pending_transforms[0][:3] == pytest.approx(expected_transform[:3]) - orientation = torch.tensor(pending_transforms[0][3:]) - if torch.dot(orientation, expected_orientation) < 0.0: + orientation = np.asarray(pending_transforms[0][3:], dtype=np.float32) + if np.dot(orientation, expected_orientation) < 0.0: orientation = -orientation assert orientation.tolist() == pytest.approx(expected_orientation.tolist()) +def test_ovphysx_context_consumes_plan(): + """The registered context publishes the rows routed to it by one clone plan.""" + stage = Usd.Stage.CreateInMemory() + UsdGeom.Xform.Define(stage, "/World/envs/env_10") + UsdGeom.Xform.Define(stage, "/World/envs/env_10/Robot") + recipes = [] + manager = SimpleNamespace(_register_clone_transforms=lambda *recipe: recipes.append(recipe)) + simulation = SimpleNamespace(stage=stage, physics_manager=manager) + plan = ClonePlan( + sources=("/World/envs/env_10/Robot",), + destinations=("/World/envs/env_{}/Robot",), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.array([10, 20], dtype=np.int64), + positions=np.array([[0.0, 0.0, 0.0], [1.0, 2.0, 3.0]], dtype=np.float32), + context_rows={OvPhysxReplicateContext: (0,)}, + ) + + OvPhysxReplicateContext(simulation).replicate(plan) + + assert recipes[0][0:2] == ("/World/envs/env_10/Robot", ["/World/envs/env_20/Robot"]) + assert recipes[0][2][0] == pytest.approx((1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0)) + + def test_register_clone_preserves_translation_only_compatibility(monkeypatch): """World positions become target-root poses with identity rotations.""" monkeypatch.setattr(OvPhysxManager, "_active_clone_recipes", []) @@ -88,21 +117,20 @@ def test_register_clone_preserves_translation_only_compatibility(monkeypatch): assert OvPhysxManager._pending_clones == expected_recipes -def test_queue_mapping_rejects_invalid_source_prim(): +def test_raw_replicate_rejects_invalid_source_prim(): """Active clone rows require a valid source prim.""" stage = Usd.Stage.CreateInMemory() - context = OvPhysxReplicateContext(stage) - with pytest.raises(ValueError, match="/World/envs/env_0/Robot"): - context.queue_mapping( + ovphysx_replicate( + stage, sources=["/World/envs/env_0/Robot"], destinations=["/World/envs/env_{}/Robot"], - env_ids=torch.tensor([0, 1]), - mapping=torch.tensor([[True, True]]), + env_ids=np.array([0, 1], dtype=np.int64), + mapping=np.array([[True, True]], dtype=np.bool_), ) -def test_queue_mapping_rejects_invalid_source_anchor(): +def test_raw_replicate_rejects_invalid_source_anchor(): """Active nested clone rows require a valid source-environment anchor.""" stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World/envs/env_0/Robot") @@ -113,51 +141,49 @@ def GetPrimAtPath(self, path): return Usd.Prim() return stage.GetPrimAtPath(path) - context = OvPhysxReplicateContext(StageWithoutAnchor()) with pytest.raises(ValueError, match="/World/envs/env_0"): - context.queue_mapping( + ovphysx_replicate( + StageWithoutAnchor(), sources=["/World/envs/env_0/Robot"], destinations=["/World/envs/env_{}/Robot"], - env_ids=torch.tensor([0, 1]), - mapping=torch.tensor([[True, True]]), + env_ids=np.array([0, 1], dtype=np.int64), + mapping=np.array([[True, True]], dtype=np.bool_), ) @pytest.mark.parametrize( ("name", "value"), - [("positions", torch.zeros((1, 3))), ("quaternions", torch.zeros((1, 4)))], + [("positions", np.zeros((1, 3), dtype=np.float32)), ("quaternions", np.zeros((1, 4), dtype=np.float32))], ) -def test_queue_mapping_rejects_pose_tensor_missing_selected_environment(name, value): - """Provided pose tensors include every selected environment.""" +def test_raw_replicate_rejects_pose_array_missing_selected_environment(name, value): + """Provided pose arrays include every selected environment.""" stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World/envs/env_0/Robot") - context = OvPhysxReplicateContext(stage) - with pytest.raises(ValueError, match=name): - context.queue_mapping( + ovphysx_replicate( + stage, sources=["/World/envs/env_0/Robot"], destinations=["/World/envs/env_{}/Robot"], - env_ids=torch.tensor([0, 1]), - mapping=torch.tensor([[True, True]]), + env_ids=np.array([0, 1], dtype=np.int64), + mapping=np.array([[True, True]], dtype=np.bool_), **{name: value}, ) @pytest.mark.parametrize( ("name", "value"), - [("positions", torch.zeros((2, 2))), ("quaternions", torch.zeros((2, 3)))], + [("positions", np.zeros((2, 2), dtype=np.float32)), ("quaternions", np.zeros((2, 3), dtype=np.float32))], ) -def test_queue_mapping_rejects_malformed_pose_tensor(name, value): - """Provided pose tensors use the documented component counts.""" +def test_raw_replicate_rejects_malformed_pose_array(name, value): + """Provided pose arrays use the documented component counts.""" stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World/envs/env_0/Robot") - context = OvPhysxReplicateContext(stage) - with pytest.raises(ValueError, match=rf"{name} must have shape"): - context.queue_mapping( + ovphysx_replicate( + stage, sources=["/World/envs/env_0/Robot"], destinations=["/World/envs/env_{}/Robot"], - env_ids=torch.tensor([0, 1]), - mapping=torch.tensor([[True, True]]), + env_ids=np.array([0, 1], dtype=np.int64), + mapping=np.array([[True, True]], dtype=np.bool_), **{name: value}, ) diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index d439854c947c..265e8d201d39 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -146,16 +146,16 @@ def test_clone_sources_in_ovrtx_uses_active_plan_rows(): renderer._clone_plan = ClonePlan( sources=("/World/envs/env_0/Robot", "/World/envs/env_1/Object", "/World/envs/env_0/Light"), destinations=("/World/envs/env_{}/Robot", "/World/envs/env_{}/Object", "/World/envs/env_{}/Light"), - clone_mask=torch.tensor( + clone_mask=np.array( [ [True, True, True, True], [False, True, True, True], [True, False, False, False], ], - dtype=torch.bool, + dtype=np.bool_, ), - env_ids=torch.arange(4), - positions=torch.zeros((4, 3)), + env_ids=np.arange(4, dtype=np.int64), + positions=np.zeros((4, 3), dtype=np.float32), ) clone_calls: list[tuple[str, list[str]]] = [] @@ -181,9 +181,9 @@ def test_clone_sources_in_ovrtx_raises_on_clone_failure(): renderer._clone_plan = ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2), - positions=torch.zeros((2, 3)), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), ) def _clone_usd(source: str, target_paths: list[str]) -> None: @@ -198,12 +198,12 @@ def _clone_usd(source: str, target_paths: list[str]) -> None: def test_clone_sources_in_ovrtx_writes_plan_positions_after_cloning(): """Legacy OVRTX cloning writes translated identity root transforms from the plan.""" renderer = _make_ovrtx_renderer_without_backend() - positions = torch.tensor([[0.0, 0.0, 0.0], [2.0, -1.0, 0.5], [-3.0, 4.0, 1.5]]) + positions = np.array([[0.0, 0.0, 0.0], [2.0, -1.0, 0.5], [-3.0, 4.0, 1.5]], dtype=np.float32) renderer._clone_plan = ClonePlan( sources=("/World/envs/env_5",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 3), dtype=torch.bool), - env_ids=torch.tensor([5, 11, 3]), + clone_mask=np.ones((1, 3), dtype=np.bool_), + env_ids=np.array([5, 11, 3], dtype=np.int64), positions=positions, ) call_order: list[str] = [] @@ -225,7 +225,7 @@ def _write_attribute(**kwargs): renderer._clone_sources_in_ovrtx() expected = np.tile(np.eye(4, dtype=np.float64), (3, 1, 1)) - expected[:, 3, :3] = positions.numpy() + expected[:, 3, :3] = positions assert call_order == ["clone", "write"] assert clone_calls == [("/World/envs/env_5", ["/World/envs/env_11", "/World/envs/env_3"])] assert len(write_calls) == 1 @@ -238,12 +238,12 @@ def _write_attribute(**kwargs): def test_clone_sources_ovstage_writes_plan_positions_after_cloning(monkeypatch: pytest.MonkeyPatch): """Ovstage skips inactive rows and writes translated identity root transforms from the plan.""" renderer = _make_ovrtx_renderer_without_backend() - positions = torch.tensor([[0.0, 0.0, 0.0], [1.5, -2.0, 0.25], [3.0, 4.0, 0.5]]) + positions = np.array([[0.0, 0.0, 0.0], [1.5, -2.0, 0.25], [3.0, 4.0, 0.5]], dtype=np.float32) renderer._clone_plan = ClonePlan( sources=("/World/envs/env_7/Robot", "/World/envs/env_3/Object"), destinations=("/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"), - clone_mask=torch.tensor([[True, False, False], [False, True, True]]), - env_ids=torch.tensor([7, 3, 12]), + clone_mask=np.array([[True, False, False], [False, True, True]], dtype=np.bool_), + env_ids=np.array([7, 3, 12], dtype=np.int64), positions=positions, ) events: list[tuple[str, str, object]] = [] @@ -286,7 +286,7 @@ def _record_xforms(value: np.ndarray) -> str: renderer._clone_sources_ovstage() expected = np.tile(np.eye(4, dtype=np.float64), (3, 1, 1)) - expected[:, 3, :3] = positions.numpy() + expected[:, 3, :3] = positions assert events == [ ("clone", "/World/envs/env_3/Object", ["/World/envs/env_12/Object"]), ("paths", "envs", ["/World/envs/env_7", "/World/envs/env_3", "/World/envs/env_12"]), @@ -315,14 +315,14 @@ def test_write_file_creates_parent_directory_and_writes_utf8(tmp_path: Path): ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), ), ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - positions=torch.zeros((2, 3)), + clone_mask=np.ones((1, 2), dtype=np.bool_), + positions=np.zeros((2, 3), dtype=np.float32), ), ], ) @@ -340,9 +340,9 @@ def test_prepare_stage_rejects_non_dense_environment_ids(monkeypatch: pytest.Mon plan = ClonePlan( sources=("/World/envs/env_7",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.tensor([7, 3]), - positions=torch.zeros((2, 3)), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.array([7, 3], dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), ) _patch_simulation_context(monkeypatch, plan) @@ -350,6 +350,35 @@ def test_prepare_stage_rejects_non_dense_environment_ids(monkeypatch: pytest.Mon _make_ovrtx_renderer_without_backend().prepare_stage(_make_multi_env_stage(2), 2) +@pytest.mark.parametrize("use_ovstage", [False, True]) +def test_prepare_stage_ignores_unspawned_variant_sources(monkeypatch: pytest.MonkeyPatch, use_ovstage: bool): + """Unused variants retain plan rows but have no source prim to export.""" + num_envs, num_variants = 4, 16 + stage = _make_multi_env_stage(num_envs) + plan = ClonePlan( + sources=tuple(f"/World/envs/env_{index}/Robot" for index in range(num_variants)), + destinations=("/World/envs/env_{}/Robot",) * num_variants, + clone_mask=np.eye(num_variants, num_envs, dtype=np.bool_), + env_ids=np.arange(num_envs, dtype=np.int64), + positions=np.zeros((num_envs, 3), dtype=np.float32), + ) + _patch_simulation_context(monkeypatch, plan) + renderer = _make_ovrtx_renderer_without_backend() + renderer._use_ovstage = use_ovstage + + renderer.prepare_stage(stage, num_envs) + + exported_layer = Sdf.Layer.CreateAnonymous(".usda") + assert exported_layer.ImportFromString(renderer._exported_usd_string) + exported_stage = Usd.Stage.Open(exported_layer) + for source in plan.sources[:num_envs]: + assert exported_stage.GetPrimAtPath(source) + for source in plan.sources[num_envs:]: + assert not exported_stage.GetPrimAtPath(source) + assert renderer._clone_plan is plan + assert len(plan.sources) == num_variants + + def test_capture_object_scales_populates_source_and_destination_scale_array(): """Projected source scales reach the body array without replacing a real destination scale.""" stage = Usd.Stage.CreateInMemory() @@ -389,9 +418,9 @@ def test_prepare_stage_keeps_material_binding_inside_clone_source(monkeypatch: p plan = ClonePlan( sources=(source,), destinations=("/World/envs/env_{}/Robot",), - clone_mask=torch.ones((1, num_envs), dtype=torch.bool), - env_ids=torch.arange(num_envs), - positions=torch.zeros((num_envs, 3)), + clone_mask=np.ones((1, num_envs), dtype=np.bool_), + env_ids=np.arange(num_envs, dtype=np.int64), + positions=np.zeros((num_envs, 3), dtype=np.float32), ) _patch_simulation_context(monkeypatch, plan) renderer = _make_ovrtx_renderer_without_backend() @@ -414,9 +443,9 @@ def test_prepare_stage_writes_pre_ovrtx_stage_dump(tmp_path: Path, monkeypatch: ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2), - positions=torch.zeros((2, 3)), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), ), ) @@ -440,9 +469,9 @@ def test_prepare_stage_skips_temp_usd_write_when_temp_usd_dir_unset(monkeypatch: ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 2), dtype=torch.bool), - env_ids=torch.arange(2), - positions=torch.zeros((2, 3)), + clone_mask=np.ones((1, 2), dtype=np.bool_), + env_ids=np.arange(2, dtype=np.int64), + positions=np.zeros((2, 3), dtype=np.float32), ), ) write_calls: list[tuple[Path, str, str]] = [] @@ -552,9 +581,9 @@ def test_prepare_stage_stores_clone_plan_and_exports(monkeypatch: pytest.MonkeyP published = ClonePlan( sources=("/World/envs/env_0",), destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, num_envs), dtype=torch.bool), - env_ids=torch.arange(num_envs), - positions=torch.zeros((num_envs, 3)), + clone_mask=np.ones((1, num_envs), dtype=np.bool_), + env_ids=np.arange(num_envs, dtype=np.int64), + positions=np.zeros((num_envs, 3), dtype=np.float32), ) _patch_simulation_context(monkeypatch, published) diff --git a/source/isaaclab_ov/test/test_ovrtx_compat.py b/source/isaaclab_ov/test/test_ovrtx_compat.py new file mode 100644 index 000000000000..8a9bd1139558 --- /dev/null +++ b/source/isaaclab_ov/test/test_ovrtx_compat.py @@ -0,0 +1,108 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for OVRTX 0.4 / 0.5 render-var key compatibility.""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util + +import pytest +from packaging.version import Version + +_REQUIRED_MODULES = ("isaaclab_ov", "pxr") +_MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] + +pytestmark = [ + pytest.mark.isaacsim_ci, + pytest.mark.skipif( + bool(_MISSING_MODULES), + reason=f"requires optional modules: {', '.join(_MISSING_MODULES)}", + ), +] + +if not _MISSING_MODULES: + from isaaclab_ov.renderers.ovrtx_compat import ( # noqa: E402 + RENDER_VAR_FRAME_KEYS, + build_render_var_frame_keys, + detect_ovrtx_version, + uses_prim_path_render_vars, + ) + from isaaclab_ov.renderers.ovrtx_usd import render_var_prim_paths_by_source # noqa: E402 +else: + RENDER_VAR_FRAME_KEYS = None + build_render_var_frame_keys = None + detect_ovrtx_version = None + render_var_prim_paths_by_source = None + uses_prim_path_render_vars = None + + +def test_detect_ovrtx_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.4.1.364340") + assert detect_ovrtx_version() == Version("0.4.1.364340") + + +def test_detect_ovrtx_version_returns_none_when_uninstalled(monkeypatch: pytest.MonkeyPatch): + def _missing(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", _missing) + assert detect_ovrtx_version() is None + + +def test_detect_ovrtx_version_returns_none_for_unparseable_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "internal-build") + assert detect_ovrtx_version() is None + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + (None, False), + (Version("0.4.1.364340"), False), + (Version("0.4.2"), False), + (Version("0.5"), True), + (Version("0.5.0.12345"), True), + (Version("1.0"), True), + ], +) +def test_uses_prim_path_render_vars_switches_at_ovrtx_05(version: Version | None, expected: bool): + assert uses_prim_path_render_vars(version) is expected + + +@pytest.mark.parametrize("version", [None, Version("0.4.1.364340")]) +def test_ovrtx_04_frame_keys_are_source_names(version: Version | None): + keys = build_render_var_frame_keys(version) + assert keys["LdrColor"] == "LdrColor" + assert keys["DiffuseAlbedoSD"] == "DiffuseAlbedoSD" + assert keys["SemanticIdMap"] == "SemanticIdMap" + + +def test_ovrtx_05_frame_keys_are_render_var_prim_paths(): + keys = build_render_var_frame_keys(Version("0.5")) + # The prim path is not always the source name with a prefix, so it is read from the authored paths. + assert keys["LdrColor"] == "/Render/Vars/LdrColor" + assert keys["DiffuseAlbedoSD"] == "/Render/Vars/albedo" + assert keys["DistanceToImagePlaneSD"] == "/Render/Vars/depth" + assert keys["SemanticSegmentation"] == "/Render/Vars/semantic" + assert keys["SemanticIdMap"] == "/Render/Vars/SemanticIdMap" + + +def test_frame_keys_cover_every_authored_render_var(): + """Every source the renderer can read has a key, so lookups cannot silently miss an AOV.""" + sources = set(render_var_prim_paths_by_source()) + assert set(build_render_var_frame_keys(Version("0.5"))) == sources + assert set(build_render_var_frame_keys(None)) == sources + + +def test_installed_frame_keys_match_the_installed_version(): + """The published mapping is baked from the installed OVRTX version at import.""" + assert dict(RENDER_VAR_FRAME_KEYS) == dict(build_render_var_frame_keys(detect_ovrtx_version())) + + +def test_published_frame_keys_are_read_only(): + with pytest.raises(TypeError): + RENDER_VAR_FRAME_KEYS["LdrColor"] = "mutated" # type: ignore[index] diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index f2b298eeac6d..5291690e7310 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -34,6 +34,7 @@ ovrtx_mapping, # noqa: E402 ) from isaaclab_ov.renderers import ovrtx_renderer as ovrtx_renderer_module # noqa: E402 + from isaaclab_ov.renderers.ovrtx_compat import RENDER_VAR_FRAME_KEYS # noqa: E402 from isaaclab_ov.renderers.ovrtx_renderer import ( # noqa: E402 _DISABLE_LINUX_CUDA_CPU_SYNC_ENV, OVRTXRenderData, @@ -49,6 +50,7 @@ ovrtx_use_ovstage_enabled = None _DISABLE_LINUX_CUDA_CPU_SYNC_ENV = None _gpu_side_render_var_sync_enabled = None + RENDER_VAR_FRAME_KEYS = None _SPAWN = PinholeCameraCfg( focal_length=24.0, @@ -211,7 +213,7 @@ def map(self, *args, **kwargs): raise AssertionError("PPISP RGBA output must not read OVRTX LdrColor") class Frame: - render_vars = {"LdrColor": FailingRenderVar()} + render_vars = {RENDER_VAR_FRAME_KEYS["LdrColor"]: FailingRenderVar()} renderer = _make_ovrtx_renderer_without_backend() render_data = _make_ovrtx_render_data() @@ -220,6 +222,29 @@ class Frame: renderer._process_render_frame(render_data, Frame(), {"rgba": object()}) +@pytest.mark.parametrize("stale_key", ["LdrColor", "/Render/Vars/LdrColor"]) +def test_ovrtx_process_frame_reads_only_the_installed_ldr_color_key(monkeypatch: pytest.MonkeyPatch, stale_key: str): + """Frames are keyed by source name on OVRTX 0.4 and by prim path on 0.5; only one form is read.""" + installed_key = RENDER_VAR_FRAME_KEYS["LdrColor"] + + mapped = [] + + @contextlib.contextmanager + def fake_map(self, render_var): + mapped.append(render_var) + yield object() + + monkeypatch.setattr(OVRTXRenderer, "_map_render_var_to_dlpack", fake_map) + monkeypatch.setattr(OVRTXRenderer, "_extract_rgba_tiles", lambda *args, **kwargs: None) + + class Frame: + render_vars = {stale_key: "stale", installed_key: "installed"} + + renderer = _make_ovrtx_renderer_without_backend() + renderer._process_render_frame(_make_ovrtx_render_data(), Frame(), {"rgba": object()}) + assert mapped == ["installed"] + + def test_ovrtx_ppisp_hdr_source_is_cloned_to_output_device(monkeypatch): """PPISP HdrColor source is moved to the HDR output buffer device.""" diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index a8ce07384e61..d45cc07e059b 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -29,6 +29,7 @@ export_stage_to_string, get_render_var_config, get_render_var_configs, + render_var_prim_paths_by_source, ) from pxr import Sdf, Usd, UsdGeom # noqa: E402 @@ -42,6 +43,7 @@ export_stage_to_string = None get_render_var_config = None get_render_var_configs = None + render_var_prim_paths_by_source = None def _make_multi_env_stage(num_envs: int) -> Usd.Stage: @@ -114,6 +116,18 @@ def test_ovrtx_rgb_hdr_uses_hdr_color_render_var(): assert get_render_var_config(["rgb_hdr"]) == ("/Render/Vars/HdrColor", "HdrColor", "HdrColor") +def test_render_var_prim_paths_cover_every_authored_render_var(): + """Every render-var config this module authors is reachable by its source name.""" + prim_paths = render_var_prim_paths_by_source() + + authored = get_render_var_configs( + ["rgb", "rgb_hdr", "albedo", "depth", "distance_to_camera", "normals", "motion_vectors"] + ) + get_render_var_configs(["semantic_segmentation", "instance_segmentation"]) + + for path, _, source in authored: + assert prim_paths[source] == path + + def test_ovrtx_instance_segmentation_uses_non_stable_instance_segmentation_render_var(): """Requesting instance_segmentation from OVRTX selects the NonStableInstanceSegmentation render var.""" assert get_render_var_config(["instance_segmentation"]) == ( @@ -416,8 +430,8 @@ def test_export_stage_without_keep_env_roots_trims_non_source_env_roots(): """The ovstage clone path also trims the non-source env roots themselves. ``ovstage.Stage.clone`` requires every target path to not already exist, so the exported stage - must not retain env roots that the clone will recreate. This is the only difference from the - legacy ``renderer.clone_usd`` path, which keeps the roots as placeholders. + must not retain env roots that the clone will recreate. The legacy ``renderer.clone_usd`` + path keeps the roots as placeholders. """ num_envs = 4 stage = _make_multi_env_stage(num_envs) @@ -480,6 +494,44 @@ def test_export_stage_restores_active_state(): assert stage.GetPrimAtPath(f"{env_path}/Object_env{env_idx}_only").IsActive() +@pytest.mark.parametrize( + ("num_envs", "keep_env_roots", "keep_instances"), + [(4, True, False), (4, False, True), (1, True, True), (1, False, True)], +) +def test_export_stage_expands_nested_instances_only_for_legacy_cloning(num_envs, keep_env_roots, keep_instances): + """Legacy cloning receives concrete geometry without changing the simulation's instancing.""" + stage = _make_multi_env_stage(num_envs) + mesh = UsdGeom.Mesh.Define(stage, "/MeshPrototype/Mesh") + mesh.CreatePointsAttr([(0, 0, 0), (1, 0, 0), (0, 1, 0)]) + mesh.CreateFaceVertexCountsAttr([3]) + mesh.CreateFaceVertexIndicesAttr([0, 1, 2]) + visuals = UsdGeom.Xform.Define(stage, "/RobotPrototype/Visuals").GetPrim() + visuals.GetReferences().AddInternalReference("/MeshPrototype") + visuals.SetInstanceable(True) + robot_path = "/World/envs/env_0/Robot" + robot = stage.GetPrimAtPath(robot_path) + robot.GetReferences().AddInternalReference("/RobotPrototype") + robot.SetInstanceable(True) + mesh_path = f"{robot_path}/Visuals/Mesh" + assert robot.IsInstance() + assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy() + input_layers = [layer.ExportToString() for layer in stage.GetLayerStack()] + + exported = export_stage_to_string(stage, num_envs, ("/World/envs/env_0",), keep_env_roots) + exported_layer = Sdf.Layer.CreateAnonymous("exported.usda") + exported_layer.ImportFromString(exported) + exported_stage = Usd.Stage.Open(exported_layer) + + assert exported_stage.GetPrimAtPath(robot_path).IsInstance() == keep_instances + assert exported_stage.GetPrimAtPath(f"{robot_path}/Visuals").IsInstanceable() == keep_instances + exported_mesh = exported_stage.GetPrimAtPath(mesh_path) + assert exported_mesh.IsInstanceProxy() == keep_instances + assert UsdGeom.Mesh(exported_mesh).GetPointsAttr().Get() == mesh.GetPointsAttr().Get() + assert [layer.ExportToString() for layer in stage.GetLayerStack()] == input_layers + assert robot.IsInstance() + assert stage.GetPrimAtPath(mesh_path).IsInstanceProxy() + + def test_create_scene_partition_attributes_all_envs(): """Scene partition attributes are authored on every env root and camera.""" num_envs = 4 diff --git a/source/isaaclab_ov/test/test_ovstage_compat.py b/source/isaaclab_ov/test/test_ovstage_compat.py new file mode 100644 index 000000000000..fb0cbe0eb77b --- /dev/null +++ b/source/isaaclab_ov/test/test_ovstage_compat.py @@ -0,0 +1,145 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for OVStage 0.1 / 0.2 hierarchy computation model compatibility.""" + +from __future__ import annotations + +import importlib.metadata +import importlib.util +import logging + +import pytest +from packaging.version import Version + +_REQUIRED_MODULES = ("isaaclab_ov",) +_MISSING_MODULES = [module for module in _REQUIRED_MODULES if importlib.util.find_spec(module) is None] + +pytestmark = [ + pytest.mark.isaacsim_ci, + pytest.mark.skipif( + bool(_MISSING_MODULES), + reason=f"requires optional modules: {', '.join(_MISSING_MODULES)}", + ), +] + +if not _MISSING_MODULES: + from isaaclab_ov.ovstage_compat import ( # noqa: E402 + HIERARCHY_COMPUTATION_MODEL, + detect_ovstage_version, + resolve_hierarchy_computation_model, + supports_gpu_hierarchy_computation, + ) +else: + HIERARCHY_COMPUTATION_MODEL = None + detect_ovstage_version = None + resolve_hierarchy_computation_model = None + supports_gpu_hierarchy_computation = None + + +def test_detect_ovstage_version_reads_distribution_metadata(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.1.1.355824") + assert detect_ovstage_version() == Version("0.1.1.355824") + + +def test_detect_ovstage_version_returns_none_when_uninstalled(monkeypatch: pytest.MonkeyPatch): + def _missing(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", _missing) + assert detect_ovstage_version() is None + + +def test_detect_ovstage_version_returns_none_for_unparseable_version(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(importlib.metadata, "version", lambda name: "internal-build") + assert detect_ovstage_version() is None + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + (None, False), + (Version("0.1.0.346039"), False), + (Version("0.1.1.355824"), False), + (Version("0.1.2"), False), + (Version("0.2"), True), + (Version("0.2.0.377349"), True), + (Version("1.0"), True), + ], +) +def test_supports_gpu_hierarchy_computation_switches_at_ovstage_02(version: Version | None, expected: bool): + assert supports_gpu_hierarchy_computation(version) is expected + + +@pytest.mark.parametrize("version", [None, Version("0.1.1.355824")]) +def test_ovstage_01_uses_the_host_hierarchy_model(version: Version | None): + """OVStage 0.1 places objects out of position under the device model, so it must stay on the host.""" + assert resolve_hierarchy_computation_model(version) == "CPU_INCREMENTAL" + + +@pytest.mark.parametrize("version", [Version("0.2"), Version("0.2.0.377349"), Version("1.0")]) +def test_ovstage_02_uses_the_device_hierarchy_model(version: Version): + assert resolve_hierarchy_computation_model(version) == "GPU_INCREMENTAL" + + +def test_installed_model_matches_the_installed_version(): + """The published name is baked from the installed OVStage version at import.""" + assert resolve_hierarchy_computation_model(detect_ovstage_version()) == HIERARCHY_COMPUTATION_MODEL + + +@pytest.mark.skipif(importlib.util.find_spec("ovstage") is None, reason="requires optional module: ovstage") +def test_published_model_names_exist_on_the_installed_enum(): + """Both names must resolve, since :func:`create_ovstage` looks the published one up by name.""" + import ovstage + + for name in ("CPU_INCREMENTAL", "GPU_INCREMENTAL"): + assert isinstance(getattr(ovstage.HierarchyComputationModel, name), ovstage.HierarchyComputationModel) + + +@pytest.fixture() +def captured_stage_config(monkeypatch: pytest.MonkeyPatch): + """Replace stage construction with fakes and yield the requested hierarchy model. + + :func:`~isaaclab_ov.stage.create_ovstage` reaches ovstage through module attributes, so + patching them intercepts the config without building a real process-scoped stage. + """ + import ovstage + + captured: dict[str, object] = {} + + def fake_stage_config(*, runtime_default_hierarchy_computation_model): + captured["model"] = runtime_default_hierarchy_computation_model + return object() + + monkeypatch.setattr(ovstage, "StageConfig", fake_stage_config) + monkeypatch.setattr(ovstage, "Stage", lambda name, config: object()) + return captured + + +@pytest.mark.skipif(importlib.util.find_spec("ovstage") is None, reason="requires optional module: ovstage") +def test_create_ovstage_requests_the_selected_model(monkeypatch: pytest.MonkeyPatch, captured_stage_config: dict): + import ovstage + from isaaclab_ov import stage as stage_module + + monkeypatch.setattr(stage_module, "HIERARCHY_COMPUTATION_MODEL", "GPU_INCREMENTAL") + stage_module.create_ovstage("compat.selected") + + assert captured_stage_config["model"] is ovstage.HierarchyComputationModel.GPU_INCREMENTAL + + +@pytest.mark.skipif(importlib.util.find_spec("ovstage") is None, reason="requires optional module: ovstage") +def test_create_ovstage_falls_back_to_the_host_model_for_an_unknown_name( + monkeypatch: pytest.MonkeyPatch, captured_stage_config: dict, caplog: pytest.LogCaptureFixture +): + """A model this ovstage does not carry must degrade to the host model, not fail the stage.""" + import ovstage + from isaaclab_ov import stage as stage_module + + monkeypatch.setattr(stage_module, "HIERARCHY_COMPUTATION_MODEL", "MODEL_FROM_A_FUTURE_OVSTAGE") + with caplog.at_level(logging.WARNING, logger=stage_module.__name__): + stage_module.create_ovstage("compat.fallback") + + assert captured_stage_config["model"] is ovstage.HierarchyComputationModel.CPU_INCREMENTAL + assert "MODEL_FROM_A_FUTURE_OVSTAGE" in caplog.text diff --git a/source/isaaclab_physx/changelog.d/antoiner-physx-lazy-contact-loss.rst b/source/isaaclab_physx/changelog.d/antoiner-physx-lazy-contact-loss.rst new file mode 100644 index 000000000000..14d3d2f20722 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/antoiner-physx-lazy-contact-loss.rst @@ -0,0 +1,12 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.sensors.ContactSensor` reporting the last in-contact force forever after a + body left contact on GPU (issue #7613), when the sensor was configured with ``history_length=0`` and its + data was read less often than every physics step (for example once per policy step with + ``lazy_sensor_update=True``). PhysX zeroes the net contact force of a body only on the exact physics step + where its contact is lost, so a lazily refreshed sensor skipped that step. The PhysX getters are now + called on every physics step regardless of the history length, while the warp kernels that consume the + fetched buffers stay lazy. As a consequence, :meth:`~isaaclab_physx.sensors.ContactSensor.update` now + raises a ``RuntimeError`` for such sensors when called inside an outer CUDA graph capture, as it already + did for history-bearing sensors: update the sensor outside the capture. diff --git a/source/isaaclab_physx/changelog.d/fix-isaac-rtx-simple-shading-minimal-mode.rst b/source/isaaclab_physx/changelog.d/fix-isaac-rtx-simple-shading-minimal-mode.rst new file mode 100644 index 000000000000..0521c979277f --- /dev/null +++ b/source/isaaclab_physx/changelog.d/fix-isaac-rtx-simple-shading-minimal-mode.rst @@ -0,0 +1,11 @@ +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_physx.renderers.IsaacRtxRenderer` rendering ``simple_shading_*`` + camera outputs through the full path-tracing pipeline. The renderer selected only the + Minimal shading level, through the process-wide ``/rtx/minimal/mode`` carb setting, while + leaving the render mode at ``RealTimePathTracing``. It now authors + ``omni:rtx:rendermode = "Minimal"`` and ``omni:rtx:minimal:mode`` on the requesting render + product, matching the OVRTX renderer. Cameras requesting different shading levels no longer + overwrite each other, and color cameras, the Kit viewport, and deterministic rendering keep + path tracing. diff --git a/source/isaaclab_physx/changelog.d/ooctipus-clone-plan-contract.major.rst b/source/isaaclab_physx/changelog.d/ooctipus-clone-plan-contract.major.rst new file mode 100644 index 000000000000..46c775d24faa --- /dev/null +++ b/source/isaaclab_physx/changelog.d/ooctipus-clone-plan-contract.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **Breaking:** Routed production PhysX cloning through the simulation-owned + ``PhysxReplicateContext.replicate(plan)`` contract and removed ``PHYSICS_CONTEXT``, ``queue(...)``, + and ``queue_mapping(...)``. Standalone tooling may continue to use ``physx_replicate(...)`` with + NumPy arrays; its unused ``device`` argument was removed. diff --git a/source/isaaclab_physx/isaaclab_physx/cloner/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/cloner/__init__.pyi index 9f93b7418ead..9fc68fd8cf1c 100644 --- a/source/isaaclab_physx/isaaclab_physx/cloner/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/cloner/__init__.pyi @@ -4,9 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "PHYSICS_CONTEXT", "PhysxReplicateContext", "physx_replicate", ] -from .replicate import PHYSICS_CONTEXT, PhysxReplicateContext, physx_replicate +from .replicate import PhysxReplicateContext, physx_replicate diff --git a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py index 600265ef347e..7a0000803c76 100644 --- a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py +++ b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py @@ -6,29 +6,25 @@ from __future__ import annotations from collections.abc import Sequence +from typing import TYPE_CHECKING -import torch +import numpy as np from omni.physx import get_physx_replicator_interface from pxr import Sdf, Usd, UsdUtils from isaaclab import cloner - -def _select_env_ids(env_ids: torch.Tensor, mapping: torch.Tensor, row: int) -> torch.Tensor: - """Return the environment ids selected by a replication row.""" - row_mask = mapping[row] - if row_mask.dtype != torch.bool: - row_mask = row_mask.to(dtype=torch.bool) - return env_ids[row_mask] +if TYPE_CHECKING: + from isaaclab.cloner import ClonePlan class PhysxReplicateContext: - """Queue and run PhysX replication work for one stage.""" + """Apply one clone plan through the PhysX replicator.""" replicate_priority = 0 - def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): + def __init__(self, stage: Usd.Stage): """Initialize the context. Args: @@ -38,65 +34,60 @@ def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): cache = UsdUtils.StageCache.Get() cached_id = cache.GetId(stage) self._stage_id = cached_id.ToLongInt() if cached_id.IsValid() else cache.Insert(stage).ToLongInt() - physics_scene_prim = self.stage.GetPrimAtPath("/physicsScene") - if physics_scene_prim.IsValid(): - physics_scene_prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) - self._queue: list[tuple[str, str, tuple[int, ...]]] = [] - def queue(self, source: str, destination: str, target_envs: Sequence[int]) -> None: - """Queue one PhysX source row for replication. + def replicate(self, plan: ClonePlan) -> None: + """Register the PhysX replicator for this context's plan rows. Args: - source: Source prim path. - destination: Destination path template with ``"{}"`` for env id. - target_envs: Environment ids selected for this source row. + plan: Replication layout shared by every clone backend. """ - self._queue.append((source, destination, tuple(int(env_id) for env_id in target_envs))) - - def queue_mapping( + if plan.env_ids is None: + raise ValueError("ClonePlan.env_ids is required for replication.") + rows = plan.context_rows[type(self)] + native_rows = set(rows) + other_rows = { + row for context, routed in plan.context_rows.items() if context is not type(self) for row in routed + } + self._replicate_mapping( + sources=tuple(plan.sources[row] for row in rows), + destinations=tuple(plan.destinations[row] for row in rows), + env_ids=plan.env_ids, + mapping=plan.clone_mask[list(rows)], + has_usd_only_rows=bool(other_rows - native_rows), + exclude_self_replication=True, + ) + + def _replicate_mapping( self, sources: Sequence[str], destinations: Sequence[str], - env_ids: torch.Tensor, - mapping: torch.Tensor, - *, - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - exclude_self_replication: bool = True, + env_ids: np.ndarray, + mapping: np.ndarray, + has_usd_only_rows: bool, + exclude_self_replication: bool, ) -> None: - """Queue replication rows from the current flat clone mapping. + """Register one raw source-to-environment mapping with PhysX.""" + physx_queue: list[tuple[str, str, tuple[int, ...]]] = [] - Args: - sources: Source prim paths. - destinations: Destination path templates with ``"{}"`` for env id. - env_ids: Environment indices. - mapping: Bool/int mask selecting envs per source. - positions: Optional per-environment world positions [m], unused by PhysX. - quaternions: Optional per-environment orientations, unused by PhysX. - exclude_self_replication: Whether to skip replicating a source prim onto itself - when it also maps to other environments. - """ - del positions, quaternions - - if mapping.size(1) <= 1: + expected_shape = (len(sources), len(env_ids)) + if mapping.shape != expected_shape: + raise ValueError(f"mapping must have shape {expected_shape}, got {mapping.shape}.") + if mapping.shape[1] <= 1: return + native_paths: list[str] = [] + for i, src in enumerate(sources): - worlds = _select_env_ids(env_ids, mapping, i).tolist() + worlds = tuple(map(int, env_ids[np.flatnonzero(mapping[i])])) + if has_usd_only_rows: + native_paths.append(src) + native_paths.extend(destinations[i].format(world) for world in worlds) if exclude_self_replication: matched = cloner.path.match(src, destinations[i]) if matched is not None and matched.instance.isdigit(): - filtered = [w for w in worlds if w != int(matched.instance)] + filtered = tuple(world for world in worlds if world != int(matched.instance)) worlds = filtered if filtered else worlds - self.queue(src, destinations[i], worlds) - - def replicate(self) -> None: - """Register the PhysX replicator and run queued rows from ``attach_end_fn``.""" - if not self._queue: - return - - physx_queue = tuple(self._queue) - self._queue.clear() + physx_queue.append((src, destinations[i], worlds)) # Fully-heterogeneous 1:1 layouts have every source mapped only to its own # environment (no cross-env replication needed). Calling rep.replicate() once @@ -105,105 +96,80 @@ def replicate(self) -> None: # PhysX-internal allocations summing to a problematic total across processes. # For these layouts the source prims are already in their correct env positions # and PhysX can parse them from the stage without any replicator registration. - def _is_self_only(src: str, destination: str, target_envs: tuple[int, ...]) -> bool: - if len(target_envs) != 1: - return False - pre, suf = cloner.path.split(destination) - return src == f"{pre}{target_envs[0]}{suf}" - - if all(_is_self_only(src, dst, envs) for src, dst, envs in physx_queue): + if all(len(envs) == 1 and src == destination.format(envs[0]) for src, destination, envs in physx_queue): return + physics_scene_prim = self.stage.GetPrimAtPath("/physicsScene") + if physics_scene_prim.IsValid(): + physics_scene_prim.CreateAttribute("physxScene:envIdInBoundsBitCount", Sdf.ValueTypeNames.Int).Set(4) + current_worlds: list[int] = [] current_template: str = "" + prefixes = [cloner.path.split(destination)[0] for destination in destinations] + env_namespaces = [ + prefix.rstrip("/") if prefix.endswith("/") else prefix.rsplit("/", 1)[0] for prefix in prefixes + ] + excluded_paths = ( + list(dict.fromkeys(native_paths)) + if has_usd_only_rows + else list(dict.fromkeys(("/World/template", *env_namespaces))) + ) def attach_fn(_stage_id: int): - return ["/World/template", "/World/envs"] + return excluded_paths def rename_fn(_replicate_path: str, i: int): return current_template.format(current_worlds[i]) def attach_end_fn(_stage_id: int): nonlocal current_template - rep = get_physx_replicator_interface() + replicator = get_physx_replicator_interface() for src, destination, target_envs in physx_queue: current_template = destination current_worlds[:] = target_envs if not current_worlds: continue - rep.replicate( + replicator.replicate( _stage_id, src, len(current_worlds), - # TODO: envIds needs to support heterogeneous setup. for now, we rely on USD collision filtering useEnvIds=False, useFabricForReplication=False, ) - rep.unregister_replicator(_stage_id) + replicator.unregister_replicator(_stage_id) get_physx_replicator_interface().register_replicator(self._stage_id, attach_fn, attach_end_fn, rename_fn) -PHYSICS_CONTEXT = PhysxReplicateContext -"""Physics-only replication context for PhysX assets. USD replication is added automatically -by :func:`~isaaclab.cloner.replicate` when the asset has a spawner and Kit is available.""" - - def physx_replicate( stage: Usd.Stage, - sources: Sequence[str], # e.g. ["/World/Template/A", "/World/Template/B"] - destinations: Sequence[str], # e.g. ["/World/envs/env_{}/Robot", "/World/envs/env_{}/Object"] - env_ids: torch.Tensor, # env_ids - mapping: torch.Tensor, # (num_sources, num_envs) bool; True -> place sources[i] into world=j - positions: torch.Tensor | None = None, - quaternions: torch.Tensor | None = None, - device: str = "cpu", + sources: Sequence[str], + destinations: Sequence[str], + env_ids: np.ndarray, + mapping: np.ndarray, + positions: np.ndarray | None = None, + quaternions: np.ndarray | None = None, exclude_self_replication: bool = True, ) -> None: - """Replicate prims via PhysX replicator with per-row mapping. - - Builds per-source destination lists from ``mapping`` and calls PhysX ``replicate``. - The replicator is registered for the call and then unregistered. Heterogeneous - rows currently use ``useEnvIds=False`` and rely on USD collision filtering. - - ``attach_fn`` excludes ``/World/template`` and ``/World/envs`` so that PhysX does - not independently parse prims that the replicator will handle. The source prim - receives its physics body as a side-effect of ``rep.replicate()`` (which always - parses the source internally), so every source must appear in at least one - ``replicate`` call. - - When ``exclude_self_replication`` is True (default), each source environment is - removed from its own replication targets so the replicator only creates bodies at - non-self destinations. If removing self would leave the world list empty (i.e. the - source maps only to its own environment), self is kept so that ``rep.replicate()`` - is still called and the source prim gets its physics body. + """Replicate a raw source-to-environment mapping through PhysX. Args: - stage: USD stage. - sources: Source prim paths (``S``). - destinations: Destination templates (``S``) with ``"{}"`` for env index. - env_ids: Environment indices (``[E]``). - mapping: Bool/int mask (``[S, E]``) selecting envs per source. - positions: Optional positions (unused, for API compatibility). - quaternions: Optional orientations (unused, for API compatibility). - device: Unused legacy argument retained for API compatibility. - exclude_self_replication: If True, skip replicating a source prim onto itself - when the source also maps to other environments. Default is True. - Self-only sources always keep self so that ``rep.replicate()`` fires. - - Returns: - None + stage: USD stage containing the source prims. + sources: Source prim paths, one per mapping row. + destinations: Destination templates containing ``"{}"``, one per mapping row. + env_ids: Integer environment identifiers, shape ``[num_envs]``. + mapping: Boolean source-to-environment selection, shape ``[len(sources), num_envs]``. + positions: Optional environment positions [m], shape ``[num_envs, 3]``. Unused by PhysX. + quaternions: Optional environment orientations in xyzw order, shape ``[num_envs, 4]``. Unused by PhysX. + exclude_self_replication: Whether to omit a source environment from its own targets. """ - del device - - ctx = PhysxReplicateContext(stage) - ctx.queue_mapping( - sources, - destinations, - env_ids, - mapping, - positions=positions, - quaternions=quaternions, + del positions, quaternions + context = PhysxReplicateContext(stage) + context._replicate_mapping( + sources=sources, + destinations=destinations, + env_ids=env_ids, + mapping=mapping, + has_usd_only_rows=False, exclude_self_replication=exclude_self_replication, ) - ctx.replicate() diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 2b9c267b4eca..3418545ec574 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -45,6 +45,8 @@ ) from isaaclab.utils.string import to_camel_case +from isaaclab_physx.cloner import PhysxReplicateContext + if TYPE_CHECKING: from isaaclab.sim.simulation_context import SimulationContext @@ -374,6 +376,8 @@ class PhysxManager(PhysicsManager): Lifecycle: initialize() -> reset() -> step() (repeated) -> close() """ + clone_context_type = PhysxReplicateContext + _cfg: ClassVar[PhysxCfg | None] = None supports_anim_recording: ClassVar[bool] = True @@ -426,6 +430,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: super().initialize(sim_context) cls._stage_id = get_current_stage_id() + sim_context.get_or_create_backend(cls.clone_context_type, sim_context.stage) cls._setup_subscriptions() cls._configure_physics() diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index ba8f86ac88c7..7a9122ebf058 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -65,9 +65,8 @@ def _raise_missing_ppisp_error(exc: ModuleNotFoundError) -> NoReturn: # RTX simple-shading constants. # -# Simple shading is driven by Kit's RTX "Minimal" render mode via the -# ``/rtx/minimal/mode`` carb setting (key ``omni:rtx:minimal:mode``), with -# integer values: +# Simple shading requires Kit's RTX "Minimal" render mode. Its shading level is +# selected by an integer: # 0 = No Rendering (black output; only other AOVs are produced) # 1 = Constant Diffuse (single constant color for all surfaces) # 2 = Texture Diffuse (diffuse shading using texture colors) @@ -81,7 +80,14 @@ def _raise_missing_ppisp_error(exc: ModuleNotFoundError) -> NoReturn: "simple_shading_diffuse_mdl": 2, "simple_shading_full_mdl": 3, } -SIMPLE_SHADING_MODE_SETTING = "/rtx/minimal/mode" + +# Render-product attributes Kit maps the ``/rtx/rendermode`` and ``/rtx/minimal/mode`` carb +# settings onto (``OmniRtxSettingsCommonAPI_1`` and ``OmniRtxSettingsMinimalAPI_1``). Authoring +# them per render product keeps the process-wide settings — and therefore every other camera and +# the Kit viewport — on their configured render mode. +RTX_RENDER_MODE_ATTR = "omni:rtx:rendermode" +RTX_MINIMAL_MODE_ATTR = "omni:rtx:minimal:mode" +RTX_MINIMAL_RENDER_MODE = "Minimal" def _camera_semantic_filter_predicate(semantic_filter: str | list[str]) -> str: @@ -259,14 +265,16 @@ def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: settings = get_settings_manager() isaac_sim_version = get_isaac_sim_version() + simple_shading_mode = None + needs_color_render = False if isaac_sim_version.major >= 6: + simple_shading_mode = self._resolve_simple_shading_mode(spec) needs_color_render = any( data_type in spec.cfg.data_types for data_type in ("rgb", "rgba", str(RenderBufferKind.RGB_HDR)) ) - if not needs_color_render: - settings.set_bool("/rtx/sdg/force/disableColorRender", True) - if settings.get("/isaaclab/has_gui"): - settings.set_bool("/rtx/sdg/force/disableColorRender", False) + has_gui = settings.get("/isaaclab/has_gui") + if simple_shading_mode is None and (not needs_color_render or has_gui): + settings.set_bool("/rtx/sdg/force/disableColorRender", not needs_color_render and not has_gui) else: unsupported = [] if "albedo" in spec.cfg.data_types: @@ -337,14 +345,10 @@ def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: ) # Register simple shading if needed - if any(data_type in SIMPLE_SHADING_MODES for data_type in spec.cfg.data_types): + if simple_shading_mode is not None: rep.AnnotatorRegistry.register_annotator_from_aov( aov=SIMPLE_SHADING_AOV, output_data_type=np.uint8, output_channels=4 ) - # Set simple shading mode (if requested) before rendering - simple_shading_mode = self._resolve_simple_shading_mode(spec) - if simple_shading_mode is not None: - get_settings_manager().set_int(SIMPLE_SHADING_MODE_SETTING, simple_shading_mode) needs_hdr_color = str(RenderBufferKind.RGB_HDR) in spec.cfg.data_types or ( spec.cfg.isp_cfg is not None and any(data_type in ("rgb", "rgba") for data_type in spec.cfg.data_types) @@ -421,6 +425,15 @@ def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: for annotator in annotators.values(): annotator.attach([rp.path]) + # Annotator attachment may resynchronize process-wide RTX settings onto the product. + if simple_shading_mode is not None: + self._apply_simple_shading_settings( + stage, + rp.path, + simple_shading_mode, + enable_minimal_render_mode=not needs_color_render, + ) + ppisp_pipeline = None if spec.cfg.isp_cfg is not None: try: @@ -437,6 +450,48 @@ def create_render_data(self, spec: CameraRenderSpec) -> IsaacRtxRenderData: ppisp_pipeline=ppisp_pipeline, ) + def _apply_simple_shading_settings( + self, + stage: Usd.Stage, + render_product_path: str, + shading_mode: int, + *, + enable_minimal_render_mode: bool, + ) -> None: + """Configure one render product for the requested simple-shading level. + + Simple shading only becomes cheaper than a full render when the render product's render + mode is Minimal. Selecting a shading level while the product stays in + ``RealTimePathTracing`` still pays for the path-tracing pipeline on every frame. + + The shading level is always authored per render product. Minimal render mode is enabled + only when the product has no regular color output, preserving existing ``rgb``, ``rgba``, + and ``rgb_hdr`` behavior for mixed requests. These values are not written through their + process-wide carb settings, so color cameras, the Kit viewport, and + :func:`~isaaclab_physx.renderers.isaac_rtx_renderer_utils.apply_isaac_rtx_determinism_settings` + keep path tracing, and so cameras requesting different shading levels do not overwrite + each other. + + Args: + stage: Stage owning the render product. + render_product_path: Prim path of the render product to configure. + shading_mode: Minimal shading level, one of the values in :data:`SIMPLE_SHADING_MODES`. + enable_minimal_render_mode: Whether to switch the render product to RTX Minimal mode. + """ + rp_prim = stage.GetPrimAtPath(render_product_path) + if rp_prim is None or not rp_prim.IsValid(): + logger.warning( + "create_render_data: render product prim at '%s' not found; simple-shading settings will not be" + " applied and output may use default shading at full cost.", + render_product_path, + ) + return + with Usd.EditContext(stage, stage.GetSessionLayer()): + with Sdf.ChangeBlock(): + if enable_minimal_render_mode: + rp_prim.CreateAttribute(RTX_RENDER_MODE_ATTR, Sdf.ValueTypeNames.Token).Set(RTX_MINIMAL_RENDER_MODE) + rp_prim.CreateAttribute(RTX_MINIMAL_MODE_ATTR, Sdf.ValueTypeNames.Int).Set(shading_mode) + def _resolve_simple_shading_mode(self, spec: CameraRenderSpec) -> int | None: """Resolve the requested simple shading mode from data types.""" requested = [dt for dt in spec.cfg.data_types if dt in SIMPLE_SHADING_MODES] diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py index f46bbde43c51..f6fad26f1fb0 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/contact_sensor/contact_sensor.py @@ -190,6 +190,31 @@ def contact_view(self) -> physx.RigidContactView: Operations """ + def update(self, dt: float, force_recompute: bool = False) -> None: + """Advances the sensor timestamp and refreshes the PhysX contact buffers. + + PhysX zeroes the net contact force of a body only on the physics step where its contact + is lost. A sensor that is refreshed lazily (on data access) would skip that step and keep + reporting the last in-contact force until the next touchdown, so the PhysX getters are + called on every physics step. The warp kernels that turn those buffers into sensor data + remain lazy and only run when :attr:`data` is accessed. The body poses have no such + transient and are only read on data access. + + Args: + dt: Time elapsed since the previous sensor update [s]. + force_recompute: Whether to recompute the sensor buffers regardless of their + configured update period. Defaults to False. + + Raises: + RuntimeError: If an outer CUDA graph capture is active. The PhysX tensor reads + cannot be graph-captured, so the sensor must be updated outside the capture. + """ + super().update(dt, force_recompute=force_recompute) + # Skip the fetch if the base class already refreshed the buffers on this step, which it + # does when the sensor carries a history buffer. + if self._is_initialized and self._data_generation != self._data_generation_last_update: + self._fetch_physx_buffers(include_pose=False) + def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None) -> None: # resolve env_ids to warp array env_mask = self._resolve_indices_and_mask(env_ids, env_mask) @@ -435,13 +460,8 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): cannot be graph-captured, so replays of such a graph would consume stale contact data. """ + self._raise_if_graph_capturing() device = wp.get_device(self._device) - if device.is_capturing: - raise RuntimeError( - f"Cannot update the contact sensor at '{self.cfg.prim_path}' while a CUDA graph capture" - " is active: the PhysX tensor reads cannot be graph-captured, so replaying the captured" - " graph would consume stale contact data." - ) # Convert env_mask to warp array env_mask = self._resolve_indices_and_mask(None, env_mask) @@ -469,6 +489,19 @@ def _update_buffers_impl(self, env_mask: wp.array | None = None): self._compute_graph = capture.graph wp.capture_launch(self._compute_graph) + def _raise_if_graph_capturing(self) -> None: + """Rejects a PhysX tensor read while an outer CUDA graph capture is active. + + Raises: + RuntimeError: If the sensor's device is capturing a CUDA graph. + """ + if wp.get_device(self._device).is_capturing: + raise RuntimeError( + f"Cannot update the contact sensor at '{self.cfg.prim_path}' while a CUDA graph capture" + " is active: the PhysX tensor reads cannot be graph-captured, so replaying the captured" + " graph would consume stale contact data." + ) + @staticmethod def _checked_view(buffer: wp.array, view: wp.array | None, dtype) -> wp.array: """Returns the cached typed view over ``buffer``, verifying pointer stability. @@ -487,7 +520,7 @@ def _checked_view(buffer: wp.array, view: wp.array | None, dtype) -> wp.array: ) return view - def _fetch_physx_buffers(self) -> None: + def _fetch_physx_buffers(self, include_pose: bool = True) -> None: """Refreshes the contact data from PhysX and lazily builds the warp views over it. The PhysX tensor getters allocate their output buffers once and refresh them in place on @@ -496,7 +529,19 @@ def _fetch_physx_buffers(self) -> None: refreshes the same count and start-index buffers as ``get_contact_data``, the contact-point counts are staged into sensor-owned copies before the friction read overwrites them. + + Args: + include_pose: Whether to also read the body poses when ``track_pose`` is enabled. + The per-step refresh in :meth:`update` skips them since only the contact + buffers carry the contact-loss transient. Defaults to True. + + Raises: + RuntimeError: If an outer CUDA graph capture is active. The PhysX tensor reads + cannot be graph-captured, so replays of such a graph would consume stale + contact data. """ + self._raise_if_graph_capturing() + # PhysX returns (B*N, 3) float32 -> viewed as (B*N,) vec3f, body-major (one view # pattern per body) net_forces = self.contact_view.get_net_contact_forces(dt=self._sim_physics_dt) @@ -508,7 +553,7 @@ def _fetch_physx_buffers(self) -> None: self._force_matrix_flat = self._checked_view(force_matrix, self._force_matrix_flat, wp.vec3f) # PhysX returns (B*N, 7) float32 -> viewed as (B*N,) transformf, body-major - if self.cfg.track_pose: + if self.cfg.track_pose and include_pose: poses = self.body_physx_view.get_transforms() self._poses_flat = self._checked_view(poses, self._poses_flat, wp.transformf) diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 2ab12f11f9ec..4835274e4023 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -9,6 +9,7 @@ import sys import types +from contextlib import nullcontext from types import SimpleNamespace from unittest.mock import MagicMock, call, patch @@ -137,6 +138,128 @@ def test_create_render_data_uses_unique_sdf_safe_render_product_name(monkeypatch assert Sdf.Path.IsValidPathString(f"/Render/{name}") +@pytest.mark.parametrize( + ("data_types", "expected_shading_mode", "expected_minimal_render_mode"), + [ + pytest.param(["simple_shading_constant_diffuse"], 1, True, id="constant_diffuse"), + pytest.param(["simple_shading_diffuse_mdl"], 2, True, id="diffuse_mdl"), + pytest.param(["simple_shading_full_mdl"], 3, True, id="full_mdl"), + pytest.param(["simple_shading_full_mdl", "simple_shading_full_mdl"], 3, True, id="duplicate_full_mdl"), + pytest.param( + ["simple_shading_constant_diffuse", "simple_shading_full_mdl"], + 1, + True, + id="multiple_modes_use_first", + ), + pytest.param(["rgb", "simple_shading_full_mdl"], 3, False, id="rgb_keeps_path_tracing"), + pytest.param(["rgba", "simple_shading_full_mdl"], 3, False, id="rgba_keeps_path_tracing"), + pytest.param(["rgb_hdr", "simple_shading_full_mdl"], 3, False, id="rgb_hdr_keeps_path_tracing"), + ], +) +def test_simple_shading_configures_its_render_product( + monkeypatch, data_types, expected_shading_mode, expected_minimal_render_mode +): + """Simple shading must configure its product without altering requested color output. + + Selecting a shading level alone leaves the product in ``RealTimePathTracing`` and keeps the + full path-tracing cost. Products without regular color output are switched to RTX Minimal; + mixed color products retain path tracing. The shading level is authored on the render product + rather than through process-wide carb settings. + """ + replicator_core_module, syntheticdata_module = _install_omni_stubs(monkeypatch) + monkeypatch.setattr(syntheticdata_module, "SyntheticData", MagicMock(), raising=False) + + import isaaclab_physx.renderers.isaac_rtx_renderer as rtx_renderer + from isaaclab_physx.renderers.isaac_rtx_renderer_cfg import IsaacRtxRendererCfg + + from pxr import Sdf, UsdGeom + + import isaaclab.sim.utils.stage as stage_utils + + settings = MagicMock() + settings.get.return_value = False + + rp = MagicMock() + rp.path = "/Render/OmniverseKit/HydraTextures/rp_test" + + # Record the authored attributes per name; one mock per attribute keeps their ``Set`` calls + # distinguishable. + authored_attributes: dict[str, MagicMock] = {} + operation_order = [] + + def _create_attribute(name, value_type): + operation_order.append("settings") + attribute = MagicMock(value_type=value_type) + authored_attributes[name] = attribute + return attribute + + render_product_prim = MagicMock() + render_product_prim.IsValid.return_value = True + render_product_prim.CreateAttribute.side_effect = _create_attribute + + camera_prim = MagicMock() + camera_prim.IsA.side_effect = lambda typ: typ is UsdGeom.Camera + + stage = MagicMock() + stage.GetPrimAtPath.side_effect = lambda path: render_product_prim if path == rp.path else camera_prim + + annotator = MagicMock() + annotator.attach.side_effect = lambda *_args: operation_order.append("attach") + registry = MagicMock() + registry.get_annotator.return_value = annotator + replicator_core_module.create = SimpleNamespace(render_product_tiled=MagicMock(return_value=rp)) + replicator_core_module.AnnotatorRegistry = registry + + spec = SimpleNamespace( + camera_prim_paths=["/World/envs/env_0/Camera"], + device="cpu", + cfg=SimpleNamespace( + data_types=data_types, + width=64, + height=64, + isp_cfg=None, + colorize_semantic_segmentation=False, + colorize_instance_segmentation=False, + colorize_instance_id_segmentation=False, + ), + ) + renderer = rtx_renderer.IsaacRtxRenderer.__new__(rtx_renderer.IsaacRtxRenderer) + renderer.cfg = IsaacRtxRendererCfg() + + with ( + patch.object(rtx_renderer, "get_settings_manager", return_value=settings), + patch.object(rtx_renderer, "get_isaac_sim_version", return_value=version.parse("6.0")), + patch.object(stage_utils, "get_current_stage", return_value=stage), + patch.object(rtx_renderer.Usd, "EditContext", return_value=nullcontext()), + ): + renderer.create_render_data(spec) + + assert operation_order.index("settings") > max( + index for index, operation in enumerate(operation_order) if operation == "attach" + ) + stage.GetSessionLayer.assert_called_once() + if expected_minimal_render_mode: + assert authored_attributes["omni:rtx:rendermode"].Set.call_args == call("Minimal") + assert authored_attributes["omni:rtx:rendermode"].value_type == Sdf.ValueTypeNames.Token + else: + assert "omni:rtx:rendermode" not in authored_attributes + assert authored_attributes["omni:rtx:minimal:mode"].Set.call_args == call(expected_shading_mode) + assert authored_attributes["omni:rtx:minimal:mode"].value_type == Sdf.ValueTypeNames.Int + + # The shading level must not leak into process-wide state, where the last camera would win. + global_setting_calls = [ + setting_call + for setting_call in ( + *settings.set_int.call_args_list, + *settings.set.call_args_list, + *settings.set_bool.call_args_list, + ) + if setting_call.args + and setting_call.args[0] in ("/rtx/minimal/mode", "/rtx/rendermode", "/rtx/sdg/force/disableColorRender") + ] + assert global_setting_calls == [] + + def test_render_product_uuid_name_format_is_sdf_safe(): """``rp_{uuid4().hex}`` matches the create_render_data naming contract and is SDF-safe.""" import uuid diff --git a/source/isaaclab_physx/test/sensors/check_contact_sensor.py b/source/isaaclab_physx/test/sensors/check_contact_sensor.py index ad2d79ed5a2c..fc74cbe4a057 100644 --- a/source/isaaclab_physx/test/sensors/check_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/check_contact_sensor.py @@ -34,6 +34,7 @@ """Rest everything follows.""" +import numpy as np import torch import isaaclab.sim as sim_utils @@ -84,8 +85,8 @@ def main(): # Create environment clones using Lab's cloner utilities num_envs = args_cli.num_robots env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0) # Everything under the namespace "/World/envs/env_0" will be cloned sim.stage.DefinePrim("/World/envs/env_0", "Xform") # Clone the scene diff --git a/source/isaaclab_physx/test/sensors/check_pva_sensor.py b/source/isaaclab_physx/test/sensors/check_pva_sensor.py index b7df51fb23c7..36ac788b7be8 100644 --- a/source/isaaclab_physx/test/sensors/check_pva_sensor.py +++ b/source/isaaclab_physx/test/sensors/check_pva_sensor.py @@ -38,6 +38,7 @@ import logging import traceback +import numpy as np import torch from isaaclab_physx.renderers.kit_viewport_utils import _set_kit_camera_view @@ -73,8 +74,8 @@ def design_scene(sim: SimulationContext, num_envs: int = 2048) -> RigidObject: # Create interface to clone the scene # Create environment clones using Lab's cloner utilities env_fmt = "/World/envs/env_{}" - env_ids = torch.arange(num_envs, dtype=torch.long, device=sim.device) - env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0, device=sim.device) + env_ids = np.arange(num_envs, dtype=np.int64) + env_origins, _ = lab_cloner.grid_transforms(num_envs, spacing=2.0) envs_prim_paths = [f"/World/envs/env_{i}" for i in range(num_envs)] # create source prim stage.DefinePrim(envs_prim_paths[0], "Xform") diff --git a/source/isaaclab_physx/test/sensors/test_contact_sensor.py b/source/isaaclab_physx/test/sensors/test_contact_sensor.py index 7e7e67fafb90..d9d56b9f8978 100644 --- a/source/isaaclab_physx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_contact_sensor.py @@ -758,12 +758,17 @@ def test_contact_sensor_no_stale_data_after_reset(setup_simulation, device): @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize( ("history_length", "update_period_steps", "expected_fetches", "expected_last_update"), - [(0, 1, 0, 0.0), (3, 1, 4, 0.01), (3, 2, 4, 0.0075)], + [(0, 1, 4, 0.0), (3, 1, 4, 0.01), (3, 2, 4, 0.0075)], ) def test_contact_history_updates_at_sensor_period( setup_simulation, device, history_length, update_period_steps, expected_fetches, expected_last_update ): - """History-bearing sensors update at their period even when the scene is lazy.""" + """History-bearing sensors update at their period even when the scene is lazy. + + The PhysX buffers are fetched on every physics step regardless of the history length (see + issue #7613), but a history-less sensor only recomputes its data (and therefore its + timestamps) on data access. + """ sim_dt, _, _, _, settings = setup_simulation settings.set_bool("/physics/disableContactProcessing", False) with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: @@ -785,10 +790,10 @@ def test_contact_history_updates_at_sensor_period( fetch_count = 0 original_fetch = contact_sensor._fetch_physx_buffers - def count_fetches() -> None: + def count_fetches(**kwargs) -> None: nonlocal fetch_count fetch_count += 1 - original_fetch() + original_fetch(**kwargs) contact_sensor._fetch_physx_buffers = count_fetches @@ -800,7 +805,9 @@ def count_fetches() -> None: assert last_update == pytest.approx(expected_last_update) _ = contact_sensor.data - expected_fetches_after_read = 1 if history_length == 0 else expected_fetches + # A history-less sensor did not recompute during the steps, so the read triggers one + # more fetch; a history-bearing one is already up to date. + expected_fetches_after_read = expected_fetches + 1 if history_length == 0 else expected_fetches assert fetch_count == expected_fetches_after_read air_time = contact_sensor.data.current_air_time.torch.item() expected_air_time = 4 * sim_dt if history_length == 0 else expected_last_update @@ -809,6 +816,60 @@ def count_fetches() -> None: assert fetch_count == expected_fetches_after_read +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_lazy_sensor_reports_contact_loss(setup_simulation, device): + """Regression for issue #7613: a lazily read sensor must report the loss of contact. + + PhysX zeroes a body's net contact force only on the exact physics step where the contact + is lost. With ``history_length=0`` and ``lazy_sensor_update=True`` the sensor used to call + its PhysX getters only when :attr:`ContactSensor.data` was accessed, so a policy-rate + reader (one read per four physics steps) skipped the contact-loss step and kept reporting + the last in-contact force until the next touchdown. + """ + sim_dt, _, _, _, settings = setup_simulation + settings.set_bool("/physics/disableContactProcessing", False) + with build_simulation_context(device=device, dt=sim_dt, add_lighting=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=True) + scene_cfg.terrain = FLAT_TERRAIN_CFG + scene_cfg.shape = CUBE_CFG + scene_cfg.contact_sensor = ContactSensorCfg( + prim_path=CUBE_CFG.prim_path, + track_pose=True, + update_period=0.0, + track_air_time=True, + history_length=0, + ) + scene = InteractiveScene(scene_cfg) + sim.reset() + + sensor: ContactSensor = scene["contact_sensor"] + shape: RigidObject = scene["shape"] + contact_pose = CUBE_CFG.contact_pose.to(device=shape.device).unsqueeze(0) + non_contact_pose = CUBE_CFG.non_contact_pose.to(device=shape.device).unsqueeze(0) + + # Mimic a policy stepping the environment with a decimation of 4: the sensor data is + # read once per policy step and left untouched in between. + decimation = 4 + num_policy_steps = 6 + + def run_policy_step(root_pose: torch.Tensor) -> float: + """Holds the shape at ``root_pose`` for one policy step and reads the sensor once.""" + for _ in range(decimation): + shape.write_root_pose_to_sim_index(root_pose=root_pose) + _perform_sim_step(sim, scene, sim_dt) + return torch.linalg.norm(sensor.data.net_normal_forces_w.torch, dim=-1).max().item() + + contact_forces = [run_policy_step(contact_pose) for _ in range(num_policy_steps)] + air_forces = [run_policy_step(non_contact_pose) for _ in range(num_policy_steps)] + + # Guard against a vacuous test: the sensor must have reported contact while on the ground. + assert contact_forces[-1] > 0.1, f"Expected a contact force on the ground; got {contact_forces}" + # The first read in the air may still carry the impulse of the last contact step, so only + # the subsequent reads are required to be free of contact. + assert max(air_forces[1:]) < 0.1, f"Stale contact force reported in the air: {air_forces}" + + @pytest.mark.isaacsim_ci def test_sensor_print(setup_simulation): """Test sensor print is working correctly.""" diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index 3fe472c8d5aa..aadd0868f1d9 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -14,6 +14,7 @@ """Rest everything follows.""" +import numpy as np import pytest import torch import warp as wp @@ -21,6 +22,7 @@ import isaaclab.sim as sim_utils from isaaclab.cloner import ( + ClonePlan, _fabric_notices, disabled_fabric_change_notifies, sequential, @@ -29,7 +31,7 @@ from isaaclab.sim import build_simulation_context -def _make_flat_clone_plan(num_variants: int, num_clones: int, destination: str, device: str): +def _make_flat_clone_plan(num_variants: int, num_clones: int, destination: str): """Build a flat (sources, destinations, clone_mask) tuple for tests using sequential mapping. The PhysX test_cloner tests intentionally bypass cfg-driven planning and exercise @@ -37,13 +39,9 @@ def _make_flat_clone_plan(num_variants: int, num_clones: int, destination: str, captures the small amount of flat-plan logic the tests need without re-introducing the legacy ``make_clone_plan(sources, destinations, num_clones, ...)`` signature. """ - chosen = sequential( - torch.arange(num_variants, dtype=torch.long, device=device).unsqueeze(1), - num_clones, - device, - ).view(-1) - mask = torch.zeros((num_variants, num_clones), dtype=torch.bool, device=device) - mask[chosen, torch.arange(num_clones, device=device)] = True + chosen = sequential(np.arange(num_variants, dtype=np.int64)[:, None], num_clones).reshape(-1) + mask = np.zeros((num_variants, num_clones), dtype=np.bool_) + mask[chosen, np.arange(num_clones)] = True sources = tuple(destination.format(i) for i in range(num_variants)) destinations = tuple([destination] * num_variants) return sources, destinations, mask @@ -68,11 +66,11 @@ def test_physx_replicate_no_error(sim): sim_utils.create_prim("/World/template/A", "Xform") num_envs = 2 - env_ids = torch.arange(num_envs, dtype=torch.long) + env_ids = np.arange(num_envs, dtype=np.int64) for i in range(num_envs): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform") - mapping = torch.ones((1, num_envs), dtype=torch.bool) + mapping = np.ones((1, num_envs), dtype=np.bool_) physx_replicate( sim_utils.get_current_stage(), @@ -83,6 +81,19 @@ def test_physx_replicate_no_error(sim): ) +def test_physx_replicate_validates_mapping_shape(sim): + """Mapping rows and columns match the declared sources and environments.""" + env_ids = np.arange(2, dtype=np.int64) + with pytest.raises(ValueError, match="mapping must have shape"): + physx_replicate( + sim_utils.get_current_stage(), + sources=["/World/template/A"], + destinations=["/World/envs/env_{}/A"], + env_ids=env_ids, + mapping=np.ones((1, len(env_ids) + 1), dtype=np.bool_), + ) + + def _make_mock_physx_rep(): """Return (mock_rep, replicate_calls) where replicate_calls accumulates num_worlds per call. @@ -127,8 +138,8 @@ def _fake_register(_stage_id, attach_fn, attach_end_fn, rename_fn): return mock_rep, replicate_calls, attach_excluded -def test_physx_replicate_context_queue_and_replicate(sim): - """PhysxReplicateContext queues mapping rows and replicates them through attach_end_fn.""" +def test_physx_replicate_context_consumes_plan(sim): + """PhysxReplicateContext reads its mapping from the shared clone plan.""" from unittest.mock import patch stage = sim_utils.get_current_stage() @@ -139,13 +150,14 @@ def test_physx_replicate_context_queue_and_replicate(sim): mock_rep, replicate_calls = _make_mock_physx_rep() with patch("isaaclab_physx.cloner.replicate.get_physx_replicator_interface", return_value=mock_rep): ctx = PhysxReplicateContext(stage) - ctx.queue_mapping( - sources=["/World/envs/env_0/Object"], - destinations=["/World/envs/env_{}/Object"], - env_ids=torch.arange(3, dtype=torch.long), - mapping=torch.ones((1, 3), dtype=torch.bool), + plan = ClonePlan( + sources=("/World/envs/env_0/Object",), + destinations=("/World/envs/env_{}/Object",), + clone_mask=np.ones((1, 3), dtype=np.bool_), + env_ids=np.arange(3, dtype=np.int64), + context_rows={PhysxReplicateContext: (0,)}, ) - ctx.replicate() + ctx.replicate(plan) assert replicate_calls == [2] @@ -183,8 +195,8 @@ def test_physx_replicate_world_counts(sim, num_envs, src, expected_worlds): stage, sources=[src], destinations=["/World/envs/env_{}"], - env_ids=torch.arange(num_envs, dtype=torch.long), - mapping=torch.ones((1, num_envs), dtype=torch.bool), + env_ids=np.arange(num_envs, dtype=np.int64), + mapping=np.ones((1, num_envs), dtype=np.bool_), ) assert replicate_calls == expected_worlds, ( @@ -192,8 +204,7 @@ def test_physx_replicate_world_counts(sim, num_envs, src, expected_worlds): ) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_physx_replicate_isolated_source_loaded_without_replication(sim, device): +def test_physx_replicate_isolated_source_loaded_without_replication(sim): """A single-env source (worlds=[self]) is correctly loaded after physx_replicate. When there is only one environment and the source maps to itself, @@ -217,9 +228,8 @@ def test_physx_replicate_isolated_source_loaded_without_replication(sim, device) stage, sources=["/World/envs/env_0/Sphere"], destinations=["/World/envs/env_{}/Sphere"], - env_ids=torch.tensor([0], dtype=torch.long), - mapping=torch.ones((1, 1), dtype=torch.bool), - device=device, + env_ids=np.array([0], dtype=np.int64), + mapping=np.ones((1, 1), dtype=np.bool_), ) sim.reset() @@ -231,8 +241,7 @@ def test_physx_replicate_isolated_source_loaded_without_replication(sim, device) ) -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_physx_replicate_heterogeneous_isolated_sources(sim, device): +def test_physx_replicate_heterogeneous_isolated_sources(sim): """physx_replicate handles heterogeneous sources excluding self from world lists. This is the Lift scenario: multiple object types, each with a designated proto-env. @@ -254,7 +263,7 @@ def test_physx_replicate_heterogeneous_isolated_sources(sim, device): for i in range(num_envs): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform") - mapping = torch.zeros((3, num_envs), dtype=torch.bool) + mapping = np.zeros((3, num_envs), dtype=np.bool_) mapping[0, [0, 2, 4]] = True mapping[1, [5]] = True mapping[2, [7, 11]] = True @@ -265,9 +274,8 @@ def test_physx_replicate_heterogeneous_isolated_sources(sim, device): stage, sources=["/World/envs/env_0/Object", "/World/envs/env_5/Object", "/World/envs/env_7/Object"], destinations=["/World/envs/env_{}/Object"] * 3, - env_ids=torch.arange(num_envs, dtype=torch.long), + env_ids=np.arange(num_envs, dtype=np.int64), mapping=mapping, - device=device, ) expected = [ @@ -318,15 +326,14 @@ def test_direct_clone_plan_multi_asset(sim): num_variants=len(cfg.assets_cfg), num_clones=num_clones, destination="/World/envs/env_{}/Object", - device=sim.cfg.device, ) cfg.spawn_paths = list(sources) prim = cfg.func("/World/unused", cfg) assert prim.IsValid() stage = sim_utils.get_current_stage() - env_ids = torch.arange(num_clones, dtype=torch.long, device=sim.cfg.device) - physx_replicate(stage, sources, destinations, env_ids, clone_mask, device=sim.cfg.device) + env_ids = np.arange(num_clones, dtype=np.int64) + physx_replicate(stage, sources, destinations, env_ids, clone_mask) usd_replicate(stage, sources, destinations, env_ids, clone_mask) primitive_prims = sim_utils.get_all_matching_child_prims( @@ -360,7 +367,6 @@ def _run_colocation_collision_filter(sim, asset_cfg, expected_types, assert_coun num_variants=num_variants, num_clones=num_clones, destination="/World/envs/env_{}/Object", - device=sim.cfg.device, ) if isinstance(asset_cfg, sim_utils.MultiAssetSpawnerCfg): asset_cfg.spawn_paths = list(sources) @@ -370,8 +376,8 @@ def _run_colocation_collision_filter(sim, asset_cfg, expected_types, assert_coun assert prim.IsValid() stage = sim_utils.get_current_stage() - env_ids = torch.arange(num_clones, dtype=torch.long, device=sim.cfg.device) - physx_replicate(stage, sources, destinations, env_ids, clone_mask, device=sim.cfg.device) + env_ids = np.arange(num_clones, dtype=np.int64) + physx_replicate(stage, sources, destinations, env_ids, clone_mask) usd_replicate(stage, sources, destinations, env_ids, clone_mask) primitive_prims = sim_utils.get_all_matching_child_prims( @@ -475,9 +481,9 @@ def _run_sphere_velocity_sim(sim, use_physx_replicate: bool, num_steps: int = 10 ) sphere_cfg.func("/World/envs/env_0/ball", sphere_cfg, translation=(0.0, 0.0, 0.5)) - env_ids = torch.arange(num_envs, dtype=torch.long) - positions = torch.tensor([[0.0, 0.0, 0.0], [spacing, 0.0, 0.0]]) - mapping = torch.ones((1, num_envs), dtype=torch.bool) + env_ids = np.arange(num_envs, dtype=np.int64) + positions = np.array([[0.0, 0.0, 0.0], [spacing, 0.0, 0.0]], dtype=np.float32) + mapping = np.ones((1, num_envs), dtype=np.bool_) if use_physx_replicate: physx_replicate( @@ -486,7 +492,6 @@ def _run_sphere_velocity_sim(sim, use_physx_replicate: bool, num_steps: int = 10 destinations=["/World/envs/env_{}/ball"], env_ids=env_ids, mapping=mapping, - device=sim.cfg.device, ) usd_replicate( diff --git a/source/isaaclab_rl/changelog.d/antoiner-pretrained-checkpoint-errors.minor.rst b/source/isaaclab_rl/changelog.d/antoiner-pretrained-checkpoint-errors.minor.rst new file mode 100644 index 000000000000..536a6351a26a --- /dev/null +++ b/source/isaaclab_rl/changelog.d/antoiner-pretrained-checkpoint-errors.minor.rst @@ -0,0 +1,17 @@ +Fixed +^^^^^ + +* Fixed :func:`~isaaclab_rl.utils.pretrained_checkpoint.get_published_pretrained_checkpoint` reporting + every download failure as ``A pre-trained checkpoint is currently unavailable for this task.``. A + checkpoint the asset server does not provide is still reported that way, but the message now names the + location that was tried, the task and backends it was derived from, and what to do instead. + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab_rl.utils.pretrained_checkpoint.get_published_pretrained_checkpoint` to raise + ``RuntimeError`` when a published checkpoint cannot be downloaded, for instance when the + ``.pretrained_checkpoints`` cache directory is not writable, instead of returning ``None``. The + originating error is chained as the cause. ``None`` is now returned only when the asset server does not + report the checkpoint, which covers both an unpublished checkpoint and a server that could not be + reached; callers that relied on ``None`` to mask local download failures must catch ``RuntimeError``. diff --git a/source/isaaclab_rl/changelog.d/fix-wrapper-environment-type.rst b/source/isaaclab_rl/changelog.d/fix-wrapper-environment-type.rst new file mode 100644 index 000000000000..bc0d855cfd23 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/fix-wrapper-environment-type.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed RL environment wrapper validation errors reporting the outer Gymnasium wrapper type instead + of the rejected unwrapped environment type. diff --git a/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py b/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py index ccc16d29abaf..60c14fb23f14 100644 --- a/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py +++ b/source/isaaclab_rl/isaaclab_rl/rl_games/rl_games.py @@ -138,7 +138,7 @@ def __init__( raise ValueError( "The environment must be inherited from ManagerBasedRLEnv / DirectRLEnv / DirectRLEnvWarp /" " ManagerBasedRLEnvWarp. Environment type:" - f" {type(env)}" + f" {type(env.unwrapped)}" ) # initialize the wrapper self.env = env diff --git a/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py b/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py index 88c8a7ddfcee..4c9df80c615c 100644 --- a/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py +++ b/source/isaaclab_rl/isaaclab_rl/rsl_rl/vecenv_wrapper.py @@ -70,7 +70,7 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, clip_actions: float | N raise ValueError( "The environment must be inherited from ManagerBasedRLEnv / DirectRLEnv / DirectRLEnvWarp /" " ManagerBasedRLEnvWarp. Environment type:" - f" {type(env)}" + f" {type(env.unwrapped)}" ) # initialize the wrapper diff --git a/source/isaaclab_rl/isaaclab_rl/sb3.py b/source/isaaclab_rl/isaaclab_rl/sb3.py index 92a4d2ed6023..2d031f086a7c 100644 --- a/source/isaaclab_rl/isaaclab_rl/sb3.py +++ b/source/isaaclab_rl/isaaclab_rl/sb3.py @@ -170,7 +170,7 @@ def __init__(self, env: ManagerBasedRLEnv | DirectRLEnv, fast_variant: bool = Tr raise ValueError( "The environment must be inherited from ManagerBasedRLEnv / DirectRLEnv / DirectRLEnvWarp /" " ManagerBasedRLEnvWarp. Environment type:" - f" {type(env)}" + f" {type(env.unwrapped)}" ) # initialize the wrapper self.env = env diff --git a/source/isaaclab_rl/isaaclab_rl/skrl.py b/source/isaaclab_rl/isaaclab_rl/skrl.py index 40c99a17fe72..a35d92ccba72 100644 --- a/source/isaaclab_rl/isaaclab_rl/skrl.py +++ b/source/isaaclab_rl/isaaclab_rl/skrl.py @@ -111,7 +111,7 @@ def SkrlVecEnvWrapper( if not isinstance(env.unwrapped, allowed_types): raise ValueError( "The environment must be inherited from ManagerBasedRLEnv, DirectRLEnv, DirectMARLEnv," - f" DirectRLEnvWarp or ManagerBasedRLEnvWarp. Environment type: {type(env)}" + f" DirectRLEnvWarp or ManagerBasedRLEnvWarp. Environment type: {type(env.unwrapped)}" ) # import statements according to the ML framework diff --git a/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py b/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py index 4d2a30ac252e..d64c31ff9f80 100644 --- a/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py +++ b/source/isaaclab_rl/isaaclab_rl/utils/pretrained_checkpoint.py @@ -318,7 +318,16 @@ def get_published_pretrained_checkpoint( from :data:`sys.argv`. Legacy checkpoints do not use preset-qualified names. Returns: - The path. + The path, or None when the asset server does not report a checkpoint for this task + and backend combination. That covers both a checkpoint that was never published and + a server that could not be reached, which ``omni.client`` does not distinguish, so a + transient outage is not evidence that a checkpoint does not exist. The reason is + printed before returning. + + Raises: + RuntimeError: If the checkpoint is published but could not be downloaded, for + instance because the local cache directory is not writable. The originating + error is chained as the cause. """ if preset_names is None and physics_backend is not None and render_backend is not None: preset_names = get_pretrained_checkpoint_preset_names(task_name) @@ -339,9 +348,35 @@ def get_published_pretrained_checkpoint( print(f"Fetching pre-trained checkpoint : {ov_path}") try: resume_path = retrieve_file_path(ov_path, download_dir) - except Exception: - print("A pre-trained checkpoint is currently unavailable for this task.") + except FileNotFoundError: + # the asset server reports a checkpoint that was never published and a server it + # cannot reach the same way, so both are covered by the same message + backends = ( + "" + if physics_backend is None + else f" with the '{physics_backend}' physics and '{render_backend}' render backends" + ) + print( + "A pre-trained checkpoint is currently unavailable for this task.\n" + f" The asset server does not provide '{ov_path}'.\n" + f" Either no checkpoint is published for task '{task_name}'{backends}, or the asset" + " server could not be reached.\n" + " Train the task, or pass --checkpoint to use a checkpoint of your own." + ) return None + except Exception as exc: + # the checkpoint exists on the server, so this is a local failure the user has to fix; + # reporting it as an unavailable checkpoint would send them looking in the wrong place + hint = "" + if isinstance(exc, OSError): + hint = ( + " Check that the cache directory is writable and that the disk is not full;" + " a directory left behind by a container run is owned by root." + ) + raise RuntimeError( + f"Failed to download the pre-trained checkpoint '{ov_path}' into" + f" '{os.path.abspath(download_dir)}': {type(exc).__name__}: {exc}.{hint}" + ) from exc else: print("Using pre-fetched pre-trained checkpoint") return resume_path diff --git a/source/isaaclab_rl/test/test_pretrained_checkpoint.py b/source/isaaclab_rl/test/test_pretrained_checkpoint.py index 91aa9d494cd7..4983795dd8da 100644 --- a/source/isaaclab_rl/test/test_pretrained_checkpoint.py +++ b/source/isaaclab_rl/test/test_pretrained_checkpoint.py @@ -200,3 +200,55 @@ def _retrieve_file_path(remote_path: str, download_dir: str) -> str: "remote_path": f"omniverse://IsaacLab/PretrainedCheckpoints/rl_games/{filename}", "download_dir": expected_download_dir, } + + +def test_get_published_pretrained_checkpoint_reports_unpublished_checkpoint( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +): + """Test that a checkpoint missing from the asset server names the location that was tried.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pretrained_checkpoint, "ISAACLAB_NUCLEUS_DIR", "omniverse://IsaacLab") + + def _retrieve_file_path(remote_path: str, download_dir: str) -> str: + raise FileNotFoundError(f"Unable to find the file: {remote_path}") + + monkeypatch.setattr(pretrained_checkpoint, "retrieve_file_path", _retrieve_file_path) + + path = pretrained_checkpoint.get_published_pretrained_checkpoint( + "rsl_rl", + "Isaac-Cartpole", + "physx", + "none", + ) + + assert path is None + output = capsys.readouterr().out + assert "A pre-trained checkpoint is currently unavailable for this task." in output + assert "omniverse://IsaacLab/PretrainedCheckpoints/rsl_rl/Isaac-Cartpole_physx_none_rsl_rl.pt" in output + assert "'physx' physics and 'none' render backends" in output + + +def test_get_published_pretrained_checkpoint_raises_on_unwritable_cache( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + """Test that a local download failure is reported instead of being reported as unavailable.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(pretrained_checkpoint, "ISAACLAB_NUCLEUS_DIR", "omniverse://IsaacLab") + cause = PermissionError(13, "Permission denied", str(tmp_path / ".pretrained_checkpoints")) + + def _retrieve_file_path(remote_path: str, download_dir: str) -> str: + raise cause + + monkeypatch.setattr(pretrained_checkpoint, "retrieve_file_path", _retrieve_file_path) + + with pytest.raises(RuntimeError) as error: + pretrained_checkpoint.get_published_pretrained_checkpoint("rsl_rl", "Isaac-Cartpole", "physx", "none") + + message = str(error.value) + assert "Isaac-Cartpole_physx_none_rsl_rl.pt" in message + assert str(tmp_path / ".pretrained_checkpoints" / "rsl_rl") in message + assert "Permission denied" in message + assert error.value.__cause__ is cause diff --git a/source/isaaclab_rl/test/test_rsl_rl_wrapper_observations.py b/source/isaaclab_rl/test/test_rsl_rl_wrapper_observations.py index dba6b064047c..e7f22af2384a 100644 --- a/source/isaaclab_rl/test/test_rsl_rl_wrapper_observations.py +++ b/source/isaaclab_rl/test/test_rsl_rl_wrapper_observations.py @@ -12,6 +12,7 @@ import inspect +import pytest import torch from tensordict import TensorDict @@ -28,6 +29,15 @@ def __init__(self): self.obs_buf = {"policy": torch.tensor([[1.0, 2.0], [3.0, 4.0]])} +class _UnsupportedEnv: + pass + + +class _OuterEnv: + def __init__(self): + self.unwrapped = _UnsupportedEnv() + + def _make_wrapper(env: _FakeEnv, num_envs: int = 2) -> RslRlVecEnvWrapper: """Build a wrapper without ``__init__``, which requires a real environment and a live sim.""" wrapper = object.__new__(RslRlVecEnvWrapper) @@ -74,3 +84,12 @@ def test_direct_rl_env_stores_the_observation_buffer(): """The environment side of the contract: reset and step both publish ``obs_buf``.""" assert "self.obs_buf" in inspect.getsource(DirectRLEnv.reset) assert "self.obs_buf" in inspect.getsource(DirectRLEnv.step) + + +def test_invalid_environment_error_reports_unwrapped_type(): + """The validation error should identify the object that failed the type check.""" + with pytest.raises(ValueError) as exc_info: + RslRlVecEnvWrapper(_OuterEnv()) + + assert "_UnsupportedEnv" in str(exc_info.value) + assert "_OuterEnv" not in str(exc_info.value) diff --git a/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst b/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst new file mode 100644 index 000000000000..43b0be9ed46c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Moved benchmark-only tasks into a new ``isaaclab_tasks.benchmark`` package so they are no longer mixed in with + the trainable ``core`` and ``contrib`` task families. The ``IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct`` + task id is unchanged, but its configuration moved from + ``isaaclab_tasks.contrib.reorient.config.shadow_hand.shadow_hand_camera_benchmark_env_cfg`` to + ``isaaclab_tasks.benchmark.shadow_hand_camera.shadow_hand_camera_benchmark_env_cfg``. Code that imports the + configuration class directly must update the import path; code that resolves the task through ``gym.make`` or + ``load_cfg_from_registry`` needs no change. diff --git a/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst b/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst new file mode 100644 index 000000000000..c52a004b6e7e --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Franka Lift and Reorient reset sampling with updated Franka assets by explicitly selecting the convex-hull + arm colliders used by the tasks' clearance criteria. diff --git a/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst b/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst new file mode 100644 index 000000000000..3826c316199c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the Franka Pour task selecting the primitive robot collider variant, + which omitted the arm collision meshes required by the task. diff --git a/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip b/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip new file mode 100644 index 000000000000..e29cd024514a --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip @@ -0,0 +1,3 @@ +Test-only change: skip ``IsaacContrib-DrLegs-Walk`` (and its deprecated alias) in the contributed +environment smoke test because the Kamino solver intermittently diverges to NaN under random actions. +No user-visible behavior change. diff --git a/source/isaaclab_tasks/changelog.d/fix-video-idle-rendering-overhead.skip b/source/isaaclab_tasks/changelog.d/fix-video-idle-rendering-overhead.skip new file mode 100644 index 000000000000..65dfe703d85e --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-video-idle-rendering-overhead.skip @@ -0,0 +1 @@ +Test-only coverage for simultaneous headless Kit video recorders. diff --git a/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst b/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst new file mode 100644 index 000000000000..db6ccfdd69ae --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added a ``newton_mjwarp`` branch to ``DigitPhysicsCfg``, so the Digit velocity tasks run on the + Newton backend with ``presets=newton_mjwarp``. Gated on that preset: ``self_collision_enabled`` + (the asset authors ``enabledSelfCollisions=False`` and ``DIGIT_V4_CFG`` sets no + ``articulation_props``, so Newton filters all 253 intra-articulation shape pairs). An armature + floor for the ten joints below MJWarp's observed stability threshold (expressed as a second + actuator group) and ``entropy_coef = 0.005`` apply on **both** backends, consistent with #7607's + direction of not special-casing PhysX for values that do not hurt it there. + +Fixed +^^^^^ + +* Fixed 32 ``CollisionAPI`` prims on Digit's RealSense camera decoration meshes -- glass, USB-C and + case halves -- becoming collision shapes. This applies on **both** backends, so the PhysX contact + behaviour of the Digit tasks changes: those 32 shapes no longer collide. diff --git a/source/isaaclab_tasks/changelog.d/jichuanh-manipulation-determinism-mode.rst b/source/isaaclab_tasks/changelog.d/jichuanh-manipulation-determinism-mode.rst new file mode 100644 index 000000000000..4cbf551977bb --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/jichuanh-manipulation-determinism-mode.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed ``test_manipulation_env_determinism`` asserting bit-reproducible rewards without requesting + a determinism guarantee. Newton defaults to ``wp.DeterministicMode.NOT_GUARANTEED``, under which + Warp's atomics may accumulate in any order, so the test failed intermittently depending on GPU + scheduling. It now passes ``deterministic_mode="run_to_run"``, as the Newton cartpole cases + already did. diff --git a/source/isaaclab_tasks/changelog.d/multitask-manipulation.minor.rst b/source/isaaclab_tasks/changelog.d/multitask-manipulation.minor.rst new file mode 100644 index 000000000000..eea0c7cd6865 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/multitask-manipulation.minor.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added the contributed ``IsaacContrib-Multitask-Manipulation`` manager-based task for training one policy across heterogeneous + OpenArm lift, Franka cabinet, and UR10 reach environments using selection-aware physics-view indexing, including + task-specific Gaussian policy and value heads, task-wise PPO advantage normalization, bounded cabinet joint targets, + cabinet state-safety resets, task-balanced OpenArm reward scaling, and playback visualization with shortened + evaluation horizons and task-appropriate command markers. diff --git a/source/isaaclab_tasks/changelog.d/ooctipus-clone-plan-contract.skip b/source/isaaclab_tasks/changelog.d/ooctipus-clone-plan-contract.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/ovphysx_implicit_effort_goldens.skip b/source/isaaclab_tasks/changelog.d/ovphysx_implicit_effort_goldens.skip new file mode 100644 index 000000000000..39591f99a3f1 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/ovphysx_implicit_effort_goldens.skip @@ -0,0 +1 @@ +Updated ovphysx motion-vector golden images after correcting implicit effort submission. diff --git a/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst b/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst new file mode 100644 index 000000000000..ccbd42cd669f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added the ``Isaac-RenderBenchmark-Franka-Cabinet`` direct task, an animated Franka-and-cabinet scene with no + policy or reward used to compare rendering backends under ``scripts/benchmarks/benchmark_renderer.py``. diff --git a/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst b/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst new file mode 100644 index 000000000000..c29d3af3d725 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Unified rough-velocity task inputs across physics backends by removing MJWarp-only actuator armatures, + using 5,000 G1 training iterations for every backend, and representing shared base-COM randomization as a + plain event. Downstream configurations that require the former backend-specific behavior should set it + explicitly. diff --git a/source/isaaclab_tasks/isaaclab_tasks/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/__init__.py index 02cb223e544c..19ec4b1f023f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/__init__.py @@ -10,6 +10,9 @@ - ``core``: Core task families maintained as part of Isaac Lab. - ``contrib``: Contributed task families. These may depend on ``core`` tasks, but ``core`` tasks never depend on ``contrib`` tasks. +- ``benchmark``: Benchmark-only task families used to measure simulation and rendering + throughput. These may depend on ``core`` and ``contrib`` tasks, but neither depends + on ``benchmark`` tasks. - ``utils``: These include utility functions for the tasks. """ diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py new file mode 100644 index 000000000000..4bf835f15131 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Benchmark-only task families. + +These tasks exist to measure simulation and rendering throughput rather than to train +policies. They may depend on ``core`` and ``contrib`` tasks, but neither depends on them. +""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py new file mode 100644 index 000000000000..7ab3de636b7a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Render-only benchmark scene for OVRTX / Newton-Warp comparison.""" + +import gymnasium as gym + +gym.register( + id="Isaac-RenderBenchmark-Franka-Cabinet", + entry_point=f"{__name__}.render_benchmark_env:RenderBenchmarkEnv", + disable_env_checker=True, + kwargs={"env_cfg_entry_point": f"{__name__}.render_benchmark_env_cfg:RenderBenchmarkFrankaCabinetEnvCfg"}, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py new file mode 100644 index 000000000000..4c71338e4f81 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py @@ -0,0 +1,190 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Direct environment for render-only benchmarking.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +import isaaclab.sim as sim_utils +from isaaclab import cloner +from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg +from isaaclab.envs import DirectRLEnv +from isaaclab.sensors import Camera, save_images_to_file + +if TYPE_CHECKING: + from .render_benchmark_env_cfg import RenderBenchmarkFrankaCabinetEnvCfg + + +class RenderBenchmarkEnv(DirectRLEnv): + """Environment that animates its articulations so a renderer can be profiled on them. + + Every articulation in :attr:`RenderBenchmarkFrankaCabinetEnvCfg.articulations` is driven by a + sinusoid around its default joint positions. Actions are ignored and rewards are zero: the + only output that matters is the camera image, and the only cost that matters is the time + spent producing it. + """ + + cfg: RenderBenchmarkFrankaCabinetEnvCfg + + # --- scene construction -------------------------------------------------- + + def _setup_scene(self): + # Per-(env, joint) sinusoid phases [rad] and the elapsed animation time [s]. The phases + # are filled on the first step; see :meth:`_sample_animation_phases`. + self._anim_phases: dict[str, torch.Tensor] | None = None + self._anim_time: float = 0.0 + + self._articulations: dict[str, Articulation] = { + name: Articulation(articulation_cfg) for name, articulation_cfg in self.cfg.articulations.items() + } + self._tiled_camera = Camera(self.cfg.tiled_camera) + self._ground = RigidObject(self._ground_cfg()) + self._spawn_lights() + + # Assets must exist before planning: the plan keys its rows off the cfgs they queued. + src, dest = "/World/envs/env_0", "/World/envs/env_{}" + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos) + cloner.replicate(plan) + + # PhysX replication requires explicit collision filtering between environments. + if "physx" in self.scene.physics_backend: + self.scene.filter_collisions(global_prim_paths=[]) + + for name, articulation in self._articulations.items(): + self.scene.articulations[name] = articulation + self.scene.rigid_objects["ground"] = self._ground + self.scene.sensors["tiled_camera"] = self._tiled_camera + + def _ground_cfg(self) -> RigidObjectCfg: + """Build the per-environment ground cuboid. + + A flat cuboid routed through :class:`~isaaclab.assets.RigidObject` keeps the comparison + apples-to-apples: a USD ground plane is invisible to Warp's renderer, which only knows + about simulation meshes, so the two renderers would otherwise disagree on primary-ray + misses and shadow-ray counts. + + The tile is clamped to the env spacing so neighboring clones tile edge-to-edge instead of + overlapping. An oversized tile would leave every environment's camera looking at several + redundant, coplanar ground meshes -- extra geometry that neither backend needs to resolve + in the real scene, and whose BVH/intersection cost grows with the environment count. + """ + spacing = self.scene.cfg.env_spacing + size_x = min(self.cfg.ground_size[0], spacing) + size_y = min(self.cfg.ground_size[1], spacing) + thickness = self.cfg.ground_thickness + return RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Ground", + spawn=sim_utils.CuboidCfg( + size=(size_x, size_y, thickness), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=self.cfg.ground_color, metallic=0.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(disable_gravity=True, kinematic_enabled=True), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, self.cfg.ground_top_z - thickness / 2.0)), + ) + + def _spawn_lights(self) -> None: + """Spawn the ambient dome light and, when configured, the directional light above it.""" + dome_cfg = sim_utils.DomeLightCfg(intensity=self.cfg.dome_light_intensity, color=(0.75, 0.75, 0.75)) + dome_cfg.func("/World/Light", dome_cfg) + if self.cfg.light_cfg is not None: + self.cfg.light_cfg.func( + "/World/LightDirectional", + self.cfg.light_cfg, + orientation=self.cfg.light_orientation, + ) + + # --- joint animation ----------------------------------------------------- + + def _pre_physics_step(self, actions: torch.Tensor) -> None: + if self.cfg.joint_animation_amplitude <= 0.0: + return + if self._anim_phases is None: + self._anim_phases = self._sample_animation_phases() + self._animate_joints() + + def _apply_action(self) -> None: + pass + + def _sample_animation_phases(self) -> dict[str, torch.Tensor]: + """Derive a phase offset [rad] for every (env, joint) pair, keyed by articulation name. + + The phases come from a deterministic hash of the indices rather than the torch RNG so a + given frame holds the same pose in every run. The default RNG state varies between + processes, which would leave an OVRTX run and a Warp run rendering different poses at the + same frame even though the solver itself is deterministic, making their timings + incomparable. Multiplying by two coprime primes spreads the phases pseudo-uniformly. + + Deferred to the first step because it reads articulation data, which the simulation only + populates after :meth:`_setup_scene` returns. + """ + phases = {} + for name, articulation in self._articulations.items(): + default_pos = articulation.data.default_joint_pos.torch + num_envs, num_joints = default_pos.shape + env_idx = torch.arange(num_envs, device=self.device, dtype=default_pos.dtype).unsqueeze(1) + joint_idx = torch.arange(num_joints, device=self.device, dtype=default_pos.dtype).unsqueeze(0) + phases[name] = ((env_idx * 7919.0 + joint_idx * 6553.0) % 10007.0) * (2.0 * math.pi / 10007.0) + return phases + + def _animate_joints(self) -> None: + """Advance the animation clock and drive every joint to its sinusoidal target.""" + self._anim_time += self.cfg.sim.dt * self.cfg.decimation + omega = 2.0 * math.pi * self.cfg.joint_animation_freq_hz + for name, articulation in self._articulations.items(): + default_pos = articulation.data.default_joint_pos.torch + offset = self.cfg.joint_animation_amplitude * torch.sin(omega * self._anim_time + self._anim_phases[name]) + soft_limits = articulation.data.soft_joint_pos_limits.torch + target = torch.clamp(default_pos + offset, soft_limits[..., 0], soft_limits[..., 1]) + articulation.actuators.target_command.set_position_index(value=target) + + # --- DirectRLEnv plumbing ------------------------------------------------ + + def _get_observations(self) -> dict: + # Sensor buffers update lazily, so reading the camera's data is what drives the render. + # This access is the work the benchmark measures: keep it unconditional even when no + # image is written, or the profile records a scene that was never rendered. + output = self._tiled_camera.data.output + if self.cfg.write_image_to_file: + self._write_camera_image(output) + return {"policy": torch.zeros((self.num_envs, 1), device=self.device)} + + def _write_camera_image(self, output: dict) -> None: + """Dump the current camera frame to a PNG. + + Purely a debugging aid, so any failure is reported and swallowed rather than allowed to + abort a benchmark run. + + Args: + output: Rendered outputs from the tiled camera, keyed by data type. + """ + data_type = self.cfg.tiled_camera.data_types[0] + if not isinstance(output, dict) or data_type not in output: + return + try: + image = output[data_type] + if not torch.is_tensor(image): + image = torch.from_dlpack(image) + image = image.float() + # Renderers hand back either 0-255 or already-normalized data. + if image.max() > 1.5: + image = image / 255.0 + save_images_to_file(image[:, ..., :3], f"render_benchmark_{data_type}.{self.common_step_counter:06d}.png") + except Exception as error: + print(f"[render_benchmark] write_image_to_file failed: {error}") + + def _get_rewards(self) -> torch.Tensor: + return torch.zeros(self.num_envs, device=self.device) + + def _get_dones(self): + time_out = self.episode_length_buf >= self.max_episode_length - 1 + return torch.zeros_like(time_out), time_out diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py new file mode 100644 index 000000000000..d5459c316dd8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py @@ -0,0 +1,213 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import os + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_physx.physics import PhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.envs import DirectRLEnvCfg +from isaaclab.physics import PhysxAutoCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import CameraCfg +from isaaclab.sim import SimulationCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass + +from isaaclab_tasks.utils import PresetCfg +from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + +from isaaclab_assets.robots.franka import FRANKA_PANDA_HIGH_PD_CFG + + +@configclass +class RenderBenchmarkPhysicsCfg(PresetCfg): + """Physics backend presets. + + Pick via ``presets=newton_mjwarp`` (default) or ``presets=physx``, which resolves to the + concrete PhysX backend at launch. Use ``presets=isaacsim_physx`` to pin Isaac Sim PhysX. + The BVH constructors are read from the environment so ``benchmark_renderer.py`` can sweep + them without a separate preset per combination. + """ + + newton_mjwarp: NewtonCfg = NewtonCfg( + solver_cfg=MJWarpSolverCfg(solver="newton", integrator="implicitfast", njmax=200, nconmax=70), + num_substeps=2, + bvh_constructor_geometry=os.getenv("NEWTON_BVH_GEOMETRY", "cubql"), + bvh_constructor_gaussian=os.getenv("NEWTON_BVH_GAUSSIAN", "cubql"), + bvh_constructor_scene=os.getenv("NEWTON_BVH_SCENE", "sah"), + use_cuda_graph=os.getenv("NEWTON_USE_CUDA_GRAPH", "0") == "1", + ) + isaacsim_physx: PhysxCfg = PhysxCfg() + physx: PhysxAutoCfg = PhysxAutoCfg(isaacsim_physx=isaacsim_physx) + default = newton_mjwarp + + +@configclass +class RenderBenchmarkTiledCameraCfg(PresetCfg): + """Render-target presets — pick via ``presets=rgb`` (default), ``presets=depth``, and so on.""" + + @configclass + class BaseRenderBenchmarkCameraCfg(CameraCfg): + """Front view of the workspace, pitch-only (no yaw, no roll). + + The camera sits on the drawer-facing ``+X`` side of the cabinet, elevated and pitched + ~34.5 deg down so the workspace reads as a slight bird's-eye. ``rot`` is ``(qx, qy, qz, qw)`` + in ``convention="world"`` (``+X`` camera-forward): with ``qw=0`` and only ``qx``/``qz`` set it + is a 180 deg rotation about the ``(-X, 0, +Z)`` axis, aiming the camera at ``(0.4, 0, 0.4)``. + """ + + prim_path: str = "{ENV_REGEX_NS}/Camera" + offset: CameraCfg.OffsetCfg = CameraCfg.OffsetCfg( + pos=(2.0, 0.0, 1.5), rot=(-0.296, 0.0, 0.955, 0.0), convention="world" + ) + data_types: list[str] = [] + spawn: sim_utils.PinholeCameraCfg = sim_utils.PinholeCameraCfg( + focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.05, 50.0) + ) + width: int = int(os.getenv("BENCHMARK_RENDER_RESOLUTION", "256")) + height: int = int(os.getenv("BENCHMARK_RENDER_RESOLUTION", "256")) + renderer_cfg: MultiBackendRendererCfg = MultiBackendRendererCfg( + newton_renderer=NewtonWarpRendererCfg(enable_shadows=True), + ) + + default = BaseRenderBenchmarkCameraCfg(data_types=["rgb"]) + rgb = default + albedo = BaseRenderBenchmarkCameraCfg(data_types=["albedo"]) + depth = BaseRenderBenchmarkCameraCfg(data_types=["depth"]) + simple_shading_constant_diffuse = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_constant_diffuse"]) + simple_shading_diffuse_mdl = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_diffuse_mdl"]) + simple_shading_full_mdl = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_full_mdl"]) + + +@configclass +class RenderBenchmarkFrankaCabinetEnvCfg(DirectRLEnvCfg): + """Franka Panda and Sektion cabinet, animated for renderer benchmarking. + + The cabinet contributes four articulated joints (two drawers, two doors) on top of the + Franka's seven, and a sinusoidal animation drives all of them so every rendered frame has + moving articulated geometry rather than a static scene. There is no policy: actions are + ignored, rewards are zero, and the episode only ends on time-out. + + A mirrored layout of the canonical ``Isaac-Franka-Cabinet-Direct-v0`` task, which places the + Franka at the origin facing its default ``+X``: here the Franka sits at ``(1.0, 0, 0)`` rotated + 180 deg about Z (facing ``-X``, toward the cabinet), and the cabinet sits at the origin in its + default USD orientation. + """ + + decimation: int = 2 + episode_length_s: float = 60.0 + + action_space: int = 1 + observation_space: int = 1 + state_space: int = 0 + + sim: SimulationCfg = SimulationCfg(dt=1.0 / 120.0, render_interval=2, physics=RenderBenchmarkPhysicsCfg()) + + scene: InteractiveSceneCfg = InteractiveSceneCfg(num_envs=4, env_spacing=3.0, replicate_physics=True) + + tiled_camera: RenderBenchmarkTiledCameraCfg = RenderBenchmarkTiledCameraCfg() + + articulations: dict[str, ArticulationCfg] = { + # High-PD variant so the joints track the sinusoidal targets smoothly. + "robot": FRANKA_PANDA_HIGH_PD_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=FRANKA_PANDA_HIGH_PD_CFG.init_state.replace( + pos=(1.0, 0.0, 0.0), + rot=(0.0, 0.0, 1.0, 0.0), # 180 deg about Z, xyzw + ), + ), + # Loaded as an ArticulationCfg rather than a static USD reference so its four joints + # animate and it clones to every env through the standard Isaac Lab path. + "cabinet": ArticulationCfg( + prim_path="{ENV_REGEX_NS}/Cabinet", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Sektion_Cabinet/sektion_cabinet_instanceable.usd", + activate_contact_sensors=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.4), + rot=(0.0, 0.0, 0.0, 1.0), # identity: cabinet's default USD orientation + joint_pos={ + "door_left_joint": 0.0, + "door_right_joint": 0.0, + "drawer_bottom_joint": 0.0, + "drawer_top_joint": 0.0, + }, + ), + actuators={ + "drawers": ImplicitActuatorCfg( + joint_names_expr=["drawer_top_joint", "drawer_bottom_joint"], + joint_effort_limit=87.0, + stiffness=10.0, + damping=1.0, + ), + "doors": ImplicitActuatorCfg( + joint_names_expr=["door_left_joint", "door_right_joint"], + joint_effort_limit=87.0, + stiffness=10.0, + damping=2.5, + ), + }, + ), + } + """Articulations spawned into every environment, keyed by scene name.""" + + ground_top_z: float = 0.0 + """Height of the ground's top surface [m].""" + + ground_size: tuple[float, float] = (50.0, 50.0) + """Requested extent of the per-environment ground cuboid in XY [m]. + + Clamped to :attr:`scene.env_spacing` when the cuboid is built, so neighboring environments' + ground tiles meet at the boundary instead of overlapping. + """ + + ground_thickness: float = 0.1 + """Thickness of the ground cuboid along Z [m].""" + + ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) + """Diffuse color of the ground, as linear RGB in ``[0, 1]``.""" + + dome_light_intensity: float = 2000.0 + """Intensity of the ambient dome light. + + Newton's renderer has no tone mapping or ambient defaults of its own, so without this + unlit surfaces render pure black. + """ + + light_cfg: sim_utils.LightCfg | None = sim_utils.DistantLightCfg( + intensity=200.0, + exposure=0.0, + angle=0.0, + color=(1.0, 1.0, 1.0), + normalize=True, + ) + """Directional light spawned on top of the ambient dome light, or ``None`` for dome only.""" + + light_orientation: tuple[float, float, float, float] = (0.3251, 0.3251, 0.0, 0.8881) + """Orientation of :attr:`light_cfg` as ``(qx, qy, qz, qw)``. + + Rotates a USD ``DistantLight``'s default ``-Z`` onto ``(-0.57735, 0.57735, -0.57735)``, the + direction Warp's renderer hard-codes, so both renderers light the scene identically. + """ + + joint_animation_amplitude: float = 0.4 + """Peak sinusoidal offset from each joint's default position [m or rad, depending on joint type]. + + Values at or below zero disable the animation, leaving a static scene. + """ + + joint_animation_freq_hz: float = 0.35 + """Frequency of the joint animation [Hz].""" + + write_image_to_file: bool = os.getenv("BENCHMARK_SAVE_IMAGE", "0") == "1" + """Whether to dump each rendered frame to a PNG, for eyeballing renderer output.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py new file mode 100644 index 000000000000..0c377d8ded6d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Rendering-throughput benchmark variant of the Shadow Hand camera task.""" + +import gymnasium as gym + +from isaaclab_tasks.core.reorient.config.shadow_hand import agents as core_agents + +gym.register( + id="IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct", + entry_point="isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_direct_camera_env:ShadowHandCameraEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.shadow_hand_camera_benchmark_env_cfg:ShadowHandCameraBenchmarkEnvCfg", + "rsl_rl_cfg_entry_point": f"{core_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandCameraFFPPORunnerCfg", + "rl_games_cfg_entry_point": f"{core_agents.__name__}:rl_games_ppo_camera_cfg.yaml", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/shadow_hand_camera_benchmark_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/shadow_hand_camera_benchmark_env_cfg.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/shadow_hand_camera_benchmark_env_cfg.py rename to source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/shadow_hand_camera_benchmark_env_cfg.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py index 5a27645614de..cebf232cdab2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py @@ -74,10 +74,10 @@ def _setup_scene(self): self.cfg.terrain.env_spacing = self.scene.cfg.env_spacing self._terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = (self.cfg.terrain.prim_path,) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[self.cfg.terrain.prim_path]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py index 720f22d97143..2d2e2756ccb2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py @@ -260,10 +260,10 @@ def _setup_scene(self): self._fixed_asset = Articulation(self.cfg_task.fixed_asset) self._held_asset = RigidObject(self.cfg_task.held_asset) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) self.scene.filter_collisions() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py index d8d5f3b27749..537f0ae6af02 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py @@ -185,10 +185,10 @@ def _setup_scene(self): # self._fixed_asset = RigidObject(self.cfg_task.fixed_asset) self._held_asset = RigidObject(self.cfg_task.held_asset) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) self.scene.filter_collisions() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py index fdb880976d05..8e813b811538 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py @@ -99,10 +99,10 @@ def _setup_scene(self): self._small_gear_asset = Articulation(self.cfg_task.small_gear_cfg) self._large_gear_asset = Articulation(self.cfg_task.large_gear_cfg) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py index 2662affebd7a..775aeb222ec8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py @@ -339,6 +339,7 @@ class PourSceneCfg(InteractiveSceneCfg): ) robot = FRANKA_PANDA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") robot.spawn.usd_path = FRANKA_POUR_ROBOT_USD_PATH + robot.spawn.variants = {"Colliders": "convex_hulls"} robot.spawn.func = spawn_franka_with_arm_collisions robot.spawn.articulation_props.enabled_self_collisions = True robot.actuators = { diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py index a768df2f19a3..7bdff104f2ce 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py @@ -66,10 +66,10 @@ def _setup_scene(self): ), ) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=["/World/ground"]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/__init__.py new file mode 100644 index 000000000000..173a089f23c7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Contributed heterogeneous fixed-arm manipulation environments.""" + +import gymnasium as gym + +from . import agents + + +gym.register( + id="IsaacContrib-Multitask-Manipulation", + entry_point=f"{__name__}.multitask_env:MultitaskManipulationEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.multitask_env_cfg:MultitaskManipulationEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:MultitaskManipulationPPORunnerCfg", + "default_agent": "rsl_rl", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/__init__.py new file mode 100644 index 000000000000..f92c3e46e50d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Agent configurations for contributed heterogeneous manipulation training.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/models.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/models.py new file mode 100644 index 000000000000..fd3e3d4725e2 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/models.py @@ -0,0 +1,459 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Task-headed RSL-RL models for contributed heterogeneous manipulation.""" + +from __future__ import annotations + +import copy +import math +from collections.abc import Sequence + +import torch +import torch.nn as nn +import torch.nn.functional as F +from rsl_rl.models import MLPModel +from rsl_rl.modules import HiddenState +from rsl_rl.modules.distribution import Distribution +from rsl_rl.utils import resolve_callable, resolve_nn_activation, unpad_trajectories +from tensordict import TensorDict +from torch.distributions import Normal + + +def _positive_dimensions(dimensions: Sequence[int], label: str) -> tuple[int, ...]: + """Return dimensions as a tuple after validating that every entry is positive.""" + dimensions = tuple(dimensions) + if not dimensions or any(dimension <= 0 for dimension in dimensions): + raise ValueError(f"Expected positive {label} dimensions, got {dimensions}.") + return dimensions + + +def _task_action_dimensions(dimensions: Sequence[int], output_dim: int) -> tuple[int, ...]: + """Validate task action dimensions against the fixed global action width.""" + dimensions = _positive_dimensions(dimensions, "task action") + if sum(dimensions) != output_dim: + raise ValueError(f"Task action dimensions {dimensions} sum to {sum(dimensions)}, expected {output_dim}.") + return dimensions + + +def _task_encoding_slice(encoding_slice: Sequence[int], input_dim: int, task_count: int) -> tuple[int, int]: + """Validate and return the task one-hot slice within a model input.""" + if len(encoding_slice) != 2: + raise ValueError(f"Expected a two-element task encoding slice, got {encoding_slice}.") + resolved_slice = (int(encoding_slice[0]), int(encoding_slice[1])) + start, end = resolved_slice + if start < 0 or end > input_dim or end <= start: + raise ValueError(f"Task encoding slice {resolved_slice} is outside the {input_dim}D model input.") + if end - start != task_count: + raise ValueError(f"Task encoding slice {resolved_slice} has dimension {end - start}, expected {task_count}.") + return resolved_slice + + +def _task_encoding(obs: TensorDict, obs_groups: Sequence[str], encoding_slice: tuple[int, int]) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + model_input = torch.cat([obs[obs_group] for obs_group in obs_groups], dim=-1) + return model_input[..., encoding_slice[0] : encoding_slice[1]] + + +def _validate_task_encoding(task_encoding: torch.Tensor) -> None: + """Validate that each sample contains exactly one one-hot task identity.""" + is_binary = torch.logical_or(task_encoding == 0.0, task_encoding == 1.0).all() + has_one_task = (task_encoding.sum(dim=-1) == 1.0).all() + if not bool(is_binary and has_one_task): + raise ValueError("Task encoding observations must be one-hot with exactly one active task per sample.") + + +class TaskHeadedGaussianDistribution(Distribution): + """Diagonal Gaussian distribution routed over contiguous task action heads.""" + + def __init__( + self, + output_dim: int, + task_action_dims: Sequence[int], + init_std: float = 1.0, + std_range: tuple[float, float] = (1e-6, 1e6), + std_type: str = "scalar", + learn_std: bool = True, + ) -> None: + """Initialize task-specific Gaussian standard deviations. + + Args: + output_dim: Fixed global action dimension. + task_action_dims: Action dimensions of the contiguous task heads. + init_std: Initial standard deviation for every active action dimension. + std_range: Inclusive standard-deviation clamp range. + std_type: Standard-deviation parameterization, either ``"scalar"`` or ``"log"``. + learn_std: Whether the task-specific standard deviations are learnable. + + Raises: + ValueError: If the task dimensions or standard-deviation configuration are invalid. + """ + super().__init__(output_dim) + self.task_action_dims = _task_action_dimensions(task_action_dims, output_dim) + if init_std <= 0.0: + raise ValueError(f"Expected a positive initial standard deviation, got {init_std}.") + if std_range[0] <= 0.0 or std_range[0] > std_range[1]: + raise ValueError(f"Expected a positive ordered standard-deviation range, got {std_range}.") + if std_type not in ("scalar", "log"): + raise ValueError(f"Unknown standard deviation type: {std_type}. Should be 'scalar' or 'log'.") + + self.std_type = std_type + self.std_range = std_range + self.log_std_range = (math.log(std_range[0]), math.log(std_range[1])) + initial_value = init_std if std_type == "scalar" else math.log(init_std) + self._std_params = nn.ParameterList( + [nn.Parameter(torch.full((dim,), initial_value), requires_grad=learn_std) for dim in self.task_action_dims] + ) + + action_masks = torch.zeros(len(self.task_action_dims), output_dim) + start = 0 + for task_id, action_dim in enumerate(self.task_action_dims): + action_masks[task_id, start : start + action_dim] = 1.0 + start += action_dim + self.register_buffer("_task_action_masks", action_masks) + + self._distribution: Normal | None = None + self._action_mask: torch.Tensor | None = None + Normal.set_default_validate_args(False) + + def update(self, mlp_output: torch.Tensor, task_encoding: torch.Tensor | None = None) -> None: + """Update the routed distribution from global means and one-hot task identities.""" + if task_encoding is None: + raise ValueError("TaskHeadedGaussianDistribution requires a task encoding when it is updated.") + if task_encoding.shape[-1] != len(self.task_action_dims): + raise ValueError( + f"Expected {len(self.task_action_dims)} task encoding dimensions, got {task_encoding.shape[-1]}." + ) + + action_mask = task_encoding.to(dtype=mlp_output.dtype) @ self._task_action_masks.to(dtype=mlp_output.dtype) + std = self._global_std().expand_as(mlp_output) + # Unit standard deviation keeps the inactive Normal parameters numerically valid; all probability terms mask it. + distribution_std = std * action_mask + (1.0 - action_mask) + self._distribution = Normal(mlp_output, distribution_std) + self._action_mask = action_mask + + def sample(self) -> torch.Tensor: + """Sample the active task head and return zero in every inactive global dimension.""" + distribution, action_mask = self._require_state() + return distribution.sample() * action_mask + + def deterministic_output(self, mlp_output: torch.Tensor) -> torch.Tensor: + """Return the routed means produced by the task-headed model.""" + return mlp_output + + def as_deterministic_output_module(self) -> nn.Module: + """Return an identity module because the model already routes its deterministic output.""" + return nn.Identity() + + @property + def input_dim(self) -> int: + """Return the fixed global mean dimension.""" + return self.output_dim + + @property + def mean(self) -> torch.Tensor: + """Return the current routed global action mean.""" + return self._require_state()[0].mean + + @property + def std(self) -> torch.Tensor: + """Return the concatenated standard deviations of every task head.""" + return self._global_std() + + @property + def entropy(self) -> torch.Tensor: + """Return entropy summed only over the selected task head.""" + distribution, action_mask = self._require_state() + return (distribution.entropy() * action_mask).sum(dim=-1) + + @property + def params(self) -> tuple[torch.Tensor, ...]: + """Return mean, numerically valid standard deviation, and the active action mask.""" + distribution, action_mask = self._require_state() + return distribution.mean, distribution.stddev, action_mask + + def log_prob(self, outputs: torch.Tensor) -> torch.Tensor: + """Return log-probability summed only over the selected task head.""" + distribution, action_mask = self._require_state() + return (distribution.log_prob(outputs) * action_mask).sum(dim=-1) + + def kl_divergence( + self, + old_params: tuple[torch.Tensor, ...], + new_params: tuple[torch.Tensor, ...], + ) -> torch.Tensor: + """Return KL divergence summed only over the task head stored with the old policy.""" + old_mean, old_std, old_action_mask = old_params + new_mean, new_std, _ = new_params + divergence = torch.distributions.kl_divergence(Normal(old_mean, old_std), Normal(new_mean, new_std)) + return (divergence * old_action_mask).sum(dim=-1) + + def _global_std(self) -> torch.Tensor: + """Concatenate the independently parameterized task-head standard deviations.""" + if self.std_type == "scalar": + stds = [param.clamp(self.std_range[0], self.std_range[1]) for param in self._std_params] + else: + stds = [torch.exp(param.clamp(self.log_std_range[0], self.log_std_range[1])) for param in self._std_params] + return torch.cat(stds) + + def _require_state(self) -> tuple[Normal, torch.Tensor]: + """Return the current distribution and mask or reject access before an update.""" + if self._distribution is None or self._action_mask is None: + raise RuntimeError("The task-headed Gaussian distribution has not been updated.") + return self._distribution, self._action_mask + + +class TaskHeadedMLPModel(MLPModel): + """Shared observation backbone with task-specific Gaussian action heads.""" + + def __init__( + self, + obs: TensorDict, + obs_groups: dict[str, list[str]], + obs_set: str, + output_dim: int, + hidden_dims: tuple[int, ...] | list[int] = (256, 256, 256), + activation: str = "elu", + obs_normalization: bool = False, + distribution_cfg: dict | None = None, + task_action_dims: tuple[int, ...] | list[int] = (8, 8, 6), + task_encoding_slice: tuple[int, int] | list[int] = (0, 3), + ) -> None: + """Initialize the shared backbone and contiguous task action heads. + + Args: + obs: Observation dictionary used to infer model input dimensions. + obs_groups: Observation groups assigned to each model set. + obs_set: Observation set consumed by this model. + output_dim: Fixed global action dimension. + hidden_dims: Shared-backbone layer dimensions. + activation: Shared-backbone activation function. + obs_normalization: Whether to normalize the shared observation input. + distribution_cfg: Task-headed Gaussian distribution configuration. + task_action_dims: Action dimensions ordered by task identity. + task_encoding_slice: Half-open slice of the raw model input containing the task one-hot. + + Raises: + TypeError: If the configured distribution is not task-headed. + ValueError: If dimensions or the initial task encoding are invalid. + """ + hidden_dims = _positive_dimensions(hidden_dims, "shared-backbone") + + super().__init__( + obs=obs, + obs_groups=obs_groups, + obs_set=obs_set, + output_dim=output_dim, + hidden_dims=hidden_dims, + activation=activation, + obs_normalization=obs_normalization, + distribution_cfg=None, + ) + + self.task_action_dims = _task_action_dimensions(task_action_dims, output_dim) + self.task_encoding_slice = _task_encoding_slice(task_encoding_slice, self.obs_dim, len(self.task_action_dims)) + _validate_task_encoding(self._task_encoding(obs)) + + # Replace the standard monolithic MLP with an activated shared backbone and task-local linear heads. + del self.mlp + self.backbone = _make_backbone(self.obs_dim, hidden_dims, activation) + self.action_heads = nn.ModuleList([nn.Linear(hidden_dims[-1], dim) for dim in self.task_action_dims]) + + if distribution_cfg is None: + raise ValueError("TaskHeadedMLPModel requires a task-headed distribution configuration.") + distribution_cfg = dict(distribution_cfg) + distribution_class: type[Distribution] = resolve_callable(distribution_cfg.pop("class_name")) # type: ignore + if not issubclass(distribution_class, TaskHeadedGaussianDistribution): + raise TypeError( + f"TaskHeadedMLPModel requires a TaskHeadedGaussianDistribution, got {distribution_class.__name__}." + ) + self.distribution = distribution_class( + output_dim, + task_action_dims=self.task_action_dims, + **distribution_cfg, + ) + + def forward( + self, + obs: TensorDict, + masks: torch.Tensor | None = None, + hidden_state: HiddenState = None, + stochastic_output: bool = False, + ) -> torch.Tensor: + """Route shared features through the task head selected by the raw task one-hot.""" + obs = unpad_trajectories(obs, masks) if masks is not None and not self.is_recurrent else obs + latent = self.backbone(self.get_latent(obs, masks, hidden_state)) + task_ids = self._task_encoding(obs).argmax(dim=-1) + task_encoding = F.one_hot(task_ids, num_classes=len(self.task_action_dims)).to(dtype=latent.dtype) + output = torch.cat( + [ + head(latent) * task_encoding[..., task_id : task_id + 1] + for task_id, head in enumerate(self.action_heads) + ], + dim=-1, + ) + if stochastic_output: + self.distribution.update(output, task_encoding) + return self.distribution.sample() + return output + + def as_jit(self) -> nn.Module: + """Return a TorchScript-compatible deterministic task-headed policy.""" + return _TorchTaskHeadedMLPModel(self) + + def as_onnx(self, verbose: bool = False) -> nn.Module: + """Return an ONNX-compatible deterministic task-headed policy.""" + return _OnnxTaskHeadedMLPModel(self, verbose) + + def _task_encoding(self, obs: TensorDict) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + return _task_encoding(obs, self.obs_groups, self.task_encoding_slice) + + +class TaskHeadedValueModel(MLPModel): + """Shared observation backbone with task-specific scalar value heads.""" + + def __init__( + self, + obs: TensorDict, + obs_groups: dict[str, list[str]], + obs_set: str, + output_dim: int, + hidden_dims: tuple[int, ...] | list[int] = (256, 256, 256), + activation: str = "elu", + obs_normalization: bool = False, + distribution_cfg: dict | None = None, + task_head_count: int = 3, + task_encoding_slice: tuple[int, int] | list[int] = (0, 3), + ) -> None: + """Initialize a shared value backbone and one scalar head per task. + + Args: + obs: Observation dictionary used to infer model input dimensions. + obs_groups: Observation groups assigned to each model set. + obs_set: Observation set consumed by this model. + output_dim: Critic output dimension, which must be one. + hidden_dims: Shared-backbone layer dimensions. + activation: Shared-backbone activation function. + obs_normalization: Whether to normalize the shared observation input. + distribution_cfg: Output distribution configuration, which must be ``None`` for a critic. + task_head_count: Number of task-specific scalar value heads. + task_encoding_slice: Half-open slice of the raw model input containing the task one-hot. + + Raises: + ValueError: If the value, backbone, task-head, or task-encoding configuration is invalid. + """ + if output_dim != 1: + raise ValueError(f"TaskHeadedValueModel requires a scalar output, got {output_dim}.") + hidden_dims = _positive_dimensions(hidden_dims, "shared-backbone") + if task_head_count <= 0: + raise ValueError(f"Expected a positive task head count, got {task_head_count}.") + if distribution_cfg is not None: + raise ValueError("TaskHeadedValueModel does not support an output distribution.") + + super().__init__( + obs=obs, + obs_groups=obs_groups, + obs_set=obs_set, + output_dim=output_dim, + hidden_dims=hidden_dims, + activation=activation, + obs_normalization=obs_normalization, + distribution_cfg=None, + ) + + self.task_head_count = task_head_count + self.task_encoding_slice = _task_encoding_slice(task_encoding_slice, self.obs_dim, task_head_count) + _validate_task_encoding(self._task_encoding(obs)) + + del self.mlp + self.backbone = _make_backbone(self.obs_dim, hidden_dims, activation) + self.value_heads = nn.ModuleList([nn.Linear(hidden_dims[-1], 1) for _ in range(task_head_count)]) + + def forward( + self, + obs: TensorDict, + masks: torch.Tensor | None = None, + hidden_state: HiddenState = None, + stochastic_output: bool = False, + ) -> torch.Tensor: + """Return the scalar value from the head selected by the raw task one-hot.""" + del stochastic_output + obs = unpad_trajectories(obs, masks) if masks is not None and not self.is_recurrent else obs + latent = self.backbone(self.get_latent(obs, masks, hidden_state)) + task_ids = self._task_encoding(obs).argmax(dim=-1, keepdim=True) + task_values = torch.cat([head(latent) for head in self.value_heads], dim=-1) + return torch.gather(task_values, dim=-1, index=task_ids) + + def _task_encoding(self, obs: TensorDict) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + return _task_encoding(obs, self.obs_groups, self.task_encoding_slice) + + +class _TorchTaskHeadedMLPModel(nn.Module): + """TorchScript-compatible deterministic task-headed policy.""" + + def __init__(self, model: TaskHeadedMLPModel) -> None: + """Copy inference modules and task routing metadata from a trained model.""" + super().__init__() + self.obs_normalizer = copy.deepcopy(model.obs_normalizer) + self.backbone = copy.deepcopy(model.backbone) + self.action_heads = copy.deepcopy(model.action_heads) + self.task_encoding_start = model.task_encoding_slice[0] + self.task_encoding_end = model.task_encoding_slice[1] + + def forward(self, obs: torch.Tensor) -> torch.Tensor: + """Return a fixed-width action containing only the selected task head.""" + task_ids = obs[..., self.task_encoding_start : self.task_encoding_end].argmax(dim=-1) + latent = self.backbone(self.obs_normalizer(obs)) + outputs: list[torch.Tensor] = [] + for task_id, head in enumerate(self.action_heads): + selected = (task_ids == task_id).unsqueeze(-1).to(dtype=latent.dtype) + outputs.append(head(latent) * selected) + return torch.cat(outputs, dim=-1) + + @torch.jit.export + def reset(self) -> None: + """Reset recurrent export state (no-op for the feed-forward model).""" + pass + + +class _OnnxTaskHeadedMLPModel(_TorchTaskHeadedMLPModel): + """ONNX-compatible deterministic task-headed policy.""" + + is_recurrent: bool = False + + def __init__(self, model: TaskHeadedMLPModel, verbose: bool) -> None: + """Copy a task-headed model and record ONNX export metadata.""" + super().__init__(model) + self.verbose = verbose + self.input_size = model.obs_dim + + def get_dummy_inputs(self) -> tuple[torch.Tensor]: + """Return a representative task-valid observation for ONNX tracing.""" + dummy_obs = torch.zeros(1, self.input_size) + dummy_obs[0, self.task_encoding_start] = 1.0 + return (dummy_obs,) + + @property + def input_names(self) -> list[str]: + """Return ONNX input tensor names.""" + return ["obs"] + + @property + def output_names(self) -> list[str]: + """Return ONNX output tensor names.""" + return ["actions"] + + +def _make_backbone(input_dim: int, hidden_dims: Sequence[int], activation: str) -> nn.Sequential: + """Create an activated MLP whose final hidden layer is shared by every task head.""" + layers: list[nn.Module] = [] + layer_input_dim = input_dim + for hidden_dim in hidden_dims: + layers.append(nn.Linear(layer_input_dim, hidden_dim)) + layers.append(resolve_nn_activation(activation)) + layer_input_dim = hidden_dim + return nn.Sequential(*layers) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py new file mode 100644 index 000000000000..370efabeba3f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py @@ -0,0 +1,108 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Task-balanced PPO for contributed heterogeneous manipulation.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import torch +from rsl_rl.algorithms import PPO +from rsl_rl.models import MLPModel +from rsl_rl.storage import RolloutStorage +from tensordict import TensorDict + + +class TaskBalancedPPO(PPO): + """PPO that normalizes rollout advantages independently for each task.""" + + def __init__( + self, + actor: MLPModel, + critic: MLPModel, + storage: RolloutStorage, + task_names: Sequence[str], + task_encoding_obs_group: str, + task_encoding_slice: Sequence[int], + **kwargs: Any, + ) -> None: + """Initialize task routing metadata and the base PPO algorithm. + + Args: + actor: Task-headed policy model. + critic: Task-headed value model. + storage: On-policy rollout storage. + task_names: Task names ordered by one-hot identity. + task_encoding_obs_group: Observation group containing the task one-hot. + task_encoding_slice: Half-open slice containing the task one-hot. + **kwargs: Base PPO keyword arguments. + + Raises: + ValueError: If task metadata is invalid or mixed mini-batch normalization is enabled. + """ + self.task_names = tuple(task_names) + if not self.task_names: + raise ValueError("Expected at least one task name.") + if len(task_encoding_slice) != 2: + raise ValueError(f"Expected a two-element task encoding slice, got {task_encoding_slice}.") + self.task_encoding_slice = (int(task_encoding_slice[0]), int(task_encoding_slice[1])) + encoding_start, encoding_end = self.task_encoding_slice + if encoding_start < 0 or encoding_end <= encoding_start: + raise ValueError(f"Expected an ordered non-negative task encoding slice, got {self.task_encoding_slice}.") + if encoding_end - encoding_start != len(self.task_names): + raise ValueError( + f"Task encoding slice {self.task_encoding_slice} has dimension {encoding_end - encoding_start}," + f" expected {len(self.task_names)}." + ) + if kwargs.get("normalize_advantage_per_mini_batch", False): + raise ValueError("TaskBalancedPPO is incompatible with mixed-task mini-batch advantage normalization.") + + self.task_encoding_obs_group = task_encoding_obs_group + self.task_statistics: dict[str, float] = {} + super().__init__(actor, critic, storage, **kwargs) + + def compute_returns(self, obs: TensorDict) -> None: + """Compute GAE targets and normalize advantages within each task rollout.""" + super().compute_returns(obs) + storage = self.storage + raw_advantages = storage.returns - storage.values + task_encoding = storage.observations[self.task_encoding_obs_group][ + ..., self.task_encoding_slice[0] : self.task_encoding_slice[1] + ] + self._validate_task_encoding(task_encoding) + task_ids = task_encoding.argmax(dim=-1) + + task_statistics = {} + for task_id, task_name in enumerate(self.task_names): + selected = task_ids == task_id + if not torch.any(selected): + raise ValueError(f"Rollout contains no samples for task '{task_name}'.") + task_advantages = raw_advantages[selected] + task_returns = storage.returns[selected] + advantage_mean = task_advantages.mean() + advantage_std = task_advantages.std(unbiased=False) + storage.advantages[selected] = (task_advantages - advantage_mean) / (advantage_std + 1.0e-8) + task_statistics[f"{task_name}_advantage_std"] = advantage_std.item() + task_statistics[f"{task_name}_return_std"] = task_returns.std(unbiased=False).item() + self.task_statistics = task_statistics + + def update(self) -> dict[str, float]: + """Run PPO updates and append per-task rollout scale diagnostics.""" + losses = super().update() + losses.update(self.task_statistics) + return losses + + def _validate_task_encoding(self, task_encoding: torch.Tensor) -> None: + """Validate one-hot task identities stored in the rollout.""" + if task_encoding.shape[-1] != len(self.task_names): + raise ValueError( + f"Expected {len(self.task_names)} task encoding dimensions, got {task_encoding.shape[-1]}." + ) + is_binary = torch.logical_or(task_encoding == 0.0, task_encoding == 1.0).all() + has_one_task = (task_encoding.sum(dim=-1) == 1.0).all() + if not bool(is_binary and has_one_task): + raise ValueError("Task encoding observations must be one-hot with exactly one active task per sample.") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py new file mode 100644 index 000000000000..b3618332aa8a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""MDP terms for contributed heterogeneous manipulation training.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi new file mode 100644 index 000000000000..cd12356000bf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi @@ -0,0 +1,79 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "CabinetOpenDrawerBonus", + "LiftGoalTracking", + "LiftSuccess", + "SelectedBinaryJointPositionActionCfg", + "SelectedJointPositionActionCfg", + "SelectedUniformPoseCommandCfg", + "articulation_state_invalid", + "cabinet_align_ee_handle", + "cabinet_align_grasp", + "cabinet_approach_ee_handle", + "cabinet_approach_gripper", + "cabinet_drawer_state", + "cabinet_ee_to_handle", + "cabinet_grasp_handle", + "cabinet_multi_stage_open", + "lift_ee_object_distance", + "lift_goal_tracking", + "lift_object_dropped", + "lift_object_height", + "lift_object_out_of_bounds", + "lift_object_position_b", + "lift_object_pose_b", + "reach_orientation_error", + "reach_position_error", + "reach_success", + "reset_multitask_scene", + "selected_action_l2", + "selected_action_rate_l2", + "selected_joint_pos_rel", + "selected_joint_vel_l2", + "selected_joint_vel_rel", + "task_encoding", + "task_time_out", +] + +from .actions_cfg import SelectedBinaryJointPositionActionCfg, SelectedJointPositionActionCfg +from .commands_cfg import SelectedUniformPoseCommandCfg +from .events import reset_multitask_scene +from .observations import ( + cabinet_drawer_state, + cabinet_ee_to_handle, + lift_object_position_b, + lift_object_pose_b, + selected_joint_pos_rel, + selected_joint_vel_rel, + task_encoding, +) +from .rewards import ( + CabinetOpenDrawerBonus, + LiftGoalTracking, + LiftSuccess, + cabinet_align_ee_handle, + cabinet_align_grasp, + cabinet_approach_ee_handle, + cabinet_approach_gripper, + cabinet_grasp_handle, + cabinet_multi_stage_open, + lift_ee_object_distance, + lift_goal_tracking, + lift_object_height, + reach_orientation_error, + reach_position_error, + selected_action_l2, + selected_action_rate_l2, + selected_joint_vel_l2, +) +from .terminations import ( + articulation_state_invalid, + lift_object_dropped, + lift_object_out_of_bounds, + reach_success, + task_time_out, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py new file mode 100644 index 000000000000..d2cb2af78a22 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py @@ -0,0 +1,130 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware joint action terms.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import ActionTerm + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedEnv + + from .actions_cfg import SelectedBinaryJointPositionActionCfg, SelectedJointPositionActionCfg + + +class _SelectedJointAction(ActionTerm): + """Resolve joints and maintain global inputs for a partial articulation view.""" + + cfg: SelectedBinaryJointPositionActionCfg | SelectedJointPositionActionCfg + _asset: Articulation + + def __init__( + self, cfg: SelectedBinaryJointPositionActionCfg | SelectedJointPositionActionCfg, env: ManagerBasedEnv + ) -> None: + super().__init__(cfg, env) + joint_ids, self._joint_names = self._asset.find_joints(cfg.joint_names, preserve_order=True, as_proxy=True) + self._joint_ids = joint_ids.torch + self._num_joints = len(joint_ids) + self._asset_cfg = SceneEntitySelectionCfg(cfg.asset_name) + self._asset_cfg.resolve(env.scene) + self._raw_actions = torch.zeros(env.num_envs, self.action_dim, device=self.device) + + @property + def raw_actions(self) -> torch.Tensor: + """Unprocessed global action batch.""" + return self._raw_actions + + @property + def processed_actions(self) -> torch.Tensor: + """Processed actions in asset physics-view order.""" + return self._processed_actions + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Clear raw actions for the reset global environments.""" + self._raw_actions[env_ids] = 0.0 + + +class SelectedJointPositionAction(_SelectedJointAction): + """Apply a global action batch to one articulation's selected physics-view rows.""" + + cfg: SelectedJointPositionActionCfg + _asset: Articulation + + def __init__(self, cfg: SelectedJointPositionActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + self._joint_limit_margin = cfg.joint_limit_margin + if self._joint_limit_margin is not None: + if not math.isfinite(self._joint_limit_margin) or self._joint_limit_margin < 0.0: + raise ValueError("joint_limit_margin must be finite and non-negative.") + limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] + if torch.any(limits[..., 0] + self._joint_limit_margin > limits[..., 1] - self._joint_limit_margin): + raise ValueError("joint_limit_margin leaves an empty target range for at least one joint.") + self._processed_actions = torch.zeros(len(self._asset_cfg.env_ids), self.action_dim, device=self.device) + + @property + def action_dim(self) -> int: + """Number of controlled joints.""" + return self._num_joints + + def process_actions(self, actions: torch.Tensor) -> None: + """Scale global actions and align them with the articulation physics view.""" + self._raw_actions[:] = actions + self._processed_actions = actions[self._asset_cfg.env_ids] * self.cfg.scale + if not self.cfg.relative: + self._processed_actions += self._asset.data.default_joint_pos.torch[:, self._joint_ids] + + def apply_actions(self) -> None: + """Write joint-position targets to the selected articulation instances.""" + target = self._processed_actions + if self.cfg.relative: + target = target + self._asset.data.joint_pos.torch[:, self._joint_ids] + if self._joint_limit_margin is not None: + limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] + target = torch.maximum( + torch.minimum(target, limits[..., 1] - self._joint_limit_margin), + limits[..., 0] + self._joint_limit_margin, + ) + self._asset.actuators.target_command.set_position_index(value=target, joint_ids=self._joint_ids) + + +class SelectedBinaryJointPositionAction(_SelectedJointAction): + """Map one binary global action to joint targets in asset physics-view order.""" + + cfg: SelectedBinaryJointPositionActionCfg + _asset: Articulation + + def __init__(self, cfg: SelectedBinaryJointPositionActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + self._processed_actions = torch.zeros(len(self._asset_cfg.env_ids), self._num_joints, device=self.device) + self._open_command = torch.full((self._num_joints,), cfg.open_command, device=self.device) + self._close_command = torch.full((self._num_joints,), cfg.close_command, device=self.device) + + @property + def action_dim(self) -> int: + """Binary action dimension.""" + return 1 + + def process_actions(self, actions: torch.Tensor) -> None: + """Choose open or closed targets from the sign of selected actions.""" + self._raw_actions[:] = actions + self._processed_actions = torch.where( + actions[self._asset_cfg.env_ids] < 0.0, self._close_command, self._open_command + ) + + def apply_actions(self) -> None: + """Write selected binary joint targets.""" + self._asset.actuators.target_command.set_position_index( + value=self._processed_actions, joint_ids=self._joint_ids + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py new file mode 100644 index 000000000000..2f85cceb4472 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py @@ -0,0 +1,103 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware pose commands for contributed heterogeneous task batches.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm +from isaaclab.markers import VisualizationMarkers +from isaaclab.utils.math import combine_frame_transforms, quat_from_euler_xyz, quat_unique + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + from .commands_cfg import SelectedUniformPoseCommandCfg + + +class SelectedUniformPoseCommand(CommandTerm): + """Sample root-frame pose commands only where the configured scene entity exists.""" + + cfg: SelectedUniformPoseCommandCfg + + def __init__(self, cfg: SelectedUniformPoseCommandCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self.pose_command = torch.zeros(self.num_envs, 7, device=self.device) + self._all_env_ids = torch.arange(self.num_envs, device=self.device) + self._reference_cfg = cfg.reference_cfg + self._tracked_cfg = cfg.tracked_cfg + self._reference_cfg.resolve(env.scene) + self._tracked_cfg.resolve(env.scene) + self._reference = env.scene[self._reference_cfg.name] + self._tracked = env.scene[self._tracked_cfg.name] + + @property + def command(self) -> torch.Tensor: + """Pose command, shape ``(num_envs, 7)``.""" + return self.pose_command + + def _resample_command(self, env_ids: Sequence[int]) -> None: + global_env_ids = self._all_env_ids[env_ids] + _, selected_env_ids = self._reference_cfg.select(global_env_ids) + ranges = self.cfg.ranges + sample = torch.empty(len(selected_env_ids), device=self.device) + self.pose_command[selected_env_ids, 0] = sample.uniform_(*ranges.pos_x) + self.pose_command[selected_env_ids, 1] = sample.uniform_(*ranges.pos_y) + self.pose_command[selected_env_ids, 2] = sample.uniform_(*ranges.pos_z) + euler = torch.empty(len(selected_env_ids), 3, device=self.device) + euler[:, 0].uniform_(*ranges.roll) + euler[:, 1].uniform_(*ranges.pitch) + euler[:, 2].uniform_(*ranges.yaw) + self.pose_command[selected_env_ids, 3:] = quat_unique(quat_from_euler_xyz(*euler.unbind(-1))) + + def _update_metrics(self) -> None: + pass + + def _update_command(self) -> None: + pass + + def _set_debug_vis_impl(self, debug_vis: bool) -> None: + if debug_vis and not hasattr(self, "goal_pose_visualizer"): + self.goal_pose_visualizer = VisualizationMarkers(self.cfg.goal_pose_visualizer_cfg) + self.current_pose_visualizer = VisualizationMarkers(self.cfg.current_pose_visualizer_cfg) + if not hasattr(self, "goal_pose_visualizer"): + return + self.goal_pose_visualizer.set_visibility(debug_vis) + self.current_pose_visualizer.set_visibility(debug_vis) + + def _debug_vis_callback(self, event) -> None: + if not self._reference.is_initialized or not self._tracked.is_initialized: + return + + reference_pose_w = self._reference.data.root_link_pose_w.torch + command = self.pose_command[self._reference_cfg.env_ids] + goal_pos_w, goal_quat_w = combine_frame_transforms( + reference_pose_w[:, :3], reference_pose_w[:, 3:7], command[:, :3], command[:, 3:] + ) + self.goal_pose_visualizer.visualize( + goal_pos_w, + goal_quat_w, + environment_ids=self._reference_cfg.env_ids, + ) + + if self._tracked_cfg.body_names is None: + tracked_pose_w = self._tracked.data.root_link_pose_w.torch + else: + tracked_pose_w = self._tracked.data.body_link_pose_w.torch[:, self._tracked_cfg.body_ids] + if tracked_pose_w.shape[1] != 1: + raise ValueError( + f"Expected '{self._tracked_cfg.name}' to select one tracked body, got {tracked_pose_w.shape[1]}." + ) + tracked_pose_w = tracked_pose_w[:, 0] + self.current_pose_visualizer.visualize( + tracked_pose_w[:, :3], + tracked_pose_w[:, 3:7], + environment_ids=self._tracked_cfg.env_ids, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py new file mode 100644 index 000000000000..ffc4ed42fd36 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware reset events for heterogeneous manipulation scenes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedEnv + + +def _reset_root(env: ManagerBasedEnv, asset_cfg: SceneEntitySelectionCfg, env_ids: torch.Tensor) -> None: + """Write an asset's default root state for selected global environments.""" + asset: Articulation | RigidObject = env.scene[asset_cfg.name] + rows, selected_env_ids = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + root_pose = asset.data.default_root_pose.torch[rows].clone() + root_pose[:, :3] += env.scene.env_origins[selected_env_ids] + asset.write_root_pose_to_sim_index(root_pose=root_pose, env_ids=rows) + asset.write_root_velocity_to_sim_index(root_velocity=asset.data.default_root_vel.torch[rows], env_ids=rows) + + +def _reset_joints_default(env: ManagerBasedEnv, asset_cfg: SceneEntitySelectionCfg, env_ids: torch.Tensor) -> None: + """Write default articulation joint states for selected global environments.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_vel = asset.data.default_joint_vel.torch[rows].clone() + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + asset.actuators.target_command.set_velocity_index(value=joint_vel, env_ids=rows) + + +def _randomize_joint_offset( + env: ManagerBasedEnv, + asset_cfg: SceneEntitySelectionCfg, + env_ids: torch.Tensor, + position_range: tuple[float, float], +) -> None: + """Reset articulation joints with uniform offsets from their defaults.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_pos += torch.empty_like(joint_pos).uniform_(*position_range) + limits = asset.data.soft_joint_pos_limits.torch[rows] + joint_pos.clamp_(limits[..., 0], limits[..., 1]) + joint_vel = torch.zeros_like(joint_pos) + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + + +def _randomize_joint_scale( + env: ManagerBasedEnv, + asset_cfg: SceneEntitySelectionCfg, + env_ids: torch.Tensor, + position_range: tuple[float, float], +) -> None: + """Reset articulation joints by uniformly scaling their default positions.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_pos *= torch.empty_like(joint_pos).uniform_(*position_range) + limits = asset.data.soft_joint_pos_limits.torch[rows] + joint_pos.clamp_(limits[..., 0], limits[..., 1]) + joint_vel = torch.zeros_like(joint_pos) + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + + +def reset_multitask_scene( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + root_asset_cfgs: tuple[SceneEntitySelectionCfg, ...], + lift_robot_cfg: SceneEntitySelectionCfg, + lift_object_cfg: SceneEntitySelectionCfg, + cabinet_robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + reach_robot_cfg: SceneEntitySelectionCfg, +) -> None: + """Reset active assets and apply task-specific initial-state randomization.""" + for asset_cfg in root_asset_cfgs: + _reset_root(env, asset_cfg, env_ids) + + _reset_joints_default(env, lift_robot_cfg, env_ids) + _reset_joints_default(env, cabinet_cfg, env_ids) + _randomize_joint_offset(env, cabinet_robot_cfg, env_ids, (-0.1, 0.1)) + _randomize_joint_scale(env, reach_robot_cfg, env_ids, (0.75, 1.25)) + + lift_object: RigidObject = env.scene[lift_object_cfg.name] + object_rows, object_env_ids = lift_object_cfg.select(env_ids) + if object_rows.numel() == 0: + return + object_pose = lift_object.data.default_root_pose.torch[object_rows].clone() + object_pose[:, :3] += env.scene.env_origins[object_env_ids] + object_pose[:, 0] += torch.empty(len(object_rows), device=env.device).uniform_(-0.1, 0.1) + object_pose[:, 1] += torch.empty(len(object_rows), device=env.device).uniform_(-0.25, 0.25) + lift_object.write_root_pose_to_sim_index(root_pose=object_pose, env_ids=object_rows) + lift_object.write_root_velocity_to_sim_index( + root_velocity=torch.zeros(len(object_rows), 6, device=env.device), env_ids=object_rows + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py new file mode 100644 index 000000000000..0f7316e2533f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py @@ -0,0 +1,112 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware observations for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg +from .utils import _offset_body_pose + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def task_encoding(env: ManagerBasedRLEnv, task_asset_cfgs: tuple[SceneEntitySelectionCfg, ...]) -> torch.Tensor: + """Return a one-hot identity for the selected manipulation task.""" + encoding = torch.zeros(env.num_envs, len(task_asset_cfgs), device=env.device) + for task_id, asset_cfg in enumerate(task_asset_cfgs): + encoding[asset_cfg.instance_ids >= 0, task_id] = 1.0 + return encoding + + +def selected_joint_pos_rel(env: ManagerBasedRLEnv, asset_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return relative joint positions in global environment order.""" + asset: Articulation = env.scene[asset_cfg.name] + values = ( + asset.data.joint_pos.torch[:, asset_cfg.joint_ids] - asset.data.default_joint_pos.torch[:, asset_cfg.joint_ids] + ) + return asset_cfg.scatter_to_envs(values) + + +def selected_joint_vel_rel(env: ManagerBasedRLEnv, asset_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return relative joint velocities in global environment order.""" + asset: Articulation = env.scene[asset_cfg.name] + values = ( + asset.data.joint_vel.torch[:, asset_cfg.joint_ids] - asset.data.default_joint_vel.torch[:, asset_cfg.joint_ids] + ) + return asset_cfg.scatter_to_envs(values) + + +def lift_object_pose_b( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the lift object's pose in the Franka root frame.""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + pos_b, quat_b = math_utils.subtract_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], + robot.data.root_quat_w.torch[robot_rows], + object_asset.data.root_pos_w.torch, + object_asset.data.root_quat_w.torch, + ) + return object_cfg.scatter_to_envs(torch.cat((pos_b, quat_b), dim=-1)) + + +def lift_object_position_b( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the lift object's position [m] in the OpenArm root frame.""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + pos_b, _ = math_utils.subtract_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], + robot.data.root_quat_w.torch[robot_rows], + object_asset.data.root_pos_w.torch, + ) + return object_cfg.scatter_to_envs(pos_b) + + +def cabinet_drawer_state(env: ManagerBasedRLEnv, cabinet_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return top-drawer position and velocity in global environment order.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + values = torch.cat( + ( + cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids], + cabinet.data.joint_vel.torch[:, cabinet_cfg.joint_ids], + ), + dim=-1, + ) + return cabinet_cfg.scatter_to_envs(values) + + +def cabinet_ee_to_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the vector [m] from the cabinet Franka TCP to the drawer handle.""" + env_ids, ee_pos_w, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.1034)) + _, handle_pos_w, _ = _offset_body_pose( + env, + cabinet_cfg, + (0.305, 0.0, 0.01), + (0.5, -0.5, -0.5, 0.5), + ) + values = handle_pos_w[cabinet_cfg.instance_ids[env_ids]] - ee_pos_w + return robot_cfg.scatter_to_envs(values) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py new file mode 100644 index 000000000000..7b57ea79eb85 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py @@ -0,0 +1,364 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware rewards for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import ManagerTermBase, RewardTermCfg +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg +from .utils import _offset_body_pose + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def _action_slice(env: ManagerBasedRLEnv, term_names: tuple[str, ...]) -> slice: + """Return the cached contiguous global action slice occupied by action terms.""" + cache = env.__dict__.setdefault("_multitask_action_slices", {}) + if term_names not in cache: + starts = {} + start = 0 + for name, dim in zip(env.action_manager.active_terms, env.action_manager.action_term_dim): + starts[name] = (start, start + dim) + start += dim + cache[term_names] = slice(starts[term_names[0]][0], starts[term_names[-1]][1]) + return cache[term_names] + + +def _lift_goal_error( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return lift global IDs and object-to-goal position error [m].""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + command = env.command_manager.get_command(command_name)[object_cfg.env_ids] + goal_pos_w, _ = math_utils.combine_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], robot.data.root_quat_w.torch[robot_rows], command[:, :3] + ) + error = torch.linalg.norm(object_asset.data.root_pos_w.torch - goal_pos_w, dim=-1) + return object_cfg.env_ids, error + + +def lift_ee_object_distance( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + std: float, +) -> torch.Tensor: + """Reward the lift OpenArm for bringing its TCP close to the object.""" + env_ids, tcp_pos_w, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.0)) + object_asset: RigidObject = env.scene[object_cfg.name] + distance = torch.linalg.norm( + object_asset.data.root_pos_w.torch[object_cfg.instance_ids[env_ids]] - tcp_pos_w, dim=-1 + ) + return robot_cfg.scatter_to_envs(1.0 - torch.tanh(distance / std)) + + +def lift_object_height( + env: ManagerBasedRLEnv, object_cfg: SceneEntitySelectionCfg, minimum_height: float +) -> torch.Tensor: + """Reward lift environments whose object rises above an environment-frame height [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[object_cfg.env_ids, 2] + return object_cfg.scatter_to_envs((height > minimum_height).float()) + + +def lift_goal_tracking( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + std: float, + minimum_height: float, +) -> torch.Tensor: + """Reward lift-object target tracking after the object clears the table.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[env_ids, 2] + reward = (height > minimum_height) * (1.0 - torch.tanh(error / std)) + return object_cfg.scatter_to_envs(reward) + + +class _LiftSuccessTerm(ManagerTermBase): + """Maintain and log sticky success state for lift reward terms.""" + + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv) -> None: + super().__init__(cfg, env) + self.succeeded = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + + def reset(self, env_ids: torch.Tensor) -> None: + """Log and clear success for reset lift environments.""" + object_cfg: SceneEntitySelectionCfg = self.cfg.params["object_cfg"] + selected = env_ids[object_cfg.instance_ids[env_ids] >= 0] + if selected.numel() == 0: + return + log = self._env.extras.setdefault("log", {}) + log["Metrics/lift_success_rate"] = self.succeeded[selected].float().mean().item() + self.succeeded[selected] = False + + +class LiftGoalTracking(_LiftSuccessTerm): + """Reward OpenArm goal tracking and log sticky per-episode success.""" + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + std: float, + minimum_height: float, + success_threshold: float, + ) -> torch.Tensor: + """Return coarse goal tracking and update sticky success state.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[env_ids, 2] + is_lifted = height > minimum_height + self.succeeded[env_ids] |= is_lifted & (error < success_threshold) + reward = is_lifted * (1.0 - torch.tanh(error / std)) + return object_cfg.scatter_to_envs(reward) + + +class LiftSuccess(_LiftSuccessTerm): + """Reward lift-goal completion and log sticky episode success.""" + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + threshold: float, + ) -> torch.Tensor: + """Reward lift environments whose object reaches the sampled goal.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + succeeded = error < threshold + self.succeeded[env_ids] |= succeeded + return object_cfg.scatter_to_envs(succeeded.float()) + + +def _cabinet_frames( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> tuple[torch.Tensor, ...]: + """Return aligned cabinet-task TCP, handle, and fingertip poses.""" + env_ids, ee_pos, ee_quat = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.1034)) + _, handle_pos, handle_quat = _offset_body_pose(env, cabinet_cfg, (0.305, 0.0, 0.01), (0.5, -0.5, -0.5, 0.5)) + _, left_pos, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.046), body_index=1) + _, right_pos, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.046), body_index=2) + cabinet_rows = cabinet_cfg.instance_ids[env_ids] + return env_ids, ee_pos, ee_quat, handle_pos[cabinet_rows], handle_quat[cabinet_rows], left_pos, right_pos + + +def cabinet_approach_ee_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + threshold: float, +) -> torch.Tensor: + """Reward cabinet environments for approaching the drawer handle.""" + env_ids, ee_pos, _, handle_pos, _, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + distance = torch.linalg.norm(handle_pos - ee_pos, dim=-1) + reward = torch.square(1.0 / (1.0 + distance**2)) + reward = torch.where(distance <= threshold, 2.0 * reward, reward) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def cabinet_align_ee_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward cabinet environments for aligning the TCP with the drawer handle.""" + env_ids, _, ee_quat, _, handle_quat, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + ee_rot = math_utils.matrix_from_quat(ee_quat) + handle_rot = math_utils.matrix_from_quat(handle_quat) + align_z = torch.sum(ee_rot[..., 2] * -handle_rot[..., 0], dim=-1) + align_x = torch.sum(ee_rot[..., 0] * -handle_rot[..., 1], dim=-1) + reward = 0.5 * (torch.sign(align_z) * align_z**2 + torch.sign(align_x) * align_x**2) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def _cabinet_grasp_alignment( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Return cabinet IDs, grasp mask, handle position, and fingertip positions.""" + env_ids, _, _, handle_pos, _, left_pos, right_pos = _cabinet_frames(env, robot_cfg, cabinet_cfg) + graspable = (right_pos[:, 2] < handle_pos[:, 2]) & (left_pos[:, 2] > handle_pos[:, 2]) + return env_ids, graspable, handle_pos, torch.stack((left_pos, right_pos), dim=1) + + +def cabinet_align_grasp( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward cabinet environments when the fingers straddle the drawer handle.""" + env_ids, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + return robot_cfg.scatter_to_envs(graspable.float()) + + +def cabinet_approach_gripper( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + offset: float, +) -> torch.Tensor: + """Reward cabinet environments for placing both fingers around the handle.""" + env_ids, graspable, handle_pos, fingertips = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + distances = torch.abs(fingertips[..., 2] - handle_pos[:, None, 2]) + reward = graspable * torch.sum(offset - distances, dim=-1) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def cabinet_grasp_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + threshold: float, + open_joint_pos: float, +) -> torch.Tensor: + """Reward closing the cabinet Franka fingers near the drawer handle.""" + env_ids, ee_pos, _, handle_pos, _, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + robot: Articulation = env.scene[robot_cfg.name] + distance = torch.linalg.norm(handle_pos - ee_pos, dim=-1) + reward = (distance <= threshold) * torch.sum( + open_joint_pos - robot.data.joint_pos.torch[:, robot_cfg.joint_ids], dim=-1 + ) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +class CabinetOpenDrawerBonus(ManagerTermBase): + """Reward drawer opening and log cabinet-task success statistics.""" + + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv) -> None: + super().__init__(cfg, env) + self.succeeded = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + self.best_drawer_pos = torch.zeros(env.num_envs, device=env.device) + + def reset(self, env_ids: torch.Tensor) -> None: + """Log and clear episode statistics for reset cabinet environments.""" + cabinet_cfg: SceneEntitySelectionCfg = self.cfg.params["cabinet_cfg"] + selected = env_ids[cabinet_cfg.instance_ids[env_ids] >= 0] + if selected.numel() == 0: + return + log = self._env.extras.setdefault("log", {}) + log["Metrics/cabinet_success_rate"] = self.succeeded[selected].float().mean().item() + log["Metrics/cabinet_drawer_pos"] = self.best_drawer_pos[selected].mean().item() + self.succeeded[selected] = False + self.best_drawer_pos[selected] = 0.0 + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + success_threshold: float, + ) -> torch.Tensor: + """Compute drawer displacement with a grasp-alignment multiplier.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + drawer_pos = cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids[0]] + drawer_limits = cabinet.data.soft_joint_pos_limits.torch[:, cabinet_cfg.joint_ids[0]] + drawer_pos = torch.nan_to_num(drawer_pos, nan=0.0, posinf=0.0, neginf=0.0) + drawer_pos = torch.maximum(torch.minimum(drawer_pos, drawer_limits[:, 1]), drawer_limits[:, 0]) + env_ids, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + self.succeeded[env_ids] |= drawer_pos > success_threshold + self.best_drawer_pos[env_ids] = torch.maximum(self.best_drawer_pos[env_ids], drawer_pos) + return robot_cfg.scatter_to_envs((graspable.float() + 1.0) * drawer_pos) + + +def cabinet_multi_stage_open( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward easy, medium, and hard drawer-opening milestones.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + drawer_pos = cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids[0]] + _, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + reward = (drawer_pos > 0.01) * 0.5 + reward += (drawer_pos > 0.2) * graspable + reward += (drawer_pos > 0.3) * graspable + return cabinet_cfg.scatter_to_envs(reward) + + +def reach_position_error(env: ManagerBasedRLEnv, robot_cfg: SceneEntitySelectionCfg, command_name: str) -> torch.Tensor: + """Return UR10 end-effector position error [m] in reach environments.""" + robot: Articulation = env.scene[robot_cfg.name] + command = env.command_manager.get_command(command_name)[robot_cfg.env_ids] + goal_pos_w, _ = math_utils.combine_frame_transforms( + robot.data.root_pos_w.torch, robot.data.root_quat_w.torch, command[:, :3] + ) + error = torch.linalg.norm(robot.data.body_pos_w.torch[:, robot_cfg.body_ids[0]] - goal_pos_w, dim=-1) + return robot_cfg.scatter_to_envs(error) + + +def reach_orientation_error( + env: ManagerBasedRLEnv, robot_cfg: SceneEntitySelectionCfg, command_name: str +) -> torch.Tensor: + """Return UR10 end-effector orientation error [rad] in reach environments.""" + robot: Articulation = env.scene[robot_cfg.name] + command = env.command_manager.get_command(command_name)[robot_cfg.env_ids] + goal_quat_w = math_utils.quat_mul(robot.data.root_quat_w.torch, command[:, 3:7]) + error = math_utils.quat_error_magnitude(robot.data.body_quat_w.torch[:, robot_cfg.body_ids[0]], goal_quat_w) + return robot_cfg.scatter_to_envs(error) + + +def selected_action_l2( + env: ManagerBasedRLEnv, + task_asset_cfg: SceneEntitySelectionCfg, + action_term_names: tuple[str, ...], +) -> torch.Tensor: + """Penalize one task action head in environments containing that task asset.""" + selected_slice = _action_slice(env, action_term_names) + penalty = torch.sum(torch.square(env.action_manager.action[:, selected_slice]), dim=-1) + return penalty * (task_asset_cfg.instance_ids >= 0) + + +def selected_action_rate_l2( + env: ManagerBasedRLEnv, + task_asset_cfg: SceneEntitySelectionCfg, + action_term_names: tuple[str, ...], +) -> torch.Tensor: + """Penalize changes in one task action head where that task asset exists.""" + selected_slice = _action_slice(env, action_term_names) + delta = env.action_manager.action[:, selected_slice] - env.action_manager.prev_action[:, selected_slice] + return torch.sum(torch.square(delta), dim=-1) * (task_asset_cfg.instance_ids >= 0) + + +def selected_joint_vel_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + max_velocity: float | None = None, +) -> torch.Tensor: + """Penalize articulation joint velocities in environments containing it. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware articulation and joints. + max_velocity: Optional absolute velocity bound [m/s or rad/s, depending on joint type]. + """ + asset: Articulation = env.scene[asset_cfg.name] + joint_vel = torch.nan_to_num(asset.data.joint_vel.torch[:, asset_cfg.joint_ids]) + if max_velocity is not None: + joint_vel = joint_vel.clamp(-max_velocity, max_velocity) + values = torch.sum(torch.square(joint_vel), dim=-1) + return asset_cfg.scatter_to_envs(values) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py new file mode 100644 index 000000000000..3aebab1c40f4 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py @@ -0,0 +1,101 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware terminations for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from ..selection_utils import SceneEntitySelectionCfg +from .rewards import reach_orientation_error, reach_position_error + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def reach_success( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + command_name: str, + position_threshold: float, + orientation_threshold: float, +) -> torch.Tensor: + """Terminate successful UR10 reach environments.""" + position_error = reach_position_error(env, robot_cfg, command_name) + orientation_error = reach_orientation_error(env, robot_cfg, command_name) + return ( + (robot_cfg.instance_ids >= 0) + & (position_error < position_threshold) + & (orientation_error < orientation_threshold) + ) + + +def lift_object_out_of_bounds( + env: ManagerBasedRLEnv, + object_cfg: SceneEntitySelectionCfg, + bounds: tuple[tuple[float, float], tuple[float, float], tuple[float, float]], +) -> torch.Tensor: + """Terminate lift environments whose object leaves configured local bounds [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + position = object_asset.data.root_pos_w.torch - env.scene.env_origins[object_cfg.env_ids] + lower = position.new_tensor([axis[0] for axis in bounds]) + upper = position.new_tensor([axis[1] for axis in bounds]) + outside = torch.any((position < lower) | (position > upper), dim=-1) + return object_cfg.scatter_to_envs(outside) + + +def lift_object_dropped( + env: ManagerBasedRLEnv, + object_cfg: SceneEntitySelectionCfg, + minimum_height: float, +) -> torch.Tensor: + """Terminate lift environments whose object falls below a local height [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[object_cfg.env_ids, 2] + return object_cfg.scatter_to_envs(height < minimum_height) + + +def articulation_state_invalid( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + max_joint_velocity: float, + joint_position_margin: float, +) -> torch.Tensor: + """Terminate selected articulations with non-finite or implausible joint state. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware articulation and joints. + max_joint_velocity: Maximum absolute joint velocity [m/s or rad/s, depending on joint type]. + joint_position_margin: Allowed distance beyond soft position limits [m or rad, depending on joint type]. + """ + asset: Articulation = env.scene[asset_cfg.name] + joint_pos = asset.data.joint_pos.torch[:, asset_cfg.joint_ids] + joint_vel = asset.data.joint_vel.torch[:, asset_cfg.joint_ids] + limits = asset.data.soft_joint_pos_limits.torch[:, asset_cfg.joint_ids] + invalid = ~torch.isfinite(joint_pos).all(dim=-1) | ~torch.isfinite(joint_vel).all(dim=-1) + invalid |= torch.any(torch.abs(joint_vel) > max_joint_velocity, dim=-1) + invalid |= torch.any( + (joint_pos < limits[..., 0] - joint_position_margin) | (joint_pos > limits[..., 1] + joint_position_margin), + dim=-1, + ) + return asset_cfg.scatter_to_envs(invalid) + + +def task_time_out( + env: ManagerBasedRLEnv, + task_asset_cfgs: tuple[SceneEntitySelectionCfg, ...], + episode_lengths_s: tuple[float, ...], +) -> torch.Tensor: + """Time out each task at its homogeneous episode duration [s].""" + timed_out = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + for asset_cfg, episode_length_s in zip(task_asset_cfgs, episode_lengths_s): + max_steps = round(episode_length_s / env.step_dt) + timed_out |= (asset_cfg.instance_ids >= 0) & (env.episode_length_buf >= max_steps) + return timed_out diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py new file mode 100644 index 000000000000..d7725d4423bc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py @@ -0,0 +1,47 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Private tensor helpers for heterogeneous manipulation MDP terms.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def _offset_body_pose( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + offset_pos: tuple[float, float, float], + offset_quat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + body_index: int = 0, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Return selected global IDs and an offset body pose in the world frame. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware entity containing exactly one body. + offset_pos: Child-frame translation [m]. + offset_quat: Child-frame quaternion in ``(x, y, z, w)`` order. + body_index: Index into the resolved body selection. + + Returns: + Global environment IDs, positions [m], and quaternions for the selected view rows. + """ + asset: Articulation = env.scene[asset_cfg.name] + pose = asset.data.body_link_pose_w.torch[:, asset_cfg.body_ids[body_index]] + pos = pose.new_tensor(offset_pos).expand(len(pose), -1) + quat = math_utils.convert_quat(pose.new_tensor(offset_quat).expand(len(pose), -1), to="wxyz") + pos_w, quat_w = math_utils.combine_frame_transforms(pose[:, :3], pose[:, 3:7], pos, quat) + return asset_cfg.env_ids, pos_w, quat_w diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py new file mode 100644 index 000000000000..ace525ab8eac --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py @@ -0,0 +1,61 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Manager-based environment with selection-aware heterogeneous resets.""" + +from collections.abc import Sequence + +import torch + +from isaaclab.envs import ManagerBasedRLEnv + +from .selection_utils import SceneEntitySelectionCfg + + +class MultitaskManipulationEnv(ManagerBasedRLEnv): + """Manager-based manipulation environment whose assets occupy partial physics views.""" + + def _reset_idx(self, env_ids: Sequence[int]) -> None: + """Reset global environments through each asset's view-row mapping. + + Args: + env_ids: Global environment IDs to reset. + """ + global_env_ids = torch.as_tensor(env_ids, dtype=torch.long, device=self.device) + + self.curriculum_manager.compute(env_ids=global_env_ids) + for asset_name, asset in (*self.scene.articulations.items(), *self.scene.rigid_objects.items()): + rows, _ = self._get_entity_selection(asset_name).select(global_env_ids) + if rows.numel() > 0: + asset.reset(rows) + + if "reset" in self.event_manager.available_modes: + env_step_count = self._sim_step_counter // self.cfg.decimation + self.event_manager.apply(mode="reset", env_ids=global_env_ids, global_env_step_count=env_step_count) + + self.extras["log"] = {} + managers = ( + self.observation_manager, + self.action_manager, + self.reward_manager, + self.curriculum_manager, + self.command_manager, + self.event_manager, + self.termination_manager, + self.recorder_manager, + ) + for manager in managers: + self.extras["log"].update(manager.reset(global_env_ids)) + + self.episode_length_buf[global_env_ids] = 0 + self.sim.render_context.reset_scene_state_cadence() + + def _get_entity_selection(self, asset_name: str) -> SceneEntitySelectionCfg: + """Return the cached selection configuration for a complete scene asset.""" + cache: dict[str, SceneEntitySelectionCfg] = self.__dict__.setdefault("_scene_entity_selections", {}) + if asset_name not in cache: + cache[asset_name] = SceneEntitySelectionCfg(asset_name) + cache[asset_name].resolve(self.scene) + return cache[asset_name] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py index cf604bdc6274..25de2b02b8a6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py @@ -3,12 +3,11 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Contributed Shadow Hand tasks: the rendering-throughput benchmark and the OpenAI variant.""" +"""Contributed Shadow Hand tasks: the OpenAI variants.""" import gymnasium as gym from isaaclab_tasks.contrib.reorient.config.shadow_hand import agents as contrib_agents -from isaaclab_tasks.core.reorient.config.shadow_hand import agents as core_agents gym.register( id="IsaacContrib-Reorient-Cube-Shadow-OpenAI-FF-Direct", @@ -31,13 +30,3 @@ "rsl_rl_cfg_entry_point": f"{contrib_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandAsymLSTMPPORunnerCfg", }, ) -gym.register( - id="IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct", - entry_point="isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_direct_camera_env:ShadowHandCameraEnv", - disable_env_checker=True, - kwargs={ - "env_cfg_entry_point": f"{__name__}.shadow_hand_camera_benchmark_env_cfg:ShadowHandCameraBenchmarkEnvCfg", - "rsl_rl_cfg_entry_point": f"{core_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandCameraFFPPORunnerCfg", - "rl_games_cfg_entry_point": f"{core_agents.__name__}:rl_games_ppo_camera_cfg.yaml", - }, -) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py index bad828ad1f40..6f54f541c9be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py @@ -41,4 +41,4 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "trunk" self.events.base_external_force_torque.params["asset_cfg"].body_names = "trunk" - self.events.base_com.default.params["asset_cfg"].body_names = "trunk" + self.events.base_com.params["asset_cfg"].body_names = "trunk" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py index 5badbdf012d0..4d3af315ffe7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -22,6 +21,3 @@ def __post_init__(self): # scene self.scene.robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["legs"].armature = preset( - default=0.0, newton_mjwarp=0.01, physx=0.0, isaacsim_physx=0.0 - ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py index f22b239d7aab..fffbd52389c5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py @@ -30,7 +30,8 @@ class DigitRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, - entropy_coef=0.01, + # MJWarp needs a lower entropy floor than PhysX here (0.005 vs the usual 0.01) -- see #7520. + entropy_coef=0.005, num_learning_epochs=5, num_mini_batches=4, learning_rate=1.0e-3, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py index 80d7254169b1..c39dd646f0ea 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py @@ -5,25 +5,34 @@ import math +from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.physics import PhysxCfg -from isaaclab.actuators import ImplicitActuatorCfg -from isaaclab.managers import ObservationGroupCfg, ObservationTermCfg, RewardTermCfg, SceneEntityCfg, TerminationTermCfg +from isaaclab.managers import ( + ObservationGroupCfg, + ObservationTermCfg, + RewardTermCfg, + SceneEntityCfg, + TerminationTermCfg, +) from isaaclab.physics import PhysxAutoCfg from isaaclab.sim import SimulationCfg from isaaclab.utils import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.core.velocity.mdp as mdp -from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import PresetCfg +from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg, RoughPhysicsCfg +from isaaclab_tasks.utils import PresetCfg, preset from isaaclab_assets.robots.agility import ARM_JOINT_NAMES, DIGIT_V4_CFG, LEG_JOINT_NAMES +_ROUGH_NEWTON_MJWARP = RoughPhysicsCfg().newton_mjwarp +"""Bound once so ``DigitPhysicsCfg`` does not construct ``RoughPhysicsCfg`` twice.""" + @configclass class DigitPhysicsCfg(PresetCfg): - """PhysX-only physics configuration for the Digit velocity environments.""" + """Physics configuration for the Digit velocity environments.""" isaacsim_physx = PhysxCfg( gpu_max_rigid_patch_count=10 * 2**15, @@ -31,6 +40,13 @@ class DigitPhysicsCfg(PresetCfg): gpu_total_aggregate_pairs_capacity=2**23, ) physx = PhysxAutoCfg(isaacsim_physx=isaacsim_physx) + # ``class_type`` is reset to ``None`` because ``NewtonCfg.__post_init__`` re-derives it from + # ``solver_cfg`` and refuses an explicit value -- ``replace()`` would otherwise carry the + # already-derived value from the source instance forward as one. + newton_mjwarp = _ROUGH_NEWTON_MJWARP.replace( + class_type=None, + solver_cfg=_ROUGH_NEWTON_MJWARP.solver_cfg.replace(njmax=5000, nconmax=2000), + ) default = isaacsim_physx @@ -244,14 +260,6 @@ def __post_init__(self): self.scene.contact_forces.history_length = self.decimation self.scene.contact_forces.update_period = self.sim.dt self.scene.height_scanner.update_period = self.decimation * self.sim.dt - # target only actuated joints explicitly — ".*" mis-indexes Digit's ball-joint DoFs - self.scene.robot.actuators = { - "legs_arms": ImplicitActuatorCfg( - joint_names_expr=LEG_JOINT_NAMES + ARM_JOINT_NAMES, - stiffness=None, - damping=None, - ), - } # commands self.commands.base_velocity.ranges.lin_vel_x = (-0.8, 0.8) self.commands.base_velocity.ranges.lin_vel_y = (-0.5, 0.5) @@ -261,8 +269,12 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "torso_base" self.events.base_external_force_torque.params["asset_cfg"].body_names = "torso_base" - # Digit is PhysX-only, so the inherited ``newton_mjwarp`` branch names no - # reachable backend; collapse the preset so it cannot be selected on its own. - self.events.base_com = self.events.base_com.default self.events.base_com.params["asset_cfg"].body_names = "torso_base" + # The asset authors enabledSelfCollisions=False and DIGIT_V4_CFG sets no + # articulation_props, so Newton filters every intra-articulation shape pair -- 253 of + # them, exactly C(23,2) for its 23 colliding shapes -- and the legs pass through each + # other. Which links carry colliders is left as the asset authored it. + self.scene.robot.spawn.articulation_props = preset( + default=None, newton_mjwarp=[NewtonArticulationCfg(self_collision_enabled=True)] + ) self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py index 90d64e85db7a..678c33ceaa62 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -22,7 +21,6 @@ def __post_init__(self): # scene self.scene.robot = UNITREE_GO1_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["base_legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/trunk" # scale down the terrains because the robot is small self.scene.terrain.terrain_generator.sub_terrains["boxes"].grid_height_range = (0.025, 0.1) @@ -43,4 +41,4 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "trunk" self.events.base_external_force_torque.params["asset_cfg"].body_names = "trunk" - self.events.base_com.default.params["asset_cfg"].body_names = "trunk" + self.events.base_com.params["asset_cfg"].body_names = "trunk" diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py index 8c03df681655..02beba2f03c3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py @@ -58,9 +58,9 @@ def _setup_scene(self): self.cartpole = Articulation(self.cfg.robot_cfg) self._tiled_camera = Camera(self.cfg.tiled_camera) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) - cloner.replicate(plan, stage=self.scene.stage) + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py index 481299efa446..923fdba93b02 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py @@ -40,10 +40,10 @@ def _setup_scene(self): self.cartpole = Articulation(self.cfg.robot_cfg) spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py index cc561d92c46a..d3d4ed87713a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py @@ -111,10 +111,10 @@ def _setup_scene(self): # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=["/World/ground"]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py index 26c063d78da2..b244c84dcab0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py @@ -24,6 +24,8 @@ # legacy asset so the upstream franka tasks keep their demos and baselines. FRANKA_PANDA_LIFT_CFG = FRANKA_PANDA_CFG.copy() FRANKA_PANDA_LIFT_CFG.spawn.usd_path = f"{ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/franka_panda.usda" +# Reset clearance was calibrated for these arm meshes; the asset's primitive colliders intersect the ground. +FRANKA_PANDA_LIFT_CFG.spawn.variants = {"Colliders": "convex_hulls"} FRANKA_PANDA_LIFT_CFG.actuators = { # Inspired by libfranka's joint_impedance_control.cpp. ``actuator_velocity_limit`` # remains the soft task-limit snapshot; ``joint_velocity_limit`` is the diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py index 096285d2cd54..021dc9ce8e85 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/mdp/events.py @@ -249,8 +249,8 @@ def roll_once(roll_ids: torch.Tensor) -> torch.Tensor: if not self._prefilled: # envs sharing a clone-mask column are clones of the same unique asset combination - mask = env.scene.clone_plan.clone_mask.to(device=env.device, dtype=torch.uint8) - self._group = torch.unique(mask.T, dim=0, return_inverse=True)[1] + _, group = np.unique(env.scene.clone_plan.clone_mask.T, axis=0, return_inverse=True) + self._group = torch.as_tensor(group, device=env.device) num_groups = int(self._group.max().item()) + 1 # without a descriptor there is nothing to spread over, so harvesting extra is waste harvest_size = buffer_size_per_group diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py index 0ef31384c3f8..c16798766293 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py @@ -64,10 +64,10 @@ def _setup_scene(self): self.cfg.terrain.env_spacing = self.scene.cfg.env_spacing self.terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = (self.cfg.terrain.prim_path,) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[self.cfg.terrain.prim_path]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py index 75cce02ece4f..98bdeccf0c36 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py @@ -46,10 +46,10 @@ def _setup_scene(self): # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env.py index 141bba547615..318fb6ad8efa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env.py @@ -53,9 +53,9 @@ def _setup_scene(self): self._joint_wrench_sensor = self._create_joint_wrench_sensor() self._tiled_camera = Camera(self.cfg.tiled_camera) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) - cloner.replicate(plan, stage=self.scene.stage) + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py index 20f6c401b65e..0d7950905822 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py @@ -185,10 +185,10 @@ def _setup_scene(self): # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=["/World/ground"]) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py index 16170f952f12..f65b6af462a1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py @@ -13,7 +13,6 @@ LocomotionVelocityRoughEnvCfg, RewardsCfg, ) -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -60,7 +59,6 @@ def __post_init__(self): # scene self.scene.robot = CASSIE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/pelvis" # actions self.actions.joint_pos.scale = 0.5 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index 197c80a59180..7b890ebdf66c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -7,19 +7,11 @@ from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg -from isaaclab_tasks.utils import preset - @configclass class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): num_steps_per_env = 24 - # Newton needs ~1.7x the PPO iterations to match PhysX on G1. PhysX saturates near iter 3000 - # (reward ≈ +18, ep_len ≈ 980) and does not meaningfully improve on either metric past that — - # reward oscillates +16 to +19 through iter 7500, ep_len stays flat. Newton reaches the same - # (reward, ep_len) quality at iter 5000 (+16 / 984). Comparing reward alone is misleading: - # ep_len confirms the robot is stable in both cases. The gap is sample-efficiency, not a - # ceiling — no physics or reward tuning closes it. - max_iterations = preset(default=3000, newton_mjwarp=5000) + max_iterations = 5000 save_interval = 50 experiment_name = "g1_rough" obs_groups = {"actor": ["policy"], "critic": ["policy"]} diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py index 925318d6187f..ed7e260714f8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -25,7 +24,6 @@ def __post_init__(self): self.sim.use_newton_actuators = True # scene self.scene.robot = UNITREE_GO2_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["base_legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/base" # scale down the terrains because the robot is small terrains = self.scene.terrain.terrain_generator.sub_terrains diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py index 1d127d6c33e9..7aeae7f961fb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py @@ -36,7 +36,7 @@ from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.core.velocity.mdp as mdp -from isaaclab_tasks.utils import PresetCfg, preset +from isaaclab_tasks.utils import PresetCfg ## # Pre-defined configs @@ -223,15 +223,13 @@ class EventsCfg: }, ) - base_com = preset( - default=EventTerm( - func=mdp.randomize_rigid_body_com, - mode="startup", - params={ - "asset_cfg": SceneEntityCfg("robot", body_names="base"), - "com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.01, 0.01)}, - }, - ), + base_com = EventTerm( + func=mdp.randomize_rigid_body_com, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names="base"), + "com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.01, 0.01)}, + }, ) # reset diff --git a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py new file mode 100644 index 000000000000..431cdfa4730e --- /dev/null +++ b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py @@ -0,0 +1,130 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the ``Isaac-RenderBenchmark-Franka-Cabinet`` task's registration and presets. + +No Kit/GPU required: these only load and resolve the config through the registry, the same way +:mod:`scripts.benchmarks.benchmark_renderer` selects a preset before launching a run. +""" + +from types import SimpleNamespace + +import gymnasium as gym +import pytest +from isaaclab_newton.physics import NewtonCfg +from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_physx.physics import PhysxCfg + +from isaaclab.physics import PhysxAutoCfg + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env import RenderBenchmarkEnv +from isaaclab_tasks.utils.hydra import collect_presets, resolve_presets +from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry + +_TASK = "Isaac-RenderBenchmark-Franka-Cabinet" + + +def _load_cfg(): + return load_cfg_from_registry(_TASK, "env_cfg_entry_point") + + +def test_task_registered_with_direct_entry_point(): + spec = gym.spec(_TASK) + + assert spec.kwargs["env_cfg_entry_point"] == ( + "isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env_cfg:RenderBenchmarkFrankaCabinetEnvCfg" + ) + assert spec.disable_env_checker is True + + +def test_default_scene_and_articulations(): + cfg = _load_cfg() + + assert cfg.decimation == 2 + assert cfg.episode_length_s == pytest.approx(60.0) + assert cfg.scene.num_envs == 4 + assert set(cfg.articulations) == {"robot", "cabinet"} + assert cfg.joint_animation_amplitude == pytest.approx(0.4) + + +@pytest.mark.parametrize( + ("presets", "expected_type"), + [ + ((), NewtonCfg), + (("newton_mjwarp",), NewtonCfg), + (("physx",), PhysxAutoCfg), + (("isaacsim_physx",), PhysxCfg), + ], +) +def test_physics_presets_resolve_to_expected_backend(presets, expected_type): + cfg = resolve_presets(_load_cfg(), selected=presets) + + assert isinstance(cfg.sim.physics, expected_type) + + +_CAMERA_DATA_TYPE_PRESETS = [ + ("default", ["rgb"]), + ("rgb", ["rgb"]), + ("albedo", ["albedo"]), + ("depth", ["depth"]), + ("simple_shading_constant_diffuse", ["simple_shading_constant_diffuse"]), + ("simple_shading_diffuse_mdl", ["simple_shading_diffuse_mdl"]), + ("simple_shading_full_mdl", ["simple_shading_full_mdl"]), +] + + +@pytest.fixture(scope="module") +def render_benchmark_presets(): + """Collect every preset once, before any preset selection mutates the config in place.""" + return collect_presets(_load_cfg()) + + +@pytest.mark.parametrize("preset_name,expected_data_types", _CAMERA_DATA_TYPE_PRESETS) +def test_camera_presets_resolve_to_expected_data_types(render_benchmark_presets, preset_name, expected_data_types): + camera_presets = render_benchmark_presets["tiled_camera"] + assert preset_name in camera_presets, f"Preset '{preset_name}' not found in tiled_camera presets" + resolved = camera_presets[preset_name] + + assert resolved.data_types == expected_data_types + assert resolved.width > 0 + assert resolved.height > 0 + assert isinstance(resolved.renderer_cfg.newton_renderer, NewtonWarpRendererCfg) + + +def test_camera_resolution_defaults_to_256(): + """``BENCHMARK_RENDER_RESOLUTION`` is read once at import, so its effect is process-scoped; + this documents the default a fresh process gets when the env var is unset.""" + cfg = resolve_presets(_load_cfg(), selected=()) + + assert cfg.tiled_camera.width == 256 + assert cfg.tiled_camera.height == 256 + + +def _fake_env_for_ground_cfg(ground_size: tuple[float, float], env_spacing: float) -> SimpleNamespace: + return SimpleNamespace( + cfg=SimpleNamespace( + ground_size=ground_size, ground_thickness=0.1, ground_color=(0.5, 0.5, 0.5), ground_top_z=0.0 + ), + scene=SimpleNamespace(cfg=SimpleNamespace(env_spacing=env_spacing)), + ) + + +def test_ground_cfg_clamps_size_to_env_spacing(): + """An oversized ground tile must be clamped, or cloned environments' grounds overlap.""" + fake_env = _fake_env_for_ground_cfg(ground_size=(50.0, 50.0), env_spacing=3.0) + + ground_cfg = RenderBenchmarkEnv._ground_cfg(fake_env) + + assert ground_cfg.spawn.size[:2] == (3.0, 3.0) + + +def test_ground_cfg_keeps_size_smaller_than_env_spacing(): + """A tile already smaller than the env spacing must not be inflated.""" + fake_env = _fake_env_for_ground_cfg(ground_size=(2.0, 1.5), env_spacing=3.0) + + ground_cfg = RenderBenchmarkEnv._ground_cfg(fake_env) + + assert ground_cfg.spawn.size[:2] == (2.0, 1.5) diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py index a1dc901834ee..3d2b961e244d 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py @@ -32,6 +32,11 @@ "IsaacContrib-AutoMate-Disassembly-Direct": "Requires CUDA support outside the standard environment test runner.", } _SKIPPED_TASK_SUBSTRINGS = { + # Under random actions the Kamino P-ADMM solver intermittently diverges and the whole robot state + # (root pose, joint state) turns NaN mid-episode, so the run fails nondeterministically (about 1 in 12 + # seeds locally; the sibling HoldPose task stays finite). The termination terms cannot catch a NaN state. + # Re-enable once the solver instability is resolved upstream. + "DrLegs-Walk": "Kamino solver intermittently produces NaN robot state under random actions.", "RmpFlow": "Uses SingleArticulation, which requires an update.", "Skillgen": "Requires cuRobo-specific coverage.", "Suction": "Requires CPU simulation.", @@ -67,4 +72,5 @@ def _contrib_environment_params() -> list: @pytest.mark.parametrize("task_name", _contrib_environment_params()) def test_contrib_environments(task_name): - _run_environments(task_name, device="cuda", num_envs=2) + num_envs = 3 if task_name == "IsaacContrib-Multitask-Manipulation" else 2 + _run_environments(task_name, device="cuda", num_envs=num_envs) diff --git a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py index 17e936d5f5b1..37e4f5fd78dd 100644 --- a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py @@ -111,6 +111,7 @@ def test_reset_dataset_contract_stores_root_relative_robot_asset_path(): robot_asset = _reset_dataset_task_contract(cfg)["robot_asset"] assert robot_asset == "Robots/FrankaEmika/franka_panda.usda" assert f"{ISAACLAB_NUCLEUS_DIR}/{robot_asset}" == FRANKA_POUR_ROBOT_ASSET_ID + assert cfg.scene.robot.spawn.variants == {"Colliders": "convex_hulls"} def test_capacity_resolution_only_updates_world_dependent_solver_limits(): diff --git a/source/isaaclab_tasks/test/core/test_environment_determinism.py b/source/isaaclab_tasks/test/core/test_environment_determinism.py index 01fed8a4c10c..f457a6b6b043 100644 --- a/source/isaaclab_tasks/test/core/test_environment_determinism.py +++ b/source/isaaclab_tasks/test/core/test_environment_determinism.py @@ -36,16 +36,20 @@ def setup_environment(): @pytest.mark.parametrize( - "task_name", + ("task_name", "deterministic_mode"), [ - "Isaac-Open-Drawer-Franka", - "IsaacContrib-Lift-Cube-Franka", + # Newton defaults to ``wp.DeterministicMode.NOT_GUARANTEED``, under which Warp's atomics may + # accumulate in any order, so two runs are not bit-reproducible. ``run_to_run`` is exactly + # the guarantee this test asserts: one device, one process, run twice. ``deterministic_mode`` + # is a Newton setting, so the PhysX-backed task leaves it unset. + ("Isaac-Open-Drawer-Franka", "run_to_run"), + ("IsaacContrib-Lift-Cube-Franka", None), ], ) @pytest.mark.parametrize("device", ["cuda", "cpu"]) -def test_manipulation_env_determinism(task_name, device): +def test_manipulation_env_determinism(task_name, deterministic_mode, device): """Check deterministic environment creation for manipulation.""" - _test_environment_determinism(task_name, device) + _test_environment_determinism(task_name, device, deterministic_mode=deterministic_mode) @pytest.mark.parametrize( diff --git a/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py b/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py deleted file mode 100644 index 461811f05bba..000000000000 --- a/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from isaaclab_tasks.core.velocity.config.g1.agents.rsl_rl_ppo_cfg import G1RoughPPORunnerCfg -from isaaclab_tasks.utils import resolve_presets - - -def test_g1_rough_newton_preset_uses_extended_training_schedule(): - """Verify MJWarp selects the tuned G1 Newton training duration.""" - agent_cfg = resolve_presets(G1RoughPPORunnerCfg(), selected=("newton_mjwarp",)) - - assert agent_cfg.max_iterations == 5000 diff --git a/source/isaaclab_tasks/test/core/test_hydra.py b/source/isaaclab_tasks/test/core/test_hydra.py index 2a77316df346..10fd5e1807c5 100644 --- a/source/isaaclab_tasks/test/core/test_hydra.py +++ b/source/isaaclab_tasks/test/core/test_hydra.py @@ -860,24 +860,15 @@ class EnvCfgFactory: # ============================================================================= -def test_go2_rough_newton_mjwarp_armature_preset(): - """Go2 rough terrain uses higher MJWarp armature without changing PhysX.""" - from isaaclab_tasks.core.velocity.config.go2.rough_env_cfg import UnitreeGo2RoughEnvCfg - - env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg(), global_presets=["newton_mjwarp"]) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.02 - - env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg()) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.0 - - def test_go2_rough_legacy_newton_alias_resolves_to_newton_mjwarp(): """Real-config alias path: ``presets=newton`` against an actual env cfg resolves to newton_mjwarp.""" + from isaaclab_newton.physics import MJWarpSolverCfg + from isaaclab_tasks.core.velocity.config.go2.rough_env_cfg import UnitreeGo2RoughEnvCfg with pytest.warns(FutureWarning, match="Preset 'newton' is deprecated"): env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg(), global_presets=["newton"]) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.02 + assert isinstance(env_cfg.sim.physics.solver_cfg, MJWarpSolverCfg) def test_velocity_events_newton_mjwarp_keeps_base_com_randomization(): diff --git a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py index e9ab35c1dbba..eb6de255122b 100644 --- a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py +++ b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py @@ -10,9 +10,13 @@ import pytest import torch +from pxr import Usd + from isaaclab.managers import CommandTerm +from isaaclab.sim import select_usd_variants from isaaclab_tasks.core.lift import mdp +from isaaclab_tasks.core.lift.config.franka.franka_env_cfg import FrankaLiftEnvCfg, FrankaReorientEnvCfg from isaaclab_tasks.core.lift.config.franka_soft.franka_soft_env_cfg import FrankaSoftEnvCfg from isaaclab_tasks.core.lift.mdp.commands.pose_commands import ( CableUniformPoseCommand, @@ -58,6 +62,29 @@ def test_franka_soft_robot_physics_variant_matches_backend( assert cfg.scene.robot.spawn.variants == {"Physics": expected_physics} +@pytest.mark.parametrize("cfg_type", [FrankaLiftEnvCfg, FrankaReorientEnvCfg]) +def test_franka_rigid_tasks_select_collision_meshes_for_reset_clearance(cfg_type) -> None: + """Reset validation keeps the original arm meshes when the asset defaults to capsules.""" + cfg = cfg_type() + stage = Usd.Stage.CreateInMemory() + robot = stage.DefinePrim("/Robot", "Xform") + colliders = robot.GetVariantSets().AddVariantSet("Colliders") + for selection, prim_path, prim_type in ( + ("convex_hulls", "/Robot/link1_c/link1_c", "Mesh"), + ("primitives", "/Robot/link1_capsule", "Capsule"), + ): + colliders.AddVariant(selection) + colliders.SetVariantSelection(selection) + with colliders.GetVariantEditContext(): + stage.DefinePrim(prim_path, prim_type) + colliders.SetVariantSelection("primitives") + + select_usd_variants("/Robot", cfg.scene.robot.spawn.variants or {}, stage=stage) + + assert stage.GetPrimAtPath("/Robot/link1_c/link1_c").IsValid() + assert not stage.GetPrimAtPath("/Robot/link1_capsule").IsValid() + + def test_camera_normalization_is_stationary() -> None: """RGB and depth normalization must not depend on per-frame statistics.""" rgb = torch.tensor([0.0, 127.5, 255.0]) diff --git a/source/isaaclab_tasks/test/core/test_video_recording.py b/source/isaaclab_tasks/test/core/test_video_recording.py index cb9f189bd88a..bb7fa1d6255b 100644 --- a/source/isaaclab_tasks/test/core/test_video_recording.py +++ b/source/isaaclab_tasks/test/core/test_video_recording.py @@ -189,6 +189,39 @@ def test_kit_physx_records_moving_clip(): _assert_clip_has_content(os.path.join(output_dir, "kit_0000.mp4"), "kit-physx") +def test_multiple_headless_kit_recorders_simultaneous(monkeypatch): + """Multiple capture-only Kit recorders can share one visualizer.""" + from isaaclab_visualizers.kit import KitVisualizerCfg + + captured_clips: dict[str, list[np.ndarray]] = {} + + class _FrameCaptureClip: + def __init__(self, frames: list[np.ndarray], fps: int): + self.frames = [frame.copy() for frame in frames] + self.fps = fps + + def write_videofile(self, path: str, **_kwargs) -> None: + captured_clips[os.path.basename(path)] = self.frames + + monkeypatch.setattr("isaaclab.envs.utils.video_recorder.ImageSequenceClip", _FrameCaptureClip) + + with tempfile.TemporaryDirectory() as output_dir: + env_cfg = _cartpole_cfg(num_envs=1) + env_cfg.sim.visualizer_cfgs = [KitVisualizerCfg(headless=True, window_width=320, window_height=240)] + env_cfg.video_recorders = [ + _recorder_cfg(output_dir, "visualizer:kit", prefix="first"), + _recorder_cfg(output_dir, "visualizer:kit", prefix="second"), + ] + _run_cartpole(env_cfg) + + assert set(captured_clips) == {"first_0000.mp4", "second_0000.mp4"} + for frames in captured_clips.values(): + stack = np.stack(frames).astype(np.float32) + assert len(frames) == _CLIP + assert max(np.count_nonzero(frame) / frame.size for frame in stack) >= _MIN_NONZERO_RATIO + assert stack.std(axis=0).mean() >= _MIN_MOTION_STD + + def test_kit_newton_logs_warning_on_capture(caplog): """source='visualizer:kit' + Newton → logs a warning and capture proceeds. diff --git a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-motion_vectors.png index a90720b5b131..8dd4f8750c5a 100644 --- a/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-motion_vectors.png +++ b/source/isaaclab_tasks/test/golden_images/lift_kuka_homo/ovphysx-ovrtx_renderer-motion_vectors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8ab1582365f7b6f46831d50484ec892935c6aa1e6a198e5f4a7b9df51a6fe47 -size 3690 +oid sha256:30246f0a186cf85d5a37062ce07709282c265e2c06bdb2e9f5c7d8bddd5f01cf +size 5027 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/ovphysx-ovrtx_renderer-motion_vectors.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/ovphysx-ovrtx_renderer-motion_vectors.png index 8586844cf710..85f602ac2d2a 100644 --- a/source/isaaclab_tasks/test/golden_images/shadow_hand/ovphysx-ovrtx_renderer-motion_vectors.png +++ b/source/isaaclab_tasks/test/golden_images/shadow_hand/ovphysx-ovrtx_renderer-motion_vectors.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:272d21321a427ab110a5033b829a4e6f9ce33c39c9af88ff598fe9d0a7cea8f7 -size 5339 +oid sha256:b43690dd1d8fd855e59b9306323a6b6e8d61589d70eb52ddcfc95ef9b429077e +size 4962 diff --git a/source/isaaclab_tasks_experimental/changelog.d/ooctipus-clone-plan-contract.skip b/source/isaaclab_tasks_experimental/changelog.d/ooctipus-clone-plan-contract.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py index 7c184f9faf69..155244175b85 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py @@ -236,10 +236,10 @@ def _setup_scene(self) -> None: # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # we need to explicitly filter collisions for CPU simulation if self.device == "cpu": self.scene.filter_collisions(global_prim_paths=[]) diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py index 38ab532a9fe8..a5289c4fb71f 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py @@ -532,10 +532,10 @@ def _setup_scene(self) -> None: self.cfg.terrain.env_spacing = self.scene.cfg.env_spacing self.terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = (self.cfg.terrain.prim_path,) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # add articulation and the feet wrench sensor to scene self.scene.articulations["robot"] = self.robot self.joint_wrench = self.cfg.joint_wrench.class_type(self.cfg.joint_wrench) diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py index 09bc98c3225d..3e1c241b786b 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py @@ -687,10 +687,10 @@ def _setup_scene(self): # add ground plane spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # add articulation to scene - we must register to scene to randomize with EventManager self.scene.articulations["robot"] = self.hand self.scene.articulations["object"] = self.object diff --git a/source/isaaclab_visualizers/changelog.d/mpm-surface-extraction.minor.rst b/source/isaaclab_visualizers/changelog.d/mpm-surface-extraction.minor.rst new file mode 100644 index 000000000000..c59897f68e48 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/mpm-surface-extraction.minor.rst @@ -0,0 +1,9 @@ +Added +^^^^^ + +* Added dynamic mesh logging through the public Newton visualizer interface. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab_visualizers.newton.NewtonRTXVisualizer` so it honored its configured particle color. diff --git a/source/isaaclab_visualizers/changelog.d/test-scene-partitioning-perspective-golden.skip b/source/isaaclab_visualizers/changelog.d/test-scene-partitioning-perspective-golden.skip new file mode 100644 index 000000000000..a882c8161446 --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/test-scene-partitioning-perspective-golden.skip @@ -0,0 +1 @@ +Test-only visual regression coverage; no user-facing changelog entry. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index e4e4aba62133..4f70bf5e20ce 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -12,6 +12,7 @@ import math import os import sys +from dataclasses import dataclass from typing import TYPE_CHECKING, Any import numpy as np # noqa: F401 — used in type hints and colorization helpers @@ -85,6 +86,26 @@ def _newton_scalar_base_name(name: str) -> str: CONTACT_ARROW_LENGTH = 0.1 """Length of synthesized contact arrows in meters.""" + +@dataclass(frozen=True) +class _MeshSubmission: + """Mesh data staged for the next Newton viewer frame.""" + + name: str + points: wp.array + indices: wp.array + normals: wp.array | None + uvs: wp.array | None + texture: np.ndarray | str | None + hidden: bool + backface_culling: bool + color: tuple[float, float, float] | None + roughness: float | None + metallic: float | None + dynamic: bool + opacity: float | None + + if TYPE_CHECKING: from newton import State @@ -645,6 +666,7 @@ def __init__( self._metadata = metadata or {} self._update_frequency = update_frequency self._color_edit3_prefers_sequence: bool | None = None + self.particle_color: tuple[float, float, float] | None = None from isaaclab.utils.backend_utils import FactoryBase @@ -658,6 +680,12 @@ def __init__( # the GUI is available); the panel patch is applied in _init_window() below. self.register_ui_callback(self._render_training_controls, position="side") + def log_points(self, name, points, radii=None, colors=None, hidden=False): + """Apply the configured color to Newton's canonical particle batch.""" + if name == "/model/particles" and points is not None and self.particle_color is not None: + colors = self.particle_color + return super().log_points(name, points, radii, colors, hidden) + def _add_camera_lights_and_render_product(self) -> None: """Author the configured RTX attributes onto the render product. @@ -1000,6 +1028,7 @@ def __init__(self, cfg: NewtonVisualizerCfg): self._scene_cameras: dict = {} self._scene_camera_names: list[str] = [] self._active_camera_idx: int = 0 + self._pending_mesh_submissions: dict[str, _MeshSubmission] = {} # ------------------------------------------------------------------ # Shared lifecycle @@ -1184,6 +1213,7 @@ def step(self, dt: float) -> None: if self._state is not None: body_q = getattr(self._state, "body_q", None) if hasattr(body_q, "shape") and body_q.shape[0] == 0: + self._log_pending_meshes() return self._viewer.log_state(self._state) contacts = NewtonManager.get_contacts() @@ -1199,6 +1229,7 @@ def step(self, dt: float) -> None: ) self._log_streaming_image() self._render_live_plots() + self._log_pending_meshes() finally: self._viewer.end_frame() if not self._viewer.is_running(): @@ -1298,6 +1329,7 @@ def close(self) -> None: try: self._release_viewer() finally: + self._pending_mesh_submissions.clear() # A viewer that fails to close must not strand the camera prims or # leave the visualizer looking open; the failure still propagates # to the caller, which logs it and drops the visualizer. @@ -1352,6 +1384,88 @@ def set_camera_view( self.cfg.lookat = target_t self._apply_camera_pose((eye_t, target_t)) + def log_mesh( + self, + name: str, + points: wp.array[wp.vec3], + indices: wp.array[wp.int32] | wp.array[wp.uint32], + normals: wp.array[wp.vec3] | None = None, + uvs: wp.array[wp.vec2] | None = None, + texture: np.ndarray | str | None = None, + hidden: bool = False, + backface_culling: bool = True, + color: tuple[float, float, float] | None = None, + roughness: float | None = None, + metallic: float | None = None, + dynamic: bool = False, + opacity: float | None = None, + ) -> None: + """Stage a mesh registration or update for the next Newton viewer frame. + + Newton viewers require geometry updates between ``begin_frame()`` and + ``end_frame()``. Staging here keeps that lifecycle internal to the + visualizer and lets callers submit meshes before :meth:`step`. + + Args: + name: Unique viewer path for the mesh. + points: Vertex positions [m]. + indices: Flattened triangle vertex indices. + normals: Optional per-vertex normals. + uvs: Optional per-vertex texture coordinates. + texture: Optional texture path or image. + hidden: Whether to hide the mesh. + backface_culling: Whether to cull back-facing triangles. + color: Optional RGB base color in ``[0, 1]``. + roughness: Optional surface roughness in ``[0, 1]``. + metallic: Optional surface metallic value in ``[0, 1]``. + dynamic: Whether the mesh topology may change between updates. + opacity: Optional surface opacity in ``[0, 1]``. + + Raises: + RuntimeError: If the Newton viewer has not been initialized. + """ + if not self._is_initialized or self._viewer is None: + raise RuntimeError("Newton visualizer must be initialized before logging meshes.") + self._pending_mesh_submissions[name] = _MeshSubmission( + name=name, + points=points, + indices=indices, + normals=normals, + uvs=uvs, + texture=texture, + hidden=hidden, + backface_culling=backface_culling, + color=color, + roughness=roughness, + metallic=metallic, + dynamic=dynamic, + opacity=opacity, + ) + + def _log_pending_meshes(self) -> None: + """Publish staged meshes inside the active viewer frame.""" + if self._viewer is None or not self._pending_mesh_submissions: + return + + submissions = tuple(self._pending_mesh_submissions.values()) + self._pending_mesh_submissions.clear() + for mesh in submissions: + self._viewer.log_mesh( + mesh.name, + mesh.points, + mesh.indices, + normals=mesh.normals, + uvs=mesh.uvs, + texture=mesh.texture, + hidden=mesh.hidden, + backface_culling=mesh.backface_culling, + color=mesh.color, + roughness=mesh.roughness, + metallic=mesh.metallic, + dynamic=mesh.dynamic, + opacity=mesh.opacity, + ) + # ------------------------------------------------------------------ # Hook methods — override in subclasses # ------------------------------------------------------------------ @@ -2078,7 +2192,11 @@ def _apply_camera_focal_length(self) -> None: self._viewer.camera.fov = self._focal_length_to_vertical_fov_degrees() def _pump_paused(self) -> None: - self._viewer._update() + self._viewer.begin_frame(self._sim_time) + try: + self._log_pending_meshes() + finally: + self._viewer.end_frame() def render_rgb_array(self) -> np.ndarray: """Return the latest RGB frame rendered by the Newton GL viewer. @@ -2107,6 +2225,7 @@ def render_rgb_array(self) -> np.ndarray: self._resolved_visible_env_ids, num_envs=NewtonManager.get_num_envs(), ) + self._log_pending_meshes() finally: self._viewer.end_frame() return self._viewer.get_frame().numpy() @@ -2216,6 +2335,10 @@ def _create_viewer(self, runtime_headless: bool, metadata: dict) -> NewtonViewer render_settings=self.cfg.render_settings, ) + def _apply_viewer_post_init(self) -> None: + """Apply RTX-specific renderer settings after viewer construction.""" + self._viewer.particle_color = self.cfg.particle_color + def _apply_camera_pose( self, pose: tuple[tuple[float, float, float], tuple[float, float, float]], @@ -2251,7 +2374,10 @@ def _pump_paused(self) -> None: # samples on it, producing a progressively cleaner image while paused. # Note: full RTX render cost is incurred every tick even while paused. self._viewer.begin_frame(self._sim_time) - self._viewer.end_frame() + try: + self._log_pending_meshes() + finally: + self._viewer.end_frame() def render_rgb_array(self) -> np.ndarray | None: """Return the latest RGB frame rendered by the Newton RTX viewer. @@ -2270,6 +2396,7 @@ def render_rgb_array(self) -> np.ndarray | None: self._viewer.begin_frame(self._sim_time) try: self._viewer.log_state(self._state) + self._log_pending_meshes() finally: self._viewer.end_frame() return self._viewer.get_frame() diff --git a/source/isaaclab_visualizers/test/golden_images/cartpole_all_envs/physx-kit-viewport.png b/source/isaaclab_visualizers/test/golden_images/cartpole_all_envs/physx-kit-viewport.png new file mode 100644 index 000000000000..aa2e5f9b1ea4 Binary files /dev/null and b/source/isaaclab_visualizers/test/golden_images/cartpole_all_envs/physx-kit-viewport.png differ diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index c1226d408950..45828483ba05 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -22,7 +22,7 @@ ) from isaaclab_visualizers.newton import newton_visualization_markers as newton_markers from isaaclab_visualizers.newton import newton_visualizer as newton_visualizer_module -from isaaclab_visualizers.newton.newton_visualizer import NewtonViewerGL, _eye_lookat_to_pitch_yaw +from isaaclab_visualizers.newton.newton_visualizer import NewtonViewerGL, NewtonViewerRTX, _eye_lookat_to_pitch_yaw from isaaclab_visualizers.newton_adapter import ( VISUALIZER_INFINITE_PLANE_SIZE, apply_viewer_visible_worlds, @@ -125,6 +125,71 @@ def test_newton_visualizer_cfg_exposes_viewer_options(): assert cfg.particle_color == (0.1, 0.2, 0.3) +def test_newton_visualizer_log_mesh_stages_for_next_frame(): + viewer = Mock() + visualizer = _make_newton_visualizer(None) + visualizer._viewer = viewer + points = wp.zeros(3, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + normals = wp.zeros(3, dtype=wp.vec3) + + visualizer.log_mesh( + "/surface", + points, + indices, + normals=normals, + color=(0.1, 0.2, 0.3), + roughness=0.2, + dynamic=True, + opacity=0.35, + ) + + viewer.log_mesh.assert_not_called() + visualizer._log_pending_meshes() + + viewer.log_mesh.assert_called_once_with( + "/surface", + points, + indices, + normals=normals, + uvs=None, + texture=None, + hidden=False, + backface_culling=True, + color=(0.1, 0.2, 0.3), + roughness=0.2, + metallic=None, + dynamic=True, + opacity=0.35, + ) + assert visualizer._pending_mesh_submissions == {} + + +def test_newton_visualizer_log_mesh_keeps_latest_submission_per_name(): + viewer = Mock() + visualizer = _make_newton_visualizer(None) + visualizer._viewer = viewer + points_0 = wp.zeros(3, dtype=wp.vec3) + points_1 = wp.zeros(6, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + + visualizer.log_mesh("/surface", points_0, indices, dynamic=True) + visualizer.log_mesh("/surface", points_1, indices, dynamic=True) + visualizer._log_pending_meshes() + + viewer.log_mesh.assert_called_once() + assert viewer.log_mesh.call_args.args[:3] == ("/surface", points_1, indices) + + +def test_newton_visualizer_log_mesh_requires_initialized_viewer(): + visualizer = _make_newton_visualizer(None) + points = wp.zeros(3, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + + with pytest.raises(RuntimeError, match="must be initialized"): + visualizer.log_mesh("/surface", points, indices) + + def test_newton_marker_registry_lifecycle(monkeypatch: pytest.MonkeyPatch): """Construction caches the registry; close survives context teardown and is idempotent.""" @@ -421,6 +486,36 @@ def _log_points(self, name, points, radii=None, colors=None, hidden=False): assert colors is custom_colors +def test_newton_rtx_viewer_particle_color_override(monkeypatch): + from newton.viewer import ViewerRTX + + viewer = NewtonViewerRTX.__new__(NewtonViewerRTX) + viewer.particle_color = (0.1, 0.2, 0.3) + points = wp.zeros(4, dtype=wp.vec3, device="cpu") + calls = [] + + def _log_points(self, name, points, radii=None, colors=None, hidden=False): + calls.append((name, points, radii, colors, hidden)) + + monkeypatch.setattr(ViewerRTX, "log_points", _log_points) + + viewer.log_points("/model/particles", points, colors=None) + + name, _, _, colors, hidden = calls[-1] + assert name == "/model/particles" + assert hidden is False + assert colors == (0.1, 0.2, 0.3) + + +def test_newton_rtx_visualizer_applies_particle_color(): + visualizer = NewtonRTXVisualizer(NewtonRTXVisualizerCfg(particle_color=(0.1, 0.2, 0.3))) + visualizer._viewer = SimpleNamespace(particle_color=None) + + visualizer._apply_viewer_post_init() + + assert visualizer._viewer.particle_color == (0.1, 0.2, 0.3) + + def test_newton_viewer_fast_paths_all_active_mpm_particles(monkeypatch): import newton as nt @@ -484,23 +579,31 @@ class _Viewer: def __init__(self): self.device = "cpu" self.show_contacts = False + self.paused = False self.logged_state = None self.logged_contacts = None self.logged_arrows = None + self.logged_mesh = None + self.events = [] self.closed = False def is_paused(self): - return False + return self.paused def is_running(self): return True def begin_frame(self, _time): - pass + self.events.append("begin_frame") def log_state(self, state): + self.events.append("log_state") self.logged_state = state + def log_mesh(self, name, points, indices, **kwargs): + self.events.append("log_mesh") + self.logged_mesh = (name, points, indices, kwargs) + def log_contacts(self, contacts, state): self.logged_contacts = (contacts, state) @@ -508,7 +611,7 @@ def log_arrows(self, name, starts, ends, colors): self.logged_arrows = (name, starts, ends, colors) def end_frame(self): - pass + self.events.append("end_frame") def close(self): # Mirrors ViewerBase.close(), which every real viewer inherits. @@ -639,6 +742,79 @@ def test_newton_visualizer_logs_native_contacts_when_available(monkeypatch): assert viewer.logged_contacts == (contacts, state) +def test_newton_visualizer_logs_staged_mesh_inside_frame(monkeypatch): + from isaaclab_newton.physics import NewtonManager + + state = SimpleNamespace(body_q=_BodyQ()) + viewer = _Viewer() + visualizer = _make_newton_visualizer(viewer) + points = wp.zeros(3, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + + monkeypatch.setattr(NewtonManager, "get_state", lambda _scene_data_provider=None: state) + monkeypatch.setattr(NewtonManager, "get_contacts", lambda: None) + monkeypatch.setattr(NewtonManager, "get_num_envs", lambda: 1) + + visualizer.log_mesh("/surface", points, indices, dynamic=True) + visualizer.step(0.1) + + assert viewer.events == ["begin_frame", "log_state", "log_mesh", "end_frame"] + assert viewer.logged_mesh == ( + "/surface", + points, + indices, + { + "normals": None, + "uvs": None, + "texture": None, + "hidden": False, + "backface_culling": True, + "color": None, + "roughness": None, + "metallic": None, + "dynamic": True, + "opacity": None, + }, + ) + + +def test_newton_visualizer_logs_staged_mesh_for_bodyless_state(monkeypatch): + from isaaclab_newton.physics import NewtonManager + + state = SimpleNamespace(body_q=SimpleNamespace(shape=(0,))) + viewer = _Viewer() + visualizer = _make_newton_visualizer(viewer) + points = wp.zeros(3, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + + monkeypatch.setattr(NewtonManager, "get_state", lambda _scene_data_provider=None: state) + monkeypatch.setattr(NewtonManager, "get_num_envs", lambda: 1) + + visualizer.log_mesh("/surface", points, indices, dynamic=True) + visualizer.step(0.1) + + assert viewer.events == ["begin_frame", "log_mesh", "end_frame"] + assert viewer.logged_state is None + + +def test_newton_gl_visualizer_logs_staged_mesh_while_paused(monkeypatch): + from isaaclab_newton.physics import NewtonManager + + viewer = _Viewer() + viewer.paused = True + visualizer = _make_newton_visualizer(viewer) + points = wp.zeros(3, dtype=wp.vec3) + indices = wp.zeros(3, dtype=wp.int32) + + monkeypatch.setattr(NewtonManager, "get_num_envs", lambda: 1) + + visualizer.log_mesh("/surface", points, indices, dynamic=True) + visualizer.step(0.1) + + assert viewer.events == ["begin_frame", "log_mesh", "end_frame"] + assert viewer.logged_state is None + + def test_newton_visualizer_headless_renders_frame_on_demand(monkeypatch): """Headless EGL should defer rendering until a frame is requested.""" from isaaclab_newton.physics import NewtonManager diff --git a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py index 8c854035d3c6..42353d1934e1 100644 --- a/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py +++ b/source/isaaclab_visualizers/test/test_newton_visualizer_viewer_release.py @@ -86,6 +86,7 @@ def _make_visualizer(viewer: _SpyRTXViewer | _SpyGLViewer | None) -> NewtonVisua visualizer._viewer = viewer visualizer._camera_sensor = None visualizer._camera_is_owned = False + visualizer._pending_mesh_submissions = {} if viewer is not None: viewer.owner = visualizer return visualizer @@ -150,12 +151,14 @@ def test_close_releases_the_viewer() -> None: """``close()`` must release the viewer through the shared path.""" viewer = _SpyRTXViewer() visualizer = _make_visualizer(viewer) + visualizer._pending_mesh_submissions["/surface"] = object() visualizer.close() assert viewer.close_calls == 1 assert viewer.referenced_by_owner_at_close == [True] assert visualizer._viewer is None + assert visualizer._pending_mesh_submissions == {} assert visualizer._is_closed is True diff --git a/source/isaaclab_visualizers/test/test_visualizer_golden_physx.py b/source/isaaclab_visualizers/test/test_visualizer_golden_physx.py index e23eeddb0cec..7dfe5fb254a3 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_golden_physx.py +++ b/source/isaaclab_visualizers/test/test_visualizer_golden_physx.py @@ -9,6 +9,8 @@ viewport and tiled-camera capture modes for the cartpole, shadow hand, and AnymalD environments. Golden images are stored under ``golden_images/{scene}/{physics_backend}-{visualizer_type}-{mode}.png``. +The Kit perspective-camera coverage also includes one four-environment cartpole +view to guard the all-partitions spectator path. On the first run for a new combination the current frame is saved as the golden; the test fails so the file can be reviewed before committing. @@ -89,6 +91,17 @@ def _run_anymal_d_physx( ] +def test_kit_perspective_all_envs_golden() -> None: + """The Kit perspective camera renders all four scene-partitioned environments.""" + _golden.run_visualizer_golden_cartpole( + _PHYSICS_BACKEND, + "kit", + "viewport", + _COMPARISON_SCORES, + all_envs_perspective=True, + ) + + @pytest.mark.parametrize("scene,visualizer_type,mode", SCENE_VISUALIZER_MODE_COMBINATIONS) def test_visualizer_golden(scene: str, visualizer_type: str, mode: str) -> None: """Golden image correctness for PhysX across scenes, visualizer types, and modes.""" diff --git a/source/isaaclab_visualizers/test/visualizer_golden_utils.py b/source/isaaclab_visualizers/test/visualizer_golden_utils.py index e7cc7e70a420..871f0198cbc7 100644 --- a/source/isaaclab_visualizers/test/visualizer_golden_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_golden_utils.py @@ -359,6 +359,7 @@ def run_visualizer_golden_cartpole( comparison_scores: list[dict], *, buffer_steps: int = 0, + all_envs_perspective: bool = False, ) -> None: """Run a golden-image test for one ``(physics_backend, visualizer_type, mode)`` combination. @@ -372,7 +373,11 @@ def run_visualizer_golden_cartpole( comparison_scores: Module-level accumulator forwarded to :func:`validate_visualizer_frame`. buffer_steps: Physics steps to run before capture (default 0 — capture the reset pose so the pole remains at its initial 45° angle and is clearly attached to the cart). + all_envs_perspective: Whether to frame four environments in one Kit perspective-camera image. """ + if all_envs_perspective and (visualizer_type != "kit" or mode != "viewport"): + raise ValueError("The all-environment perspective golden requires Kit viewport capture.") + import torch import visualizer_integration_utils as _viz_utils @@ -396,7 +401,12 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: _viz_utils._prepare_visualizer_test_process() sim_utils.create_new_stage() tiled = mode == "tiled" - env = _viz_utils._make_cartpole_camera_env(visualizer_type, physics_backend, tiled_camera=tiled) + env = _viz_utils._make_cartpole_camera_env( + visualizer_type, + physics_backend, + tiled_camera=tiled, + all_envs_perspective=all_envs_perspective, + ) _viz_utils._configure_sim_for_visualizer_test(env) actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) # Pin the initial pole angle to a fixed value so the golden image shows a clearly @@ -417,8 +427,11 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: env.step(action=actions) frame = _capture_frame(env, visualizer_type, mode, physics_backend, actions) + if all_envs_perspective: + _viz_utils._assert_non_flat_frame_array(frame) - validate_visualizer_frame("cartpole", physics_backend, visualizer_type, mode, frame, comparison_scores) + test_name = "cartpole_all_envs" if all_envs_perspective else "cartpole" + validate_visualizer_frame(test_name, physics_backend, visualizer_type, mode, frame, comparison_scores) finally: _viz_utils._cleanup_visualizer_test_process(env) diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index c3dc7cd086a4..929a95e47a92 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -69,12 +69,24 @@ _CARTPOLE_TILED_CAMERA_INTEGRATION_NUM_ENVS = 4 """Vectorized env count for generated visualizer tiled-camera integration tests.""" +_CARTPOLE_ALL_ENVS_INTEGRATION_NUM_ENVS = 4 +"""Vectorized env count for the all-environment perspective-camera golden test.""" + +_CARTPOLE_ALL_ENVS_INTEGRATION_SPACING = 8.0 +"""Environment spacing [m] for a clear four-cartpole perspective view.""" + _CARTPOLE_INTEGRATION_VISUALIZER_EYE: tuple[float, float, float] = (2.25, 0.0, 3.5) """Passed to :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` subclasses (``eye``).""" _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT: tuple[float, float, float] = (0.0, 0.0, 2.25) """Passed to visualizer cfgs (``lookat``); also applied to :class:`~isaaclab.envs.common.ViewerCfg` for the env.""" +_CARTPOLE_ALL_ENVS_VISUALIZER_EYE: tuple[float, float, float] = (9.0, 9.0, 10.0) +"""Perspective-camera eye position that frames all four cartpole environments.""" + +_CARTPOLE_ALL_ENVS_VISUALIZER_LOOKAT: tuple[float, float, float] = (0.0, 0.0, 2.25) +"""Perspective-camera target at the center of the four-environment grid.""" + _CARTPOLE_INTEGRATION_TILED_CAMERA_EYE_OFFSET: tuple[float, float, float] = tuple( eye - lookat for eye, lookat in zip(_CARTPOLE_INTEGRATION_VISUALIZER_EYE, _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT) ) @@ -307,17 +319,26 @@ def _allocate_rerun_test_ports(host: str = "127.0.0.1") -> tuple[int, int]: return web_port, grpc_port -def _cartpole_integration_visualizer_camera_kwargs() -> dict[str, tuple[float, float, float]]: +def _cartpole_integration_visualizer_camera_kwargs( + *, all_envs_perspective: bool = False +) -> dict[str, tuple[float, float, float]]: """Eye/lookat for all :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` subclasses in these tests.""" + if all_envs_perspective: + return { + "eye": _CARTPOLE_ALL_ENVS_VISUALIZER_EYE, + "lookat": _CARTPOLE_ALL_ENVS_VISUALIZER_LOOKAT, + } return { "eye": _CARTPOLE_INTEGRATION_VISUALIZER_EYE, "lookat": _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT, } -def _get_visualizer_cfg(visualizer_kind: str, *, tiled_camera: bool = False): +def _get_visualizer_cfg(visualizer_kind: str, *, tiled_camera: bool = False, all_envs_perspective: bool = False): """Return (visualizer_cfg, expected_visualizer_cls) for the given visualizer kind.""" - cam = _cartpole_integration_visualizer_camera_kwargs() + if tiled_camera and all_envs_perspective: + raise ValueError("Tiled-camera and all-environment perspective modes are mutually exclusive.") + cam = _cartpole_integration_visualizer_camera_kwargs(all_envs_perspective=all_envs_perspective) tiled_cam = ( { "streaming_view": True, @@ -1815,16 +1836,29 @@ def _make_franka_cloth_env(visualizer_kind: str | tuple[str, ...], *, tiled_came def _make_cartpole_camera_env( - visualizer_kind: str | tuple[str, ...], backend_kind: str, *, tiled_camera: bool = False + visualizer_kind: str | tuple[str, ...], + backend_kind: str, + *, + tiled_camera: bool = False, + all_envs_perspective: bool = False, ) -> CartpoleCameraEnv: """Create cartpole camera env configured with selected visualizer and physics backend.""" + if tiled_camera and all_envs_perspective: + raise ValueError("Tiled-camera and all-environment perspective modes are mutually exclusive.") physics = "newton_mjwarp" if backend_kind == "newton" else "physx" env_cfg = _compose_task_cfg("Isaac-Cartpole-Camera-Direct", physics, "renderer=isaacsim_rtx") env_cfg.scene.num_envs = ( - _CARTPOLE_TILED_CAMERA_INTEGRATION_NUM_ENVS if tiled_camera else _CARTPOLE_INTEGRATION_NUM_ENVS + _CARTPOLE_TILED_CAMERA_INTEGRATION_NUM_ENVS + if tiled_camera + else _CARTPOLE_ALL_ENVS_INTEGRATION_NUM_ENVS + if all_envs_perspective + else _CARTPOLE_INTEGRATION_NUM_ENVS ) - env_cfg.viewer.eye = _CARTPOLE_INTEGRATION_VISUALIZER_EYE - env_cfg.viewer.lookat = _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT + if all_envs_perspective: + env_cfg.scene.env_spacing = _CARTPOLE_ALL_ENVS_INTEGRATION_SPACING + camera_kwargs = _cartpole_integration_visualizer_camera_kwargs(all_envs_perspective=all_envs_perspective) + env_cfg.viewer.eye = camera_kwargs["eye"] + env_cfg.viewer.lookat = camera_kwargs["lookat"] tw, th = _CARTPOLE_TILED_CAMERA_INTEGRATION_WH env_cfg.tiled_camera.width = tw env_cfg.tiled_camera.height = th @@ -1832,7 +1866,14 @@ def _make_cartpole_camera_env( env_cfg.observation_space = [th, tw, env_cfg.observation_space[2]] env_cfg.seed = None visualizer_kinds = (visualizer_kind,) if isinstance(visualizer_kind, str) else tuple(visualizer_kind) - visualizer_cfgs = [_get_visualizer_cfg(kind, tiled_camera=tiled_camera)[0] for kind in visualizer_kinds] + visualizer_cfgs = [ + _get_visualizer_cfg( + kind, + tiled_camera=tiled_camera, + all_envs_perspective=all_envs_perspective, + )[0] + for kind in visualizer_kinds + ] env_cfg.sim.visualizer_cfgs = visualizer_cfgs[0] if len(visualizer_cfgs) == 1 else visualizer_cfgs return CartpoleCameraEnv(env_cfg) diff --git a/tools/install_deps.py b/tools/install_deps.py index d2f08bb52e7c..bbac6725f62d 100644 --- a/tools/install_deps.py +++ b/tools/install_deps.py @@ -32,7 +32,7 @@ import shutil from subprocess import PIPE, STDOUT, Popen -import toml +import tomllib # add argparse arguments parser = argparse.ArgumentParser(description="A utility to install dependencies based on extension.toml files.") @@ -65,8 +65,8 @@ def install_apt_packages(paths: list[str]): ) continue # Load the extension.toml file and check for apt_deps - with open(f"{path}/config/extension.toml") as fd: - ext_toml = toml.load(fd) + with open(f"{path}/config/extension.toml", "rb") as fd: + ext_toml = tomllib.load(fd) if "isaac_lab_settings" in ext_toml and "apt_deps" in ext_toml["isaac_lab_settings"]: deps = ext_toml["isaac_lab_settings"]["apt_deps"] print(f"[INFO] Installing the following apt packages: {deps}") @@ -108,8 +108,8 @@ def install_rosdep_packages(paths: list[str], ros_distro: str = "humble"): ) continue # Load the extension.toml file and check for ros_ws - with open(f"{path}/config/extension.toml") as fd: - ext_toml = toml.load(fd) + with open(f"{path}/config/extension.toml", "rb") as fd: + ext_toml = tomllib.load(fd) if "isaac_lab_settings" in ext_toml and "ros_ws" in ext_toml["isaac_lab_settings"]: # resolve the path to the ROS workspace ws_path = ext_toml["isaac_lab_settings"]["ros_ws"] diff --git a/tools/template/templates/tasks/direct_multi-agent/env b/tools/template/templates/tasks/direct_multi-agent/env index 1a1719e35f76..29f869dbbe9a 100644 --- a/tools/template/templates/tasks/direct_multi-agent/env +++ b/tools/template/templates/tasks/direct_multi-agent/env @@ -41,10 +41,10 @@ class {{ task.classname }}Env(DirectMARLEnv): self.robot = Articulation(self.cfg.robot_cfg) spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) diff --git a/tools/template/templates/tasks/direct_single-agent/env b/tools/template/templates/tasks/direct_single-agent/env index 73a6906ac8c0..897c19894665 100644 --- a/tools/template/templates/tasks/direct_single-agent/env +++ b/tools/template/templates/tasks/direct_single-agent/env @@ -61,10 +61,10 @@ class {{ task.classname }}Env(DirectRLEnv): self.robot = Articulation(self.cfg.robot_cfg) spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" - pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] global_paths = ("/World/ground",) - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) - cloner.replicate(plan, stage=self.scene.stage) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) + cloner.replicate(plan) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: self.scene.filter_collisions(global_prim_paths=[]) diff --git a/tools/wheel_builder/build.sh b/tools/wheel_builder/build.sh index 1bb7c2d8c95b..859205a9a97f 100755 --- a/tools/wheel_builder/build.sh +++ b/tools/wheel_builder/build.sh @@ -35,60 +35,9 @@ case "$ARCH" in esac rm -rf "$BUILD_DIR" "$DIST_DIR" -mkdir -p "$BUILD_DIR/src/isaaclab" -# 1. Copy inventory (the full source tree: apps/ + source/) -cp -r apps "$BUILD_DIR/src/isaaclab/" -cp -r source "$BUILD_DIR/src/isaaclab/" -mkdir -p "$BUILD_DIR/src/isaaclab/tools" -cp -r tools/template "$BUILD_DIR/src/isaaclab/tools/" - -# Ensure apps/ is discovered as a Python sub-package (it has no __init__.py) -find "$BUILD_DIR/src/isaaclab/apps" -type d -exec touch {}/__init__.py \; - -# Promote sub-packages (isaaclab_assets, isaaclab_rl, etc.) to top-level -# so they are importable as e.g. `import isaaclab_assets`. -# Each extension has the structure: source/isaaclab_FOO/isaaclab_FOO/ (Python pkg) -# plus sibling dirs like config/, data/. The __init__.py references ../config etc. -# We copy the inner Python package to src/ and also copy sibling resource dirs -# (config, data) into it so the relative-path lookups in __init__.py work. -for ext_dir in "$BUILD_DIR"/src/isaaclab/source/isaaclab_*; do - pkg=$(basename "$ext_dir") - inner="$ext_dir/$pkg" - if [ -d "$inner" ] && [ -f "$inner/__init__.py" ]; then - cp -r "$inner" "$BUILD_DIR/src/$pkg" - # Copy resource dirs (config/, data/) into the Python package - for res_dir in config data; do - if [ -d "$ext_dir/$res_dir" ]; then - cp -r "$ext_dir/$res_dir" "$BUILD_DIR/src/$pkg/$res_dir" - fi - done - # Patch EXT_DIR: change '../' to '.' so __init__.py finds config/ inside - # the package dir rather than one level up. - sed -i 's|os\.path\.join(os\.path\.dirname(__file__), "\.\./"|os.path.join(os.path.dirname(__file__), ""|g' \ - "$BUILD_DIR/src/$pkg/__init__.py" - # Keep the extension discoverable by Kit under source/. The .kit experience - # files in apps/ register "${app}/../source" as an extension search folder, - # so each extension's config/extension.toml must remain there or the Kit - # dependency solver fails with "isaaclab_assets ... (none found)". We drop - # the inner Python package (imported from the promoted top-level copy above) - # and the duplicated data/ to avoid a second copy on sys.path and bloat, - # but leave config/extension.toml in place for discovery. - rm -rf "$inner" "$ext_dir/data" - fi -done - -# Clean build artifacts that shouldn't be in the wheel -find "$BUILD_DIR/src" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true -find "$BUILD_DIR/src" -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true -find "$BUILD_DIR/src" -name "*.pyc" -delete 2>/dev/null || true - -# 2. Copy the custom res __init__.py and __main__.py -cp "$SELF_DIR/res/__init__.py" "$BUILD_DIR/src/isaaclab/" -cp "$SELF_DIR/res/__main__.py" "$BUILD_DIR/src/isaaclab/" - -# 3. Generate pyproject.toml with dependencies from the root pyproject.toml -python3 "$SELF_DIR/gen_pyproject.py" "$SELF_DIR/../../pyproject.toml" "$BUILD_DIR/pyproject.toml" "$WHEEL_VERSION" +# Stage the same aggregate source tree used by the PEP 517 Git-source backend. +python3 "$SELF_DIR/stage.py" "$BUILD_DIR" "$WHEEL_VERSION" # 4. Build the wheel cd "$BUILD_DIR" diff --git a/tools/wheel_builder/build_backend.py b/tools/wheel_builder/build_backend.py new file mode 100644 index 000000000000..f8234cf45979 --- /dev/null +++ b/tools/wheel_builder/build_backend.py @@ -0,0 +1,68 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""PEP 517 backend for building the aggregate Isaac Lab package from a source checkout.""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +from setuptools import build_meta +from stage import stage_package + +_BUILDER_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _BUILDER_DIR.parents[1] + + +def _wheel_version() -> str: + version = (_REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() + build_number = os.getenv("WHEEL_BUILD_NUMBER") + commit = os.getenv("WHEEL_SHA") + if build_number and commit: + return f"{version}+build{build_number}.{commit[:7]}" + return version + + +@contextmanager +def _staged_project() -> Iterator[Path]: + with tempfile.TemporaryDirectory(prefix="isaaclab-wheel-") as temp_dir: + stage_dir = Path(temp_dir) + stage_package(_REPO_ROOT, stage_dir, _wheel_version()) + previous_directory = Path.cwd() + os.chdir(stage_dir) + try: + yield stage_dir + finally: + os.chdir(previous_directory) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build the aggregate Isaac Lab wheel.""" + with _staged_project(): + return build_meta.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str: + """Build an aggregate Isaac Lab source distribution.""" + with _staged_project(): + return build_meta.build_sdist(sdist_directory, config_settings) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare metadata for the aggregate Isaac Lab wheel.""" + with _staged_project(): + return build_meta.prepare_metadata_for_build_wheel(metadata_directory, config_settings) diff --git a/tools/wheel_builder/gen_pyproject.py b/tools/wheel_builder/gen_pyproject.py index 8efdff07fdc9..7f5882bd422a 100644 --- a/tools/wheel_builder/gen_pyproject.py +++ b/tools/wheel_builder/gen_pyproject.py @@ -143,7 +143,7 @@ def _dedup(requirements: list[str]) -> list[str]: lines.append("]") lines.append("") lines.append("[project.scripts]") -lines.append('isaaclab = "isaaclab:main"') +lines.append('isaaclab = "isaaclab.__main__:main"') lines.append("") lines.append("[project.optional-dependencies]") for name, dep_list in opt_deps.items(): diff --git a/tools/wheel_builder/pyproject.toml b/tools/wheel_builder/pyproject.toml new file mode 100644 index 000000000000..7861684f3157 --- /dev/null +++ b/tools/wheel_builder/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools>=70.0,<82.0.0", "wheel"] +build-backend = "build_backend" +backend-path = ["."] diff --git a/tools/wheel_builder/res/__init__.py b/tools/wheel_builder/res/__init__.py deleted file mode 100644 index a82d932d74d3..000000000000 --- a/tools/wheel_builder/res/__init__.py +++ /dev/null @@ -1,145 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -import os -import sys -from importlib.metadata import version -from importlib.util import find_spec - -__version__ = version("isaaclab") - -# Extend the package search path so subpackages (app/, envs/, etc.) in the -# nested source tree are importable as isaaclab.app, isaaclab.envs, etc. -__path__.append(os.path.join(os.path.dirname(__file__), "source", "isaaclab", "isaaclab")) - - -def _deprioritize_prebundle_paths(): - """Move Isaac Sim ``pip_prebundle`` and known conflicting extension directories to the end of ``sys.path``. - - Isaac Sim's ``setup_python_env.sh`` injects ``pip_prebundle`` directories - onto ``PYTHONPATH``. These contain older copies of packages like torch, - warp, and nvidia-cudnn that shadow the versions installed by Isaac Lab, - causing CUDA runtime errors. - - Additionally, certain Isaac Sim kit extensions (such as ``omni.warp.core``) - bundle their own copies of Python packages that conflict with pip-installed - versions. When loaded by the extension system these paths can appear on - ``sys.path`` before ``site-packages``, leading to version mismatches. - - Rather than removing these paths entirely (which would break packages like - ``sympy`` that only exist in the prebundle), this function moves them to - the **end** of ``sys.path`` so that pip-installed packages in - ``site-packages`` take priority. - - The ``PYTHONPATH`` environment variable is also rewritten so that child - processes inherit the corrected ordering. - """ - - # Extension directory fragments that are known to ship Python packages - # which conflict with Isaac Lab's pip-installed versions. - _CONFLICTING_EXT_FRAGMENTS = ( - "omni.warp.core", - "omni.isaac.ml_archive", - "omni.isaac.core_archive", - "omni.kit.pip_archive", - "isaacsim.pip.newton", - ) - - def _should_demote(path: str) -> bool: - norm = path.replace("\\", "/").lower() - if "pip_prebundle" in norm: - return True - for frag in _CONFLICTING_EXT_FRAGMENTS: - if frag.lower() in norm: - return True - return False - - # Partition: keep non-conflicting in place, collect conflicting. - clean = [] - demoted = [] - for p in sys.path: - if _should_demote(p): - demoted.append(p) - else: - clean.append(p) - - if not demoted: - return - - # Rebuild sys.path: originals first, then demoted at the very end. - sys.path[:] = clean + demoted - - # Rewrite PYTHONPATH with the same ordering for subprocesses. - if "PYTHONPATH" in os.environ: - parts = os.environ["PYTHONPATH"].split(os.pathsep) - env_clean = [] - env_demoted = [] - for p in parts: - if _should_demote(p): - env_demoted.append(p) - else: - env_clean.append(p) - os.environ["PYTHONPATH"] = os.pathsep.join(env_clean + env_demoted) - - -def _expose_mujoco_usd_schemas(): - """Put the MuJoCo USD schemas on OpenUSD's plugin search path.""" - plugins = None - spec = find_spec("mujoco_usd_converter") - if spec is not None and spec.origin is not None: - plugins = os.path.join(os.path.dirname(spec.origin), "plugins") - - # Source Isaac Sim exposes this prebundle only after Kit starts, which is too late for the - # schema registry. Its root is available earlier through the launcher environment. - if plugins is None or not os.path.isdir(plugins): - isaac_path = os.environ.get("ISAAC_PATH") - if isaac_path is None: - return - plugins = os.path.join( - isaac_path, - "exts", - "isaacsim.pip.newton", - "pip_prebundle", - "mujoco_usd_converter", - "plugins", - ) - if not os.path.isdir(plugins): - return - search_path = os.environ.get("PXR_PLUGINPATH_NAME", "") - if plugins not in search_path.split(os.pathsep): - os.environ["PXR_PLUGINPATH_NAME"] = os.pathsep.join(filter(None, (search_path, plugins))) - - -_deprioritize_prebundle_paths() -_expose_mujoco_usd_schemas() - - -# TODO(myurasov-nv): bootstrap_kernel() is ported from the internal GitLab wheel builder -# for backwards compatibility. It is not called currently, but may be needed if Isaac Sim -# requires explicit kernel bootstrapping before use. Remove once confirmed unnecessary. -def bootstrap_kernel(): - # Isaac Lab path - isaaclab_path = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) - - # bootstrap kernel via Isaac Sim - if find_spec("isaacsim") is not None: - import isaacsim - - # log info - if find_spec("carb") is not None: - import carb - carb.log_info(f"Isaac Lab path: {isaaclab_path}") - - -def main(): - """Entry point for the ``isaaclab`` console script (python -m isaaclab).""" - from isaaclab.__main__ import main as _main - - sys.exit(_main()) - - -if __name__ == "__main__": - bootstrap_kernel() - main() diff --git a/tools/wheel_builder/stage.py b/tools/wheel_builder/stage.py new file mode 100644 index 000000000000..c7c7a5f364c7 --- /dev/null +++ b/tools/wheel_builder/stage.py @@ -0,0 +1,92 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Stage the aggregate Isaac Lab Python package.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +def stage_package(repo_root: Path, stage_dir: Path, version: str) -> None: + """Create the aggregate package source tree consumed by the wheel builder.""" + builder_dir = repo_root / "tools" / "wheel_builder" + package_dir = stage_dir / "src" / "isaaclab" + + shutil.rmtree(stage_dir, ignore_errors=True) + package_dir.mkdir(parents=True) + + shutil.copytree(repo_root / "apps", package_dir / "apps") + shutil.copytree(repo_root / "source", package_dir / "source") + shutil.copytree(repo_root / "tools" / "template", package_dir / "tools" / "template") + + for directory in (package_dir / "apps").rglob("*"): + if directory.is_dir(): + (directory / "__init__.py").touch() + + core_package = package_dir / "source" / "isaaclab" / "isaaclab" + shutil.copytree(core_package, package_dir, dirs_exist_ok=True) + shutil.rmtree(core_package) + + for extension_dir in sorted((package_dir / "source").glob("isaaclab_*")): + package_name = extension_dir.name + inner_package = extension_dir / package_name + if not (inner_package / "__init__.py").is_file(): + continue + + installed_package = stage_dir / "src" / package_name + shutil.copytree(inner_package, installed_package) + for resource_name in ("config", "data"): + resource_dir = extension_dir / resource_name + if resource_dir.is_dir(): + shutil.copytree(resource_dir, installed_package / resource_name) + + init_path = installed_package / "__init__.py" + init_contents = init_path.read_text(encoding="utf-8") + init_contents = init_contents.replace( + 'os.path.join(os.path.dirname(__file__), "../"', + 'os.path.join(os.path.dirname(__file__), ""', + ) + init_path.write_text(init_contents, encoding="utf-8") + + shutil.rmtree(inner_package) + shutil.rmtree(extension_dir / "data", ignore_errors=True) + + for cache_dir in sorted(stage_dir.rglob("__pycache__"), reverse=True): + shutil.rmtree(cache_dir, ignore_errors=True) + for egg_info_dir in sorted(stage_dir.rglob("*.egg-info"), reverse=True): + shutil.rmtree(egg_info_dir, ignore_errors=True) + for bytecode_file in stage_dir.rglob("*.pyc"): + bytecode_file.unlink() + + subprocess.run( + [ + sys.executable, + str(builder_dir / "gen_pyproject.py"), + str(repo_root / "pyproject.toml"), + str(stage_dir / "pyproject.toml"), + version, + ], + check=True, + ) + + +def main() -> None: + """Stage an aggregate package from the containing Isaac Lab checkout.""" + parser = argparse.ArgumentParser() + parser.add_argument("stage_dir", type=Path) + parser.add_argument("version") + args = parser.parse_args() + + repo_root = Path(__file__).resolve().parents[2] + stage_package(repo_root, args.stage_dir.resolve(), args.version) + + +if __name__ == "__main__": + main() diff --git a/tools/wheel_builder/uv-overrides.txt b/tools/wheel_builder/uv-overrides.txt index c742fe29cabf..a495835d3dd7 100644 --- a/tools/wheel_builder/uv-overrides.txt +++ b/tools/wheel_builder/uv-overrides.txt @@ -1,9 +1,6 @@ numpy>=2 -mujoco~=3.11.0 -mujoco-warp~=3.11.0 -newton[sim]==1.5.2 +mujoco~=3.12.0 +mujoco-warp~=3.12.0 +newton[sim]==1.6.0 newton-usd-schemas>=0.4.1 -torch==2.11.0 -torchvision==0.26.0 -torchaudio==2.11.0 packaging>=20,<27 diff --git a/uv.lock b/uv.lock index 00b7e36be7a4..491f742c67e8 100644 --- a/uv.lock +++ b/uv.lock @@ -17,24 +17,12 @@ prerelease-mode = "allow" [manifest] overrides = [ - { name = "mujoco", specifier = "~=3.11.0" }, - { name = "mujoco-warp", specifier = "~=3.11.0" }, - { name = "newton", extras = ["sim"], specifier = "==1.5.2" }, + { name = "mujoco", specifier = "~=3.12.0" }, + { name = "mujoco-warp", specifier = "~=3.12.0" }, + { name = "newton", extras = ["sim"], specifier = "==1.6.0" }, { name = "newton-usd-schemas", specifier = ">=0.4.1" }, { name = "numpy", specifier = ">=2" }, { name = "packaging", specifier = ">=20,<27" }, - { name = "torch", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==2.11.0" }, - { name = "torch", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torch", marker = "sys_platform == 'win32'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchaudio", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==2.11.0" }, - { name = "torchaudio", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torchaudio", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchaudio", marker = "sys_platform == 'win32'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchvision", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==0.26.0" }, - { name = "torchvision", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchvision", marker = "sys_platform == 'win32'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu128" }, ] [[package]] @@ -1755,7 +1743,7 @@ wheels = [ [[package]] name = "isaaclab" -version = "17.0.1" +version = "17.0.3" source = { editable = "source/isaaclab" } [[package]] @@ -1829,7 +1817,6 @@ dependencies = [ { name = "numpy" }, { name = "omniverseclient" }, { name = "onnx" }, - { name = "onnxscript" }, { name = "open3d", marker = "platform_machine == 'x86_64' or sys_platform == 'win32'" }, { name = "packaging" }, { name = "pillow" }, @@ -1866,7 +1853,6 @@ dependencies = [ all = [ { name = "aiohttp" }, { name = "gym" }, - { name = "onnxscript" }, { name = "ovphysx" }, { name = "ovrtx" }, { name = "ovstage" }, @@ -1881,6 +1867,25 @@ all = [ { name = "tqdm" }, { name = "viser" }, ] +dev = [ + { name = "autodocsumm" }, + { name = "coverage" }, + { name = "flaky" }, + { name = "junitparser" }, + { name = "myst-parser" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "sphinx" }, + { name = "sphinx-book-theme" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design" }, + { name = "sphinx-icon" }, + { name = "sphinx-multiversion" }, + { name = "sphinx-paramlinks" }, + { name = "sphinx-tabs" }, + { name = "sphinxcontrib-bibtex" }, + { name = "sphinxemoji" }, +] importers = [ { name = "isaacsim-asset-isolated" }, { name = "tinyobjloader" }, @@ -1933,7 +1938,6 @@ rlinf = [ { name = "timm" }, ] rsl-rl = [ - { name = "onnxscript" }, { name = "rsl-rl-lib" }, ] sb3 = [ @@ -1948,27 +1952,14 @@ teleop = [ { name = "dex-retargeting", marker = "platform_machine == 'x86_64' and sys_platform != 'win32'" }, { name = "isaaclab-mimic" }, { name = "isaaclab-teleop" }, - { name = "isaacsim", extra = ["all", "extscache"] }, { name = "isaacteleop", extra = ["cloudxr", "retargeters", "ui"], marker = "platform_machine == 'x86_64' and sys_platform != 'win32'" }, ] test = [ - { name = "autodocsumm" }, { name = "coverage" }, { name = "flaky" }, { name = "junitparser" }, - { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-mock" }, - { name = "sphinx" }, - { name = "sphinx-book-theme" }, - { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, - { name = "sphinx-icon" }, - { name = "sphinx-multiversion" }, - { name = "sphinx-paramlinks" }, - { name = "sphinx-tabs" }, - { name = "sphinxcontrib-bibtex" }, - { name = "sphinxemoji" }, ] tetrahedralization = [ { name = "pytetwild", extra = ["all"] }, @@ -1988,7 +1979,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'rl-games'", specifier = "==3.14.1" }, { name = "albumentations", marker = "extra == 'rlinf'", specifier = ">=1.4.18" }, { name = "alphashape", specifier = ">=1.3.1" }, - { name = "autodocsumm", marker = "extra == 'test'" }, + { name = "autodocsumm", marker = "extra == 'dev'" }, { name = "av", marker = "extra == 'rlinf'", specifier = ">=12.3.0" }, { name = "botocore" }, { name = "coacd", specifier = ">=1.0.7" }, @@ -2015,6 +2006,7 @@ requires-dist = [ { name = "isaaclab-assets", editable = "source/isaaclab_assets" }, { name = "isaaclab-contrib", editable = "source/isaaclab_contrib" }, { name = "isaaclab-dev", extras = ["sb3", "skrl", "rl-games", "rsl-rl", "viser", "rerun", "ov"], marker = "extra == 'all'" }, + { name = "isaaclab-dev", extras = ["test"], marker = "extra == 'dev'" }, { name = "isaaclab-experimental", editable = "source/isaaclab_experimental" }, { name = "isaaclab-mimic", marker = "extra == 'mimic'", editable = "source/isaaclab_mimic" }, { name = "isaaclab-mimic", marker = "extra == 'teleop'", editable = "source/isaaclab_mimic" }, @@ -2039,16 +2031,14 @@ requires-dist = [ { name = "matplotlib", specifier = ">=3.10.3" }, { name = "meshio", specifier = ">=5.3.5" }, { name = "moviepy", marker = "extra == 'video'", specifier = ">=1.0.3,<2.0.0.dev0" }, - { name = "myst-parser", marker = "extra == 'test'" }, - { name = "newton", extras = ["sim"], specifier = ">=1.2.0" }, + { name = "myst-parser", marker = "extra == 'dev'" }, + { name = "newton", extras = ["sim"], specifier = "==1.6.0" }, { name = "newton-usd-schemas", specifier = ">=0.2.0" }, { name = "numba", specifier = ">=0.63.1" }, { name = "numpy", specifier = ">=2" }, { name = "numpydantic", marker = "extra == 'rlinf'", specifier = ">=1.7.0" }, { name = "omniverseclient", specifier = "==2.72.3", index = "https://pypi.org/simple" }, { name = "onnx", specifier = ">=1.18.0" }, - { name = "onnxscript", specifier = ">=0.5" }, - { name = "onnxscript", marker = "extra == 'rsl-rl'", specifier = ">=0.5" }, { name = "open3d", marker = "(python_full_version < '3.13' and platform_machine != 'aarch64') or (python_full_version < '3.13' and sys_platform != 'linux')", specifier = ">=0.19.0" }, { name = "ovphysx", marker = "extra == 'ov'", specifier = "==0.5.11", index = "https://pypi.org/simple" }, { name = "ovphysx", marker = "extra == 'ovphysx'", specifier = "==0.5.11", index = "https://pypi.org/simple" }, @@ -2081,38 +2071,38 @@ requires-dist = [ { name = "rich", marker = "extra == 'sb3'" }, { name = "rl-games", marker = "extra == 'rl-games'", git = "https://github.com/isaac-sim/rl_games.git?rev=5cf4f9ff07e9a01bbefd167cf19b6804b294b51c" }, { name = "robomimic", marker = "sys_platform == 'linux' and extra == 'mimic'", git = "https://github.com/ARISE-Initiative/robomimic.git?rev=v0.4.0" }, - { name = "rsl-rl-lib", specifier = "==5.4.1" }, - { name = "rsl-rl-lib", marker = "extra == 'rsl-rl'", specifier = "==5.4.1" }, + { name = "rsl-rl-lib", specifier = "==5.5.1" }, + { name = "rsl-rl-lib", marker = "extra == 'rsl-rl'", specifier = "==5.5.1" }, { name = "scipy", specifier = ">=1.11.0" }, { name = "skrl", marker = "extra == 'skrl'", specifier = ">=2.1.0" }, - { name = "sphinx", marker = "extra == 'test'", specifier = ">=7.0,<9" }, - { name = "sphinx-book-theme", marker = "extra == 'test'", specifier = ">=1.1" }, - { name = "sphinx-copybutton", marker = "extra == 'test'" }, - { name = "sphinx-design", marker = "extra == 'test'" }, - { name = "sphinx-icon", marker = "extra == 'test'" }, - { name = "sphinx-multiversion", marker = "extra == 'test'", specifier = "==0.2.4" }, - { name = "sphinx-paramlinks", marker = "extra == 'test'" }, - { name = "sphinx-tabs", marker = "extra == 'test'" }, - { name = "sphinxcontrib-bibtex", marker = "extra == 'test'", specifier = "==2.5.0" }, - { name = "sphinxemoji", marker = "extra == 'test'" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.0,<9" }, + { name = "sphinx-book-theme", marker = "extra == 'dev'", specifier = ">=1.1" }, + { name = "sphinx-copybutton", marker = "extra == 'dev'" }, + { name = "sphinx-design", marker = "extra == 'dev'" }, + { name = "sphinx-icon", marker = "extra == 'dev'" }, + { name = "sphinx-multiversion", marker = "extra == 'dev'", specifier = "==0.2.4" }, + { name = "sphinx-paramlinks", marker = "extra == 'dev'" }, + { name = "sphinx-tabs", marker = "extra == 'dev'" }, + { name = "sphinxcontrib-bibtex", marker = "extra == 'dev'", specifier = "==2.5.0" }, + { name = "sphinxemoji", marker = "extra == 'dev'" }, { name = "stable-baselines3", marker = "extra == 'sb3'", specifier = ">=2.6" }, { name = "standard-distutils", marker = "extra == 'rl-games'" }, { name = "starlette", specifier = ">=1.3.1" }, { name = "tensorboard" }, { name = "timm", marker = "extra == 'rlinf'", specifier = ">=1.0.14" }, { name = "tinyobjloader", marker = "extra == 'importers'", specifier = "==2.0.0rc13" }, - { name = "torch", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = ">=2.11" }, - { name = "torch", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torch", marker = "sys_platform == 'win32'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchaudio", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = ">=2.11" }, - { name = "torchaudio", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torchaudio", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchaudio", marker = "sys_platform == 'win32'", specifier = ">=2.11", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchvision", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = ">=0.26.0" }, - { name = "torchvision", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.26.0", index = "https://download.pytorch.org/whl/cu130" }, - { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=0.26.0", index = "https://download.pytorch.org/whl/cu128" }, - { name = "torchvision", marker = "sys_platform == 'win32'", specifier = ">=0.26.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torch", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==2.11.0" }, + { name = "torch", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu130" }, + { name = "torch", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torch", marker = "sys_platform == 'win32'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchaudio", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==2.11.0" }, + { name = "torchaudio", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu130" }, + { name = "torchaudio", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchaudio", marker = "sys_platform == 'win32'", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (sys_platform != 'linux' and sys_platform != 'win32')", specifier = "==0.26.0" }, + { name = "torchvision", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu130" }, + { name = "torchvision", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", marker = "sys_platform == 'win32'", specifier = "==0.26.0", index = "https://download.pytorch.org/whl/cu128" }, { name = "tqdm", specifier = ">=4.67.1" }, { name = "tqdm", marker = "extra == 'sb3'" }, { name = "transformers", specifier = "==5.10.4" }, @@ -2121,9 +2111,9 @@ requires-dist = [ { name = "usd-exchange", specifier = "==2.3.0" }, { name = "viser", marker = "extra == 'viser'", specifier = ">=1.0.16" }, { name = "wandb", marker = "extra == 'wandb'", specifier = ">=0.13.11" }, - { name = "warp-lang", specifier = "==1.16.0" }, + { name = "warp-lang", specifier = "==1.17.0" }, ] -provides-extras = ["tetrahedralization", "video", "importers", "test", "sb3", "skrl", "rl-games", "wandb", "rsl-rl", "viser", "rerun", "isaacsim", "ov", "ovphysx", "ovrtx", "mimic", "teleop", "rlinf", "leapp", "all"] +provides-extras = ["tetrahedralization", "video", "importers", "test", "dev", "sb3", "skrl", "rl-games", "wandb", "rsl-rl", "viser", "rerun", "isaacsim", "ov", "ovphysx", "ovrtx", "mimic", "teleop", "rlinf", "leapp", "all"] [[package]] name = "isaaclab-experimental" @@ -2203,7 +2193,7 @@ requires-dist = [{ name = "isaaclab", editable = "source/isaaclab" }] [[package]] name = "isaaclab-rl" -version = "0.16.2" +version = "0.16.3" source = { editable = "source/isaaclab_rl" } dependencies = [ { name = "isaaclab" }, @@ -2220,7 +2210,7 @@ requires-dist = [ [[package]] name = "isaaclab-tasks" -version = "18.0.1" +version = "18.0.2" source = { editable = "source/isaaclab_tasks" } dependencies = [ { name = "isaaclab" }, @@ -3232,7 +3222,7 @@ wheels = [ [[package]] name = "mujoco" -version = "3.11.0" +version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "absl-py" }, @@ -3241,11 +3231,11 @@ dependencies = [ { name = "numpy" }, { name = "pyopengl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/2d/290f3f4062eec1c0d5246de0a75f1b18b59a1961081f49ddc97333fc8263/mujoco-3.11.0.tar.gz", hash = "sha256:390856102af9547dfd87cb2791eb105a3d2e00a37323fe9a12ed17e512aff1a6", size = 1139880, upload-time = "2026-07-28T01:23:32.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/cd/40c929fa073d6b25d44c9f32b3dbbfcb025053cba54f57f8d46d84cdc034/mujoco-3.12.0.tar.gz", hash = "sha256:38bb3eaf4251937a85d58b25f55b1679297155bc4f4e964490d1fd9f42f8bf93", size = 1209771, upload-time = "2026-08-20T11:01:09.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/65/d8130bb8a673f9cdc8a8fb2ef02f9b6931708567de57f17395106e83b4e3/mujoco-3.11.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:517bff03623c3329a563f575afd7d731b0be5c4f92a54dcec934b99120b4a9ec", size = 17704436, upload-time = "2026-07-28T01:22:47.05Z" }, - { url = "https://files.pythonhosted.org/packages/41/d6/7b79f5d8fbd019658a3b5feb6ffd09c1e727eaf93f518685d3cc105a28f5/mujoco-3.11.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f40214fefc8c2fe0002a3c8abadf30de7a3330634f3c45cc29b407b40a0173fc", size = 18868030, upload-time = "2026-07-28T01:22:50.494Z" }, - { url = "https://files.pythonhosted.org/packages/19/46/f994cd7d973c4db4f6b5af19ba6eb62703b9aafc8f894c5bc5ceadd31b0d/mujoco-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:98c08a5eec9411ecd6f763f1e635fbc8f31e3ea3701584d6b7edcc24d8d6c575", size = 15791637, upload-time = "2026-07-28T01:22:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/51/d9/f3cb98d7e37439ffa912b8c63f63e065473523d958cc9eebf40bf8cf9c96/mujoco-3.12.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b10fd3040f5c71504f25a560d8e2fea881f0dc27a9cfd22b4b0a79e65c942f20", size = 18506697, upload-time = "2026-08-20T11:47:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2e/389e8aaa3ce75e21f97ac9e7c48235b6b7acbfb1f370fd3ffb334f77b905/mujoco-3.12.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ec16ce408871a0a9157cc556958ab66cd34db9fc1dccd3ef07717170163a4e0", size = 19816226, upload-time = "2026-08-20T11:47:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/7c42bb3c57b0ae6c37f55f47af94bcc7ea1fe9c39d76df99fb55c3d28591/mujoco-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:d575c455829c8b8d2ffeff4eb3341115623f2e10a250271e73ad94219b707a35", size = 16733176, upload-time = "2026-08-20T11:48:01.552Z" }, ] [[package]] @@ -3266,7 +3256,7 @@ wheels = [ [[package]] name = "mujoco-warp" -version = "3.11.0" +version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "absl-py" }, @@ -3275,9 +3265,9 @@ dependencies = [ { name = "numpy" }, { name = "warp-lang" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/02/da883029b3661619651d0e2469ec17df92dd64ca5de25119886d516ee1de/mujoco_warp-3.11.0.tar.gz", hash = "sha256:e9e521a3809b95baa98c44bd4fcaccb035b5c700e2616efc52ebc33d29ae2eef", size = 2075418, upload-time = "2026-07-28T01:21:41.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/4f/41acd8a084dc8f140594fcca74f2dda61f89705505df20885bd6e65c0146/mujoco_warp-3.12.0.tar.gz", hash = "sha256:6937b83e9eb1632446a70104867d2b0c0ff028613ebc377eee992d30640d6554", size = 2085022, upload-time = "2026-08-20T11:58:15.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/03/9bf86946c755a9a020c1bea587841472b0206351f73f67e41050fcb2a20c/mujoco_warp-3.11.0-py3-none-any.whl", hash = "sha256:97e77e877c1c2ea064ae68eaace2333dec94f2d8984091ba7b383bc8c34958f6", size = 2153348, upload-time = "2026-07-28T01:21:40.18Z" }, + { url = "https://files.pythonhosted.org/packages/47/24/d60f407b469be023566c8e149eb1598ff7d8f9b16332cdadc4fa45ebee3a/mujoco_warp-3.12.0-py3-none-any.whl", hash = "sha256:a0d5d5df854dbf49727cbe2463f8b1c44117cc979cf17ae91d81f291ae0a3b62", size = 2164496, upload-time = "2026-08-20T11:58:13.887Z" }, ] [[package]] @@ -3355,13 +3345,13 @@ wheels = [ [[package]] name = "newton" -version = "1.5.2" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "warp-lang" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/03/a5/fba1e550b36cae939aaaea40612a6f70a754747b5129ef978add2569da49/newton-1.5.2-py3-none-any.whl", hash = "sha256:b432b0db9ee963c1fe570b88952d913b9eb98c9ca190c58f80882f28dd0dd5d6", size = 5567727, upload-time = "2026-09-10T16:43:59.648Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d0/1955c2623762fd1d9f3bc0bb9866b6c0669e1e277202bdc16772520d7da1/newton-1.6.0-py3-none-any.whl", hash = "sha256:5eedebe17d7261ca39e8f97c2f2f20e22e7264d0aed01782f3cb1dd60180b872", size = 6159741, upload-time = "2026-09-10T13:50:10.666Z" }, ] [package.optional-dependencies] @@ -5007,7 +4997,7 @@ wheels = [ [[package]] name = "rsl-rl-lib" -version = "5.4.1" +version = "5.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitpython" }, @@ -5018,12 +5008,10 @@ dependencies = [ { name = "tensordict" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'x86_64' or sys_platform == 'win32'" }, { name = "torch", version = "2.11.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "platform_machine != 'x86_64' and sys_platform != 'win32'" }, - { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine == 'x86_64' or sys_platform == 'win32'" }, - { name = "torchvision", version = "0.26.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "platform_machine != 'x86_64' and sys_platform != 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/7e/b4461227bd281aa9ddd3f82e59ebb4cc70eb82511ad6197a0821e878916c/rsl_rl_lib-5.4.1.tar.gz", hash = "sha256:d8857f9e9a1ef616ad7b7e19cd7a604afc81b9712e3f6d4929b68e131eacb394", size = 65933, upload-time = "2026-06-04T10:48:14.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/0e/142a31e8124255ec9276516c49c6fbe1497f2146ea384a92b81d23e3c525/rsl_rl_lib-5.5.1.tar.gz", hash = "sha256:50129065b35c69735ea197343321145a400af8c1b5fe9a28116fdaf1890fab43", size = 68077, upload-time = "2026-09-09T11:28:51.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/64/8f2d1b390d658b4a054c6800fe87bcd2aa537bfa99c7682bd44f9a971b05/rsl_rl_lib-5.4.1-py3-none-any.whl", hash = "sha256:064c7cec1960566ffe07741915e436c0598ecb906387205356f1554aac017934", size = 92911, upload-time = "2026-06-04T10:48:12.926Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d3/0d4d446c61cb61377802c9e4366cfab8a6c378b9dbcaaad376dcc152c03c/rsl_rl_lib-5.5.1-py3-none-any.whl", hash = "sha256:bdb6382e21c62ddc2b47a37f39318486d0dfde234c7d446bfd42be482ecbd6ea", size = 100035, upload-time = "2026-09-09T11:28:50.331Z" }, ] [[package]] @@ -6031,15 +6019,15 @@ wheels = [ [[package]] name = "warp-lang" -version = "1.16.0" +version = "1.17.0" source = { registry = "https://pypi.nvidia.com/" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:96449fc1e3b354185e2f09434fb794b5953ab2e8673b104d7e7f48d5d418bb35" }, - { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:d4715171bd6821436b82293d774c9a082ace08215c189572b4348d1e48fb8ee8" }, - { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.16.0-py3-none-win_amd64.whl", hash = "sha256:8690c9096e0a271985339d4aa4b37675e7d62852620ca861ac032dc85b124542" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.17.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:47cd93636828dd16e55eeb4e77a0e3547b5fb0f383000a3d228629df68f28fd8" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.17.0-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:5051271048da956a8b94e2db9771ce60ce9591a3ac5bb49037f3d6812d1eea00" }, + { url = "https://pypi.nvidia.com/warp-lang/warp_lang-1.17.0-py3-none-win_amd64.whl", hash = "sha256:ca35c82242a7553046f09023bbe56750b5c3d9af72625bd1a905a56d3189771d" }, ] [[package]]