From a10e75b8e81010e35214892d331f7c37152e45ae Mon Sep 17 00:00:00 2001 From: snkmcb Date: Tue, 22 Sep 2026 14:37:45 +0900 Subject: [PATCH 1/2] feat: add physics core world contract --- README.md | 8 +- docs/README.md | 5 +- docs/architecture/PACKAGE_CONTRACT.md | 6 +- docs/architecture/WORKSPACE.md | 6 +- docs/design/RIGID_BODY_CONTRACT.md | 39 ++- docs/reference/CAPABILITY_MATRIX.md | 14 +- docs/roadmap/README.md | 2 +- docs/roadmap/current.md | 20 +- libs/physicsCore/CMakeLists.txt | 2 + .../include/usd_physics/core/errors.h | 24 ++ .../include/usd_physics/core/world.h | 47 +++ libs/physicsCore/src/errors.cpp | 8 + libs/physicsCore/src/world.cpp | 26 ++ libs/physicsCore/tests/CMakeLists.txt | 4 + .../tests/physics_core_world_test.cpp | 301 ++++++++++++++++++ tests/installed_consumer/main.cpp | 13 +- 16 files changed, 479 insertions(+), 46 deletions(-) create mode 100644 libs/physicsCore/include/usd_physics/core/errors.h create mode 100644 libs/physicsCore/include/usd_physics/core/world.h create mode 100644 libs/physicsCore/src/errors.cpp create mode 100644 libs/physicsCore/src/world.cpp create mode 100644 libs/physicsCore/tests/physics_core_world_test.cpp diff --git a/README.md b/README.md index 47e6fc2..bc1fbee 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Backend-neutral physics runtime components for OpenUSD-based applications. > **Status: Phase 1 core extraction.** The repository builds and installs > `physicsCore` and `physicsJolt`; neutral rigid-transform values, typed -> handles, and validated box/body/fixed-constraint descriptors are -> implemented, but rigid-body runtime behavior, an OpenUSD bridge, and a -> solver backend are not. The +> handles, validated box/body/fixed-constraint descriptors, and the +> single-owner world contract are implemented, but optional query contracts, +> an OpenUSD bridge, and a solver backend are not. The > [capability matrix](docs/reference/CAPABILITY_MATRIX.md) is the only page > that states what exists, and the [current roadmap](docs/roadmap/current.md) > states what comes next. @@ -56,7 +56,7 @@ their backend. | Component | Kind | Role | State | | --- | --- | --- | --- | -| `physicsCore` | plain C++ library | Handles, descriptors, world lifecycle, state, and optional query contracts; no OpenUSD and no backend SDK | neutral values, handles, and validated descriptors present; remaining runtime API planned | +| `physicsCore` | plain C++ library | Handles, descriptors, world lifecycle, state, and optional query contracts; no OpenUSD and no backend SDK | neutral values, handles, descriptors, and world contract present; optional queries planned | | `physicsJolt` | plain C++ library | First rigid-body backend implementing `physicsCore` | package scaffold present; backend unavailable | | `physicsUsd` | OpenUSD-facing C++ library | Translate standard `UsdPhysics` declarations to runtime descriptors and maintain transient prim/resource mappings | planned | | `secondaryMotion` | plain C++ library | Solver-neutral spring-chain and collider contracts | deferred until the rigid-body boundary is extracted | diff --git a/docs/README.md b/docs/README.md index ec37f25..ba05f6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,8 +6,9 @@ reference reports what exists, and the roadmap contains incomplete work. **Current tree (2026-09-22): Phase 1 core extraction.** The root build, packages, boundary checks, and installed-consumer test exist. Neutral rigid -transform values and typed handles are implemented; descriptors, world -behavior, a solver backend, an OpenUSD bridge, and a release are not. The +transform values, typed handles, validated descriptors, and the single-owner +world contract are implemented; optional queries, a solver backend, an OpenUSD +bridge, and a release are not. The [capability matrix](reference/CAPABILITY_MATRIX.md) is the only page that may claim implementation status. diff --git a/docs/architecture/PACKAGE_CONTRACT.md b/docs/architecture/PACKAGE_CONTRACT.md index 74581a4..a951311 100644 --- a/docs/architecture/PACKAGE_CONTRACT.md +++ b/docs/architecture/PACKAGE_CONTRACT.md @@ -2,8 +2,8 @@ > **Status: accepted, revised 2026-09-22.** `physicsCore` and `physicsJolt` > install and pass a clean-prefix consumer test. `physicsCore` now includes -> neutral values, typed handles, and validated descriptors; world behavior -> remains unimplemented. +> neutral values, typed handles, validated descriptors, typed errors, and the +> single-owner world interface and validation surface. ## 1. Purpose @@ -15,7 +15,7 @@ not need this repository's source tree. | Package | Imported target | Provides | Planned phase | | --- | --- | --- | --- | -| `physicsCore` | `physicsCore::physicsCore` | Version surface plus neutral vector, quaternion, transform, typed handles, validated box/body/fixed-constraint descriptors, and semantic collision filters; world and query contracts follow in Phase 1 | values, handles, and descriptors present; runtime Phase 1 | +| `physicsCore` | `physicsCore::physicsCore` | Version surface plus neutral vector, quaternion, transform, typed handles, validated box/body/fixed-constraint descriptors, semantic collision filters, typed errors, and the world lifecycle/state interface; optional query contracts follow in Phase 1 | values, handles, descriptors, errors, and world contract present; queries remain Phase 1 | | `physicsJolt` | `physicsJolt::physicsJolt` | Phase 0 explicit unavailable status; Jolt-backed world construction follows in Phase 2 | scaffold present; backend Phase 2 | | `physicsUsd` | `physicsUsd::physicsUsd` | USD translation, mappings, and synchronization records | 4 | | `secondaryMotion` | `secondaryMotion::secondaryMotion` | generic secondary-motion contracts | 6, if admitted | diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index bf17281..ff00590 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -15,13 +15,13 @@ that contradicts it updates this document first, in a focused change. | Identity | Kind | Directory | Planned manifest | Role | Created in | Status | | --- | --- | --- | --- | --- | --- | --- | -| `physicsCore` | plain static CMake library | `libs/physicsCore/` | `openstrata.library.yaml` | Solver-neutral value types, handles, descriptors, world lifecycle, state, validation, and optional query contracts. No OpenUSD or backend SDK. | Phase 0 scaffold; Phase 1 runtime | values, handles, and validated descriptors implemented; remaining runtime planned | +| `physicsCore` | plain static CMake library | `libs/physicsCore/` | `openstrata.library.yaml` | Solver-neutral value types, handles, descriptors, world lifecycle, state, validation, and optional query contracts. No OpenUSD or backend SDK. | Phase 0 scaffold; Phase 1 runtime | values, handles, validated descriptors, and world contract implemented; optional queries planned | | `physicsJolt` | plain static CMake library | `backends/physicsJolt/` | `openstrata.library.yaml` | Jolt implementation of `physicsCore`; owns Jolt initialization, filters, jobs, resources, stepping, and queries. | Phase 0 scaffold; Phase 2 backend | scaffolded; backend unavailable | | `physicsUsd` | plain static CMake library | `libs/physicsUsd/` | `openstrata.library.yaml` | Standard `UsdPhysics` to core descriptors, transform conversion, transient scene/resource mapping, and synchronization records. | Phase 4 | reserved | The Phase 0 targets established package and dependency boundaries. -`physicsCore` now has supported value, handle, and descriptor slices, while -its package as a whole remains partial. `physicsJolt` remains a partial +`physicsCore` now has supported value, handle, descriptor, and world-contract +slices, while its package as a whole remains partial. `physicsJolt` remains a partial unavailable scaffold. ### 1.2 Later, only when the responsibility is real diff --git a/docs/design/RIGID_BODY_CONTRACT.md b/docs/design/RIGID_BODY_CONTRACT.md index ec44d11..e115cf3 100644 --- a/docs/design/RIGID_BODY_CONTRACT.md +++ b/docs/design/RIGID_BODY_CONTRACT.md @@ -1,9 +1,10 @@ # Rigid-body runtime contract > **Status: proposed, revised 2026-09-22.** This document describes the -> intended extraction target. Neutral values, handles, and descriptors are -> implemented; see the [capability matrix](../reference/CAPABILITY_MATRIX.md) -> for the exact current boundary. +> intended extraction target. Neutral values, handles, descriptors, typed +> errors, and the single-owner world interface are implemented; see the +> [capability matrix](../reference/CAPABILITY_MATRIX.md) for the exact current +> boundary. ## 1. Purpose @@ -101,11 +102,13 @@ destroy constraint -> destroy body -> destroy unused shape ``` Destroying an invalid or stale handle must not access freed backend memory. -The contract must define whether an operation reports failure or is idempotent -before Phase 1 freezes the API. +Destruction returns `false` for an unknown, stale, cross-world, or already +destroyed handle. Destroying a body also destroys its dependent constraints; +destroying an in-use shape fails. -A world reset destroys every transient resource and invalidates every handle -from that world. +Destroying a world destroys every transient resource and invalidates every +handle from that world. A separate in-place reset operation is not part of the +Phase 1 contract. ## 7. Stepping and state @@ -120,6 +123,14 @@ State retrieval has two paths: Changed-body extraction returns neutral body handles and physical state. It does not write a USD stage or a Stage Runner runtime world. +Phase 1 extraction is deterministic: each body appears at most once, results +are ordered by ascending opaque handle value, and taking the results drains the +queue. Creation is represented by the descriptor and direct initial state, not +by the changed queue. A successful command becomes observable through changed +state after stepping; destruction removes pending state for that body. Sleep, +wake, and teleport operations are not in the Phase 1 API and must define their +queue behavior when admitted. + ## 8. Optional query capabilities Queries are capability interfaces rather than mandatory methods on a @@ -149,7 +160,7 @@ dimensions, or impossible descriptors are rejected consistently before backend calls. Resource exhaustion and unsupported backend capabilities are distinguishable from invalid input. -`RB-O1` has the following proposed mixed policy for Phase 1: +`RB-O1` uses the following mixed policy for Phase 1: - value and descriptor validation throws `std::invalid_argument` before a backend call; @@ -167,8 +178,8 @@ distinguishable from invalid input. typed `PhysicsError` carrying a solver-neutral `PhysicsErrorCode`. This retains the proven Stage Runner call patterns while making backend -failures distinguishable. Phase 1 descriptor and world slices must test the -relevant branch before this proposal becomes accepted. +failures distinguishable. Descriptor and world contract tests cover the +Phase 1 branches; a concrete backend must retain the same outcomes. ## 10. Threading and ownership @@ -184,13 +195,13 @@ contract. Broad additions require a consumer scenario and tests. ## 12. Open questions -- **RB-O1 (proposed resolution):** use the mixed policy in §9; accept it only - with the descriptor and world contract tests. +- **RB-O1 (resolved for Phase 1):** use the tested mixed policy in §9. - **RB-O2 (resolved for Phase 1):** minimal vector, rotation, and transform values live in `physicsCore`; a separate package requires an independent consumer and an architecture revision. - **RB-O3 (resolved for Phase 1):** use the 64-bit `categories` and `collidesWith` sets defined in §5.2. Backend layer assignment is private and may cache distinct filter combinations without changing this contract. -- **RB-O4:** semantics and ordering of changed-body extraction across create, - sleep, wake, teleport, and destroy. +- **RB-O4 (resolved for Phase 1):** use the ordering and drain semantics in + §7. Sleep, wake, and teleport remain outside the current API and must extend + this rule explicitly when admitted. diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index 99b5a19..0d5ca6a 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -8,8 +8,8 @@ capability on this page. > installable `physicsCore` and `physicsJolt` package boundaries exist. > `physicsCore` provides neutral rigid-transform values, typed handles, and > validated box/body/fixed-constraint descriptors with semantic collision -> filters, but no world contract exists yet. No solver backend or OpenUSD -> plugin exists. +> filters plus a single-owner world contract. Optional queries, a solver +> backend, and an OpenUSD plugin do not exist yet. ## 1. Status vocabulary @@ -39,16 +39,16 @@ capability on this page. | Capability | Status | Evidence / note | | --- | --- | --- | -| `physicsCore` package | partial | Installable `physicsCore::physicsCore` provides version, value, handle, and descriptor surfaces; the runtime contract remains Phase 1 | +| `physicsCore` package | partial | Installable `physicsCore::physicsCore` provides version, value, handle, descriptor, error, and world surfaces; optional queries remain Phase 1 | | Neutral vector, quaternion, and transform values | supported | `physicsCore.values_handles` and the installed-consumer test cover the public headers and identity defaults | | Opaque shape/body/constraint handles | supported | [`physics_core_values_handles_test.cpp`](../../libs/physicsCore/tests/physics_core_values_handles_test.cpp) covers invalid zero, typed identity, comparison, hashing, and representation size | | Shape descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers the validated box descriptor; sphere/capsule remain Phase 4 | | Static and dynamic body descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers shape identity, motion type, rigid transform, mass, and collision filter validation; no body lifecycle exists yet | | Fixed-constraint descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers two distinct valid body handles; no constraint lifecycle exists yet | -| Force and velocity operations | planned | No implementation | -| Fixed-step world | planned | No implementation | -| Direct body state | planned | No implementation | -| Changed-body extraction | planned | No implementation | +| Force and velocity operations | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers the public command contract, validation, and dynamic/static/stale/cross-world outcomes through a deterministic contract world; no solver backend exists | +| Fixed-step world | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers the single-owner lifecycle interface and finite positive step validation; no solver backend exists | +| Direct body state | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers neutral state and unknown/stale/cross-world errors through the contract world; no solver backend exists | +| Changed-body extraction | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers unique handle ordering, drain behavior, creation exclusion, and destruction cleanup through the contract world; sleep, wake, and teleport are outside the Phase 1 API | | Segment query capability | planned | No implementation | | Ground query capability | planned | No implementation | | Ray or shape-cast capabilities | planned | Later consumer-driven addition | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 845443f..2edad17 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -9,7 +9,7 @@ status and ordering. | Phase | Status | Current outcome | | --- | --- | --- | -| 1 — extract `physicsCore` | in progress | Neutral values, typed handles, validated descriptors, and semantic collision filters are implemented; world lifecycle is next. | +| 1 — extract `physicsCore` | in progress | Neutral values, typed handles, validated descriptors, semantic collision filters, and the world contract are implemented; optional queries are next. | | 2 — extract `physicsJolt` | not started | Waits for Phase 1 and an accepted Jolt dependency. | | 3 — Stage Runner migration | not started | Waits for installable Phase 1–2 packages. | | 4 — `physicsUsd` foundation | not started | Waits for the neutral rigid-body contract. | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1288b4c..d820222 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -2,7 +2,8 @@ > **Status: Phase 1 in progress, 2026-09-22.** Phase 0 closed with successful > hosted graph, Linux, and Windows cells. Neutral values, typed handles, and -> validated descriptors are the implemented extraction slices. +> validated descriptors plus the world lifecycle/state contract are the +> implemented extraction slices. ## 1. Outcome @@ -32,19 +33,17 @@ values, handles, and descriptors backend layer identifier enters the public contract. - `physicsCore.values_handles`, `physicsCore.descriptors`, and the clean-prefix installed consumer cover these public slices. -- `RB-O1` has a proposed mixed error policy in the - [rigid-body contract](../design/RIGID_BODY_CONTRACT.md#9-errors-and-validation). +- `physicsCore.world` covers fixed-step/vector validation, explicit resource + lifecycle, commands, direct state, deterministic changed-state draining, + stale and cross-world handle behavior, and typed backend errors. +- `RB-O1` is resolved by the tested mixed error policy in the + [rigid-body contract](../design/RIGID_BODY_CONTRACT.md#9-errors-and-validation), + and the Phase 1 portion of `RB-O4` fixes changed-state drain ordering. - `RB-O3` is resolved by the semantic category/mask contract in [the body descriptor](../design/RIGID_BODY_CONTRACT.md#52-bodies). ## 3. Remaining Phase 1 work -- Implement and test fixed-step validation; use the world tests to accept or - revise the remaining `RB-O1` branches. -- Extract the single-owner world lifecycle, force/velocity commands, direct - state, fixed stepping, and changed-state draining contract. -- Settle `RB-O4` changed-state ordering across create, sleep, wake, teleport, - and destroy before the world contract is accepted. - Extract optional segment and ground query capabilities with ignored-body and support-contact behavior. - Keep `PhysicsRuntime`, prim identity, dirty synchronization, and Stage @@ -57,6 +56,5 @@ values, handles, and descriptors - `physicsCore` public headers and link interfaces contain no OpenUSD, Jolt, Stage Runner, MMD, or VRM dependency. - A clean-prefix consumer includes and exercises the runtime contract. -- `RB-O1` and the Phase 1 portion of `RB-O4` are accepted or narrowed to - explicitly deferred behavior; the accepted `RB-O3` filter remains covered. +- The accepted `RB-O1`, `RB-O3`, and Phase 1 `RB-O4` behavior remains covered. - The capability matrix and package contract describe only the tested surface. diff --git a/libs/physicsCore/CMakeLists.txt b/libs/physicsCore/CMakeLists.txt index 0c3f029..8c3f0ae 100644 --- a/libs/physicsCore/CMakeLists.txt +++ b/libs/physicsCore/CMakeLists.txt @@ -25,6 +25,8 @@ endif() add_library(physicsCore STATIC src/descriptors.cpp + src/errors.cpp + src/world.cpp src/version.cpp) add_library(physicsCore::physicsCore ALIAS physicsCore) set_target_properties(physicsCore PROPERTIES diff --git a/libs/physicsCore/include/usd_physics/core/errors.h b/libs/physicsCore/include/usd_physics/core/errors.h new file mode 100644 index 0000000..1ce2a45 --- /dev/null +++ b/libs/physicsCore/include/usd_physics/core/errors.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace usd_physics::core { + +enum class PhysicsErrorCode { + backendUnavailable, + resourceExhausted, + solverFailure, +}; + +class PhysicsError : public std::runtime_error { +public: + PhysicsError(PhysicsErrorCode code, const std::string& message); + + [[nodiscard]] PhysicsErrorCode code() const noexcept { return code_; } + +private: + PhysicsErrorCode code_; +}; + +} // namespace usd_physics::core diff --git a/libs/physicsCore/include/usd_physics/core/world.h b/libs/physicsCore/include/usd_physics/core/world.h new file mode 100644 index 0000000..1384b11 --- /dev/null +++ b/libs/physicsCore/include/usd_physics/core/world.h @@ -0,0 +1,47 @@ +#pragma once + +#include "usd_physics/core/descriptors.h" + +#include +#include + +namespace usd_physics::core { + +struct BodyState { + BodyHandle body; + Transform transform; + Vector3 linearVelocity; +}; + +class PhysicsWorld { +public: + using Duration = std::chrono::duration; + + PhysicsWorld() = default; + virtual ~PhysicsWorld(); + PhysicsWorld(const PhysicsWorld&) = delete; + PhysicsWorld& operator=(const PhysicsWorld&) = delete; + PhysicsWorld(PhysicsWorld&&) = delete; + PhysicsWorld& operator=(PhysicsWorld&&) = delete; + + virtual ShapeHandle createShape(const ShapeDescriptor& descriptor) = 0; + virtual bool destroyShape(ShapeHandle shape) noexcept = 0; + + virtual BodyHandle createBody(const BodyDescriptor& descriptor) = 0; + virtual bool destroyBody(BodyHandle body) noexcept = 0; + + virtual ConstraintHandle createConstraint(const ConstraintDescriptor& descriptor) = 0; + virtual bool destroyConstraint(ConstraintHandle constraint) noexcept = 0; + + virtual bool applyForce(BodyHandle body, Vector3 force) = 0; + virtual bool setLinearVelocity(BodyHandle body, Vector3 velocity) = 0; + [[nodiscard]] virtual BodyState bodyState(BodyHandle body) const = 0; + + virtual void step(Duration fixedStep) = 0; + virtual std::vector takeChangedBodyStates() = 0; +}; + +void validatePhysicsStep(PhysicsWorld::Duration fixedStep); +void validatePhysicsVector(Vector3 vector, const char* name); + +} // namespace usd_physics::core diff --git a/libs/physicsCore/src/errors.cpp b/libs/physicsCore/src/errors.cpp new file mode 100644 index 0000000..1b5d931 --- /dev/null +++ b/libs/physicsCore/src/errors.cpp @@ -0,0 +1,8 @@ +#include "usd_physics/core/errors.h" + +namespace usd_physics::core { + +PhysicsError::PhysicsError(PhysicsErrorCode code, const std::string& message) + : std::runtime_error(message), code_(code) {} + +} // namespace usd_physics::core diff --git a/libs/physicsCore/src/world.cpp b/libs/physicsCore/src/world.cpp new file mode 100644 index 0000000..062c3c9 --- /dev/null +++ b/libs/physicsCore/src/world.cpp @@ -0,0 +1,26 @@ +#include "usd_physics/core/world.h" + +#include +#include +#include + +namespace usd_physics::core { + +PhysicsWorld::~PhysicsWorld() = default; + +void validatePhysicsStep(PhysicsWorld::Duration fixedStep) { + if (!std::isfinite(fixedStep.count()) || + fixedStep <= PhysicsWorld::Duration::zero()) { + throw std::invalid_argument("physics step must be finite and positive"); + } +} + +void validatePhysicsVector(Vector3 vector, const char* name) { + if (!std::isfinite(vector.x) || !std::isfinite(vector.y) || + !std::isfinite(vector.z)) { + throw std::invalid_argument(std::string{name == nullptr ? "physics vector" : name} + + " must be finite"); + } +} + +} // namespace usd_physics::core diff --git a/libs/physicsCore/tests/CMakeLists.txt b/libs/physicsCore/tests/CMakeLists.txt index fba196a..3759973 100644 --- a/libs/physicsCore/tests/CMakeLists.txt +++ b/libs/physicsCore/tests/CMakeLists.txt @@ -9,3 +9,7 @@ add_test(NAME physicsCore.values_handles COMMAND physics_core_values_handles_tes add_executable(physics_core_descriptors_test physics_core_descriptors_test.cpp) target_link_libraries(physics_core_descriptors_test PRIVATE physicsCore::physicsCore) add_test(NAME physicsCore.descriptors COMMAND physics_core_descriptors_test) + +add_executable(physics_core_world_test physics_core_world_test.cpp) +target_link_libraries(physics_core_world_test PRIVATE physicsCore::physicsCore) +add_test(NAME physicsCore.world COMMAND physics_core_world_test) diff --git a/libs/physicsCore/tests/physics_core_world_test.cpp b/libs/physicsCore/tests/physics_core_world_test.cpp new file mode 100644 index 0000000..3682132 --- /dev/null +++ b/libs/physicsCore/tests/physics_core_world_test.cpp @@ -0,0 +1,301 @@ +#include "usd_physics/core/errors.h" +#include "usd_physics/core/world.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace usd_physics::core; + +template bool rejectsInvalidArgument(Function&& function) { + try { + std::forward(function)(); + } catch (const std::invalid_argument&) { + return true; + } + return false; +} + +template bool rejectsOutOfRange(Function&& function) { + try { + std::forward(function)(); + } catch (const std::out_of_range&) { + return true; + } + return false; +} + +class DeterministicWorld final : public PhysicsWorld { +public: + DeterministicWorld() : handleBase_(nextWorld_++ << 32) {} + + ShapeHandle createShape(const ShapeDescriptor& descriptor) override { + validateShapeDescriptor(descriptor); + const ShapeHandle handle{handleBase_ + nextShape_++}; + shapes_.emplace(handle, descriptor); + return handle; + } + + bool destroyShape(ShapeHandle shape) noexcept override { + for (const auto& entry : bodies_) { + if (entry.second.descriptor.shape == shape) { + return false; + } + } + return shapes_.erase(shape) != 0; + } + + BodyHandle createBody(const BodyDescriptor& descriptor) override { + validateBodyDescriptor(descriptor); + if (shapes_.find(descriptor.shape) == shapes_.end()) { + throw std::invalid_argument("body references an unknown shape"); + } + const BodyHandle handle{handleBase_ + nextBody_++}; + bodies_.emplace(handle, + BodyRecord{descriptor, {handle, descriptor.initialTransform, {}}, {}}); + return handle; + } + + bool destroyBody(BodyHandle body) noexcept override { + const auto found = bodies_.find(body); + if (found == bodies_.end()) { + return false; + } + for (auto iterator = constraints_.begin(); iterator != constraints_.end();) { + if (iterator->second.firstBody == body || + iterator->second.secondBody == body) { + iterator = constraints_.erase(iterator); + } else { + ++iterator; + } + } + changed_.erase(body); + bodies_.erase(found); + return true; + } + + ConstraintHandle + createConstraint(const ConstraintDescriptor& descriptor) override { + validateConstraintDescriptor(descriptor); + if (bodies_.find(descriptor.firstBody) == bodies_.end() || + bodies_.find(descriptor.secondBody) == bodies_.end()) { + throw std::invalid_argument("constraint references an unknown body"); + } + const ConstraintHandle handle{handleBase_ + nextConstraint_++}; + constraints_.emplace(handle, descriptor); + return handle; + } + + bool destroyConstraint(ConstraintHandle constraint) noexcept override { + return constraints_.erase(constraint) != 0; + } + + bool applyForce(BodyHandle body, Vector3 force) override { + validatePhysicsVector(force, "force"); + const auto found = bodies_.find(body); + if (found == bodies_.end() || + found->second.descriptor.motionType != MotionType::dynamicBody) { + return false; + } + found->second.accumulatedForce.x += force.x; + found->second.accumulatedForce.y += force.y; + found->second.accumulatedForce.z += force.z; + return true; + } + + bool setLinearVelocity(BodyHandle body, Vector3 velocity) override { + validatePhysicsVector(velocity, "linear velocity"); + const auto found = bodies_.find(body); + if (found == bodies_.end() || + found->second.descriptor.motionType != MotionType::dynamicBody) { + return false; + } + found->second.state.linearVelocity = velocity; + return true; + } + + BodyState bodyState(BodyHandle body) const override { + const auto found = bodies_.find(body); + if (found == bodies_.end()) { + throw std::out_of_range("unknown body handle"); + } + return found->second.state; + } + + void step(Duration fixedStep) override { + validatePhysicsStep(fixedStep); + const double seconds = fixedStep.count(); + for (auto& entry : bodies_) { + auto& record = entry.second; + if (record.descriptor.motionType != MotionType::dynamicBody) { + continue; + } + const BodyState previous = record.state; + record.state.linearVelocity.x += + record.accumulatedForce.x / record.descriptor.mass * seconds; + record.state.linearVelocity.y += + record.accumulatedForce.y / record.descriptor.mass * seconds; + record.state.linearVelocity.z += + record.accumulatedForce.z / record.descriptor.mass * seconds; + record.state.transform.translation.x += + record.state.linearVelocity.x * seconds; + record.state.transform.translation.y += + record.state.linearVelocity.y * seconds; + record.state.transform.translation.z += + record.state.linearVelocity.z * seconds; + record.accumulatedForce = {}; + if (record.state.transform != previous.transform || + record.state.linearVelocity != previous.linearVelocity) { + changed_.insert(entry.first); + } + } + } + + std::vector takeChangedBodyStates() override { + std::vector result; + result.reserve(changed_.size()); + for (const auto body : changed_) { + result.push_back(bodies_.at(body).state); + } + std::sort(result.begin(), result.end(), [](const BodyState& left, + const BodyState& right) { + return left.body.value() < right.body.value(); + }); + changed_.clear(); + return result; + } + +private: + struct BodyRecord { + BodyDescriptor descriptor; + BodyState state; + Vector3 accumulatedForce; + }; + + template + using HandleMap = std::unordered_map>; + template + using HandleSet = std::unordered_set>; + + inline static std::uint64_t nextWorld_{1}; + const std::uint64_t handleBase_; + std::uint64_t nextShape_{1}; + std::uint64_t nextBody_{1}; + std::uint64_t nextConstraint_{1}; + HandleMap shapes_; + HandleMap bodies_; + HandleMap constraints_; + HandleSet changed_; +}; + +bool near(double left, double right) { + return std::abs(left - right) < 1.0e-9; +} + +} // namespace + +int main() { + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + + DeterministicWorld world; + DeterministicWorld otherWorld; + + const auto box = world.createShape({ShapeType::box, {0.5, 0.5, 0.5}}); + const auto floor = world.createBody( + {box, MotionType::staticBody, Transform{{0.0, -0.5, 0.0}, {}}, 0.0, {}}); + const auto first = world.createBody( + {box, MotionType::dynamicBody, Transform{{0.0, 2.0, 0.0}, {}}, 2.0, {}}); + const auto second = world.createBody( + {box, MotionType::dynamicBody, Transform{{0.0, 3.0, 0.0}, {}}, 1.0, {}}); + const auto idle = world.createBody( + {box, MotionType::dynamicBody, Transform{{0.0, 4.0, 0.0}, {}}, 1.0, {}}); + if (!box || !floor || !first || !second || !idle || first == second || + !world.takeChangedBodyStates().empty()) { + return 1; + } + + if (!world.applyForce(first, {4.0, 0.0, 0.0}) || + !world.setLinearVelocity(second, {0.0, -2.0, 0.0}) || + world.applyForce(floor, {1.0, 0.0, 0.0}) || + world.applyForce(BodyHandle{}, {1.0, 0.0, 0.0}) || + world.applyForce(BodyHandle{1}, {1.0, 0.0, 0.0}) || + otherWorld.applyForce(first, {1.0, 0.0, 0.0})) { + return 2; + } + + world.step(PhysicsWorld::Duration{0.5}); + const auto changed = world.takeChangedBodyStates(); + if (changed.size() != 2 || changed[0].body != first || + changed[1].body != second || + !near(changed[0].linearVelocity.x, 1.0) || + !near(changed[0].transform.translation.x, 0.5) || + !near(changed[1].transform.translation.y, 2.0) || + !world.takeChangedBodyStates().empty() || + world.bodyState(floor).transform.translation.y != -0.5) { + return 3; + } + + if (!rejectsInvalidArgument([&] { + world.createBody({ShapeHandle{box.value() + 1000}, + MotionType::dynamicBody, {}, 1.0, {}}); + }) || + !rejectsInvalidArgument([&] { + otherWorld.createBody({box, MotionType::dynamicBody, {}, 1.0, {}}); + }) || + !rejectsOutOfRange([&] { world.bodyState(BodyHandle{}); }) || + !rejectsOutOfRange([&] { otherWorld.bodyState(first); })) { + return 4; + } + + const auto infinity = std::numeric_limits::infinity(); + const auto nan = std::numeric_limits::quiet_NaN(); + if (!rejectsInvalidArgument( + [&] { world.step(PhysicsWorld::Duration::zero()); }) || + !rejectsInvalidArgument( + [&] { world.step(PhysicsWorld::Duration{-0.1}); }) || + !rejectsInvalidArgument( + [&] { world.step(PhysicsWorld::Duration{infinity}); }) || + !rejectsInvalidArgument( + [&] { world.step(PhysicsWorld::Duration{nan}); }) || + !rejectsInvalidArgument( + [&] { world.applyForce(first, {infinity, 0.0, 0.0}); }) || + !rejectsInvalidArgument( + [&] { world.setLinearVelocity(first, {0.0, nan, 0.0}); })) { + return 5; + } + + const auto constraint = + world.createConstraint({ConstraintType::fixed, floor, first}); + world.step(PhysicsWorld::Duration{0.5}); + if (!constraint || world.destroyShape(box) || + !world.destroyBody(first) || world.destroyBody(first) || + world.destroyConstraint(constraint) || + !rejectsOutOfRange([&] { world.bodyState(first); }) || + !world.destroyBody(second) || !world.destroyBody(idle) || + !world.destroyBody(floor) || + !world.takeChangedBodyStates().empty() || !world.destroyShape(box) || + world.destroyShape(box)) { + return 6; + } + + const PhysicsError error{PhysicsErrorCode::resourceExhausted, + "physics capacity exhausted"}; + if (error.code() != PhysicsErrorCode::resourceExhausted || + std::string{error.what()} != "physics capacity exhausted") { + return 7; + } + + return 0; +} diff --git a/tests/installed_consumer/main.cpp b/tests/installed_consumer/main.cpp index 3a33ac9..0b75b09 100644 --- a/tests/installed_consumer/main.cpp +++ b/tests/installed_consumer/main.cpp @@ -1,7 +1,9 @@ #include "usd_physics/core/descriptors.h" +#include "usd_physics/core/errors.h" #include "usd_physics/core/handles.h" #include "usd_physics/core/value_types.h" #include "usd_physics/core/version.h" +#include "usd_physics/core/world.h" #include "usd_physics/jolt/availability.h" int main() { @@ -20,5 +22,14 @@ int main() { usd_physics::core::validateBodyDescriptor( {usd_physics::core::ShapeHandle{1}, usd_physics::core::MotionType::dynamicBody, transform, 1.0, {}}); - return usd_physics::jolt::backendAvailable() ? 3 : 0; + usd_physics::core::validatePhysicsStep( + usd_physics::core::PhysicsWorld::Duration{1.0 / 60.0}); + const usd_physics::core::BodyState state{body, transform, {}}; + const usd_physics::core::PhysicsError error{ + usd_physics::core::PhysicsErrorCode::solverFailure, "solver failed"}; + if (state.body != body || + error.code() != usd_physics::core::PhysicsErrorCode::solverFailure) { + return 3; + } + return usd_physics::jolt::backendAvailable() ? 4 : 0; } From 272766955c1027a6f9ae7f13cec80116626350ca Mon Sep 17 00:00:00 2001 From: snkmcb Date: Tue, 22 Sep 2026 14:43:11 +0900 Subject: [PATCH 2/2] test: strengthen physics world contract coverage --- docs/architecture/WORKSPACE.md | 4 ++-- docs/reference/CAPABILITY_MATRIX.md | 4 ++-- .../tests/physics_core_world_test.cpp | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index ff00590..4689300 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -21,8 +21,8 @@ that contradicts it updates this document first, in a focused change. The Phase 0 targets established package and dependency boundaries. `physicsCore` now has supported value, handle, descriptor, and world-contract -slices, while its package as a whole remains partial. `physicsJolt` remains a partial -unavailable scaffold. +slices, while its package as a whole remains partial. `physicsJolt` remains a +partial unavailable scaffold. ### 1.2 Later, only when the responsibility is real diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index 0d5ca6a..0443439 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -43,8 +43,8 @@ capability on this page. | Neutral vector, quaternion, and transform values | supported | `physicsCore.values_handles` and the installed-consumer test cover the public headers and identity defaults | | Opaque shape/body/constraint handles | supported | [`physics_core_values_handles_test.cpp`](../../libs/physicsCore/tests/physics_core_values_handles_test.cpp) covers invalid zero, typed identity, comparison, hashing, and representation size | | Shape descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers the validated box descriptor; sphere/capsule remain Phase 4 | -| Static and dynamic body descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers shape identity, motion type, rigid transform, mass, and collision filter validation; no body lifecycle exists yet | -| Fixed-constraint descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers two distinct valid body handles; no constraint lifecycle exists yet | +| Static and dynamic body descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers shape identity, motion type, rigid transform, mass, and collision filter validation; lifecycle is covered by the partial world contract below, while no solver backend exists yet | +| Fixed-constraint descriptors | supported | [`physics_core_descriptors_test.cpp`](../../libs/physicsCore/tests/physics_core_descriptors_test.cpp) covers two distinct valid body handles; lifecycle is covered by the partial world contract below, while no solver backend exists yet | | Force and velocity operations | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers the public command contract, validation, and dynamic/static/stale/cross-world outcomes through a deterministic contract world; no solver backend exists | | Fixed-step world | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers the single-owner lifecycle interface and finite positive step validation; no solver backend exists | | Direct body state | partial | [`physics_core_world_test.cpp`](../../libs/physicsCore/tests/physics_core_world_test.cpp) covers neutral state and unknown/stale/cross-world errors through the contract world; no solver backend exists | diff --git a/libs/physicsCore/tests/physics_core_world_test.cpp b/libs/physicsCore/tests/physics_core_world_test.cpp index 3682132..0a1acf4 100644 --- a/libs/physicsCore/tests/physics_core_world_test.cpp +++ b/libs/physicsCore/tests/physics_core_world_test.cpp @@ -213,6 +213,10 @@ int main() { DeterministicWorld otherWorld; const auto box = world.createShape({ShapeType::box, {0.5, 0.5, 0.5}}); + const auto otherBox = + otherWorld.createShape({ShapeType::box, {0.5, 0.5, 0.5}}); + const auto otherBody = otherWorld.createBody( + {otherBox, MotionType::dynamicBody, Transform{}, 1.0, {}}); const auto floor = world.createBody( {box, MotionType::staticBody, Transform{{0.0, -0.5, 0.0}, {}}, 0.0, {}}); const auto first = world.createBody( @@ -221,8 +225,8 @@ int main() { {box, MotionType::dynamicBody, Transform{{0.0, 3.0, 0.0}, {}}, 1.0, {}}); const auto idle = world.createBody( {box, MotionType::dynamicBody, Transform{{0.0, 4.0, 0.0}, {}}, 1.0, {}}); - if (!box || !floor || !first || !second || !idle || first == second || - !world.takeChangedBodyStates().empty()) { + if (!box || !otherBox || !otherBody || !floor || !first || !second || + !idle || first == second || !world.takeChangedBodyStates().empty()) { return 1; } @@ -254,6 +258,13 @@ int main() { !rejectsInvalidArgument([&] { otherWorld.createBody({box, MotionType::dynamicBody, {}, 1.0, {}}); }) || + !rejectsInvalidArgument([&] { + world.createConstraint({ConstraintType::fixed, floor, otherBody}); + }) || + !rejectsInvalidArgument([&] { + world.createConstraint( + {ConstraintType::fixed, floor, BodyHandle{floor.value() + 1000}}); + }) || !rejectsOutOfRange([&] { world.bodyState(BodyHandle{}); }) || !rejectsOutOfRange([&] { otherWorld.bodyState(first); })) { return 4; @@ -281,6 +292,9 @@ int main() { world.step(PhysicsWorld::Duration{0.5}); if (!constraint || world.destroyShape(box) || !world.destroyBody(first) || world.destroyBody(first) || + world.applyForce(first, {1.0, 0.0, 0.0}) || + world.destroyBody(otherBody) || otherWorld.destroyBody(first) || + world.destroyShape(otherBox) || world.destroyConstraint(constraint) || !rejectsOutOfRange([&] { world.bodyState(first); }) || !world.destroyBody(second) || !world.destroyBody(idle) ||