#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>
This commit is contained in:
Erik 2026-06-11 22:39:05 +02:00
parent 1ca412d07b
commit 6a9b529113
4 changed files with 248 additions and 13 deletions

View file

@ -52,6 +52,67 @@ public class WorldEntityAabbTests
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()
{