diff --git a/.github/workflows/ost-source-ci.yml b/.github/workflows/ost-source-ci.yml index a70130e..97e34bf 100644 --- a/.github/workflows/ost-source-ci.yml +++ b/.github/workflows/ost-source-ci.yml @@ -1,5 +1,6 @@ -# Generated by `ost ci generate github` from openstrata.ci.yaml. -# Regenerate after editing the matrix; do not edit the jobs by hand. +# Generated by `ost ci generate github` from openstrata.ci.yaml, then extended +# with the repository-specific Jolt setup documented below. Regenerate after +# editing the matrix and reapply that setup before committing the workflow. # # Source CI: each job checks out the repo, obtains a digest-pinned runtime # SDK artifact, and builds/tests/packages the bundle from source. On @@ -11,6 +12,9 @@ # # Fork-PR safety: this workflow never publishes, never promotes, and uses no # secrets; keep it that way when editing the matrix. +# +# Repository additions retained after regeneration: build pinned Jolt beside +# the workspace and expose its install prefix to the generated `jolt` intent. name: ost source ci on: @@ -166,7 +170,7 @@ jobs: hosted: true runner_profile: windows-hosted verify: test - intent_flags: "" + intent_flags: "--intent jolt" runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:d3ff79a6f330558c3b9a427a927d340fe3dcab1fe89107faa0ea9f66a104b7bf" host_python: "3.13" host_packages_apt: "" @@ -187,7 +191,7 @@ jobs: hosted: true runner_profile: linux-hosted verify: test - intent_flags: "" + intent_flags: "--intent jolt" runtime_remote: "oci://ghcr.io/animu-sphere/openstrata-runtime-cy2026-usd@sha256:8d2d599489ee623044f4360ad3a2b979e2966e6ad2f949e5aa9083570e59cc4f" host_python: "3.13" host_packages_apt: "libx11-dev libxt-dev" @@ -195,6 +199,46 @@ jobs: steps: - name: Check out the repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Check out pinned Jolt Physics 5.5.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: jrouwe/JoltPhysics + ref: 23dadd0e603f1b321142d4c74df07fce85064989 + path: .ci/jolt + - name: Build and install pinned Jolt Physics + shell: bash + run: | + set -euo pipefail + prefix="$GITHUB_WORKSPACE/.ci/jolt-install" + configure=( + -S .ci/jolt/Build + -B .ci/jolt-build + "-DCMAKE_INSTALL_PREFIX=$prefix" + -DCMAKE_BUILD_TYPE=Release + -DINTERPROCEDURAL_OPTIMIZATION=OFF + -DDOUBLE_PRECISION=OFF + -DCROSS_PLATFORM_DETERMINISTIC=OFF + -DENABLE_ALL_WARNINGS=OFF + -DGENERATE_DEBUG_SYMBOLS=OFF + -DDEBUG_RENDERER_IN_DEBUG_AND_RELEASE=OFF + -DPROFILER_IN_DEBUG_AND_RELEASE=OFF + -DUSE_STATIC_MSVC_RUNTIME_LIBRARY=OFF + -DTARGET_UNIT_TESTS=OFF + -DTARGET_HELLO_WORLD=OFF + -DTARGET_PERFORMANCE_TEST=OFF + -DTARGET_SAMPLES=OFF + -DTARGET_VIEWER=OFF + ) + if [ "$RUNNER_OS" = "Windows" ]; then + configure+=(-A x64) + fi + cmake "${configure[@]}" + cmake --build .ci/jolt-build --config Release --target Jolt + cmake --install .ci/jolt-build --config Release + if [ "$RUNNER_OS" = "Windows" ]; then + prefix="$(cygpath -w "$prefix")" + fi + echo "JOLT_PREFIX=$prefix" >> "$GITHUB_ENV" - name: Hosted runner billing notice if: ${{ matrix.hosted }} shell: bash @@ -388,10 +432,14 @@ jobs: run: ost plugin test --workspace --graph-only --json - name: Build the workspace from source shell: bash + env: + CMAKE_PREFIX_PATH: ${{ env.JOLT_PREFIX }} run: ost build --target ${{ matrix.platform }} --profile ${{ matrix.profile }} ${{ matrix.intent_flags }} - name: Run the workspace test suite if: ${{ matrix.verify != 'build' }} shell: bash + env: + CMAKE_PREFIX_PATH: ${{ env.JOLT_PREFIX }} run: ost test --target ${{ matrix.platform }} --profile ${{ matrix.profile }} ${{ matrix.intent_flags }} - name: Run the workspace verification pyramid if: ${{ matrix.verify == 'pyramid' || matrix.verify == 'package' }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 2974f23..b4d8359 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,9 @@ usdphysics_assert_target_dependencies( usdphysics_assert_target_dependencies( TARGET physicsJolt ALLOWED_PUBLIC physicsCore::physicsCore - ALLOWED_PRIVATE physicsCore::physicsCore) + "$" + "$" + ALLOWED_PRIVATE physicsCore::physicsCore Jolt::Jolt Jolt) if(USDPHYSICS_BUILD_TESTS) add_subdirectory(tests) diff --git a/README.md b/README.md index 8aea857..7587a4e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Backend-neutral physics runtime components for OpenUSD-based applications. -> **Status: Phase 1 core extraction complete.** The repository builds and -> installs `physicsCore` and `physicsJolt`; neutral rigid-transform values, -> typed handles, validated box/body/fixed-constraint descriptors, the -> single-owner world contract, and optional segment and ground queries are -> implemented. An OpenUSD bridge and a solver backend are not. The +> **Status (2026-09-23): Phase 2 backend extraction in progress.** The +> repository builds and installs `physicsCore` and `physicsJolt`; the neutral +> core contract and a Jolt-backed world with fixed stepping, changed state, +> segment queries, and ground queries are implemented. The Jolt-required +> OpenStrata intent passes +> locally; hosted Phase 2 evidence and an OpenUSD bridge remain. 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. @@ -57,7 +58,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 | Phase 1 contract implemented and installed | -| `physicsJolt` | plain C++ library | First rigid-body backend implementing `physicsCore` | package scaffold present; backend unavailable | +| `physicsJolt` | plain C++ library | First rigid-body backend implementing `physicsCore` | Phase 2 implementation present; hosted evidence pending | | `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 | | `secondaryMotionVerlet` | plain C++ library | First deterministic CPU secondary-motion solver | deferred until a real VRM integration slice requires it | diff --git a/backends/physicsJolt/CMakeLists.txt b/backends/physicsJolt/CMakeLists.txt index 852d316..7e561b9 100644 --- a/backends/physicsJolt/CMakeLists.txt +++ b/backends/physicsJolt/CMakeLists.txt @@ -10,7 +10,7 @@ endif() project(physicsJolt VERSION ${_physics_jolt_version} - DESCRIPTION "Jolt backend package scaffold for physicsCore" + DESCRIPTION "Jolt Physics backend for physicsCore" LANGUAGES CXX) if(NOT DEFINED PHYSICSJOLT_BUILD_TESTS) @@ -27,7 +27,41 @@ if(NOT TARGET physicsCore::physicsCore) find_package(physicsCore CONFIG REQUIRED) endif() -add_library(physicsJolt STATIC src/availability.cpp) +option(PHYSICSJOLT_REQUIRE_JOLT + "Fail configuration when Jolt Physics is unavailable" OFF) + +set(PHYSICSJOLT_BACKEND_TARGET "") +find_package(Jolt CONFIG QUIET) +if(TARGET Jolt::Jolt) + set(PHYSICSJOLT_BACKEND_TARGET Jolt::Jolt) +elseif(TARGET Jolt) + set(PHYSICSJOLT_BACKEND_TARGET Jolt) +endif() + +if(PHYSICSJOLT_REQUIRE_JOLT AND NOT PHYSICSJOLT_BACKEND_TARGET) + message(FATAL_ERROR + "physicsJolt requires Jolt Physics 5.5.0, but no Jolt CMake target was found") +endif() + +if(PHYSICSJOLT_BACKEND_TARGET) + set(_physics_jolt_source src/jolt_world.cpp) + set(_physics_jolt_backend_label "${PHYSICSJOLT_BACKEND_TARGET}") + set(PHYSICSJOLT_BACKEND_AVAILABLE ON) + set(PHYSICSJOLT_FIND_JOLT_DEPENDENCY "find_dependency(Jolt CONFIG)") + get_target_property(PHYSICSJOLT_JOLT_INTERFACE_COMPILE_DEFINITIONS + ${PHYSICSJOLT_BACKEND_TARGET} INTERFACE_COMPILE_DEFINITIONS) + if(PHYSICSJOLT_JOLT_INTERFACE_COMPILE_DEFINITIONS MATCHES "-NOTFOUND$") + set(PHYSICSJOLT_JOLT_INTERFACE_COMPILE_DEFINITIONS "") + endif() +else() + set(_physics_jolt_source src/availability.cpp) + set(_physics_jolt_backend_label "unavailable") + set(PHYSICSJOLT_BACKEND_AVAILABLE OFF) + set(PHYSICSJOLT_FIND_JOLT_DEPENDENCY "") + set(PHYSICSJOLT_JOLT_INTERFACE_COMPILE_DEFINITIONS "") +endif() + +add_library(physicsJolt STATIC ${_physics_jolt_source}) add_library(physicsJolt::physicsJolt ALIAS physicsJolt) set_target_properties(physicsJolt PROPERTIES EXPORT_NAME physicsJolt @@ -37,6 +71,10 @@ target_include_directories(physicsJolt PUBLIC "$" "$") target_link_libraries(physicsJolt PUBLIC physicsCore::physicsCore) +if(PHYSICSJOLT_BACKEND_TARGET) + target_link_libraries(physicsJolt PRIVATE ${PHYSICSJOLT_BACKEND_TARGET}) +endif() +message(STATUS "physicsJolt: backend ${_physics_jolt_backend_label}") include(GNUInstallDirs) install(TARGETS physicsJolt diff --git a/backends/physicsJolt/cmake/physicsJoltConfig.cmake.in b/backends/physicsJolt/cmake/physicsJoltConfig.cmake.in index 8f9c9e0..3257cc4 100644 --- a/backends/physicsJolt/cmake/physicsJoltConfig.cmake.in +++ b/backends/physicsJolt/cmake/physicsJoltConfig.cmake.in @@ -4,5 +4,67 @@ include(CMakeFindDependencyMacro) if(NOT TARGET physicsCore::physicsCore) find_dependency(physicsCore CONFIG) endif() +@PHYSICSJOLT_FIND_JOLT_DEPENDENCY@ + +set(_physicsJolt_requires_jolt @PHYSICSJOLT_BACKEND_AVAILABLE@) +if(_physicsJolt_requires_jolt) + set(_physicsJolt_jolt_target [==[@PHYSICSJOLT_BACKEND_TARGET@]==]) + if(NOT TARGET "${_physicsJolt_jolt_target}") + message(FATAL_ERROR + "physicsJolt requires the Jolt target '${_physicsJolt_jolt_target}'") + endif() + + get_target_property(_physicsJolt_actual_jolt_definitions + "${_physicsJolt_jolt_target}" INTERFACE_COMPILE_DEFINITIONS) + if(_physicsJolt_actual_jolt_definitions MATCHES "-NOTFOUND$") + set(_physicsJolt_actual_jolt_definitions "") + endif() + set(_physicsJolt_expected_jolt_definitions + [==[@PHYSICSJOLT_JOLT_INTERFACE_COMPILE_DEFINITIONS@]==]) + if(NOT "${_physicsJolt_actual_jolt_definitions}" STREQUAL + "${_physicsJolt_expected_jolt_definitions}") + message(FATAL_ERROR + "physicsJolt found a Jolt package with incompatible ABI compile definitions") + endif() + + get_target_property(_physicsJolt_jolt_includes + "${_physicsJolt_jolt_target}" INTERFACE_INCLUDE_DIRECTORIES) + set(_physicsJolt_jolt_core_header "") + foreach(_physicsJolt_include IN LISTS _physicsJolt_jolt_includes) + if(NOT _physicsJolt_include MATCHES "^\\$<" AND + EXISTS "${_physicsJolt_include}/Jolt/Core/Core.h") + set(_physicsJolt_jolt_core_header + "${_physicsJolt_include}/Jolt/Core/Core.h") + break() + endif() + endforeach() + if(NOT _physicsJolt_jolt_core_header) + message(FATAL_ERROR + "physicsJolt could not verify the discovered Jolt package version") + endif() + file(STRINGS "${_physicsJolt_jolt_core_header}" + _physicsJolt_jolt_version_lines + REGEX "^#[ \t]*define[ \t]+JPH_VERSION_(MAJOR|MINOR|PATCH)[ \t]+[0-9]+") + foreach(_physicsJolt_required_version IN ITEMS + "JPH_VERSION_MAJOR 5" + "JPH_VERSION_MINOR 5" + "JPH_VERSION_PATCH 0") + string(REPLACE " " "[ \\t]+" _physicsJolt_version_pattern + "${_physicsJolt_required_version}") + set(_physicsJolt_version_matches FALSE) + foreach(_physicsJolt_version_line IN LISTS _physicsJolt_jolt_version_lines) + if(_physicsJolt_version_line MATCHES + "${_physicsJolt_version_pattern}([ \\t]|$)") + set(_physicsJolt_version_matches TRUE) + break() + endif() + endforeach() + if(NOT _physicsJolt_version_matches) + message(FATAL_ERROR + "physicsJolt requires Jolt Physics 5.5.0 exactly") + endif() + endforeach() +endif() + include("${CMAKE_CURRENT_LIST_DIR}/physicsJoltTargets.cmake") check_required_components(physicsJolt) diff --git a/backends/physicsJolt/include/usd_physics/jolt/availability.h b/backends/physicsJolt/include/usd_physics/jolt/availability.h index 46d5d1a..b3b3e0e 100644 --- a/backends/physicsJolt/include/usd_physics/jolt/availability.h +++ b/backends/physicsJolt/include/usd_physics/jolt/availability.h @@ -1,9 +1,16 @@ #pragma once +#include "usd_physics/core/world.h" + +#include + namespace usd_physics::jolt { -// Phase 0 installs the package boundary, not a solver. This function remains -// false until the Phase 2 Jolt implementation is linked into this component. +// Reports whether this package was built with its private Jolt dependency. [[nodiscard]] bool backendAvailable() noexcept; +// Constructs a Jolt-backed world when the backend is available. A package +// built without Jolt throws PhysicsErrorCode::backendUnavailable here. +[[nodiscard]] std::unique_ptr createWorld(); + } // namespace usd_physics::jolt diff --git a/backends/physicsJolt/openstrata.library.yaml b/backends/physicsJolt/openstrata.library.yaml index 2569f08..bcde99b 100644 --- a/backends/physicsJolt/openstrata.library.yaml +++ b/backends/physicsJolt/openstrata.library.yaml @@ -18,4 +18,4 @@ package_contract: public_headers: [include/usd_physics/jolt/**] consumer: include: usd_physics/jolt/availability.h - symbol: usd_physics::jolt::backendAvailable + symbol: usd_physics::jolt::createWorld diff --git a/backends/physicsJolt/src/availability.cpp b/backends/physicsJolt/src/availability.cpp index 1400072..c9d679d 100644 --- a/backends/physicsJolt/src/availability.cpp +++ b/backends/physicsJolt/src/availability.cpp @@ -1,9 +1,16 @@ #include "usd_physics/jolt/availability.h" +#include "usd_physics/core/errors.h" + namespace usd_physics::jolt { bool backendAvailable() noexcept { return false; } +std::unique_ptr createWorld() { + throw core::PhysicsError{core::PhysicsErrorCode::backendUnavailable, + "physicsJolt was built without Jolt Physics"}; +} + } // namespace usd_physics::jolt diff --git a/backends/physicsJolt/src/jolt_world.cpp b/backends/physicsJolt/src/jolt_world.cpp new file mode 100644 index 0000000..301010a --- /dev/null +++ b/backends/physicsJolt/src/jolt_world.cpp @@ -0,0 +1,665 @@ +#include "usd_physics/jolt/availability.h" + +#include "usd_physics/core/errors.h" +#include "usd_physics/core/ground_query.h" +#include "usd_physics/core/segment_query.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(JPH_DOUBLE_PRECISION) +#error "physicsJolt requires a single-precision Jolt Physics build" +#endif + +namespace usd_physics::jolt { +namespace { + +static_assert(JPH_VERSION_MAJOR == 5 && JPH_VERSION_MINOR == 5 && + JPH_VERSION_PATCH == 0, + "physicsJolt requires Jolt Physics 5.5.0"); + +constexpr std::uint32_t maxBodies = 1024; +constexpr std::uint32_t maxBodyPairs = 1024; +constexpr std::uint32_t maxContactConstraints = 1024; +constexpr std::size_t tempAllocatorBytes = 10 * 1024 * 1024; + +namespace BroadPhaseLayers { +const JPH::BroadPhaseLayer nonMoving{0}; +const JPH::BroadPhaseLayer moving{1}; +constexpr JPH::uint count = 2; +} // namespace BroadPhaseLayers + +std::mutex joltRuntimeMutex; +std::size_t joltRuntimeUsers = 0; +std::atomic nextWorldId{1}; + +class JoltRuntime final { +public: + JoltRuntime() { + std::lock_guard lock{joltRuntimeMutex}; + if (joltRuntimeUsers == 0) { + if (JPH::Factory::sInstance != nullptr) { + throw core::PhysicsError{ + core::PhysicsErrorCode::solverFailure, + "Jolt Physics was initialized outside physicsJolt"}; + } + JPH::RegisterDefaultAllocator(); + JPH::Factory::sInstance = new JPH::Factory(); + JPH::RegisterTypes(); + } + ++joltRuntimeUsers; + } + + ~JoltRuntime() { + std::lock_guard lock{joltRuntimeMutex}; + --joltRuntimeUsers; + if (joltRuntimeUsers == 0) { + JPH::UnregisterTypes(); + delete JPH::Factory::sInstance; + JPH::Factory::sInstance = nullptr; + } + } + + JoltRuntime(const JoltRuntime&) = delete; + JoltRuntime& operator=(const JoltRuntime&) = delete; +}; + +struct ObjectLayerRecord { + core::MotionType motionType; + core::CollisionFilter filter; +}; + +class ObjectLayerRegistry final { +public: + JPH::ObjectLayer layerFor(core::MotionType motionType, + core::CollisionFilter filter) { + for (std::size_t index = 0; index < records_.size(); ++index) { + const auto& record = records_[index]; + if (record.motionType == motionType && + record.filter.categories == filter.categories && + record.filter.collidesWith == filter.collidesWith) { + return static_cast(index); + } + } + if (records_.size() >= + static_cast(JPH::cObjectLayerInvalid)) { + throw core::PhysicsError{core::PhysicsErrorCode::resourceExhausted, + "Jolt object-layer capacity was exhausted"}; + } + records_.push_back({motionType, filter}); + return static_cast(records_.size() - 1); + } + + [[nodiscard]] const ObjectLayerRecord& record(JPH::ObjectLayer layer) const { + return records_[static_cast(layer)]; + } + +private: + std::vector records_; +}; + +class ObjectLayerPairFilter final : public JPH::ObjectLayerPairFilter { +public: + explicit ObjectLayerPairFilter(const ObjectLayerRegistry& layers) + : layers_(layers) {} + + bool ShouldCollide(JPH::ObjectLayer first, + JPH::ObjectLayer second) const override { + return core::collisionFiltersAllowPair(layers_.record(first).filter, + layers_.record(second).filter); + } + +private: + const ObjectLayerRegistry& layers_; +}; + +class BroadPhaseLayerInterface final : public JPH::BroadPhaseLayerInterface { +public: + explicit BroadPhaseLayerInterface(const ObjectLayerRegistry& layers) + : layers_(layers) {} + + JPH::uint GetNumBroadPhaseLayers() const override { + return BroadPhaseLayers::count; + } + + JPH::BroadPhaseLayer GetBroadPhaseLayer(JPH::ObjectLayer layer) const override { + return layers_.record(layer).motionType == core::MotionType::staticBody + ? BroadPhaseLayers::nonMoving + : BroadPhaseLayers::moving; + } + +#if defined(JPH_EXTERNAL_PROFILE) || defined(JPH_PROFILE_ENABLED) + const char* GetBroadPhaseLayerName(JPH::BroadPhaseLayer layer) const override { + return layer == BroadPhaseLayers::nonMoving ? "non-moving" : "moving"; + } +#endif + +private: + const ObjectLayerRegistry& layers_; +}; + +class ObjectVsBroadPhaseLayerFilter final + : public JPH::ObjectVsBroadPhaseLayerFilter { +public: + explicit ObjectVsBroadPhaseLayerFilter(const ObjectLayerRegistry& layers) + : layers_(layers) {} + + bool ShouldCollide(JPH::ObjectLayer objectLayer, + JPH::BroadPhaseLayer broadPhaseLayer) const override { + if (layers_.record(objectLayer).motionType == core::MotionType::staticBody) { + return broadPhaseLayer == BroadPhaseLayers::moving; + } + return true; + } + +private: + const ObjectLayerRegistry& layers_; +}; + +template +using HandleMap = + std::unordered_map>; + +float toJoltFloat(double value, const char* name) { + constexpr double max = static_cast(std::numeric_limits::max()); + if (!std::isfinite(value) || value < -max || value > max) { + throw std::invalid_argument(std::string{name} + + " must fit in the Jolt scalar range"); + } + return static_cast(value); +} + +JPH::Vec3 toJoltVector(core::Vector3 value, const char* name) { + return {toJoltFloat(value.x, name), toJoltFloat(value.y, name), + toJoltFloat(value.z, name)}; +} + +JPH::RVec3 toJoltPosition(core::Vector3 value, const char* name) { + return {static_cast(toJoltFloat(value.x, name)), + static_cast(toJoltFloat(value.y, name)), + static_cast(toJoltFloat(value.z, name))}; +} + +JPH::Quat toJoltRotation(core::Quaternion value) { + const double magnitude = std::sqrt(value.w * value.w + value.x * value.x + + value.y * value.y + value.z * value.z); + if (!std::isfinite(magnitude) || magnitude == 0.0) { + throw std::invalid_argument("body rotation must have non-zero magnitude"); + } + return {toJoltFloat(value.x / magnitude, "body rotation"), + toJoltFloat(value.y / magnitude, "body rotation"), + toJoltFloat(value.z / magnitude, "body rotation"), + toJoltFloat(value.w / magnitude, "body rotation")}; +} + +core::Vector3 toCoreVector(JPH::Vec3Arg value) { + return {static_cast(value.GetX()), static_cast(value.GetY()), + static_cast(value.GetZ())}; +} + +core::Vector3 toCorePosition(JPH::RVec3Arg value) { + return {static_cast(value.GetX()), static_cast(value.GetY()), + static_cast(value.GetZ())}; +} + +core::Quaternion toCoreRotation(JPH::QuatArg value) { + return {static_cast(value.GetW()), static_cast(value.GetX()), + static_cast(value.GetY()), static_cast(value.GetZ())}; +} + +bool stateChanged(const core::BodyState& previous, + const core::BodyState& current) { + return previous.transform != current.transform || + previous.linearVelocity != current.linearVelocity; +} + +class JoltWorld final : public core::PhysicsWorld, + public core::GroundQuery, + public core::SegmentQuery { +public: + JoltWorld() + : worldId_(nextWorldId.fetch_add(1, std::memory_order_relaxed)), + pairFilter_(layers_), broadPhaseInterface_(layers_), + objectVsBroadPhaseFilter_(layers_), + jobSystem_(JPH::cMaxPhysicsJobs, JPH::cMaxPhysicsBarriers, + workerThreadCount()) { + if (worldId_ == 0 || worldId_ > std::numeric_limits::max()) { + throw core::PhysicsError{core::PhysicsErrorCode::resourceExhausted, + "physics world handle capacity was exhausted"}; + } + physicsSystem_.Init(maxBodies, 0, maxBodyPairs, maxContactConstraints, + broadPhaseInterface_, objectVsBroadPhaseFilter_, + pairFilter_); + } + + ~JoltWorld() override { + auto& bodyInterface = physicsSystem_.GetBodyInterface(); + for (auto& entry : constraints_) { + physicsSystem_.RemoveConstraint(entry.second.constraint.GetPtr()); + } + constraints_.clear(); + for (auto& entry : bodies_) { + bodyInterface.RemoveBody(entry.second.id); + bodyInterface.DestroyBody(entry.second.id); + } + } + + core::ShapeHandle + createShape(const core::ShapeDescriptor& descriptor) override { + core::validateShapeDescriptor(descriptor); + const JPH::Vec3 halfExtents = + toJoltVector(descriptor.halfExtents, "box half extents"); + const float minimumExtent = + std::min({halfExtents.GetX(), halfExtents.GetY(), halfExtents.GetZ()}); + if (minimumExtent <= 0.0f) { + throw std::invalid_argument( + "box half extents must remain positive in the Jolt scalar range"); + } + const float convexRadius = + std::min(JPH::cDefaultConvexRadius, minimumExtent * 0.5f); + JPH::ShapeRefC shape = new JPH::BoxShape(halfExtents, convexRadius); + const core::ShapeHandle handle = nextHandle(nextShape_); + shapes_.emplace(handle, ShapeRecord{std::move(shape)}); + return handle; + } + + bool destroyShape(core::ShapeHandle shape) noexcept override { + for (const auto& entry : bodies_) { + if (entry.second.shape == shape) { + return false; + } + } + return shapes_.erase(shape) != 0; + } + + core::BodyHandle + createBody(const core::BodyDescriptor& descriptor) override { + core::validateBodyDescriptor(descriptor); + const auto shape = shapes_.find(descriptor.shape); + if (shape == shapes_.end()) { + throw std::invalid_argument("body references an unknown physicsJolt shape"); + } + + const bool dynamic = descriptor.motionType == core::MotionType::dynamicBody; + const JPH::ObjectLayer objectLayer = + layers_.layerFor(descriptor.motionType, descriptor.collisionFilter); + JPH::BodyCreationSettings settings( + shape->second.shape, + toJoltPosition(descriptor.initialTransform.translation, "body position"), + toJoltRotation(descriptor.initialTransform.rotation), + dynamic ? JPH::EMotionType::Dynamic : JPH::EMotionType::Static, + objectLayer); + if (dynamic) { + const float mass = toJoltFloat(descriptor.mass, "body mass"); + if (mass <= 0.0f || !std::isfinite(1.0f / mass)) { + throw std::invalid_argument( + "body mass must remain positive with finite inverse mass in the " + "Jolt scalar range"); + } + settings.mOverrideMassProperties = + JPH::EOverrideMassProperties::CalculateInertia; + settings.mMassPropertiesOverride.mMass = mass; + } + + JPH::Body* body = physicsSystem_.GetBodyInterface().CreateBody(settings); + if (body == nullptr) { + throw core::PhysicsError{core::PhysicsErrorCode::resourceExhausted, + "Jolt body capacity was exhausted"}; + } + physicsSystem_.GetBodyInterface().AddBody( + body->GetID(), dynamic ? JPH::EActivation::Activate + : JPH::EActivation::DontActivate); + + const core::BodyHandle handle = nextHandle(nextBody_); + const core::BodyState initial{handle, descriptor.initialTransform, {}}; + auto inserted = bodies_.emplace( + handle, BodyRecord{body->GetID(), body, descriptor.shape, + descriptor.motionType, initial}); + inserted.first->second.lastState = + readBodyState(handle, inserted.first->second); + return handle; + } + + bool destroyBody(core::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) { + physicsSystem_.RemoveConstraint(iterator->second.constraint.GetPtr()); + iterator = constraints_.erase(iterator); + } else { + ++iterator; + } + } + auto& bodyInterface = physicsSystem_.GetBodyInterface(); + bodyInterface.RemoveBody(found->second.id); + bodyInterface.DestroyBody(found->second.id); + bodies_.erase(found); + changed_.erase(body); + return true; + } + + core::ConstraintHandle createConstraint( + const core::ConstraintDescriptor& descriptor) override { + core::validateConstraintDescriptor(descriptor); + const auto first = bodies_.find(descriptor.firstBody); + const auto second = bodies_.find(descriptor.secondBody); + if (first == bodies_.end() || second == bodies_.end()) { + throw std::invalid_argument( + "constraint references an unknown physicsJolt body"); + } + + JPH::FixedConstraintSettings settings; + settings.mAutoDetectPoint = true; + JPH::Ref constraint = + settings.Create(*first->second.body, *second->second.body); + physicsSystem_.AddConstraint(constraint.GetPtr()); + const core::ConstraintHandle handle = + nextHandle(nextConstraint_); + constraints_.emplace( + handle, ConstraintRecord{descriptor.firstBody, descriptor.secondBody, + std::move(constraint)}); + return handle; + } + + bool destroyConstraint(core::ConstraintHandle constraint) noexcept override { + const auto found = constraints_.find(constraint); + if (found == constraints_.end()) { + return false; + } + physicsSystem_.RemoveConstraint(found->second.constraint.GetPtr()); + constraints_.erase(found); + return true; + } + + bool applyForce(core::BodyHandle body, core::Vector3 force) override { + core::validatePhysicsVector(force, "force"); + const auto found = bodies_.find(body); + if (found == bodies_.end() || + found->second.motionType != core::MotionType::dynamicBody) { + return false; + } + physicsSystem_.GetBodyInterface().AddForce( + found->second.id, toJoltVector(force, "force"), + JPH::EActivation::Activate); + return true; + } + + bool setLinearVelocity(core::BodyHandle body, + core::Vector3 velocity) override { + core::validatePhysicsVector(velocity, "linear velocity"); + const auto found = bodies_.find(body); + if (found == bodies_.end() || + found->second.motionType != core::MotionType::dynamicBody) { + return false; + } + physicsSystem_.GetBodyInterface().SetLinearVelocity( + found->second.id, toJoltVector(velocity, "linear velocity")); + return true; + } + + core::BodyState bodyState(core::BodyHandle body) const override { + const auto found = bodies_.find(body); + if (found == bodies_.end()) { + throw std::out_of_range("unknown physicsJolt body handle"); + } + return readBodyState(body, found->second); + } + + std::optional + groundContact(core::BodyHandle body, double maxDistance) const override { + core::validateGroundProbeDistance(maxDistance); + const auto found = bodies_.find(body); + if (found == bodies_.end()) { + throw std::out_of_range("unknown physicsJolt body handle"); + } + const float joltDistance = toJoltFloat(maxDistance, "ground probe distance"); + + const auto shape = shapes_.find(found->second.shape); + if (shape == shapes_.end()) { + throw core::PhysicsError{core::PhysicsErrorCode::solverFailure, + "physicsJolt body references a missing shape"}; + } + + const auto& bodyInterface = physicsSystem_.GetBodyInterface(); + const JPH::RShapeCast cast{ + shape->second.shape, JPH::Vec3::sOne(), + bodyInterface.GetCenterOfMassTransform(found->second.id), + JPH::Vec3{0.0f, -joltDistance, 0.0f}}; + JPH::ShapeCastSettings settings; + settings.mReturnDeepestPoint = true; + JPH::AllHitCollisionCollector collector; + const JPH::IgnoreSingleBodyFilter ignoreTarget{found->second.id}; + const JPH::DefaultObjectLayerFilter objectFilter{ + pairFilter_, bodyInterface.GetObjectLayer(found->second.id)}; + physicsSystem_.GetNarrowPhaseQuery().CastShape( + cast, settings, cast.mCenterOfMassStart.GetTranslation(), collector, {}, + objectFilter, ignoreTarget); + if (!collector.HadHit()) { + return std::nullopt; + } + + const JPH::ShapeCastResult* groundHit = nullptr; + JPH::Vec3 groundNormal = JPH::Vec3::sAxisY(); + double groundDistance = 0.0; + for (const auto& hit : collector.mHits) { + const JPH::Vec3 normal = + -hit.mPenetrationAxis.NormalizedOr(-JPH::Vec3::sAxisY()); + if (normal.GetY() <= 0.0f) { + continue; + } + const double distance = + std::max(0.0, static_cast(hit.mFraction) * maxDistance); + if (groundHit == nullptr || distance < groundDistance || + (distance == groundDistance && normal.GetY() > groundNormal.GetY())) { + groundHit = &hit; + groundNormal = normal; + groundDistance = distance; + } + } + if (groundHit == nullptr) { + return std::nullopt; + } + + const core::BodyHandle support = bodyHandle(groundHit->mBodyID2); + if (!support) { + throw core::PhysicsError{ + core::PhysicsErrorCode::solverFailure, + "physicsJolt ground query returned an untracked body"}; + } + core::GroundContact contact{support, toCoreVector(groundNormal), + groundDistance}; + core::validateGroundContact(contact); + return contact; + } + + std::optional + segmentHit(core::Vector3 origin, core::Vector3 target, + core::BodyHandle ignoredBody) const override { + core::validateSegmentEndpoints(origin, target); + const core::Vector3 displacement{target.x - origin.x, target.y - origin.y, + target.z - origin.z}; + const JPH::RRayCast ray{toJoltPosition(origin, "segment origin"), + toJoltVector(displacement, "segment displacement")}; + JPH::RayCastResult hit; + bool hadHit = false; + if (ignoredBody) { + const auto ignored = bodies_.find(ignoredBody); + if (ignored == bodies_.end()) { + throw std::out_of_range("unknown ignored physicsJolt body handle"); + } + const JPH::IgnoreSingleBodyFilter filter{ignored->second.id}; + hadHit = physicsSystem_.GetNarrowPhaseQuery().CastRay(ray, hit, {}, {}, + filter); + } else { + hadHit = physicsSystem_.GetNarrowPhaseQuery().CastRay(ray, hit); + } + if (!hadHit) { + return std::nullopt; + } + const core::BodyHandle body = bodyHandle(hit.mBodyID); + if (!body) { + throw core::PhysicsError{ + core::PhysicsErrorCode::solverFailure, + "physicsJolt segment query returned an untracked body"}; + } + core::SegmentHit result{body, static_cast(hit.mFraction)}; + core::validateSegmentHit(result); + return result; + } + + void step(Duration fixedStep) override { + core::validatePhysicsStep(fixedStep); + const float seconds = toJoltFloat(fixedStep.count(), "physics step"); + if (seconds <= 0.0f) { + throw std::invalid_argument( + "physics step must remain positive in the Jolt scalar range"); + } + const JPH::EPhysicsUpdateError updateError = + physicsSystem_.Update(seconds, 1, &tempAllocator_, &jobSystem_); + if (updateError != JPH::EPhysicsUpdateError::None) { + throw core::PhysicsError{ + core::PhysicsErrorCode::solverFailure, + "Jolt physics update exhausted a configured simulation capacity"}; + } + for (auto& entry : bodies_) { + if (entry.second.motionType != core::MotionType::dynamicBody) { + continue; + } + core::BodyState current = readBodyState(entry.first, entry.second); + if (stateChanged(entry.second.lastState, current)) { + entry.second.lastState = current; + changed_[entry.first] = std::move(current); + } + } + } + + std::vector takeChangedBodyStates() override { + std::vector result; + result.reserve(changed_.size()); + for (auto& entry : changed_) { + result.push_back(std::move(entry.second)); + } + std::sort(result.begin(), result.end(), [](const auto& left, const auto& right) { + return left.body.value() < right.body.value(); + }); + changed_.clear(); + return result; + } + +private: + struct ShapeRecord { + JPH::ShapeRefC shape; + }; + + struct BodyRecord { + JPH::BodyID id; + JPH::Body* body; + core::ShapeHandle shape; + core::MotionType motionType; + core::BodyState lastState; + }; + + struct ConstraintRecord { + core::BodyHandle firstBody; + core::BodyHandle secondBody; + JPH::Ref constraint; + }; + + static int workerThreadCount() { + const unsigned int concurrency = std::thread::hardware_concurrency(); + return concurrency > 1 ? static_cast(concurrency - 1) : 0; + } + + template + Handle nextHandle(std::uint64_t& nextLocalValue) const { + if (nextLocalValue == 0 || + nextLocalValue > std::numeric_limits::max()) { + throw core::PhysicsError{core::PhysicsErrorCode::resourceExhausted, + "physics handle capacity was exhausted"}; + } + const std::uint64_t value = (worldId_ << 32U) | nextLocalValue++; + return Handle{value}; + } + + core::BodyState readBodyState(core::BodyHandle handle, + const BodyRecord& record) const { + const auto& bodyInterface = physicsSystem_.GetBodyInterface(); + return {handle, + core::Transform{toCorePosition(bodyInterface.GetPosition(record.id)), + toCoreRotation(bodyInterface.GetRotation(record.id))}, + toCoreVector(bodyInterface.GetLinearVelocity(record.id))}; + } + + core::BodyHandle bodyHandle(JPH::BodyID id) const noexcept { + for (const auto& entry : bodies_) { + if (entry.second.id == id) { + return entry.first; + } + } + return {}; + } + + JoltRuntime runtime_; + const std::uint64_t worldId_; + ObjectLayerRegistry layers_; + ObjectLayerPairFilter pairFilter_; + BroadPhaseLayerInterface broadPhaseInterface_; + ObjectVsBroadPhaseLayerFilter objectVsBroadPhaseFilter_; + JPH::TempAllocatorImpl tempAllocator_{tempAllocatorBytes}; + JPH::JobSystemThreadPool jobSystem_; + JPH::PhysicsSystem physicsSystem_; + std::uint64_t nextShape_{1}; + std::uint64_t nextBody_{1}; + std::uint64_t nextConstraint_{1}; + HandleMap shapes_; + HandleMap bodies_; + HandleMap constraints_; + HandleMap changed_; +}; + +} // namespace + +bool backendAvailable() noexcept { + return true; +} + +std::unique_ptr createWorld() { + return std::make_unique(); +} + +} // namespace usd_physics::jolt diff --git a/backends/physicsJolt/tests/CMakeLists.txt b/backends/physicsJolt/tests/CMakeLists.txt index ecf4c79..43a361c 100644 --- a/backends/physicsJolt/tests/CMakeLists.txt +++ b/backends/physicsJolt/tests/CMakeLists.txt @@ -1,3 +1,9 @@ add_executable(physics_jolt_scaffold_test physics_jolt_scaffold_test.cpp) target_link_libraries(physics_jolt_scaffold_test PRIVATE physicsJolt::physicsJolt) add_test(NAME physicsJolt.scaffold COMMAND physics_jolt_scaffold_test) + +if(PHYSICSJOLT_BACKEND_AVAILABLE) + add_executable(physics_jolt_backend_test physics_jolt_backend_test.cpp) + target_link_libraries(physics_jolt_backend_test PRIVATE physicsJolt::physicsJolt) + add_test(NAME physicsJolt.backend COMMAND physics_jolt_backend_test) +endif() diff --git a/backends/physicsJolt/tests/physics_jolt_backend_test.cpp b/backends/physicsJolt/tests/physics_jolt_backend_test.cpp new file mode 100644 index 0000000..1253d59 --- /dev/null +++ b/backends/physicsJolt/tests/physics_jolt_backend_test.cpp @@ -0,0 +1,223 @@ +#include "usd_physics/core/ground_query.h" +#include "usd_physics/core/segment_query.h" +#include "usd_physics/jolt/availability.h" + +#include +#include +#include +#include + +namespace { + +int fail(const char* message) { + std::cerr << message << '\n'; + return 1; +} + +} // namespace + +int main() { + using namespace usd_physics; + + if (!jolt::backendAvailable()) { + return fail("the Jolt backend test requires an available backend"); + } + + auto world = jolt::createWorld(); + auto* groundQuery = dynamic_cast(world.get()); + auto* segmentQuery = dynamic_cast(world.get()); + if (groundQuery == nullptr || segmentQuery == nullptr) { + return fail("the Jolt world must expose ground and segment queries"); + } + + const auto floorShape = + world->createShape({core::ShapeType::box, {5.0, 0.5, 5.0}}); + const auto cubeShape = + world->createShape({core::ShapeType::box, {0.5, 0.5, 0.5}}); + const auto wallShape = + world->createShape({core::ShapeType::box, {0.5, 2.0, 0.5}}); + const auto supportShape = + world->createShape({core::ShapeType::box, {2.0, 0.1, 2.0}}); + + const auto floor = world->createBody( + {floorShape, core::MotionType::staticBody, + core::Transform{{0.0, -0.5, 0.0}, {}}, 0.0, {1, ~std::uint64_t{0}}}); + const auto wall = world->createBody( + {wallShape, core::MotionType::staticBody, + core::Transform{{2.9, 2.0, 0.0}, {}}, 0.0, {1, ~std::uint64_t{0}}}); + const auto probe = world->createBody( + {cubeShape, core::MotionType::dynamicBody, + core::Transform{{2.0, 0.65, 0.0}, {}}, 1.0, + {1, ~std::uint64_t{0}}}); + + const auto elevatedContact = groundQuery->groundContact(probe, 0.2); + if (!elevatedContact || elevatedContact->supportBody != floor || + std::abs(elevatedContact->distance - 0.15) > 0.01 || + elevatedContact->normal.y < 0.99) { + return fail("the ground query must select the closest upward support"); + } + if (groundQuery->groundContact(probe, 0.1)) { + return fail("the ground query must honor the requested probe distance"); + } + const auto filteredProbe = world->createBody( + {cubeShape, core::MotionType::dynamicBody, + core::Transform{{-2.0, 0.65, 0.0}, {}}, 1.0, {2, 2}}); + if (groundQuery->groundContact(filteredProbe, 0.2)) { + return fail("the ground query must honor the target collision filter"); + } + const auto lowerSupport = world->createBody( + {supportShape, core::MotionType::staticBody, + core::Transform{{10.0, -0.4, 0.0}, {}}, 0.0, {}}); + const auto slopedSupport = world->createBody( + {supportShape, core::MotionType::staticBody, + core::Transform{{10.0, 0.0, 0.0}, + {0.965925826, 0.0, 0.0, 0.258819045}}, + 0.0, {}}); + const auto nearestProbe = world->createBody( + {cubeShape, core::MotionType::dynamicBody, + core::Transform{{10.0, 1.0, 0.0}, {}}, 1.0, {}}); + const auto nearestContact = groundQuery->groundContact(nearestProbe, 1.0); + if (!nearestContact || nearestContact->supportBody != slopedSupport) { + return fail("the ground query must prefer the nearest upward support"); + } + + try { + static_cast(world->createBody( + {cubeShape, core::MotionType::dynamicBody, {}, + std::numeric_limits::denorm_min(), {}})); + return fail("dynamic body mass must remain representable in Jolt"); + } catch (const std::invalid_argument&) { + } + + const auto wallHit = + segmentQuery->segmentHit({2.0, 2.0, 0.0}, {4.0, 2.0, 0.0}); + if (!wallHit || wallHit->body != wall || + std::abs(wallHit->fraction - 0.2) > 0.01) { + return fail("the segment query must return the closest hit"); + } + if (segmentQuery->segmentHit({2.0, 2.0, 0.0}, {4.0, 2.0, 0.0}, + wall)) { + return fail("the segment query must ignore the requested body"); + } + + try { + static_cast(groundQuery->groundContact( + probe, std::numeric_limits::quiet_NaN())); + return fail("the ground query must reject invalid distances"); + } catch (const std::invalid_argument&) { + } + + auto otherWorld = jolt::createWorld(); + const auto otherShape = + otherWorld->createShape({core::ShapeType::box, {0.5, 0.5, 0.5}}); + const auto otherBody = otherWorld->createBody( + {otherShape, core::MotionType::dynamicBody, + core::Transform{{}, {2.0, 0.0, 0.0, 0.0}}, 1.0, {}}); + const auto normalizedState = otherWorld->bodyState(otherBody); + if (std::abs(normalizedState.transform.rotation.w - 1.0) > 0.001 || + std::abs(normalizedState.transform.rotation.x) > 0.001 || + std::abs(normalizedState.transform.rotation.y) > 0.001 || + std::abs(normalizedState.transform.rotation.z) > 0.001) { + return fail("body creation must normalize admitted rotations"); + } + try { + static_cast(world->createBody( + {otherShape, core::MotionType::dynamicBody, {}, 1.0, {}})); + return fail("body creation must reject a cross-world shape handle"); + } catch (const std::invalid_argument&) { + } + if (world->destroyBody(otherBody) || world->setLinearVelocity(otherBody, {})) { + return fail("commands must reject cross-world handles"); + } + try { + static_cast(world->bodyState(otherBody)); + return fail("state lookup must reject cross-world handles"); + } catch (const std::out_of_range&) { + } + try { + static_cast(segmentQuery->segmentHit( + {2.0, 2.0, 0.0}, {4.0, 2.0, 0.0}, otherBody)); + return fail("queries must reject cross-world handles"); + } catch (const std::out_of_range&) { + } + + if (!world->destroyBody(probe) || !world->destroyBody(filteredProbe) || + !world->destroyBody(nearestProbe) || !world->destroyBody(slopedSupport) || + !world->destroyBody(lowerSupport) || !world->destroyBody(wall) || + !world->destroyShape(supportShape) || !world->destroyShape(wallShape)) { + return fail("queried resources must retain explicit lifetime"); + } + + const auto cube = world->createBody( + {cubeShape, core::MotionType::dynamicBody, + core::Transform{{0.0, 3.0, 0.0}, {}}, 1.0, {}}); + const auto filteredCube = world->createBody( + {cubeShape, core::MotionType::dynamicBody, + core::Transform{{3.0, 3.0, 0.0}, {}}, 1.0, {2, 2}}); + + if (!world->setLinearVelocity(cube, {0.0, -1.0, 0.0}) || + !world->applyForce(cube, {0.0, -1.0, 0.0}) || + world->setLinearVelocity(floor, {}) || world->applyForce(floor, {})) { + return fail("velocity and force commands must admit only dynamic bodies"); + } + try { + static_cast(world->applyForce( + cube, {std::numeric_limits::quiet_NaN(), 0.0, 0.0})); + return fail("force commands must validate finite vectors"); + } catch (const std::invalid_argument&) { + } + try { + static_cast(world->createConstraint( + {core::ConstraintType::fixed, floor, otherBody})); + return fail("constraint creation must reject cross-world body handles"); + } catch (const std::invalid_argument&) { + } + + for (int step = 0; step < 240; ++step) { + world->step(core::PhysicsWorld::Duration{1.0 / 60.0}); + } + + const auto settled = world->bodyState(cube); + if (std::abs(settled.transform.translation.y - 0.5) > 0.03 || + std::abs(settled.linearVelocity.y) > 0.05) { + return fail("an admitted dynamic body must settle on the static floor"); + } + const auto fallenThrough = world->bodyState(filteredCube); + if (fallenThrough.transform.translation.y > -1.0) { + return fail("semantic collision filters must reject disallowed pairs"); + } + + const auto settledContact = groundQuery->groundContact(cube, 0.1); + if (!settledContact || settledContact->supportBody != floor || + settledContact->distance > 0.02 || settledContact->normal.y < 0.99) { + return fail("a settled body must report its supporting body"); + } + + const auto changed = world->takeChangedBodyStates(); + if (changed.size() != 2 || changed[0].body.value() >= changed[1].body.value()) { + return fail("changed state must be unique and ordered by handle"); + } + if (!world->takeChangedBodyStates().empty()) { + return fail("taking changed state must drain the queue"); + } + + const auto fixed = world->createConstraint( + {core::ConstraintType::fixed, floor, cube}); + if (!fixed || !world->destroyBody(cube) || world->destroyConstraint(fixed)) { + return fail("destroying a body must destroy dependent constraints"); + } + try { + static_cast(world->bodyState(cube)); + return fail("state lookup must reject stale body handles"); + } catch (const std::out_of_range&) { + } + if (!world->destroyBody(filteredCube) || !world->destroyBody(floor) || + !world->destroyShape(cubeShape) || !world->destroyShape(floorShape)) { + return fail("Jolt resources must have explicit handle-based lifetime"); + } + if (world->destroyBody(cube) || world->destroyShape(cubeShape)) { + return fail("repeated destruction must report a safe no-op"); + } + + return 0; +} diff --git a/backends/physicsJolt/tests/physics_jolt_scaffold_test.cpp b/backends/physicsJolt/tests/physics_jolt_scaffold_test.cpp index fda81af..63e472b 100644 --- a/backends/physicsJolt/tests/physics_jolt_scaffold_test.cpp +++ b/backends/physicsJolt/tests/physics_jolt_scaffold_test.cpp @@ -1,7 +1,22 @@ #include "usd_physics/core/version.h" +#include "usd_physics/core/errors.h" #include "usd_physics/jolt/availability.h" int main() { const auto version = usd_physics::core::version(); - return version.major == 0 && !usd_physics::jolt::backendAvailable() ? 0 : 1; + if (version.major != 0) { + return 1; + } + if (usd_physics::jolt::backendAvailable()) { + return usd_physics::jolt::createWorld() ? 0 : 2; + } + try { + static_cast(usd_physics::jolt::createWorld()); + } catch (const usd_physics::core::PhysicsError& error) { + return error.code() == + usd_physics::core::PhysicsErrorCode::backendUnavailable + ? 0 + : 3; + } + return 4; } diff --git a/docs/README.md b/docs/README.md index ac07d3c..ce97f75 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,11 +4,12 @@ Documentation is organized by responsibility. The taxonomy follows `usd-mmd-plugins`: design says what is intended, architecture fixes structure, reference reports what exists, and the roadmap contains incomplete work. -**Current tree (2026-09-22): Phase 1 core extraction complete.** The root +**Current tree (2026-09-23): Phase 2 backend extraction in progress.** The root build, packages, boundary checks, and installed-consumer test exist. Neutral rigid transform values, typed handles, validated descriptors, the single-owner -world contract, and optional segment and ground queries are implemented; a -solver backend, an OpenUSD bridge, and a release are not. The +world contract, optional segment and ground queries, and the initial Jolt +backend are implemented; its OpenStrata intent passes locally. Hosted backend +evidence, 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/DEPENDENCIES.md b/docs/architecture/DEPENDENCIES.md index 76b1d93..cc6bad7 100644 --- a/docs/architecture/DEPENDENCIES.md +++ b/docs/architecture/DEPENDENCIES.md @@ -16,8 +16,8 @@ claims. | OpenStrata | 0.23.2 | workspace composition and CI generation | Windows root build/test, isolated library tests, consumer verification, and packaging pass | | OpenStrata platform | `cy2026`, `usd` profile | workspace composition | digest-pinned Windows runtime materialized and validated; scaffold code does not link OpenUSD | | OpenUSD | 26.08 exact for the first ecosystem integration | future `physicsUsd`, `physicsSchema`, USD tests | aligned and CI runtime pinned; no target currently links it | -| Jolt Physics | 5.5.0, commit `23dadd0e603f1b321142d4c74df07fce85064989` | future `physicsJolt` implementation only | selected from the extraction source; not linked by the Phase 0 scaffold | -| CTest | version shipped with CMake | tests | five root tests pass on Linux and through OpenStrata on Windows | +| Jolt Physics | 5.5.0, tag `v5.5.0`, commit `23dadd0e603f1b321142d4c74df07fce85064989` | private `physicsJolt` implementation | Jolt-backed plain-CMake tests pass locally on Windows and Linux and the `jolt` OpenStrata intent passes on Windows; hosted Phase 2 evidence remains open | +| CTest | version shipped with CMake | tests | 11 Jolt-enabled root tests pass on Windows and Linux and through OpenStrata on Windows | ## 2. OpenUSD @@ -36,14 +36,33 @@ varies between distributions. Jolt is private to `physicsJolt`. Its headers and compile definitions do not appear in the `physicsCore` install interface. -Before the backend changes to supported, Phase 2 records: - -- source or binary provenance and license; -- exact version or commit; -- CMake target name; -- compile options that affect ABI or determinism; -- allocator and job-system integration policy; -- Windows and Linux evidence where claimed. +The selected source is +[`jrouwe/JoltPhysics`](https://github.com/jrouwe/JoltPhysics) tag `v5.5.0` at +commit `23dadd0e603f1b321142d4c74df07fce85064989`. It is MIT licensed and exports +the installed CMake target `Jolt::Jolt`. `physicsJolt` also accepts the +unnamespaced `Jolt` target for source distributions that expose upstream's +alternate target spelling; installed-package verification uses `Jolt::Jolt`. + +The verified Windows artifact is a Release, single-precision build with +`DOUBLE_PRECISION=OFF`, `CROSS_PLATFORM_DETERMINISTIC=OFF`, and +`INTERPROCEDURAL_OPTIMIZATION=OFF`. Its enabled x86 features are SSE4.1, +SSE4.2, AVX, AVX2, LZCNT, TZCNT, F16C, and FMADD; AVX512 is disabled. A Jolt +artifact with different ABI-affecting definitions must not be mixed into the +same process. + +The installed `physicsJolt` package rechecks Jolt 5.5.0 and the exact exported +ABI compile-definition set before importing its static target. A downstream +configure fails rather than binding the package to a different Jolt build. + +`physicsJolt` uses `RegisterDefaultAllocator`, one reference-counted +process-global Jolt factory/type-registration lifetime, and a 10 MiB temporary +allocator per world. Each world owns a `JobSystemThreadPool` with Jolt's +maximum job/barrier counts and `hardware_concurrency - 1` workers, falling +back to zero worker threads when only one hardware thread is reported. The +initial world capacity is 1,024 bodies, 1,024 body pairs, and 1,024 contact +constraints. Exhausted creation capacity is reported as +`PhysicsErrorCode::resourceExhausted`; update failures are reported as +`PhysicsErrorCode::solverFailure`. An optional no-Jolt stub may help a workspace configure, but it does not count as backend support and cannot make backend tests pass vacuously. diff --git a/docs/architecture/PACKAGE_CONTRACT.md b/docs/architecture/PACKAGE_CONTRACT.md index 98f6bcb..de7baf7 100644 --- a/docs/architecture/PACKAGE_CONTRACT.md +++ b/docs/architecture/PACKAGE_CONTRACT.md @@ -4,7 +4,8 @@ > install and pass a clean-prefix consumer test. `physicsCore` now includes > neutral values, typed handles, validated descriptors, typed errors, and the > single-owner world plus optional segment and ground query interfaces and -> validation surfaces. +> validation surfaces. A Jolt-enabled `physicsJolt` install also resolves its +> private SDK dependency and constructs a world through `createWorld()`. ## 1. Purpose @@ -17,7 +18,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, semantic collision filters, typed errors, world lifecycle/state, and optional segment and ground query interfaces | Phase 1 surface present | -| `physicsJolt` | `physicsJolt::physicsJolt` | Phase 0 explicit unavailable status; Jolt-backed world construction follows in Phase 2 | scaffold present; backend Phase 2 | +| `physicsJolt` | `physicsJolt::physicsJolt` | `backendAvailable()` plus `createWorld()`; a Jolt-enabled build provides shapes, bodies, fixed constraints, commands, stepping, changed state, segment queries, and ground queries, while a no-Jolt build reports typed unavailability | Phase 2 implementation present; hosted evidence pending | | `physicsUsd` | `physicsUsd::physicsUsd` | USD translation, mappings, and synchronization records | 4 | | `secondaryMotion` | `secondaryMotion::secondaryMotion` | generic secondary-motion contracts | 6, if admitted | | `secondaryMotionVerlet` | `secondaryMotionVerlet::secondaryMotionVerlet` | first CPU solver | 6, if admitted | @@ -58,8 +59,9 @@ find_package(physicsJolt CONFIG REQUIRED) target_link_libraries(my_runtime PRIVATE physicsJolt::physicsJolt) ``` -These examples are illustrative until installed-consumer tests exist and must -not be copied into a build guide as verified commands before then. +The installed-consumer test verifies both package imports. A Jolt-enabled +consumer must also make the selected Jolt installation discoverable to CMake; +the installed `physicsJolt` config resolves that declared dependency. ## 5. Versioning diff --git a/docs/architecture/WORKSPACE.md b/docs/architecture/WORKSPACE.md index 1892053..820f364 100644 --- a/docs/architecture/WORKSPACE.md +++ b/docs/architecture/WORKSPACE.md @@ -5,7 +5,8 @@ This document is the proposed binding contract for the planned directions, build modes, and structural invariants. Once accepted, a change that contradicts it updates this document first, in a focused change. -> **Status (2026-09-22): accepted for the Phase 1 core extraction.** +> **Status (2026-09-22): accepted for the Phase 1 core extraction and Phase 2 +> backend boundary.** > Creation status is recorded in > the [capability matrix](../reference/CAPABILITY_MATRIX.md). @@ -16,13 +17,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 | Phase 1 values, handles, descriptors, world contract, and segment/ground queries implemented | -| `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 | +| `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 | backend implemented; Windows and Linux plain-CMake and installed-consumer evidence present | | `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, descriptor, and world-contract -slices, while its package as a whole remains partial. `physicsJolt` remains a -partial unavailable scaffold. +`physicsCore` has supported value, handle, descriptor, and world-contract +slices. `physicsJolt` implements the initial backend slice and retains a typed +unavailable fallback for builds that do not provide Jolt. ### 1.2 Later, only when the responsibility is real diff --git a/docs/reference/CAPABILITY_MATRIX.md b/docs/reference/CAPABILITY_MATRIX.md index d420f8c..03b8650 100644 --- a/docs/reference/CAPABILITY_MATRIX.md +++ b/docs/reference/CAPABILITY_MATRIX.md @@ -4,12 +4,16 @@ This page is the only source of truth for what the current repository tree implements. Design documents describe intended behavior; they do not upgrade a capability on this page. -> **Tree status (2026-09-22): Phase 1 core extraction complete.** Buildable and -> installable `physicsCore` and `physicsJolt` package boundaries exist. +> **Tree status (2026-09-23): Phase 2 backend extraction in progress.** +> Buildable and 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, a single-owner world contract, and optional segment and ground -> query interfaces. A solver backend and an OpenUSD plugin do not exist yet. +> query interfaces. `physicsJolt` implements those contracts when a compatible +> Jolt package is present and retains a typed unavailable fallback otherwise. +> The Jolt-required OpenStrata intent passes locally; hosted backend evidence +> and an OpenUSD plugin do not exist yet. ## 1. Status vocabulary @@ -32,7 +36,7 @@ capability on this page. | Windows build | supported | MSVC 19.51 and OpenStrata evidence in the [Windows OpenStrata report](../reports/2026-09-21-openstrata-windows.md) | | Linux build | supported | GCC 15.2.0 evidence in the [Phase 0 report](../reports/2026-09-21-phase0-bootstrap.md) | | Hosted Windows and Linux CI | supported | Generated graph and test cells passed in the [hosted CI report](../reports/2026-09-22-phase0-hosted-ci.md) | -| Installed-consumer test | supported | Root clean-prefix test and OpenStrata-generated consumers pass for both packages | +| Installed-consumer test | supported | Root clean-prefix test passes for both no-Jolt and Jolt-enabled package installs on Windows and Linux; see the [Windows](../reports/2026-09-22-phase2-windows-backend.md) and [Linux](../reports/2026-09-22-phase2-linux-backend.md) Phase 2 reports | | Versioned release | not present | `VERSION` exists for package coherence; no tag or release record exists | ## 3. Rigid-body core @@ -43,12 +47,12 @@ 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; 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 | -| 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 | +| 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; [`physics_jolt_backend_test.cpp`](../../backends/physicsJolt/tests/physics_jolt_backend_test.cpp) covers backend lifecycle and filtering | +| Fixed-constraint descriptors | supported | Core tests cover two distinct valid body handles; the Jolt backend test covers creation and dependent cleanup | +| Force and velocity operations | supported | Core tests cover validation and dynamic/static/stale/cross-world outcomes; the focused Jolt test covers admitted dynamic commands, static and cross-world rejection, and finite-vector validation | +| Fixed-step world | supported | Core validation plus the Jolt falling/settling test pass on Windows and Linux; see the Phase 2 backend reports | +| Direct body state | supported | Core contract tests and the Jolt falling, filtering, stale-handle, and cross-world tests pass on Windows and Linux | +| Changed-body extraction | supported | Core and Jolt tests cover unique handle ordering, drain behavior, creation exclusion, and destruction cleanup on Windows and Linux; sleep, wake, and teleport remain outside the current API | | Segment query capability | supported | [`physics_core_queries_test.cpp`](../../libs/physicsCore/tests/physics_core_queries_test.cpp) covers optional capability discovery, finite distinct endpoints, an ignored body, and first-hit body/fraction validation | | Ground query capability | supported | [`physics_core_queries_test.cpp`](../../libs/physicsCore/tests/physics_core_queries_test.cpp) covers optional capability discovery, body and probe-distance forwarding, and support body/normal/distance validation | | Ray or shape-cast capabilities | planned | Later consumer-driven addition | @@ -58,10 +62,10 @@ capability on this page. | Capability | Status | Evidence / note | | --- | --- | --- | -| `physicsJolt` package | partial | Installable `physicsJolt::physicsJolt` scaffold explicitly reports the backend unavailable; solver is Phase 2 | -| Jolt initialization and lifetime | planned | No implementation | -| Jolt body/shape/constraint conversion | planned | No implementation | -| Jolt collision and support queries | planned | No implementation | +| `physicsJolt` package | partial | Installable `physicsJolt::physicsJolt` exposes `backendAvailable()` and `createWorld()`; Jolt-enabled and typed unavailable clean-prefix consumers pass on Windows and Linux, and the Jolt-required OpenStrata intent passes locally; hosted evidence remains open | +| Jolt initialization and lifetime | partial | Reference-counted factory/type registration, per-world temporary allocator and job system, explicit resource cleanup, and typed errors are covered by Windows/Linux plain-CMake and local Windows OpenStrata evidence; hosted evidence remains open | +| Jolt body/shape/constraint conversion | supported | Box shapes, static/dynamic bodies, normalized rotations, mass, semantic filters, fixed constraints, force/velocity commands, stepping, state, and cleanup pass focused Windows and Linux tests | +| Jolt collision and support queries | supported | Closest segment hit, ignored body, support identity, upward normal, distance, validation, and cross-world rejection pass focused Windows and Linux tests | | Alternative rigid-body backend | unsupported | No implementation phase committed | ## 5. OpenUSD bridge diff --git a/docs/reports/2026-09-22-phase2-linux-backend.md b/docs/reports/2026-09-22-phase2-linux-backend.md new file mode 100644 index 0000000..4e55628 --- /dev/null +++ b/docs/reports/2026-09-22-phase2-linux-backend.md @@ -0,0 +1,29 @@ +# 2026-09-22 Phase 2 Linux backend verification + +## Scope + +This report records local Linux verification of the extracted `physicsJolt` +implementation under WSL. It complements the Windows backend report but does +not replace OpenStrata or hosted CI evidence. + +The Jolt package came from `jrouwe/JoltPhysics` tag `v5.5.0`, commit +`23dadd0e603f1b321142d4c74df07fce85064989`, installed with the +`Jolt::Jolt` CMake target. The compiler was GCC 15.2.0 and the generator was +Ninja Multi-Config. + +## Result + +The Release root build completed with `PHYSICSJOLT_REQUIRE_JOLT=ON`. All 11 +tests passed, including: + +- the focused Jolt backend lifecycle, filtering, stepping, changed-state, + segment-query, and ground-query test; +- source and public-header boundary checks; +- documentation and version checks; and +- clean-prefix installation, downstream configure/link, world construction, + and execution through the imported packages. + +## Remaining evidence + +Phase 2 still requires the Jolt-enabled OpenStrata dependency edge and the +generated hosted Windows/Linux cells recorded by the current roadmap. diff --git a/docs/reports/2026-09-22-phase2-windows-backend.md b/docs/reports/2026-09-22-phase2-windows-backend.md new file mode 100644 index 0000000..d1d51a9 --- /dev/null +++ b/docs/reports/2026-09-22-phase2-windows-backend.md @@ -0,0 +1,54 @@ +# 2026-09-22 Phase 2 Windows backend verification + +## Scope + +This report records the first local Windows verification of the extracted +`physicsJolt` implementation. It is evidence for the current tree, not a claim +that Linux, OpenStrata, or hosted Phase 2 verification has completed. + +The Jolt package came from `jrouwe/JoltPhysics` tag `v5.5.0`, commit +`23dadd0e603f1b321142d4c74df07fce85064989`, installed with the +`Jolt::Jolt` CMake target. The compiler was MSVC 19.51.36256.0 through Visual +Studio 18 2026, targeting Windows SDK 10.0.26100.0. + +## Configurations + +Two Release configurations were exercised: + +1. no Jolt package discoverable, retaining the typed unavailable fallback; +2. `PHYSICSJOLT_REQUIRE_JOLT=ON` with `Jolt_DIR` pointing at the pinned Jolt + install. + +The no-Jolt configuration passed all 10 tests, including boundary, docs, +version, and clean-prefix installed-consumer checks. The Jolt-enabled +configuration passed all 11 tests. Its additional `physicsJolt.backend` test +covered: + +- world construction and optional capability discovery; +- static and dynamic box bodies, falling, and settling; +- semantic collision-filter rejection; +- fixed constraints and dependent cleanup; +- cross-world handle rejection; +- deterministic changed-state ordering and drain behavior; +- closest segment hits and ignored bodies; and +- support body, normal, and distance ground results. + +The Jolt-enabled installed-consumer test installed both packages into a clean +prefix, resolved Jolt as a declared external dependency, linked the imported +targets, constructed a backend world, and ran successfully. + +## Remaining evidence + +This run does not close Phase 2. OpenStrata composition, Linux execution, and +the generated hosted Windows/Linux cells remain required by the current +roadmap. + +## Follow-up (2026-09-23): OpenStrata `jolt` intent + +After the initial plain-CMake run, the workspace added a `jolt` build intent +that sets `PHYSICSJOLT_REQUIRE_JOLT=ON`. With the same pinned Jolt installation +on `CMAKE_PREFIX_PATH`, `ost build --intent jolt` selected `Jolt::Jolt` and +completed the Release workspace build. `ost test --intent jolt` then passed +all 11 tests, including the focused backend and clean-prefix installed +consumer. The later Linux report also closes the local Linux item above; +hosted Windows/Linux cells remain the final Phase 2 evidence gate. diff --git a/docs/reports/README.md b/docs/reports/README.md index 06d0230..4713b5f 100644 --- a/docs/reports/README.md +++ b/docs/reports/README.md @@ -6,3 +6,5 @@ remain in the [capability matrix](../reference/CAPABILITY_MATRIX.md). - [2026-09-21 Phase 0 bootstrap](2026-09-21-phase0-bootstrap.md) - [2026-09-21 Windows OpenStrata verification](2026-09-21-openstrata-windows.md) - [2026-09-22 Phase 0 hosted CI verification](2026-09-22-phase0-hosted-ci.md) +- [2026-09-22 Phase 2 Windows backend verification](2026-09-22-phase2-windows-backend.md) +- [2026-09-22 Phase 2 Linux backend verification](2026-09-22-phase2-linux-backend.md) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 29b26fd..fa7ebc8 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,8 +1,9 @@ # Current roadmap — Phase 2 `physicsJolt` extraction -> **Status: not started, 2026-09-22.** Phase 1 is complete. The next delivery -> slice moves the proven Stage Runner Jolt implementation behind the installed -> `physicsCore` contract without changing its observable behavior. +> **Status: in progress, 2026-09-23.** The Jolt-backed runtime and Windows/Linux +> plain-CMake/installed-consumer slices are implemented, and the Jolt-required +> OpenStrata intent passes locally on Windows. Hosted evidence remains before +> Phase 2 is complete. ## 1. Outcome @@ -33,22 +34,11 @@ installed physicsCore contract ## 3. Remaining Phase 2 work -- Record Jolt source or binary provenance, license, CMake target, ABI-affecting - compile options, allocator policy, and job-system policy alongside the - dependency contract. -- Replace the unavailable scaffold with a factory for a Jolt-backed - `PhysicsWorld` while keeping backend headers and link details out of - `physicsCore`. -- Implement neutral shape, body, constraint, force, velocity, state, and - fixed-step conversion with the Phase 1 validation and error outcomes. -- Implement deterministic changed-state draining and explicit cleanup without - exposing native IDs through public handles. -- Implement `SegmentQuery` with ignored-body behavior and `GroundQuery` with - support body, normal, and distance results. -- Add focused backend tests for falling bodies, constraints, cleanup, changed - state, closest segment hits, ignored bodies, and ground support. -- Verify plain CMake, clean-prefix installed consumption, OpenStrata - composition, and the claimed Windows and Linux cells. +- Run the configured Jolt-enabled hosted Windows/Linux Phase 2 cells and + record dated evidence. +- Reconcile any platform-specific Jolt compile options discovered by those + cells with the dependency contract before changing the capability claim to + fully supported. ## 4. Phase 2 completion criteria @@ -58,6 +48,6 @@ installed physicsCore contract resource identifier. - The backend preserves Phase 1 stale, cross-world, validation, error, and changed-state ordering behavior. -- Package metadata and OpenStrata composition describe the same private Jolt - dependency edge. +- Package metadata and the `jolt` OpenStrata intent describe the same private + Jolt dependency edge. - The capability matrix claims only the backend behavior exercised by tests. diff --git a/openstrata.ci.yaml b/openstrata.ci.yaml index e250671..d910561 100644 --- a/openstrata.ci.yaml +++ b/openstrata.ci.yaml @@ -1,5 +1,8 @@ schema: 1 +# The generated workflow must retain the repository-specific pinned Jolt +# checkout/build steps and export its install prefix to the `jolt` intent. + bootstrap: ost: version: "0.23.2" @@ -40,6 +43,7 @@ cells: expected_oci_digest: sha256:d3ff79a6f330558c3b9a427a927d340fe3dcab1fe89107faa0ea9f66a104b7bf platform: cy2026 profile: usd + intent: jolt host_python: "3.13" verify: test @@ -53,6 +57,7 @@ cells: expected_oci_digest: sha256:8d2d599489ee623044f4360ad3a2b979e2966e6ad2f949e5aa9083570e59cc4f platform: cy2026 profile: usd + intent: jolt host_python: "3.13" host_packages: apt: [libx11-dev, libxt-dev] diff --git a/openstrata.toml b/openstrata.toml index 6fa71ad..4df1866 100644 --- a/openstrata.toml +++ b/openstrata.toml @@ -6,6 +6,11 @@ version = "0.1.0" platform = "cy2026" profile = "usd" +[build.intents.jolt] +cache = { + PHYSICSJOLT_REQUIRE_JOLT = { type = "BOOL", value = true } +} + [workspace] members = [ "libs/physicsCore", diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b0bc291..46e29bb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_test(NAME workspace.installed_consumer "-DTEST_RC_COMPILER=${CMAKE_RC_COMPILER}" "-DTEST_MT=${CMAKE_MT}" "-DTEST_TOOLCHAIN_FILE=${CMAKE_TOOLCHAIN_FILE}" + "-DTEST_JOLT_DIR=${Jolt_DIR}" "-DTEST_CONFIG=$" -P "${CMAKE_CURRENT_SOURCE_DIR}/run_installed_consumer.cmake") set_tests_properties(workspace.installed_consumer PROPERTIES diff --git a/tests/installed_consumer/CMakeLists.txt b/tests/installed_consumer/CMakeLists.txt index 70c0930..2ca6103 100644 --- a/tests/installed_consumer/CMakeLists.txt +++ b/tests/installed_consumer/CMakeLists.txt @@ -1,6 +1,10 @@ cmake_minimum_required(VERSION 3.25) project(UsdPhysicsInstalledConsumer LANGUAGES CXX) +if(TEST_FAKE_INCOMPATIBLE_JOLT) + add_library(Jolt::Jolt INTERFACE IMPORTED) +endif() + find_package(physicsCore CONFIG REQUIRED) find_package(physicsJolt CONFIG REQUIRED) diff --git a/tests/installed_consumer/main.cpp b/tests/installed_consumer/main.cpp index 493786d..7c13f25 100644 --- a/tests/installed_consumer/main.cpp +++ b/tests/installed_consumer/main.cpp @@ -38,5 +38,16 @@ int main() { error.code() != usd_physics::core::PhysicsErrorCode::solverFailure) { return 3; } - return usd_physics::jolt::backendAvailable() ? 4 : 0; + if (usd_physics::jolt::backendAvailable()) { + return usd_physics::jolt::createWorld() ? 0 : 4; + } + try { + static_cast(usd_physics::jolt::createWorld()); + } catch (const usd_physics::core::PhysicsError& unavailable) { + return unavailable.code() == + usd_physics::core::PhysicsErrorCode::backendUnavailable + ? 0 + : 5; + } + return 6; } diff --git a/tests/run_installed_consumer.cmake b/tests/run_installed_consumer.cmake index edb5cbe..3ab7183 100644 --- a/tests/run_installed_consumer.cmake +++ b/tests/run_installed_consumer.cmake @@ -4,7 +4,8 @@ endif() set(_prefix "${PROJECT_BUILD_DIR}/installed-consumer-prefix") set(_build "${PROJECT_BUILD_DIR}/installed-consumer-build") -file(REMOVE_RECURSE "${_prefix}" "${_build}") +set(_mismatch_build "${PROJECT_BUILD_DIR}/incompatible-jolt-consumer-build") +file(REMOVE_RECURSE "${_prefix}" "${_build}" "${_mismatch_build}") execute_process( COMMAND "${CMAKE_COMMAND}" --install "${PROJECT_BUILD_DIR}" @@ -56,6 +57,29 @@ endif() if(TEST_TOOLCHAIN_FILE) list(APPEND _configure_args "-DCMAKE_TOOLCHAIN_FILE=${TEST_TOOLCHAIN_FILE}") endif() +if(TEST_JOLT_DIR) + list(APPEND _configure_args "-DJolt_DIR=${TEST_JOLT_DIR}") + + set(_mismatch_args ${_configure_args}) + list(FIND _mismatch_args "${_build}" _mismatch_build_index) + if(_mismatch_build_index EQUAL -1) + message(FATAL_ERROR "could not prepare incompatible Jolt consumer check") + endif() + list(REMOVE_AT _mismatch_args ${_mismatch_build_index}) + list(INSERT _mismatch_args ${_mismatch_build_index} "${_mismatch_build}") + list(APPEND _mismatch_args "-DTEST_FAKE_INCOMPATIBLE_JOLT=ON") + execute_process( + COMMAND "${CMAKE_COMMAND}" ${_mismatch_args} + RESULT_VARIABLE _mismatch_result + OUTPUT_VARIABLE _mismatch_output + ERROR_VARIABLE _mismatch_error) + if(NOT _mismatch_result OR + NOT "${_mismatch_output}\n${_mismatch_error}" MATCHES + "incompatible ABI compile definitions") + message(FATAL_ERROR + "installed package did not reject an incompatible Jolt target") + endif() +endif() execute_process( COMMAND "${CMAKE_COMMAND}" ${_configure_args} RESULT_VARIABLE _configure_result)