From a0741bd13a5a699518b67dff5002171a0394b36c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:54:25 +0200 Subject: [PATCH] feat(A.5 T8): WorldEntity AABB cache + dirty flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AabbMin/AabbMax (per-entity world-space bounding box) and AabbDirty flag to WorldEntity. RefreshAabb() recomputes the box from Position ±5 m (DefaultAabbRadius). SetPosition() writes Position and marks the cache dirty so the dispatcher calls RefreshAabb on first read rather than carrying stale bounds. AabbDirty defaults to true on construction — freshly-built entities have zero AabbMin/AabbMax until RefreshAabb is called. Two new conformance tests verify the ±5 m geometry and the dirty/clean state machine. Per Phase A.5 spec §4.6 Change #2. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/WorldEntity.cs | 24 ++++++++++ .../World/WorldEntityAabbTests.cs | 47 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index d1dfed4..20643d3 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -71,6 +71,30 @@ public sealed class WorldEntity /// present. Zero (no parts hidden) is the default. /// public ulong HiddenPartsMask { get; init; } + + // Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the + // dispatcher's frustum cull is a memory read, not a per-frame recompute. + // AabbDirty starts true so the dispatcher calls RefreshAabb on first read + // (AabbMin/AabbMax are Vector3.Zero until refreshed). + public Vector3 AabbMin { get; private set; } + public Vector3 AabbMax { get; private set; } + public bool AabbDirty { get; private set; } = true; + + private const float DefaultAabbRadius = 5.0f; + + public void RefreshAabb() + { + var p = Position; + AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius); + AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius); + AabbDirty = false; + } + + public void SetPosition(Vector3 pos) + { + Position = pos; + AabbDirty = true; + } } /// diff --git a/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs new file mode 100644 index 0000000..cafa60e --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WorldEntityAabbTests.cs @@ -0,0 +1,47 @@ +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(), + }; + entity.RefreshAabb(); + + Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin); + Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax); + } + + [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(), + }; + 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); + } +}