From 0ffad2a4f06f3cdef1154ef4274df05d353aa56d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 14 Jul 2026 19:25:02 +0100 Subject: [PATCH] Cover Box3D world-step semantics --- Ymir.slnx | 1 + docs/box3d-parity.md | 19 ++- native/Ymir.Box3D/ymir_box3d.c | 130 ++++++++++++++++++ tests/Ymir.Box3D.Parity/Box3DOracle.cs | 44 ++++++ tests/Ymir.Box3D.Parity/Box3DParityTests.cs | 37 +++++ .../Ymir.Box3D.Parity.csproj | 4 +- 6 files changed, 226 insertions(+), 9 deletions(-) diff --git a/Ymir.slnx b/Ymir.slnx index 2db5400..53b0fac 100644 --- a/Ymir.slnx +++ b/Ymir.slnx @@ -4,6 +4,7 @@ + diff --git a/docs/box3d-parity.md b/docs/box3d-parity.md index d33f2b7..d6f129f 100644 --- a/docs/box3d-parity.md +++ b/docs/box3d-parity.md @@ -16,8 +16,9 @@ torque lifetime, and numeric tolerances. `native/Ymir.Box3D` statically links the pinned C17 library into one small shared-library facade. The facade exports only blittable sphere overlap, -sphere cast, capsule-versus-sphere membership, and version operations. This -keeps Box3D's alpha C ABI out of Ymir's public managed contracts. +sphere cast, capsule-versus-sphere membership, planar pair stepping, torque +lifetime, and version operations. This keeps Box3D's alpha C ABI out of Ymir's +public managed contracts. `tests/Ymir.Box3D.Parity` builds that facade through CMake and invokes it with source-generated .NET interop. Run it with: @@ -42,12 +43,16 @@ is retained in the pinned submodule. includes rounded start and end caps and the same overlap slop. - Box3D query callback order is not a contract. Ymir sorts projected results by distance and stable entity id. +- Contact begin facts are emitted after the world step. +- Restitution uses the maximum of the two shape materials. +- Applied torque is transient and cleared by each world step; a continuous + torque must be submitted again for the next step. ## Cut Line The current managed collision formulas are transitional. They may exist only -while differential tests prove compatibility. The next parity slice covers a -planar sphere-world step, transient forces and torques, restitution mixing, -and begin/end contact events. Once those facts are projected through a stable -Ymir native port, the managed solver, query geometry, and spatial indexes are -deleted rather than retained as fallback authority. +while differential tests prove compatibility. The remaining parity slice +covers retained world sessions, end-contact lifecycle, continuous collision, +and replay. Once those facts are projected through a stable Ymir native port, +the managed solver, query geometry, and spatial indexes are deleted rather +than retained as fallback authority. diff --git a/native/Ymir.Box3D/ymir_box3d.c b/native/Ymir.Box3D/ymir_box3d.c index f1ed29e..0756e3c 100644 --- a/native/Ymir.Box3D/ymir_box3d.c +++ b/native/Ymir.Box3D/ymir_box3d.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -28,11 +29,88 @@ typedef struct ymir_box3d_cast_result float normal_y; } ymir_box3d_cast_result; +typedef struct ymir_box3d_body_input +{ + float position_x; + float position_y; + float velocity_x; + float velocity_y; + float radius; + float mass; + float restitution; + int is_static; +} ymir_box3d_body_input; + +typedef struct ymir_box3d_body_output +{ + float position_x; + float position_y; + float velocity_x; + float velocity_y; + float angular_velocity; +} ymir_box3d_body_output; + +typedef struct ymir_box3d_pair_step_result +{ + ymir_box3d_body_output body_a; + ymir_box3d_body_output body_b; + int begin_contact_count; + int end_contact_count; + int hit_contact_count; +} ymir_box3d_pair_step_result; + +typedef struct ymir_box3d_torque_result +{ + float angular_velocity_after_applied_step; + float angular_velocity_after_unforced_step; +} ymir_box3d_torque_result; + static b3Vec3 ymir_vec(float x, float y) { return (b3Vec3){x, 0.0f, y}; } +static b3BodyId ymir_create_body(b3WorldId world_id, const ymir_box3d_body_input* input) +{ + b3BodyDef body_def = b3DefaultBodyDef(); + body_def.type = input->is_static != 0 ? b3_staticBody : b3_dynamicBody; + body_def.position = ymir_vec(input->position_x, input->position_y); + body_def.linearVelocity = ymir_vec(input->velocity_x, input->velocity_y); + body_def.motionLocks.linearY = true; + body_def.motionLocks.angularX = true; + body_def.motionLocks.angularZ = true; + body_def.enableSleep = false; + b3BodyId body_id = b3CreateBody(world_id, &body_def); + + b3ShapeDef shape_def = b3DefaultShapeDef(); + if (input->is_static == 0) + { + float volume = 4.0f / 3.0f * B3_PI * input->radius * input->radius * input->radius; + shape_def.density = input->mass / volume; + } + shape_def.baseMaterial.friction = 0.0f; + shape_def.baseMaterial.restitution = input->restitution; + shape_def.enableContactEvents = true; + shape_def.enableHitEvents = true; + b3Sphere sphere = {{0.0f, 0.0f, 0.0f}, input->radius}; + b3CreateSphereShape(body_id, &shape_def, &sphere); + return body_id; +} + +static ymir_box3d_body_output ymir_read_body(b3BodyId body_id) +{ + b3Pos position = b3Body_GetPosition(body_id); + b3Vec3 velocity = b3Body_GetLinearVelocity(body_id); + b3Vec3 angular_velocity = b3Body_GetAngularVelocity(body_id); + return (ymir_box3d_body_output){ + .position_x = position.x, + .position_y = position.z, + .velocity_x = velocity.x, + .velocity_y = velocity.z, + .angular_velocity = angular_velocity.y, + }; +} + YMIR_EXPORT int ymir_box3d_get_version(int* major, int* minor, int* revision) { b3Version version = b3GetVersion(); @@ -132,3 +210,55 @@ YMIR_EXPORT ymir_box3d_cast_result ymir_box3d_cast_sphere( .normal_y = output.normal.z, }; } + +YMIR_EXPORT void ymir_box3d_step_pair( + const ymir_box3d_body_input* body_a_input, + const ymir_box3d_body_input* body_b_input, + float time_step, + int sub_step_count, + float restitution_threshold, + ymir_box3d_pair_step_result* result) +{ + b3WorldDef world_def = b3DefaultWorldDef(); + world_def.gravity = b3Vec3_zero; + world_def.enableSleep = false; + world_def.restitutionThreshold = restitution_threshold; + b3WorldId world_id = b3CreateWorld(&world_def); + + b3BodyId body_a = ymir_create_body(world_id, body_a_input); + b3BodyId body_b = ymir_create_body(world_id, body_b_input); + b3World_Step(world_id, time_step, sub_step_count); + + b3ContactEvents events = b3World_GetContactEvents(world_id); + result->body_a = ymir_read_body(body_a); + result->body_b = ymir_read_body(body_b); + result->begin_contact_count = events.beginCount; + result->end_contact_count = events.endCount; + result->hit_contact_count = events.hitCount; + + b3DestroyWorld(world_id); +} + +YMIR_EXPORT ymir_box3d_torque_result ymir_box3d_torque_lifetime(float torque, float time_step, int sub_step_count) +{ + b3WorldDef world_def = b3DefaultWorldDef(); + world_def.gravity = b3Vec3_zero; + world_def.enableSleep = false; + b3WorldId world_id = b3CreateWorld(&world_def); + + ymir_box3d_body_input input = { + .radius = 1.0f, + .mass = 1.0f, + .is_static = 0, + }; + b3BodyId body_id = ymir_create_body(world_id, &input); + b3Body_ApplyTorque(body_id, (b3Vec3){0.0f, torque, 0.0f}, true); + b3World_Step(world_id, time_step, sub_step_count); + float after_applied_step = b3Body_GetAngularVelocity(body_id).y; + + b3World_Step(world_id, time_step, sub_step_count); + float after_unforced_step = b3Body_GetAngularVelocity(body_id).y; + b3DestroyWorld(world_id); + + return (ymir_box3d_torque_result){after_applied_step, after_unforced_step}; +} diff --git a/tests/Ymir.Box3D.Parity/Box3DOracle.cs b/tests/Ymir.Box3D.Parity/Box3DOracle.cs index ca9345c..5e501b0 100644 --- a/tests/Ymir.Box3D.Parity/Box3DOracle.cs +++ b/tests/Ymir.Box3D.Parity/Box3DOracle.cs @@ -40,6 +40,18 @@ internal static partial class Box3DOracle float bodyY, float bodyRadius, int canEncroach); + + [LibraryImport(LibraryName)] + internal static partial void ymir_box3d_step_pair( + in Box3DBodyInput bodyA, + in Box3DBodyInput bodyB, + float timeStep, + int subStepCount, + float restitutionThreshold, + out Box3DPairStepResult result); + + [LibraryImport(LibraryName)] + internal static partial Box3DTorqueResult ymir_box3d_torque_lifetime(float torque, float timeStep, int subStepCount); } [StructLayout(LayoutKind.Sequential)] @@ -58,3 +70,35 @@ internal readonly record struct Box3DCastResult( { public bool IsHit => Hit != 0; } + +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DBodyInput( + float PositionX, + float PositionY, + float VelocityX, + float VelocityY, + float Radius, + float Mass, + float Restitution, + int IsStatic); + +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DBodyOutput( + float PositionX, + float PositionY, + float VelocityX, + float VelocityY, + float AngularVelocity); + +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DPairStepResult( + Box3DBodyOutput BodyA, + Box3DBodyOutput BodyB, + int BeginContactCount, + int EndContactCount, + int HitContactCount); + +[StructLayout(LayoutKind.Sequential)] +internal readonly record struct Box3DTorqueResult( + float AngularVelocityAfterAppliedStep, + float AngularVelocityAfterUnforcedStep); diff --git a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs index 2c740cd..828c501 100644 --- a/tests/Ymir.Box3D.Parity/Box3DParityTests.cs +++ b/tests/Ymir.Box3D.Parity/Box3DParityTests.cs @@ -83,4 +83,41 @@ public sealed class Box3DParityTests Assert.True(result.IsHit); } + + [Fact] + public void PairStepEmitsBeginContactAndSeparatesOverlappingSpheres() + { + var bodyA = new Box3DBodyInput(-0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0); + var bodyB = new Box3DBodyInput(0.9f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0); + + Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result); + + Assert.Equal(1, result.BeginContactCount); + Assert.True(result.BodyA.PositionX < -0.9f); + Assert.True(result.BodyB.PositionX > 0.9f); + } + + [Fact] + public void PairStepUsesMaximumRestitution() + { + var bodyA = new Box3DBodyInput(-1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.1f, 0); + var bodyB = new Box3DBodyInput(1.0f, 0.0f, -1.0f, 0.0f, 1.0f, 1.0f, 0.8f, 0); + + Box3DOracle.ymir_box3d_step_pair(in bodyA, in bodyB, 1.0f / 60.0f, 4, 0.0f, out var result); + + Assert.True(result.BodyA.VelocityX < -0.7f); + Assert.True(result.BodyB.VelocityX > 0.7f); + } + + [Fact] + public void TorqueIsClearedAfterEachWorldStep() + { + var result = Box3DOracle.ymir_box3d_torque_lifetime(0.4f, 0.1f, 4); + + Assert.True(result.AngularVelocityAfterAppliedStep > 0.0f); + Assert.Equal( + result.AngularVelocityAfterAppliedStep, + result.AngularVelocityAfterUnforcedStep, + precision: 5); + } } diff --git a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj index 78d766d..79466d8 100644 --- a/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj +++ b/tests/Ymir.Box3D.Parity/Ymir.Box3D.Parity.csproj @@ -8,8 +8,8 @@ false $(MSBuildThisFileDirectory)..\..\native\Ymir.Box3D $(MSBuildThisFileDirectory)..\..\artifacts\box3d-native\$(Configuration) - C:\Program Files\CMake\bin\cmake.exe - cmake + C:\Program Files\CMake\bin\cmake.exe + cmake