# Changelog

All notable changes to `ovphysx` are documented in this file.

## [0.4.1] - 2026-05-12

### Breaking Changes
- **`gpu_index` replaced by `active_cuda_gpus` on `ovphysx_create_args`.** The `int32_t gpu_index` field is removed. Use `ovphysx_string_t active_cuda_gpus` instead: a comma-separated string of CUDA device ordinals. Single-GPU usage: `args.active_cuda_gpus = ovphysx_cstr("2")` (C), `PhysX(active_cuda_gpus="2")` (Python), `createArgs.setActiveCudaGpus("2")` (C++). Default (empty) selects GPU 0, matching the old `gpu_index=0` default. Multi-GPU patterns `"0,1,...,N-1"` (all GPUs, round-robin) and `"1,2,...,N-1"` (all except first) are now supported. The C++ `CreateArgs::setGpuIndex(int32_t)` is replaced by `setActiveCudaGpus(const std::string&)`.
- **Pre-loaded Carbonite PhysX stacks are rejected.** ovphysx can share a process with other OV libraries through namespaced USD reuse, but it no longer reuses a pre-existing `omni.physx.plugin` / `IPhysxSimulation` stack. If another Carbonite user already registered PhysX plugins before `ovphysx_create_instance()`, creation fails with a clear error. Conflicting pre-set `/physics/cudaDevice` or `/physics/suppressReadback` values now fail instead of being silently accepted.
- **DirectGPU mode is now opt-in instead of auto-enabled for GPU instances.** Previously, `ovphysx_create_instance(device=GPU)` unconditionally set `/physics/suppressReadback=true` and `/physics/suppressFabricUpdate=true` (via two paths: a startup-time `CarboniteLoader::setStartupSuppressReadback` write before PhysX plugins loaded, and a per-instance write at GPU bootstrap), which routes PhysX into `eENABLE_DIRECT_GPU_API` mode. DirectGPU is incompatible with contact modification (per PhysX SDK guidance: contact-modify requires a CPU roundtrip), so any scene relying on `eMODIFY_CONTACTS` -- including the surface-velocity / conveyor pattern, custom contact callbacks, and contact-shaping filter rules -- silently failed to form kinematic-vs-dynamic broadphase pairs in GPU mode. **Both** write paths are removed: ovphysx no longer touches `/physics/suppressReadback` or `/physics/suppressFabricUpdate` at any point in its lifecycle. The previously-public `CarboniteLoader::setStartupSuppressReadback` static method is removed (internal API; no public callers). Hosts that want DirectGPU's tensor-pipeline performance (e.g. Isaac Lab) opt in by either (a) setting `/physics/suppressReadback=true` via Carbonite settings *before* `ovphysx_create_instance` if they have direct ISettings access, or (b) passing `ovphysx_config_entry_carbonite("/physics/suppressReadback", "true")` in `create_args.config_entries` (Python: `PhysXConfig(carbonite_overrides={"/physics/suppressReadback": True})`). At startup ovphysx now logs a `[CarboniteLoader] /physics/suppressReadback=...` INFO line so misconfigurations are visible. See `ovphysx_create_args` in [`include/ovphysx/ovphysx_types.h`](../include/ovphysx/ovphysx_types.h) for the full trade-off documentation. **Migration:** GPU users who relied on suppressReadback being auto-enabled must set it explicitly via one of the routes above. **Perf note:** DirectGPU is the faster simulation path for tensor-pipeline workloads, so existing GPU consumers will see slower per-step times until they opt in -- the change here is correctness (contact-modify scenes now work in GPU mode), not a perf regression of the new default. Users who need contact-modify in GPU mode now get correct behavior with no code change.
- **`ovphysx`'s Python `import` now auto-registers ovphysx's namespaced USD schema/plugin path** by appending it to `OV_PXR_PLUGINPATH_2511` once at module load. Eliminates a class of silent-schema-drop bugs in mixed-process apps (`import ovphysx; import ovrtx` or vice versa) where USD's `PlugRegistry` is populated lazily on first stage open and never re-scans, so any subsystem that didn't get its plugin path published *before that moment* has its applied schemas silently dropped. The implicit registration in `CarboniteLoader` continues to cover the ovphysx-only-in-process case; this Python-side change is purely additive. The auto-call is pure-Python (env-var append only) and does not trigger native loading -- libcarb / USD / `_bindings` remain deferred to first native-attribute access (`PhysX`, `ContactBinding`, …). Failures (e.g. partially-installed checkout with no `plugins/usd` directory) are surfaced as a logged warning rather than raised so `import ovphysx` does not hard-fail. The existing public `register_schema_paths()` is unchanged and remains idempotent (already-called guard); apps that called it explicitly continue to work and the explicit call becomes a cheap no-op on success. C/C++ apps are unaffected -- still use `ovphysx_register_schema_paths()` explicitly. **Migration:** none required. Apps that need an env-var-pure import can `os.environ.pop("OV_PXR_PLUGINPATH_2511")` after `import ovphysx`.

### Added
- **`add_subdirectory()` support for source-link builds.** Users can now include ovphysx in their own CMake project via `add_subdirectory()` instead of `find_package()`, enabling an edit-rebuild workflow where changing ovphysx sources automatically recompiles them. Dependencies are auto-fetched at configure time (`OVPHYSX_FETCH_DEPS=ON`). See the [source-link tutorial](tutorials/source_link_build.md) and `tests/c_samples/hello_world_source_link/` for a complete example.
- **Clone events from `ovstage_clone_subtree()` are consumed by ovphysx during step ingest.** When an ovstage Stage is attached, `physx.step()` consumes `OVSTAGE_EVENT_CLONE` events surfaced by `ovstage_query_changes()` and drives PhysX-side replication via the clone plugin — same plugin the existing `ovphysx_clone()` entrypoint calls, just routed through ovstage instead of an explicit C / Python call. The standalone clone APIs (`ovphysx_clone()` C, `PhysX::clone()` C++, `PhysX.clone()` Python) remain fully supported for callers that don't have an attached Stage or that want the async multi-target convenience; both paths funnel into the same `ovphysx_clone_replicate_internal` helper. **Known limitation:** clones that arrive *after* the bridge's first successful `publish_outbound` (i.e. once `ensure_publish_initialized` has bound a non-zero `RIGID_BODY_POSE` prim list) simulate on the PhysX side but their poses are NOT published back to ovstage — the publish prim list is frozen at first publish. Issue a `[OvstageBridge] clone-after-publish:` warning per affected target identifies this case in logs. Workarounds: clone everything before the first non-empty `physx.step()`, or detach + re-attach the stage to rebuild the publish bindings. Refresh-on-clone is tracked as a follow-up because it requires `ovstage_unregister_consumer` + re-registration through the bridge state machine.
- **`ovphysx_attach_stage()` / `ovphysx_detach_stage()` for ovstage consumer integration.** Pair an `ovstage_instance_t*` with an ovphysx instance and `physx.step()` becomes the canonical "consume control attributes from ovstage and publish poses back" step: the OvstageBridge pulls dirty `drive:force`, `physics:mass`, `drive:velocity`, `drive:position_target` (Tensor route), `physxSurfaceVelocity:surfaceVelocity`, `physics:gravityMagnitude`, `physics:gravityDirection` (Fabric route), and clone events (`ovstage_query_changes`) ahead of integrate; after `fetchResults` it publishes `RIGID_BODY_POSE` as `xformOp:transform` (float64 row-major mat4) to ovstage's output-buffer contract. Init-only attach (rejects re-attach with `OVPHYSX_API_ERROR`); idempotent detach. Python: `PhysX.attach_stage(stage)` auto-unwraps via `Stage.handle()`.
- **Cross-device staged reads AND writes in `ovphysx_read_tensor_binding` / `ovphysx_write_tensor_binding` / `ovphysx_write_tensor_binding_masked`.** A CPU-declared `src`/`dst`/`mask`/`index` tensor now works against a GPU-bound binding, and a GPU-declared tensor works against a CPU-bound binding: the call transparently allocates a staging buffer on the binding's device (or host) and routes through `IOptionalCuda::memcpyDtoH` / `memcpyHtoD`. Reads stage-and-copy after the simView read; writes copy-and-stage before the simView write. Same-device combinations are unchanged. Cross-GPU (different ordinals) still returns `OVPHYSX_API_DEVICE_MISMATCH`.
- **Schema path pre-registration API for multi-subsystem USD processes.** New C API `ovphysx_register_schema_paths()` and Python helper `ovphysx.register_schema_paths()` append ovphysx's namespaced USD schema/plugin root to `OV_PXR_PLUGINPATH_2511` before ovphysx initialization. Use this with peer subsystem equivalents such as `ovrtx_register_schema_paths()` before the first USD stage open or schema-registry access.
- **Safer behavior when loading into a process with another Carbonite-owning library.** Previously, ovphysx would blindly load its own USD-dependent plugin stack on top of any framework already bootstrapped in the process. `loadUsdDependentPlugins`' `usdrt::population::IUtils`-availability guard is now narrowed to fire only in full-host mode (Kit / Isaac Sim, i.e. `IPhysxSimulation` already registered) rather than any-usdrt-user, so partial hosts that provide `IUtils` but not the physics-schema plugins now trigger ovphysx's own `omni.usdphysics.plugin` load instead of silently skipping it. In-process coexistence remains subject to ABI compatibility of the shared deps that both libraries link against (Fabric interface version, Carbonite version, USD build-package); when those don't line up, the new drift diagnostics (see `OVPHYSX_COEXIST_DIAGNOSTICS` and `config.toml build_package` below, plus the refuse-branch in `CarboniteLoader::initialize()` under *Changed or Fixed*) convert the failure into a named, actionable error instead of a silent plugin-resolution cascade.
- **`OVPHYSX_COEXIST_DIAGNOSTICS=1` diagnostic env var.** Set to enable stderr-only diagnostic logging of the plugin-load and Fabric-acquire paths inside `ovphysx_create_instance` -- useful when triaging a "Dependency: [omni::physics::schema::IUsdPhysics v1.1] failed to be resolved" cascade or "Fabric interfaces unavailable" error in a coexistence context. Logs the Framework pointer, plugin-registry contents, `omni.physicsschema.plugin` registration state, and the Fabric interface-version list as seen by ovphysx. Unset: no behavior change.
- **`OVPHYSX_COEXIST_REFUSE=1` opt-out env var.** Restores the legacy fail-fast-at-load behavior for users who would rather not attempt coexistence with another Carbonite-owning library. With the default coexistence flip (see *Changed or Fixed* below), `OVPHYSX_COEXIST_REFUSE=1` is the explicit off-ramp: ovphysx detects a foreign Carbonite framework and refuses to initialize with a sharp error message instead of proceeding. Default unset: ovphysx proceeds with coexistence and lets per-phase interface probes name any version skew at the failing plugin.
- **Per-phase plugin-interface probes after `loadPlugins()`.** `CarboniteLoader::initialize()` now runs `tryAcquireInterface<>` against the primary interface of each plugin loaded in the foundation and early-PhysX phases (`carb.dictionary`, `carb.settings`, `carb.tokens`, `carb.tasking`, `carb.filesystem`, `omni.physx.foundation`). If any returns null -- because a foreign-host plugin advertises an incompatible major/minor or because the plugin failed to load -- ovphysx fails fast with a single error line that names every plugin that did not satisfy ovphysx's expected interface version. Standalone ovphysx is unaffected. The probe converts what was previously an opaque `nullptr` cascade two layers down into a named, actionable error at load time, mirroring the existing `UsdVersionCheck` pattern for USD.
- **USD build-package drift diagnostic in `config.toml`.** `config.toml` now records the exact USD packman package identifier ovphysx was linked against (e.g. `build_package = "0.25.11.kit.2-gl.19811"`, auto-populated at build time from the resolved `_build/target-deps/usd/release` symlink). At runtime, `UsdVersionCheck` resolves the loaded USD's canonical path, extracts its packman id, and compares. If the two differ -- e.g. ovphysx built against `0.25.11.kit.2` but another library preloaded `0.25.11.kit.1` -- a single warning line names both ids and points at Carbonite-plugin-resolution failure as the probable downstream symptom. Same-build case is silent.
- **New `--devschema` flag** forwards to ovruntime for local physics schema builds.
- **Multi-GPU scene distribution.** Pass `active_cuda_gpus="0,1,2"` (all N GPUs) or `active_cuda_gpus="1,2"` (all except GPU 0) to distribute scenes round-robin across the specified devices. Internally maps to `/physics/sceneMultiGPUMode = eAll` or `eSkipFirst`. Other patterns (e.g. `"0,2"` on a 4-GPU machine) return `OVPHYSX_API_INVALID_ARGUMENT` with a descriptive error; arbitrary subset support can be added in a future release without API changes.
- **TensorBindingsAPI: standalone rigid body read-only queries** - `RIGID_BODY_ACCELERATION` (`[N,6]`), `RIGID_BODY_INV_MASS` (`[N]`), and `RIGID_BODY_INV_INERTIA` (`[N,9]`). The enum values use previously unused slots after `RIGID_BODY_WRENCH`; existing tensor enum values were not reordered.
- **Articulation kinematic update API.** New C `ovphysx_update_articulations_kinematic()`, Python `PhysX.update_articulations_kinematic()`, and experimental C++ `PhysX::updateArticulationsKinematic()` recompute articulation link poses from current DOF positions. In GPU mode the first call may perform the standard DirectGPU warmup step; after warmup, the FK refresh does not run a normal simulation step or collision/contact work.
- **TensorBinding row metadata for tensor bindings.** Python `TensorBinding.prim_paths` and C `ovphysx_tensor_binding_get_prim_paths()` return the resolved USD prim path for each rigid-body tensor row and the articulation root prim path for each articulation row.
- **Python tensor-binding empty-match guard.** `PhysX.create_tensor_binding(..., raise_if_empty=True)` now raises `ValueError` when the binding would match zero prims. The default remains `False`, preserving zero-count bindings for optional scene elements.
- **Python binding lifetime warnings.** `TensorBinding` and `ContactBinding` now emit `ResourceWarning` if garbage collection cleans them up without an explicit `destroy()` or context-manager exit. This does not affect normal read/write paths; create bindings once outside simulation loops and reuse or destroy them explicitly.
- **ContactBinding detailed contact/friction reads.** Added `sensor_paths`, `filter_paths`, `max_contact_data_count`, C `ovphysx_contact_binding_get_sensor_paths()`, `ovphysx_contact_binding_get_filter_paths()`, `ovphysx_get_contact_binding_capacity()`, `ovphysx_read_contact_data()`, `ovphysx_read_friction_data()`, and matching Python methods. The API uses flat `[C,*]` buffers with `[S,F]` count/start-index tensors. Detailed reads require filtered bindings (`filters_per_sensor > 0`); unfiltered bindings remain valid for aggregate net-force reads.
- **Experimental C++ `CreateArgs` type and `PhysX::create(out, CreateArgs)`.** `CreateArgs` is a safe C++ wrapper for `ovphysx_create_args` that default-constructs to `OVPHYSX_CREATE_ARGS_DEFAULT` and exposes setters for `device`, `active_cuda_gpus`, `bundled_deps_path`, and config entries. Replaces the previous `create(PhysX&, config_entries, count)` overload.
- **OmniPVD recording support via typed config.** Two new config entries enable `.ovd` recording: `omnipvd_output_enabled` (bool) and `omnipvd_ovd_recording_directory` (string). Both must be set at instance creation via `PhysXConfig` (Python) or `config_entries` on `ovphysx_create_args` (C/C++). These entries were previously available only via `carbonite_overrides`; they now have first-class typed fields. See [OmniPVD Recording tutorial](tutorials/omnipvd_recording.md).
- **Source builds now use namespaced USD.** ovphysx builds against namespaced monolithic USD (`PXR_NS=ov`) so it can coexist with host applications that already load another USD runtime. **Migration for source builds:** update build scripts to fetch namespaced USD packages; classic modular USD is no longer supported.

### Changed or Fixed
- **ContactBinding device-mismatch errors now explain the DirectGPU opt-in.**
  When a CPU-backed ContactBinding receives a CUDA output tensor, the error now
  points out that `device="gpu"` enables GPU dynamics only and that
  TensorAPI/ContactBinding CUDA views require `/physics/suppressReadback=true`
  before instance creation. Behavior is unchanged.
- **ovphysx startup now appends its namespaced USD schema path when `OV_PXR_PLUGINPATH_2511` is already set.** Previously `CarboniteLoader` only set the variable when it was empty, so pre-existing ovrtx or application paths could prevent ovphysx's own schema root from being published. Startup now shares the same append/dedupe logic as `ovphysx_register_schema_paths()` and does not modify `PXR_PLUGINPATH_NAME`. Startup and explicit pre-registration fail fast if ovphysx cannot find an existing `plugins/usd` directory, rather than publishing a missing path that USD silently ignores.
- **Improved automatic GPU selection on multi-GPU systems.** When the physics device is not set explicitly, ovphysx now prefers the GPU already in use by the renderer, and otherwise picks the discrete GPU with the most memory. Previously device 0 was always chosen, which could result in physics running on a different GPU than the renderer on multi-GPU workstations. Users on single-GPU machines are unaffected; users on multi-GPU systems may see a different device selected than before.
- **`ovphysx_clone()` now preserves `xformOp:scale` from source prims.** Previously, clones of prims with non-unit scale appeared at incorrect world-space size. The fix reads the source's scale (supporting both `double3` and `float3` attributes), writes it to each target, includes it in `xformOpOrder`, and incorporates it into the `localMatrix`.
- **Contact binding now matches runtime-cloned sensor bodies.** Runtime clones created with `ovphysx_clone()` can exist only in Fabric/usdrt, where copied USD API-schema metadata may not be visible. Contact binding now keeps the strict `PhysxContactReportAPI` requirement for real USD prims while allowing clone-only runtime paths to validate through their PhysX actor pointer, so cloned environments produce the same contact-binding rows as rigid-body tensor bindings.
- **`ovphysx_step_sync()` now calls `ensure_physics_attached()` before stepping**, matching the async `ovphysx_step()` path. Previously, the first `step_sync` after `add_usd` would run against an unattached stage — features like OmniPVD recording finalization silently failed.
- **Config entries are now applied before PhysX plugin loading** so that settings read during `createPhysics()` (e.g., OmniPVD recording) are in place at initialization time.
- **TensorBindingsAPI shape property tensors now accept CPU buffers in GPU simulations.** Rigid-body and articulation material/contact/rest-offset tensors are PhysX parameter tensors and remain CPU-resident even when state tensors use CUDA buffers.
- **ovphysx wheel bootstrap no longer sets `PYTHONHOME`.** Fixes mis-resolution of `sys.prefix` in child processes that spawn venv interpreters (Windows; e.g. rerun.io).
- **Unified build flags.** `build.sh` / `build.bat` now accept the same flags as ovruntime: `-c`/`--clean`, `-x`/`--rebuild`, `-d`/`--debug`, `-r`/`--release` (default), `-t`/`--target <name>`, `-g`/`--generate`. **Note:** default is now release-only (was debug+release); `-t` is now `--target <name>` (use `--test-venv` for the old test-venv behavior).
- **`create_articulation_view()` and `create_rigid_body_view()` pattern matching extended to reach nested bodies.** Patterns now support `**` for recursive descent and — by default — match a named final component against descendants at any depth so patterns like `/World/envs/env_*/Robot/<name>` keep working on deeper hierarchies. Views dedup by backing PhysX pointer. Set `/physics/tensors/recursiveLeafPatternMatch` to `false` to restore strict per-level matching. **Migration:** existing patterns may pick up additional prims at deeper levels. If that changes behavior, either set the Carbonite flag to `false` or switch to explicit absolute paths.
- **`/ovphysx/latest/` on GitHub Pages now serves the docs directly** instead of redirecting to `/ovphysx/<version>/`. This fixes deep links such as the PyPI Changelog URL (`/latest/changelog.html`) which previously 404'd. Bookmarks to `/latest/` continue to work; bookmarks to versioned URLs are unchanged.

### Removed
- **Environment variables `OVPHYSX_ROOT`, `OVPHYSX_BIN_DIR`, `OVPHYSX_PLUGINS_DIR` removed.** All runtime paths are now derived from `OVPHYSX_LIB` (the single dev-mode override for the shared library path). **Migration:** replace any of the removed env vars with `OVPHYSX_LIB` pointing at the shared library.
- **`ovphysx_set_shutting_down()` removed.** Full teardown now happens automatically when the last instance is destroyed via `ovphysx_destroy_instance()` (C/C++) or `release()` (Python). **Migration:** remove any `ovphysx_set_shutting_down()` calls.
- **Legacy Python TensorAPI compatibility removed.** `ovphysx.tensors` is no longer shipped or exported. **Migration:** replace `ovphysx.tensors` imports with TensorBindingsAPI: use `physx.create_tensor_binding()` to create bindings, then `binding.read()` / `binding.write()` for data access.

## [0.3] - 2026-03-31

### Breaking Changes
- **Settings API replaced with typed config system.** The old string-based settings (`settings_keys`/`settings_values`/`settings_count` on `ovphysx_create_args`; `ovphysx_set_global_setting()`/`ovphysx_get_global_setting()`; Python `PhysX(settings=...)`, `set_setting()`, `get_setting()`; C++ `PhysX::setSetting()`/`getSetting()`) are all removed. **Migration:** C — use `ovphysx_config_entry_t` array on `config_entries`/`config_entry_count` with builders from `ovphysx_config.h`, runtime `ovphysx_set_global_config()` and typed getters `ovphysx_get_global_config_bool/int32/float/string()`. Python — use `PhysX(config=PhysXConfig(num_threads=4))`, runtime `set_config_bool()`/`set_config_int32()` and matching getters. Arbitrary Carbonite paths remain accessible via `ovphysx_config_entry_carbonite()` (C/C++) or `PhysXConfig(carbonite_overrides={...})` (Python).
- **`OVPHYSX_CONFIG_CUDA_DEVICE` removed** from `ovphysx_config_int32_t`. **Migration:** use `active_cuda_gpus` on `ovphysx_create_args` (C) or `PhysX(active_cuda_gpus=...)` (Python). Setting `/physics/cudaDevice` via `carbonite_overrides` now raises an error.
- **Removed typed config entries** for settings not in Physics Preferences: `OVPHYSX_CONFIG_PHYSX_DISPATCHER`, `OVPHYSX_CONFIG_OMNI_PVD_OUTPUT_ENABLED`, `OVPHYSX_CONFIG_UJITSO_COLLISION_COOKING` (bool); `OVPHYSX_CONFIG_PVD_RECORDING_DIRECTORY` (string). **Migration:** these settings remain accessible via `carbonite_overrides`.
- **Log level enum reordered to ascending severity** (matches ovrtx and industry convention). New values: `OVPHYSX_LOG_VERBOSE=0`, `OVPHYSX_LOG_INFO=1`, `OVPHYSX_LOG_WARNING=2` (unchanged), `OVPHYSX_LOG_ERROR=3`, `OVPHYSX_LOG_NONE=4`. **Migration:** code using symbolic names is unaffected; code using raw integer values must update.
- **Error handling switched from inline error strings to thread-local `get_last_error()` query.** `ovphysx_result_t` and `ovphysx_enqueue_result_t` no longer have an `error` field. **Migration:** on failure, call `ovphysx_get_last_error()` on the same thread to retrieve the error message (valid until the next ovphysx API call on that thread). For `wait_op`, iterate `error_op_indices` and call `ovphysx_get_last_op_error()` per failed index, then `ovphysx_destroy_wait_result(&result)`. Removed: `ovphysx_destroy_error()`, `ovphysx_destroy_errors()`. Python API is unaffected (errors are raised as exceptions).
- **`ContactEventHeader.stageId` type changed from `long` to `int64_t`** for cross-platform ABI stability. This changes the struct layout on Windows.
- **`ovphysx_get_contact_report()` C signature changed**: now returns typed pointers (`const ovphysx_contact_event_header_t**`, `const ovphysx_contact_point_t**`) instead of `const void**`, and gained 2 new parameters (`out_friction_anchors`, `out_num_friction_anchors`). **Migration:** update pointer types and append `, NULL, NULL` for friction anchor parameters. C++ callers are unaffected (new params default to `nullptr`).
- **Removed** `ovphysx_articulation_get_dof_count`, `_body_count`, `_joint_count`, `_is_fixed_base`, `_fixed_tendon_count`, `_spatial_tendon_count`. **Migration:** use `ovphysx_get_articulation_metadata(handle, binding, &meta)`. Python `TensorBinding` properties are unchanged.
- **`ovphysx_clone()` parameter `parent_positions_xyz` replaced with `parent_transforms`** (7 floats per target: px, py, pz, qx, qy, qz, qw). **Migration:** replace `parent_positions_xyz=[x, y, z]` with `parent_transforms=[px, py, pz, 0, 0, 0, 1]` (append identity quaternion).
- **Removed** `ovphysx_set_clone_env_root()` (C) and `set_clone_env_root()` (Python). **Migration:** no replacement needed — the first `clone()`, `simulate()`, or `warmup_gpu()` call triggers stage attach lazily.
- **`ovphysx_device_t` enum reordered:** `OVPHYSX_DEVICE_AUTO = 0` (was 2) so zero-initialized args default to AUTO. **Migration:** code using symbolic names is unaffected; code using raw integers must update (`0=AUTO, 1=GPU, 2=CPU`).
- **`OVPHYSX_TENSOR_ARTICULATION_CORIOLIS_FORCE_F32` renamed** to `ARTICULATION_CORIOLIS_AND_CENTRIFUGAL_FORCE` (enum value 72 unchanged). **Migration:** update references to the old name.
- Scene query enum constants disambiguated: `OVPHYSX_SCENE_QUERY_CLOSEST` → `OVPHYSX_SCENE_QUERY_MODE_CLOSEST` (and `_ANY`, `_ALL`); `OVPHYSX_SCENE_QUERY_SPHERE` → `OVPHYSX_SCENE_QUERY_GEOMETRY_SPHERE` (and `_BOX`, `_SHAPE`). **Migration:** update to the new constant names.
- **Python enum cleanup.** All `OVPHYSX_TENSOR_*_F32` bare-int constants removed (use `TensorType.*` members); `OVPHYSX_API_*`, `OVPHYSX_LOG_*`, `OVPHYSX_DEVICE_*` constants removed (use `ApiStatus.*`, `LogLevel.*`, `DeviceType.*`); `OVPHYSX_OP_INDEX_ALL` renamed to `OP_INDEX_ALL`; `kDLCPU`, `kDLCUDA`, `kDLInt`, `kDLUInt`, `kDLFloat` removed (use `DLDeviceType.kDLCPU` / `DLDataTypeCode.kDLFloat`).
- **Removed `ovphysx_finalize()`**. Full Carbonite framework teardown now happens automatically when the last instance is destroyed. **Migration:** remove any `ovphysx_finalize()` calls.

### Added
- `ovphysx_articulation_metadata_t` struct and `ovphysx_get_articulation_metadata()` — fills 6 scalar topology fields in one call, one lock acquire, one stream fence.
- `OVPHYSX_CONFIG_SCENE_MULTI_GPU_MODE` (int32) config entry for `/physics/sceneMultiGPUMode`.
- `ConfigBool`, `ConfigInt32`, `ConfigFloat`, `ConfigString` IntEnums in Python for typed config key access.
- `PhysXConfig` dataclass in Python for typed initialization config.
- Remote USD loading: `add_usd()` now accepts remote URIs (`omniverse://`, S3, Azure Blob). Use HTTPS virtual-hosted S3 URLs. New `ovphysx_configure_s3()` / `configure_s3()` and `ovphysx_configure_azure_sas()` / `configure_azure_sas()` for credential setup.
- TensorBindingsAPI: DOF property tensors — stiffness, damping, limits, max velocity, max force, armature, friction properties (read/write, indexed, masked).
- TensorBindingsAPI: body property tensors — mass, center-of-mass pose, inertia tensor (read/write, indexed, masked).
- TensorBindingsAPI: link acceleration tensor (`[N, L, 6]`, read-only).
- TensorBindingsAPI: dynamics query tensors (read-only) — Jacobian, generalized mass matrix, Coriolis + centrifugal forces, gravity compensation, link incoming joint force, DOF projected joint forces.
- TensorBindingsAPI: standalone rigid body properties — mass, inertia, COM pose (read/write with indexed/masked write support). Articulation body inverse mass and inverse inertia (read-only).
- TensorBindingsAPI: fixed tendon properties — stiffness, damping, limit stiffness, limits, rest length, offset (read/write, indexed, masked).
- TensorBindingsAPI: spatial tendon properties — stiffness, damping, limit stiffness, offset (read/write, indexed, masked).
- TensorBindingsAPI: shape-level tensors — `RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION` (`[N,S,3]`), `RIGID_BODY_CONTACT_OFFSET` (`[N,S]`), `RIGID_BODY_REST_OFFSET` (`[N,S]`), and articulation equivalents. All support read, indexed write, and masked write.
- Articulation metadata name queries: `ovphysx_articulation_get_dof_names`, `get_body_names`, `get_joint_names`.
- Contact binding API: `ovphysx_create_contact_binding` / `destroy` / `get_spec` / `read_net_forces` / `read_force_matrix`. Python `ContactBinding` class for reading contact forces via DLPack tensors.
- PhysX object interop: `ovphysx_get_physx_ptr()` returns raw PhysX SDK pointers by USD prim path and type enum. SDK ships PhysX headers under `include/physx/`. See [PhysX Interop tutorial](tutorials/physx_interop.md).
- Contact report API: `ovphysx_get_contact_report()` exposes per-step contact data as typed C struct pointers. Python `PhysX.get_contact_report()` returns a dict with ctypes arrays.
- Scene query API: `ovphysx_raycast()`, `ovphysx_sweep()`, `ovphysx_overlap()` — raycast, geometry sweep, and overlap queries. Supports CLOSEST/ANY/ALL hit modes with sphere, box, and arbitrary-shape geometry.
- Synchronous stepping: `ovphysx_step_sync()` / `step_sync()` combines step + wait into one call. `ovphysx_step_n_sync()` / `step_n_sync()` batches N steps.
- `OVPHYSX_API_BUFFER_TOO_SMALL = 6` status code.
- CMake package config: `find_package(ovphysx)` provides `ovphysx::ovphysx` target and Windows `ovphysx_copy_runtime_dlls()` helper.
- Visual sample using [Rerun](https://rerun.io) for rigid body visualization ([Rendering Handoff](tutorials/render_handoff.md)).
- Linux aarch64 support.

### Changed or Fixed
- Physics scene is now parsed lazily on the first `simulate()` call instead of during `add_usd()`. This avoids GPU buffer corruption when `clone()` adds environments after the initial load. Correct sequence: `add_usd()` → `clone()` → `warmup_gpu()`/`step()`.
- A GPU and CUDA installation is no longer required for CPU-only simulation.
- Fixed a fatal TF_DEBUG crash when ovphysx and OVRTX co-loaded in Python due to a collision with USD libraries.
- Fixed various memory leaks across C, C++, and Python error-handling and cleanup paths.
- Contact binding read functions now validate dtype is float32 before dispatching.
- `ContactBinding.destroy()` now checks status and raises on failure; validates parent SDK is alive.
- `create_contact_binding` now validates `sensor_patterns` is non-empty and `filter_patterns` length.
- Wheel metadata corrected: `Root-Is-Purelib: false`, OS classifiers set to Linux and Windows.
- Fixed shutdown crash where stepper tasks could access freed CUDA context.
- Fixed process-exit crashes caused by non-deterministic DLL/SO unload ordering. `ovphysx_destroy_instance()` now performs full teardown when the last instance is destroyed.
- `ovphysx_remove_usd()` now validates `usd_handle` and returns `OVPHYSX_API_NOT_FOUND` for unknown handles.

### Removed
- `ovphysx.pc` (pkg-config file) removed; CMake is the supported integration path.
- `hdStorm` (Hydra Storm renderer) removed from SDK; not needed for headless physics simulation.

## [0.2] - 2026-03-03

First released version. No changelog was maintained prior to v0.3.
