acdream/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs
Erik 6a9b529113 #119: entity bounds from dat vertex data - works for every case, not just multi-part
The 1ca412d part-offset expansion fixed the staircase but still rested
on the 5 m promise one level down: a SINGLE part whose mesh extends
more than 5 m from its own origin (offset 0 -> box +-5 m) keeps the
gaze-dependent vanish. Per the user's mandate ("it must work for every
case"), the bound now derives from the dat VERTEX data - the same
vertices that get drawn - so no synthetic containment promise remains.

Oracle context (read this session): retail has NO whole-entity
visibility volume - CPhysicsPart::Draw (0x0050d7a0) viewcone-checks
each part's dat-authored CGfxObj.drawing_sphere at the part's own
world position (RenderDeviceD3D::DrawMesh 0x005a0860). Retail's bound
IS data; ours was a promise. Our per-ENTITY granularity stays (a
deliberate batching-era choice, WB-owned per the inventory) but the
volume is now data-derived and conservative: visually identical by
construction, never culls what retail would draw.

- GfxObjBounds: per-GfxObj vertex AABB, cached by id (parts repeat
  heavily); LocalBoundsAccumulator: union of part-transformed AABB
  corners (conservative-correct under any affine transform).
- WorldEntity.SetLocalBounds + RefreshAabb preferred path: rotate the
  root-local bounds' 8 corners into world axes + DefaultAabbRadius
  margin (absorbs animated-pose drift vs the rest-pose bounds; keeps
  small objects at their historical box size). Offset heuristic stays
  as the fallback for boundless fixtures.
- All four hydration sites wired (outdoor stabs, scenery incl. baked
  scale, interior cell statics, server live spawns).

Tests: tall-single-part coverage (the case 1ca412d could not see),
rotation-following, accumulator union. Suites: App 246+1skip / Core
1434+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:39:05 +02:00

137 lines
5.3 KiB
C#

using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public class WorldEntityAabbTests
{
[Fact]
public void Aabb_DefaultRadius_PositionPlusMinus5()
{
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0,
Position = new Vector3(10, 20, 30),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
entity.RefreshAabb();
Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin);
Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax);
}
[Fact]
public void Aabb_MultiPartOffsets_CoverTheParts()
{
// #119 follow-up: the AAB3 tower staircase — 43 parts spiralling to
// (3, 3, 15.15) root-relative. The box (and the viewcone sphere derived
// from it) must cover the parts, not just the ±5 m anchor neighborhood;
// the anchor-only box made the staircase vanish whenever the gaze left
// the base (visible climbing down, gone climbing up).
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0x020003F2,
Position = new Vector3(300, -132, 112),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[]
{
new MeshRef(0x01000E2A, Matrix4x4.CreateTranslation(0f, 3f, 1.55f)),
new MeshRef(0x01000E2D, Matrix4x4.CreateTranslation(-1.75f, 3f, 15.15f)),
},
};
entity.RefreshAabb();
// Top part sits at z = 112 + 15.15 = 127.15 — must be inside the box.
Assert.True(entity.AabbMax.Z >= 127.15f,
$"box top {entity.AabbMax.Z} must cover the highest part (z=127.15)");
// Conservative symmetric expansion still contains the old ±5 box.
Assert.True(entity.AabbMin.Z <= 107f && entity.AabbMax.X >= 305f);
}
[Fact]
public void Aabb_LocalBounds_TallSinglePart_Covered()
{
// The every-case fix (#119 follow-up 2): a SINGLE part at identity transform
// whose MESH is 12 m tall. The part-offset heuristic cannot see this (offset 0
// -> box ±5 m, mesh sticks 7 m out); vertex-derived local bounds must cover it.
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0x01000001,
Position = new Vector3(100, 100, 50),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) },
};
entity.SetLocalBounds(new Vector3(-1, -1, 0), new Vector3(1, 1, 12));
entity.RefreshAabb();
// Mesh top = position.Z (50) + local 12 = 62; the old ±5 anchor box topped out at 55.
Assert.True(entity.AabbMax.Z >= 62f, $"box top {entity.AabbMax.Z} must cover mesh top (z=62)");
Assert.True(entity.AabbMin.Z <= 50f);
}
[Fact]
public void Aabb_LocalBounds_FollowRotation()
{
// A mesh extending 12 m along +Y, entity rotated 90° about Z -> the extent
// now points along -X (or +X depending on sign); the world box must follow.
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0x01000001,
Position = Vector3.Zero,
Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, System.MathF.PI / 2f),
MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) },
};
entity.SetLocalBounds(new Vector3(-1, 0, -1), new Vector3(1, 12, 1));
entity.RefreshAabb();
// Rotating +Y by +90° about Z lands on -X: the box must extend ≥12 m on X
// (one side), and no longer require 12 m on Y.
bool coversX = entity.AabbMin.X <= -12f || entity.AabbMax.X >= 12f;
Assert.True(coversX, $"rotated extent must show on X axis (box X [{entity.AabbMin.X},{entity.AabbMax.X}])");
Assert.True(entity.AabbMax.Y < 12f, "the unrotated +Y extent must not persist after rotation");
}
[Fact]
public void Aabb_LocalBoundsAccumulator_UnionsTransformedParts()
{
var acc = new AcDream.Core.Meshing.LocalBoundsAccumulator();
Assert.False(acc.TryGet(out _, out _));
// Part 1: unit cube at origin. Part 2: unit cube translated to z=15.
acc.Add(Matrix4x4.Identity, (new Vector3(-0.5f), new Vector3(0.5f)));
acc.Add(Matrix4x4.CreateTranslation(3, 3, 15), (new Vector3(-0.5f), new Vector3(0.5f)));
Assert.True(acc.TryGet(out var min, out var max));
Assert.Equal(-0.5f, min.Z, 3);
Assert.Equal(15.5f, max.Z, 3);
Assert.Equal(3.5f, max.X, 3);
}
[Fact]
public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh()
{
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0,
Position = new Vector3(10, 20, 30),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
entity.RefreshAabb();
Assert.False(entity.AabbDirty);
entity.SetPosition(new Vector3(100, 200, 300));
Assert.True(entity.AabbDirty);
entity.RefreshAabb();
Assert.False(entity.AabbDirty);
Assert.Equal(new Vector3(95, 195, 295), entity.AabbMin);
}
}