feat(phys): ShadowShapeBuilder.FromSetup

Pure function translating Setup -> IReadOnlyList<ShadowShape>. 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 15:12:56 +02:00
parent ab4278c272
commit 7f5c28777a
2 changed files with 278 additions and 0 deletions

View file

@ -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;
/// <summary>
/// Pure-function builder that translates a <see cref="Setup"/> into a list of
/// <see cref="ShadowShape"/>s suitable for registration via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/>.
///
/// <para>
/// 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].
/// </para>
///
/// <para>
/// Retail anchor: <c>CPhysicsObj::FindObjCollisions</c> calls
/// <c>CPartArray::FindObjCollisions</c> which iterates parts; each part's
/// <c>find_obj_collisions</c> tests CylSpheres + GfxObj BSP. We emit one
/// ShadowShape per part contribution so the existing FindObjCollisions
/// iteration loop in <see cref="Transition"/> tests each part independently.
/// </para>
/// </summary>
public static class ShadowShapeBuilder
{
/// <summary>
/// Build the shape list for a Setup.
/// </summary>
/// <param name="setup">The Setup to walk.</param>
/// <param name="entScale">The entity's overall scale factor; multiplies
/// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
var result = new List<ShadowShape>();
// 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;
}
/// <summary>Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
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;
}
}

View file

@ -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
{
/// <summary>
/// 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.
/// </summary>
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<uint, bool> 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<uint, bool> 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<ArgumentNullException>(
() => ShadowShapeBuilder.FromSetup(null!, 1.0f, _ => true));
}
}