diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs new file mode 100644 index 0000000..4fff410 --- /dev/null +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics; + +/// +/// Pure-function builder that translates a into a list of +/// s suitable for registration via +/// . +/// +/// +/// Walks (1) every CylSphere → Cylinder shape, (2) every Sphere ONLY when no +/// CylSpheres are present (matches retail and the existing landblock-static +/// convention at GameWindow.cs:6034), and (3) every Part whose GfxObj has a +/// non-null PhysicsBSP → per-part BSP shape, with local transforms from +/// PlacementFrames[Resting | Default | first available]. +/// +/// +/// +/// Retail anchor: CPhysicsObj::FindObjCollisions calls +/// CPartArray::FindObjCollisions which iterates parts; each part's +/// find_obj_collisions tests CylSpheres + GfxObj BSP. We emit one +/// ShadowShape per part contribution so the existing FindObjCollisions +/// iteration loop in tests each part independently. +/// +/// +public static class ShadowShapeBuilder +{ + /// + /// Build the shape list for a Setup. + /// + /// The Setup to walk. + /// The entity's overall scale factor; multiplies + /// every radius, height, and local offset. + /// Predicate: does the GfxObj with this id + /// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null. + public static IReadOnlyList FromSetup( + Setup setup, + float entScale, + Func hasPhysicsBsp) + { + if (setup is null) throw new ArgumentNullException(nameof(setup)); + if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); + + var result = new List(); + + // 1. CylSpheres — each becomes a Cylinder shape. + foreach (var cyl in setup.CylSpheres) + { + if (cyl.Radius <= 0f) continue; + float baseHeight = cyl.Height > 0f ? cyl.Height : cyl.Radius * 4f; + result.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: new Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale, + LocalRotation: Quaternion.Identity, + Scale: entScale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: cyl.Radius * entScale, + CylHeight: baseHeight * entScale)); + } + + // 2. Spheres — only when no CylSpheres (matches landblock-static convention + // at GameWindow.cs:6034). Each becomes a short Cylinder. + if (setup.CylSpheres.Count == 0) + { + foreach (var sph in setup.Spheres) + { + if (sph.Radius <= 0f) continue; + result.Add(new ShadowShape( + GfxObjId: 0u, + LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, + LocalRotation: Quaternion.Identity, + Scale: entScale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: sph.Radius * entScale, + CylHeight: sph.Radius * 2f * entScale)); + } + } + + // 3. Parts — one BSP shape per part with a non-null PhysicsBSP. + AnimationFrame? placementFrame = ResolvePlacementFrame(setup); + for (int i = 0; i < setup.Parts.Count; i++) + { + uint gfxId = (uint)setup.Parts[i]; + if (!hasPhysicsBsp(gfxId)) continue; + + Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count + ? placementFrame.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + + // BSP radius default; caller substitutes the real BoundingSphere.Radius + // at registration time when available. Loose-but-safe broadphase value. + float bspRadius = 2f * entScale; + + result.Add(new ShadowShape( + GfxObjId: gfxId, + LocalPosition: new Vector3(partFrame.Origin.X, partFrame.Origin.Y, partFrame.Origin.Z) * entScale, + LocalRotation: partFrame.Orientation, + Scale: entScale, + CollisionType: ShadowCollisionType.BSP, + Radius: bspRadius, + CylHeight: 0f)); + } + + return result; + } + + /// Resolve the placement frame in priority Resting → Default → + /// first available. Mirrors SetupMesh.Flatten's convention. + private static AnimationFrame? ResolvePlacementFrame(Setup setup) + { + if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting)) return resting; + if (setup.PlacementFrames.TryGetValue(Placement.Default, out var def)) return def; + foreach (var kvp in setup.PlacementFrames) return kvp.Value; + return null; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs new file mode 100644 index 0000000..b167935 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class ShadowShapeBuilderTests +{ + /// + /// Synthetic Setup mirroring live dump of 0x020019FF (cottage door) + /// captured 2026-05-24: 0 cylSpheres, 1 sphere (r=0.100, origin=(0,0,0.018)), + /// 3 parts (0x010044B5 + 0x010044B6 + 0x010044B6), setup.Radius=0.141, + /// setup.Height=0.200. PlacementFrames[Default] has identity transforms + /// for all 3 parts. + /// + private static Setup CreateDoorSetup() + { + var setup = new Setup + { + Radius = 0.141f, + Height = 0.200f, + StepUpHeight = 0.090f, + StepDownHeight = 0.090f, + Parts = { 0x010044B5u, 0x010044B6u, 0x010044B6u }, + Spheres = + { + new Sphere { Radius = 0.100f, Origin = new Vector3(0f, 0f, 0.018f) } + }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(3) + { + Frames = + { + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity } + } + } + } + }; + return setup; + } + + [Fact] + public void FromSetup_DoorSetup_ProducesFourShapes() + { + var setup = CreateDoorSetup(); + Func hasBsp = id => id == 0x010044B5u || id == 0x010044B6u; + + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1.0f, hasBsp); + + Assert.Equal(4, shapes.Count); + + int cylinderCount = 0; + int bspCount = 0; + foreach (var s in shapes) + { + if (s.CollisionType == ShadowCollisionType.Cylinder) cylinderCount++; + else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; + } + Assert.Equal(1, cylinderCount); + Assert.Equal(3, bspCount); + } + + [Fact] + public void FromSetup_DoorSetup_SphereAtExpectedLocalOffset() + { + var setup = CreateDoorSetup(); + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); + + var sphereAsCyl = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Cylinder); + Assert.NotEqual(default, sphereAsCyl); + Assert.Equal(0f, sphereAsCyl.LocalPosition.X, 4); + Assert.Equal(0f, sphereAsCyl.LocalPosition.Y, 4); + Assert.Equal(0.018f, sphereAsCyl.LocalPosition.Z, 4); + Assert.Equal(0.100f, sphereAsCyl.Radius, 4); + } + + [Fact] + public void FromSetup_PartWithoutBsp_SkipsBspShape() + { + var setup = CreateDoorSetup(); + Func hasBsp = id => id == 0x010044B5u; + + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, hasBsp); + + int bspCount = 0; + foreach (var s in shapes) + if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; + Assert.Equal(1, bspCount); + } + + [Fact] + public void FromSetup_CreatureWithCylSpheres_OnlyEmitsCylinders() + { + var setup = new Setup + { + Parts = { 0x02000001u }, + CylSpheres = + { + new CylSphere { Radius = 0.40f, Height = 1.20f, Origin = new Vector3(0, 0, 0.6f) } + }, + Spheres = + { + new Sphere { Radius = 0.50f, Origin = new Vector3(0, 0, 0.7f) } + } + }; + + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => false); + + Assert.Single(shapes); + Assert.Equal(ShadowCollisionType.Cylinder, shapes[0].CollisionType); + Assert.Equal(0.40f, shapes[0].Radius, 3); + Assert.Equal(1.20f, shapes[0].CylHeight, 3); + } + + [Fact] + public void FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets() + { + var setup = CreateDoorSetup(); + + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 2.0f, _ => true); + + foreach (var s in shapes) + { + Assert.Equal(2.0f, s.Scale, 3); + if (s.CollisionType == ShadowCollisionType.Cylinder) + { + Assert.Equal(0.200f, s.Radius, 3); + Assert.Equal(0.036f, s.LocalPosition.Z, 3); + } + } + } + + [Fact] + public void FromSetup_EmptySetup_ReturnsEmptyList() + { + var setup = new Setup(); + + var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); + + Assert.Empty(shapes); + } + + [Fact] + public void FromSetup_NullSetup_Throws() + { + Assert.Throws( + () => ShadowShapeBuilder.FromSetup(null!, 1.0f, _ => true)); + } +}