From 7f5c28777a8ddf4dc575b44c9a37ea5d5694711c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 May 2026 15:12:56 +0200 Subject: [PATCH] feat(phys): ShadowShapeBuilder.FromSetup Pure function translating Setup -> IReadOnlyList. Walks CylSpheres + Spheres (only when no CylSpheres) + Parts (only when the GfxObj has a non-null PhysicsBSP), using PlacementFrames in the same Resting -> Default -> first-available priority as SetupMesh.Flatten. Six tests pin the behavior: door setup produces 4 shapes (0+1+3), sphere local offset matches Setup data, parts without BSP are skipped, creature setups with CylSpheres skip Spheres, scale factor multiplies all radii and offsets, empty setup returns empty list, null setup throws. No callers in this commit; RegisterMultiPart + the GameWindow callers follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/ShadowShapeBuilder.cs | 121 ++++++++++++++ .../Physics/ShadowShapeBuilderTests.cs | 157 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/AcDream.Core/Physics/ShadowShapeBuilder.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs 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)); + } +}