The1ca412dpart-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 case1ca412dcould 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>
137 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|