Ymir/src/Ymir.Box3D/Box3DSession.cs
2026-07-14 22:01:19 +01:00

404 lines
13 KiB
C#

using System.Runtime.InteropServices;
namespace Ymir.Box3D;
internal readonly record struct Box3DBody(
ulong StableId,
ulong CollisionCategoryBits,
ulong CollisionMaskBits,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity,
float Torque,
float Radius,
float Mass,
float Restitution,
bool IsStatic,
bool IsKinematic,
bool IsBullet,
bool ParticipatesInFields,
int CollisionGroupIndex);
internal readonly record struct Box3DField(float PositionX, float PositionZ, float Strength, float Radius);
internal readonly record struct Box3DBodyState(
ulong StableId,
float PositionX,
float PositionZ,
float VelocityX,
float VelocityZ,
float DirectionX,
float DirectionZ,
float AngularVelocity);
internal readonly record struct Box3DContact(
ulong StableIdA,
ulong StableIdB,
float PointX,
float PointZ,
float NormalX,
float NormalZ,
float Penetration,
float RelativeSpeed);
internal enum Box3DContactKind
{
Begin = 1,
Hit = 2,
End = 3
}
internal readonly record struct Box3DContactKey(uint Part0, uint Part1, uint Part2);
internal readonly record struct Box3DContactFact(
Box3DContactKind Kind,
Box3DContactKey ContactKey,
ulong StableIdA,
ulong StableIdB,
bool HasDetails,
float PointX,
float PointZ,
float NormalX,
float NormalZ,
float Penetration,
float RelativeSpeed);
internal sealed class Box3DSession : IDisposable
{
internal const uint AbiVersion = 4;
internal const int DefaultSubstepCount = 4;
private readonly Box3DSessionHandle _handle;
private bool _disposed;
internal Box3DSession()
{
EnsureCompatibleNativeLibrary();
ThrowIfFailed(Box3DNative.ymir_box3d_session_create(out var value), "create session");
if (value == 0)
{
throw new InvalidOperationException("Box3D returned a null Ymir session.");
}
_handle = new Box3DSessionHandle(value);
}
internal unsafe void SyncBodies(ReadOnlySpan<Box3DBody> bodies)
{
ThrowIfDisposed();
var native = new Box3DBodyInput[bodies.Length];
for (var i = 0; i < bodies.Length; i++)
{
var body = bodies[i];
native[i] = new Box3DBodyInput(
body.StableId,
body.CollisionCategoryBits,
body.CollisionMaskBits,
body.PositionX,
body.PositionZ,
body.VelocityX,
body.VelocityZ,
body.DirectionX,
body.DirectionZ,
body.AngularVelocity,
body.Torque,
body.Radius,
body.Mass,
body.Restitution,
body.IsStatic ? 1u : 0u,
body.IsKinematic ? 1u : 0u,
body.IsBullet ? 1u : 0u,
body.ParticipatesInFields ? 1u : 0u,
body.CollisionGroupIndex);
}
fixed (Box3DBodyInput* pointer = native)
{
var status = Box3DNative.ymir_box3d_session_sync_bodies(_handle, pointer, native.Length);
if (status is Box3DStatus.OutOfMemory or Box3DStatus.InternalError)
{
Dispose();
}
ThrowIfFailed(status, "synchronize bodies");
}
}
internal unsafe void Spawn(Box3DBody body)
{
ThrowIfDisposed();
var native = ToNative(body);
ThrowIfMutationFailed(Box3DNative.ymir_box3d_session_spawn(_handle, &native), "spawn body");
}
internal void Remove(ulong stableId)
{
ThrowIfDisposed();
ThrowIfMutationFailed(Box3DNative.ymir_box3d_session_remove(_handle, stableId), "remove body");
}
internal void Teleport(
ulong stableId,
float positionX,
float positionZ,
float directionX,
float directionZ)
{
ThrowIfDisposed();
ThrowIfMutationFailed(
Box3DNative.ymir_box3d_session_teleport(
_handle, stableId, positionX, positionZ, directionX, directionZ),
"teleport body");
}
internal void SetVelocity(ulong stableId, float linearX, float linearZ, float angular)
{
ThrowIfDisposed();
ThrowIfMutationFailed(
Box3DNative.ymir_box3d_session_set_velocity(_handle, stableId, linearX, linearZ, angular),
"set body velocity");
}
internal void Configure(
ulong stableId,
float radius,
float mass,
float restitution,
bool isStatic,
bool isKinematic)
{
ThrowIfDisposed();
ThrowIfMutationFailed(
Box3DNative.ymir_box3d_session_configure(
_handle, stableId, radius, mass, restitution, isStatic ? 1u : 0u, isKinematic ? 1u : 0u),
"configure body");
}
internal void ApplyForce(ulong stableId, float x, float z)
{
ThrowIfDisposed();
ThrowIfMutationFailed(Box3DNative.ymir_box3d_session_apply_force(_handle, stableId, x, z), "apply body force");
}
internal void ApplyTorque(ulong stableId, float value)
{
ThrowIfDisposed();
ThrowIfMutationFailed(Box3DNative.ymir_box3d_session_apply_torque(_handle, stableId, value), "apply body torque");
}
internal unsafe void Step(float deltaTime, ReadOnlySpan<Box3DField> fields, int substepCount = DefaultSubstepCount)
{
ThrowIfDisposed();
if (!float.IsFinite(deltaTime) || deltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be finite and positive.");
}
if (substepCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(substepCount), "Substep count must be positive.");
}
var native = new Box3DRadialField[fields.Length];
for (var i = 0; i < fields.Length; i++)
{
native[i] = new Box3DRadialField(fields[i].PositionX, fields[i].PositionZ, fields[i].Strength, fields[i].Radius);
}
fixed (Box3DRadialField* pointer = native)
{
ThrowIfMutationFailed(
Box3DNative.ymir_box3d_session_step(_handle, deltaTime, substepCount, pointer, native.Length),
"step session");
}
}
internal unsafe Box3DBodyState[] CopyBodies()
{
ThrowIfDisposed();
var count = Box3DNative.ymir_box3d_session_get_body_count(_handle);
if (count < 0)
{
throw new InvalidOperationException("Box3D returned an invalid body count.");
}
var native = new Box3DBodyOutput[count];
fixed (Box3DBodyOutput* pointer = native)
{
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_bodies(_handle, pointer, count, out var written), "copy bodies");
if (written != count)
{
throw new InvalidOperationException($"Box3D body snapshot changed during copy: expected {count}, received {written}.");
}
}
return native.Select(body => new Box3DBodyState(
body.StableId,
body.PositionX,
body.PositionZ,
body.VelocityX,
body.VelocityZ,
body.DirectionX,
body.DirectionZ,
body.AngularVelocity)).ToArray();
}
internal unsafe Box3DContact[] CopyContacts()
{
ThrowIfDisposed();
var count = Box3DNative.ymir_box3d_session_get_contact_count(_handle);
if (count < 0)
{
throw new InvalidOperationException("Box3D returned an invalid contact count.");
}
var native = new Box3DContactOutput[count];
fixed (Box3DContactOutput* pointer = native)
{
ThrowIfFailed(Box3DNative.ymir_box3d_session_copy_contacts(_handle, pointer, count, out var written), "copy contacts");
if (written != count)
{
throw new InvalidOperationException($"Box3D contact snapshot changed during copy: expected {count}, received {written}.");
}
}
return native.Select(contact => new Box3DContact(
contact.StableIdA,
contact.StableIdB,
contact.PointX,
contact.PointZ,
contact.NormalX,
contact.NormalZ,
contact.Penetration,
contact.RelativeSpeed)).ToArray();
}
internal unsafe Box3DContactFact[] CopyContactEvents()
{
ThrowIfDisposed();
var count = Box3DNative.ymir_box3d_session_get_contact_event_count(_handle);
if (count < 0)
{
throw new InvalidOperationException("Box3D returned an invalid contact event count.");
}
var native = new Box3DContactEventOutput[count];
fixed (Box3DContactEventOutput* pointer = native)
{
ThrowIfFailed(
Box3DNative.ymir_box3d_session_copy_contact_events(_handle, pointer, count, out var written),
"copy contact events");
if (written != count)
{
throw new InvalidOperationException(
$"Box3D contact event snapshot changed during copy: expected {count}, received {written}.");
}
}
return native.Select(contact => new Box3DContactFact(
(Box3DContactKind)contact.Kind,
new Box3DContactKey(contact.ContactId0, contact.ContactId1, contact.ContactId2),
contact.StableIdA,
contact.StableIdB,
contact.HasDetails != 0,
contact.PointX,
contact.PointZ,
contact.NormalX,
contact.NormalZ,
contact.Penetration,
contact.RelativeSpeed)).ToArray();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_handle.Dispose();
_disposed = true;
}
internal static void EnsureCompatibleNativeLibrary()
{
var abi = Box3DNative.ymir_box3d_get_abi_version();
if (abi != AbiVersion)
{
throw new InvalidOperationException($"Ymir Box3D ABI mismatch. Managed expects {AbiVersion}, native provides {abi}.");
}
ThrowIfFailed(
Box3DNative.ymir_box3d_get_abi_layout(
out var bodyInputSize,
out var fieldInputSize,
out var bodyOutputSize,
out var contactOutputSize,
out var contactEventOutputSize),
"read native ABI layout");
EnsureSize<Box3DBodyInput>(bodyInputSize);
EnsureSize<Box3DRadialField>(fieldInputSize);
EnsureSize<Box3DBodyOutput>(bodyOutputSize);
EnsureSize<Box3DContactOutput>(contactOutputSize);
EnsureSize<Box3DContactEventOutput>(contactEventOutputSize);
Box3DNative.ymir_box3d_get_version(out var major, out var minor, out var revision);
if (major != 0 || minor != 1 || revision != 0)
{
throw new InvalidOperationException($"Ymir requires Box3D 0.1.0, but loaded {major}.{minor}.{revision}.");
}
}
private static void EnsureSize<T>(uint nativeSize) where T : struct
{
var managedSize = checked((uint)Marshal.SizeOf<T>());
if (managedSize != nativeSize)
{
throw new InvalidOperationException(
$"Ymir Box3D ABI layout mismatch for {typeof(T).Name}. Managed expects {managedSize} bytes, native provides {nativeSize}.");
}
}
private static void ThrowIfFailed(Box3DStatus status, string operation)
{
if (status != Box3DStatus.Ok)
{
throw new InvalidOperationException($"Ymir could not {operation}: native Box3D status {status}.");
}
}
private void ThrowIfMutationFailed(Box3DStatus status, string operation)
{
if (status is Box3DStatus.OutOfMemory or Box3DStatus.InternalError)
{
Dispose();
}
ThrowIfFailed(status, operation);
}
private static Box3DBodyInput ToNative(Box3DBody body) => new(
body.StableId,
body.CollisionCategoryBits,
body.CollisionMaskBits,
body.PositionX,
body.PositionZ,
body.VelocityX,
body.VelocityZ,
body.DirectionX,
body.DirectionZ,
body.AngularVelocity,
body.Torque,
body.Radius,
body.Mass,
body.Restitution,
body.IsStatic ? 1u : 0u,
body.IsKinematic ? 1u : 0u,
body.IsBullet ? 1u : 0u,
body.ParticipatesInFields ? 1u : 0u,
body.CollisionGroupIndex);
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}