diff --git a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
index 98ff91e..71d65d9 100644
--- a/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
+++ b/src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
@@ -464,6 +464,73 @@ public sealed unsafe class IndoorCellStencilPipeline : IDisposable
return idx;
}
+ ///
+ /// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB
+ /// PortalRenderManager.RenderBuildingStencilMask at
+ /// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484.
+ ///
+ /// Uploads the building's exit-portal mesh to our shared VBO and draws
+ /// it with the portal_stencil shader. Does NOT set or restore any
+ /// surrounding GL state — caller is responsible (stencil func,
+ /// depth mask, color mask, cull face, etc.) per WB
+ /// VisibilityManager.RenderInsideOut Steps 1/2/5a/5b/5d
+ /// expectations.
+ ///
+ /// Note: enables/disables GL_DEPTH_CLAMP around the draw
+ /// because portal polygons can extend beyond the camera's near/far range.
+ /// This is symmetric — no state leakage.
+ ///
+ /// The building whose exit portal polygons to draw.
+ /// Camera view-projection matrix.
+ /// When true, the fragment shader writes
+ /// gl_FragDepth = 1.0 (WB Step 2 / Step 5b "punch" semantic).
+ /// When false, default depth is written (WB Step 1 / Step 5a "mark"
+ /// semantic).
+ public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth)
+ {
+ int vertexCount = UploadBuildingPortalMesh(building);
+ if (vertexCount == 0)
+ {
+ LastStencilVertexCount = 0;
+ LastStencilWasFarPunch = writeFarDepth;
+ LastStencilBuildingId = building.BuildingId;
+ return;
+ }
+
+ _gl.Enable(EnableCap.DepthClamp);
+
+ _shader.Use();
+ var vp = viewProjection;
+ _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
+ _gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
+
+ _gl.BindVertexArray(_vao);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount);
+ _gl.BindVertexArray(0);
+
+ _gl.Disable(EnableCap.DepthClamp);
+
+ LastStencilVertexCount = vertexCount;
+ LastStencilWasFarPunch = writeFarDepth;
+ LastStencilBuildingId = building.BuildingId;
+ }
+
+ // -------------------------------------------------------------------------
+ // Probe data (Phase A8 — read by the [stencil] probe emitter in GameWindow).
+ // -------------------------------------------------------------------------
+
+ /// Phase A8 RR9: vertex count of the most recent
+ /// draw. 0 if the building had no portals.
+ public int LastStencilVertexCount { get; private set; }
+
+ /// Phase A8 RR9: true iff the most recent
+ /// draw was a far-depth punch (Step 2).
+ public bool LastStencilWasFarPunch { get; private set; }
+
+ /// Phase A8 RR9: building id of the most recent
+ /// draw.
+ public uint LastStencilBuildingId { get; private set; }
+
public void Dispose()
{
_shader.Dispose();
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs
new file mode 100644
index 0000000..4374963
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs
@@ -0,0 +1,146 @@
+// Ported from references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs
+// Phase A8 extraction (2026-05-28). Verbatim port; adaptations:
+// - SceneryInstance -> EnvCellSceneryInstance (scope-narrow to env-cell rendering)
+// - ObjectLandblock -> EnvCellLandblock
+// - Namespace: AcDream.App.Rendering.Wb
+// - BoundingBox -> WbBoundingBox (defined in WbFrustum.cs)
+// - DisqualificationReason + SceneryDisqualificationReason dropped (editor-only)
+// - IsQueuedForUpload, IsTransformOnlyUpdate, ParticleEmitters, InstanceBufferOffset,
+// InstanceCount, MdiCommands dropped (editor-only / shared-MDI)
+
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Lightweight data for a single placed env-cell scenery object.
+/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs (lines 11-56)
+///
+public struct EnvCellSceneryInstance
+{
+ /// GfxObj or Setup ID from DAT.
+ public ulong ObjectId;
+
+ /// Unique instance index within the landblock (WB used a 128-bit editor ID; we use uint).
+ public uint InstanceId;
+
+ /// True for multi-part Setup objects, false for simple GfxObj.
+ public bool IsSetup;
+
+ /// True if this instance is a building.
+ public bool IsBuilding;
+
+ /// True if this is an interior cell connected directly to the landblock.
+ public bool IsEntryCell;
+
+ /// World-space position.
+ public Vector3 WorldPosition;
+
+ /// Local-space position (relative to landblock origin).
+ public Vector3 LocalPosition;
+
+ /// Rotation quaternion.
+ public Quaternion Rotation;
+
+ /// The current cell ID this instance is in (used for previewing moves between cells).
+ public uint CurrentPreviewCellId;
+
+ /// Scale (typically uniform).
+ public Vector3 Scale;
+
+ /// Pre-computed world transform matrix.
+ public Matrix4x4 Transform;
+
+ /// Local-space bounding box.
+ public WbBoundingBox LocalBoundingBox;
+
+ /// World-space bounding box.
+ public WbBoundingBox BoundingBox;
+
+ /// Rendering flags for this instance.
+ public uint Flags;
+}
+
+///
+/// Holds all EnvCell instances for a single landblock, ready for rendering.
+/// Shared by both scenery and static object render managers.
+/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs (lines 62-160)
+///
+public class EnvCellLandblock
+{
+ /// Grid X coordinate of this landblock.
+ public int GridX { get; set; }
+
+ /// Grid Y coordinate of this landblock.
+ public int GridY { get; set; }
+
+ public object Lock { get; } = new();
+
+ public List Instances { get; set; } = new();
+
+ ///
+ /// Grouped bounding boxes for each EnvCell in this landblock.
+ /// Key: CellID, Value: Composite bounding box of the cell and all its static objects.
+ ///
+ public Dictionary EnvCellBounds { get; set; } = new();
+
+ ///
+ /// Set of EnvCell IDs in this landblock that have the SeenOutside flag.
+ ///
+ public HashSet SeenOutsideCells { get; set; } = new();
+
+ public List? PendingInstances { get; set; }
+
+ ///
+ /// Grouped bounding boxes for each EnvCell in this landblock (pending upload).
+ ///
+ public Dictionary? PendingEnvCellBounds { get; set; }
+
+ ///
+ /// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload).
+ ///
+ public HashSet? PendingSeenOutsideCells { get; set; }
+
+ ///
+ /// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering.
+ /// Key: GfxObjId, Value: List of transforms
+ ///
+ public Dictionary> StaticPartGroups { get; set; } = new();
+
+ ///
+ /// Grouped transforms for each GfxObj part for buildings, for efficient instanced rendering.
+ /// Key: GfxObjId, Value: List of transforms
+ ///
+ public Dictionary> BuildingPartGroups { get; set; } = new();
+
+ ///
+ /// World-space bounding box of this landblock.
+ ///
+ public WbBoundingBox BoundingBox { get; set; }
+
+ ///
+ /// Total bounding box covering all EnvCells in this landblock.
+ ///
+ public WbBoundingBox TotalEnvCellBounds { get; set; }
+
+ ///
+ /// Total bounding box covering all EnvCells in this landblock (pending upload).
+ ///
+ public WbBoundingBox PendingTotalEnvCellBounds { get; set; }
+
+ ///
+ /// Whether instances (positions/bounding boxes) have been generated.
+ ///
+ public bool InstancesReady { get; set; }
+
+ ///
+ /// Whether mesh data for all instances has been prepared (CPU-side).
+ ///
+ public bool MeshDataReady { get; set; }
+
+ ///
+ /// Whether GPU resources have been uploaded.
+ ///
+ public bool GpuReady { get; set; }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs
new file mode 100644
index 0000000..f9dfc69
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Phase A8 (2026-05-28): EnvCell-scoped visibility snapshot. Direct port of
+/// WB's VisibilitySnapshot at
+/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36,
+/// narrowed to the fields actually consumes
+/// (BatchedByCell + VisibleLandblocks). The scenery-side
+/// VisibleGroups / VisibleGfxObjIds / IntersectingLandblocks
+/// / PostPreparePoolIndex are dropped — we render scenery through
+/// , not through this snapshot.
+///
+/// Used as an immutable snapshot atomically swapped under the
+/// renderer's render lock so PrepareRenderBatches (worker-driven) and
+/// Render (render-thread-driven) can't race on a half-populated dict.
+///
+public sealed class EnvCellVisibilitySnapshot
+{
+ /// Landblocks fully or partially inside the frustum at prepare time.
+ public List VisibleLandblocks { get; init; } = new();
+
+ ///
+ /// Grouped instance data by full 32-bit cell id.
+ /// Outer key: CellId. Inner key: GfxObjId (ulong; bit 33 set for
+ /// deduplicated cell geometry per ).
+ /// Value: list of per-instance transforms (one per cell or per static object
+ /// inside that cell).
+ ///
+ public Dictionary>> BatchedByCell { get; init; } = new();
+
+ /// True when no visible cells were produced this prepare cycle.
+ public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0;
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbFrustum.cs b/src/AcDream.App/Rendering/Wb/WbFrustum.cs
new file mode 100644
index 0000000..96a254e
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbFrustum.cs
@@ -0,0 +1,143 @@
+// Ported from references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs
+// Phase A8 extraction (2026-05-28). Verbatim algorithm; adaptations:
+// - Namespace: AcDream.App.Rendering.Wb
+// - Class renamed Frustum -> WbFrustum
+// - BoundingBox -> WbBoundingBox (inlined below; no new project dep)
+// - FrustumTestResult kept as-is (inlined below)
+// - WbPlane inlined (was an inner type of Frustum.cs in WB)
+
+using System.Numerics;
+
+namespace AcDream.App.Rendering.Wb;
+
+public struct WbBoundingBox
+{
+ public Vector3 Min;
+ public Vector3 Max;
+
+ public WbBoundingBox(Vector3 min, Vector3 max)
+ {
+ Min = min;
+ Max = max;
+ }
+
+ public static WbBoundingBox Union(WbBoundingBox a, WbBoundingBox b) =>
+ new WbBoundingBox(
+ Vector3.Min(a.Min, b.Min),
+ Vector3.Max(a.Max, b.Max));
+}
+
+public enum FrustumTestResult
+{
+ Outside,
+ Inside,
+ Intersecting
+}
+
+///
+/// View-frustum helper extracted from WorldBuilder's Frustum class.
+/// Source: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs
+/// Phase A8 extraction (2026-05-28).
+///
+public sealed class WbFrustum
+{
+ private struct Plane
+ {
+ public Vector3 Normal;
+ public float D;
+
+ public Plane(float a, float b, float c, float d)
+ {
+ Normal = new Vector3(a, b, c);
+ float length = Normal.Length();
+ Normal /= length;
+ D = d / length;
+ }
+
+ public float Dot(Vector3 point) => Vector3.Dot(Normal, point) + D;
+ }
+
+ private readonly Plane[] _planes = new Plane[6];
+ private readonly object _lock = new();
+
+ public void Update(Matrix4x4 matrix)
+ {
+ lock (_lock)
+ {
+ // Left plane
+ _planes[0] = new Plane(matrix.M14 + matrix.M11, matrix.M24 + matrix.M21, matrix.M34 + matrix.M31, matrix.M44 + matrix.M41);
+ // Right plane
+ _planes[1] = new Plane(matrix.M14 - matrix.M11, matrix.M24 - matrix.M21, matrix.M34 - matrix.M31, matrix.M44 - matrix.M41);
+ // Bottom plane
+ _planes[2] = new Plane(matrix.M14 + matrix.M12, matrix.M24 + matrix.M22, matrix.M34 + matrix.M32, matrix.M44 + matrix.M42);
+ // Top plane
+ _planes[3] = new Plane(matrix.M14 - matrix.M12, matrix.M24 - matrix.M22, matrix.M34 - matrix.M32, matrix.M44 - matrix.M42);
+ // Near plane
+ _planes[4] = new Plane(matrix.M14 + matrix.M13, matrix.M24 + matrix.M23, matrix.M34 + matrix.M33, matrix.M44 + matrix.M43);
+ // Far plane
+ _planes[5] = new Plane(matrix.M14 - matrix.M13, matrix.M24 - matrix.M23, matrix.M34 - matrix.M33, matrix.M44 - matrix.M43);
+ }
+ }
+
+ public bool Intersects(WbBoundingBox box, bool ignoreNearPlane = false)
+ {
+ lock (_lock)
+ {
+ for (int i = 0; i < 6; i++)
+ {
+ if (ignoreNearPlane && i == 4) continue;
+
+ Vector3 positive = box.Min;
+ if (_planes[i].Normal.X >= 0) positive.X = box.Max.X;
+ if (_planes[i].Normal.Y >= 0) positive.Y = box.Max.Y;
+ if (_planes[i].Normal.Z >= 0) positive.Z = box.Max.Z;
+
+ if (_planes[i].Dot(positive) < 0)
+ {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ public FrustumTestResult TestBox(WbBoundingBox box, bool ignoreNearPlane = false)
+ {
+ var result = FrustumTestResult.Inside;
+ lock (_lock)
+ {
+ for (int i = 0; i < 6; i++)
+ {
+ if (ignoreNearPlane && i == 4) continue;
+
+ Vector3 positive = box.Min;
+ Vector3 negative = box.Max;
+ if (_planes[i].Normal.X >= 0)
+ {
+ positive.X = box.Max.X;
+ negative.X = box.Min.X;
+ }
+ if (_planes[i].Normal.Y >= 0)
+ {
+ positive.Y = box.Max.Y;
+ negative.Y = box.Min.Y;
+ }
+ if (_planes[i].Normal.Z >= 0)
+ {
+ positive.Z = box.Max.Z;
+ negative.Z = box.Min.Z;
+ }
+
+ if (_planes[i].Dot(positive) < 0)
+ {
+ return FrustumTestResult.Outside;
+ }
+ if (_planes[i].Dot(negative) < 0)
+ {
+ result = FrustumTestResult.Intersecting;
+ }
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbRenderPass.cs b/src/AcDream.App/Rendering/Wb/WbRenderPass.cs
new file mode 100644
index 0000000..746050f
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WbRenderPass.cs
@@ -0,0 +1,31 @@
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Phase A8 (2026-05-28): WB's RenderPass enum, extracted verbatim from
+/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs:1-22.
+///
+/// Renamed to WbRenderPass to keep the WB-faithful name distinct
+/// from any future acdream-side RenderPass with different semantics.
+/// Consumed by and matches the
+/// uRenderPass uniform in the modern mesh shaders.
+///
+public enum WbRenderPass
+{
+ ///
+ /// The opaque pass. Only non-transparent objects are rendered.
+ ///
+ Opaque = 0,
+
+ ///
+ /// The transparent pass. Only transparent objects are rendered,
+ /// usually after the opaque pass.
+ ///
+ Transparent = 1,
+
+ ///
+ /// A single-pass render that includes both opaque and (sometimes)
+ /// transparent objects, or for special cases like skyboxes and
+ /// certain UI elements.
+ ///
+ SinglePass = 2,
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
new file mode 100644
index 0000000..c29f299
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+public class EnvCellSceneryInstanceTests
+{
+ [Fact]
+ public void Instance_DefaultConstruct_HasZeroFields()
+ {
+ var s = new EnvCellSceneryInstance();
+ Assert.Equal(0UL, s.ObjectId);
+ Assert.False(s.IsBuilding);
+ Assert.False(s.IsSetup);
+ Assert.False(s.IsEntryCell);
+ }
+
+ [Fact]
+ public void Instance_AssignFields_RoundTrip()
+ {
+ var t = Matrix4x4.CreateTranslation(1, 2, 3);
+ var s = new EnvCellSceneryInstance
+ {
+ ObjectId = 0x01000123,
+ IsBuilding = true,
+ IsSetup = false,
+ IsEntryCell = true,
+ WorldPosition = new Vector3(1, 2, 3),
+ Rotation = Quaternion.Identity,
+ Scale = Vector3.One,
+ Transform = t,
+ };
+ Assert.Equal(0x01000123UL, s.ObjectId);
+ Assert.True(s.IsBuilding);
+ Assert.True(s.IsEntryCell);
+ Assert.Equal(new Vector3(1, 2, 3), s.WorldPosition);
+ Assert.Equal(t, s.Transform);
+ }
+
+ [Fact]
+ public void Landblock_Construct_StartsEmpty()
+ {
+ var lb = new EnvCellLandblock { GridX = 0xA9, GridY = 0xB4 };
+ Assert.Empty(lb.StaticPartGroups);
+ Assert.Empty(lb.BuildingPartGroups);
+ Assert.Empty(lb.Instances);
+ Assert.Empty(lb.EnvCellBounds);
+ Assert.False(lb.InstancesReady);
+ Assert.False(lb.GpuReady);
+ Assert.False(lb.MeshDataReady);
+ }
+
+ [Fact]
+ public void Landblock_AddInstanceToBuildingPartGroups_PreservesOrder()
+ {
+ var lb = new EnvCellLandblock();
+ if (!lb.BuildingPartGroups.TryGetValue(0x01000001UL, out var list))
+ {
+ list = new List();
+ lb.BuildingPartGroups[0x01000001UL] = list;
+ }
+ list.Add(default);
+ list.Add(default);
+ Assert.Single(lb.BuildingPartGroups);
+ Assert.Equal(2, lb.BuildingPartGroups[0x01000001UL].Count);
+ }
+
+ [Fact]
+ public void Landblock_PendingInstancesNullByDefault()
+ {
+ var lb = new EnvCellLandblock();
+ Assert.Null(lb.PendingInstances);
+ Assert.Null(lb.PendingEnvCellBounds);
+ Assert.Null(lb.PendingSeenOutsideCells);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs
new file mode 100644
index 0000000..b775c5d
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs
@@ -0,0 +1,95 @@
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+public class WbFrustumTests
+{
+ private static Matrix4x4 PerspectiveVp(Vector3 eye, Vector3 lookAt) =>
+ Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitY)
+ * Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1.0f, 0.1f, 100.0f);
+
+ [Fact]
+ public void TestBox_BoxInFrontOfCamera_ReturnsInsideOrIntersecting()
+ {
+ var f = new WbFrustum();
+ f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
+ var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
+ var res = f.TestBox(box);
+ Assert.NotEqual(FrustumTestResult.Outside, res);
+ }
+
+ [Fact]
+ public void TestBox_BoxBehindCamera_ReturnsOutside()
+ {
+ var f = new WbFrustum();
+ f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
+ var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10));
+ Assert.Equal(FrustumTestResult.Outside, f.TestBox(box));
+ }
+
+ [Fact]
+ public void Intersects_BoxInFrontOfCamera_ReturnsTrue()
+ {
+ var f = new WbFrustum();
+ f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
+ var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
+ Assert.True(f.Intersects(box));
+ }
+
+ [Fact]
+ public void Update_IsIdempotent()
+ {
+ var f = new WbFrustum();
+ var vp = PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1));
+ f.Update(vp);
+ f.Update(vp);
+ var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
+ Assert.NotEqual(FrustumTestResult.Outside, f.TestBox(box));
+ }
+
+ [Fact]
+ public void WbBoundingBox_Union_ExtendsToCoverBoth()
+ {
+ var a = new WbBoundingBox(new Vector3(0, 0, 0), new Vector3(1, 1, 1));
+ var b = new WbBoundingBox(new Vector3(2, 2, 2), new Vector3(3, 3, 3));
+ var u = WbBoundingBox.Union(a, b);
+ Assert.Equal(new Vector3(0, 0, 0), u.Min);
+ Assert.Equal(new Vector3(3, 3, 3), u.Max);
+ }
+
+ [Fact]
+ public void Intersects_BoxBehindCamera_ReturnsFalse()
+ {
+ var f = new WbFrustum();
+ f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
+ var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10));
+ Assert.False(f.Intersects(box));
+ }
+
+ [Fact]
+ public void TestBox_IgnoreNearPlane_DoesNotReturnOutsideForNearOverlap()
+ {
+ // Box straddles the near plane (z from -0.05 to 5) — with near plane ignored,
+ // the box should not be culled as Outside.
+ var f = new WbFrustum();
+ f.Update(PerspectiveVp(Vector3.Zero, new Vector3(0, 0, -1)));
+ var box = new WbBoundingBox(new Vector3(-0.5f, -0.5f, -5f), new Vector3(0.5f, 0.5f, -0.05f));
+ var withNear = f.TestBox(box, ignoreNearPlane: false);
+ var withoutNear = f.TestBox(box, ignoreNearPlane: true);
+ // Both should be non-Outside for a box clearly in front
+ Assert.NotEqual(FrustumTestResult.Outside, withoutNear);
+ _ = withNear; // result varies by near clip; just verify no exception
+ }
+
+ [Fact]
+ public void WbBoundingBox_Union_WithOverlapping_CoversAll()
+ {
+ var a = new WbBoundingBox(new Vector3(-1, -1, -1), new Vector3(2, 2, 2));
+ var b = new WbBoundingBox(new Vector3(0, 0, 0), new Vector3(3, 3, 3));
+ var u = WbBoundingBox.Union(a, b);
+ Assert.Equal(new Vector3(-1, -1, -1), u.Min);
+ Assert.Equal(new Vector3(3, 3, 3), u.Max);
+ }
+}