using System; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class ClipPlaneSetTests { // A CellView holding one regular n-gon centred at the origin, wound CCW. private static CellView RegularNgonCellView(int n, float radius) { var verts = new Vector2[n]; for (int i = 0; i < n; i++) { // CCW: increasing angle. float a = MathF.Tau * i / n; verts[i] = new Vector2(radius * MathF.Cos(a), radius * MathF.Sin(a)); } var cv = new CellView(); cv.Add(new ViewPolygon(verts)); return cv; } private static CellView SquareCellView(float min, float max) { var cv = new CellView(); cv.Add(new ViewPolygon(new[] { new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max), })); return cv; } // --- The three required tests (verbatim intent from the plan) ------------- [Fact] public void From_AxisAlignedSquare_FourPlanes_PointInsideHasPositiveDistances() { var sq = new CellView(); sq.Add(new ViewPolygon(new[] { new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f), })); var cps = ClipPlaneSet.From(sq); Assert.Equal(4, cps.Count); var clip = new Vector4(0, 0, 0, 1); // NDC (0,0), inside foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, clip) >= 0); var outClip = new Vector4(0.9f, 0, 0, 1); // NDC (0.9,0), outside the square Assert.Contains(cps.Planes, p => Vector4.Dot(p, outClip) < 0); } [Fact] public void From_NineEdgePolygon_FallsBackToScissor() { var poly = RegularNgonCellView(n: 9, radius: 0.6f); var cps = ClipPlaneSet.From(poly); Assert.True(cps.UseScissorFallback || cps.Count <= 8); if (cps.UseScissorFallback) { Assert.Equal(0, cps.Count); // AABB carries the gate } } [Fact] public void From_EmptyRegion_IsEmpty() { Assert.Equal(0, ClipPlaneSet.From(new CellView()).Count); } // --- Multi-polygon safety (the under-inclusion guard) --------------------- [Fact] public void From_MultiplePolygons_FallsBackToUnionScissor_NeverEmitsOnePolygonsPlanes() { // Two disjoint squares: a CONVEX plane set can never represent their union. var cv = new CellView(); cv.Add(new ViewPolygon(new[] { new Vector2(-0.8f, -0.8f), new Vector2(-0.4f, -0.8f), new Vector2(-0.4f, -0.4f), new Vector2(-0.8f, -0.4f), })); cv.Add(new ViewPolygon(new[] { new Vector2(0.4f, 0.4f), new Vector2(0.8f, 0.4f), new Vector2(0.8f, 0.8f), new Vector2(0.4f, 0.8f), })); var cps = ClipPlaneSet.From(cv); // MUST NOT emit a single polygon's convex planes (that would hide the other). Assert.True(cps.UseScissorFallback); Assert.Equal(0, cps.Count); Assert.Empty(cps.Planes); // Scissor AABB is the UNION of both polygons (over-include, safe). Assert.Equal(-0.8f, cps.ScissorNdcAabb.X, 5); // minX Assert.Equal(-0.8f, cps.ScissorNdcAabb.Y, 5); // minY Assert.Equal(0.8f, cps.ScissorNdcAabb.Z, 5); // maxX Assert.Equal(0.8f, cps.ScissorNdcAabb.W, 5); // maxY // The far corner of the second square is inside the union AABB → not hidden. Assert.True(0.7f >= cps.ScissorNdcAabb.X && 0.7f <= cps.ScissorNdcAabb.Z); Assert.True(0.7f >= cps.ScissorNdcAabb.Y && 0.7f <= cps.ScissorNdcAabb.W); } // --- Distinguishing the three Count==0 states ---------------------------- [Fact] public void Empty_IsNothingVisible_NotScissorFallback() { var e = ClipPlaneSet.From(new CellView()); Assert.Equal(0, e.Count); Assert.False(e.UseScissorFallback); // NOT "draw the AABB box" Assert.True(e.IsNothingVisible); // "draw nothing" Assert.Empty(e.Planes); } [Fact] public void Empty_StaticProperty_DrawsNothing() { var e = ClipPlaneSet.Empty; Assert.Equal(0, e.Count); Assert.False(e.UseScissorFallback); Assert.True(e.IsNothingVisible); // Degenerate AABB (min > max) so a consumer that naively scissors on it draws nothing. Assert.True(e.ScissorNdcAabb.X > e.ScissorNdcAabb.Z); Assert.True(e.ScissorNdcAabb.Y > e.ScissorNdcAabb.W); } [Fact] public void ScissorFallback_IsNotNothingVisible() { var poly = RegularNgonCellView(n: 9, radius: 0.6f); var cps = ClipPlaneSet.From(poly); Assert.True(cps.UseScissorFallback); Assert.False(cps.IsNothingVisible); // "draw the AABB box", not "draw nothing" // The fallback AABB bounds the 9-gon (radius 0.6). Assert.True(cps.ScissorNdcAabb.Z - cps.ScissorNdcAabb.X > 0.5f); Assert.True(cps.ScissorNdcAabb.W - cps.ScissorNdcAabb.Y > 0.5f); } // --- Plane-sign correctness for every edge ------------------------------- [Fact] public void From_Square_EveryEdgePlane_IsPositiveAtCenter_NegativeJustOutsideThatEdge() { var cps = ClipPlaneSet.From(SquareCellView(-0.5f, 0.5f)); Assert.Equal(4, cps.Count); var center = new Vector4(0, 0, 0, 1); // Inside ⇒ EVERY plane non-negative. foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, center) >= 0, $"plane {p} should be >=0 at center"); // For each of the four cardinal directions just outside the square, at least one // plane goes negative (the edge facing that direction). foreach (var pt in new[] { new Vector4(0.6f, 0, 0, 1), new Vector4(-0.6f, 0, 0, 1), new Vector4(0, 0.6f, 0, 1), new Vector4(0, -0.6f, 0, 1), }) { Assert.Contains(cps.Planes, p => Vector4.Dot(p, pt) < 0); } } [Fact] public void From_Triangle_ThreePlanes_AllZeroZComponent() { var cv = new CellView(); cv.Add(new ViewPolygon(new[] { new Vector2(0f, 0.5f), new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f), // CCW })); var cps = ClipPlaneSet.From(cv); Assert.Equal(3, cps.Count); foreach (var p in cps.Planes) { Assert.Equal(0f, p.Z, 6); // clip-space planes are (nx, ny, 0, d) // Normal is unit length in xy. Assert.Equal(1f, MathF.Sqrt(p.X * p.X + p.Y * p.Y), 4); } } // --- Winding normalization: a CW square must still yield inside-positive planes. [Fact] public void From_ClockwiseSquare_NormalizesWinding_InsideStillPositive() { var cv = new CellView(); // Same square but wound CW (reverse order). Builder normally EnsureCcw's, // but From must be robust to either winding. cv.Add(new ViewPolygon(new[] { new Vector2(-0.5f, -0.5f), new Vector2(-0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, -0.5f), })); var cps = ClipPlaneSet.From(cv); Assert.Equal(4, cps.Count); var center = new Vector4(0, 0, 0, 1); foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, center) >= 0, $"plane {p} must be >=0 at center even for CW input"); } // --- Collinear-edge merge: a square with a midpoint added on one edge → 4 planes. [Fact] public void From_SquareWithCollinearMidpoint_MergesToFourPlanes() { var cv = new CellView(); cv.Add(new ViewPolygon(new[] { new Vector2(-0.5f, -0.5f), new Vector2(0f, -0.5f), // collinear midpoint on the bottom edge new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f), })); var cps = ClipPlaneSet.From(cv); Assert.Equal(4, cps.Count); // the redundant collinear edge is merged away } // --- Exactly 8 edges fit (octagon); 9 spills to scissor (after merge fails to help). [Fact] public void From_RegularOctagon_FitsInEightPlanes() { var cps = ClipPlaneSet.From(RegularNgonCellView(n: 8, radius: 0.6f)); Assert.False(cps.UseScissorFallback); Assert.Equal(8, cps.Count); var center = new Vector4(0, 0, 0, 1); foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, center) >= 0); } // --- Degenerate single polygon (all collinear after merge) → nothing convex → empty. [Fact] public void From_DegenerateSinglePolygon_IsNothingVisible() { // A ViewPolygon needs >=3 verts to be added, but they can be (nearly) collinear, // leaving <3 distinct edges after merge → no convex region → treat as nothing visible. var cv = new CellView(); cv.Add(new ViewPolygon(new[] { new Vector2(-0.5f, -0.5f), new Vector2(0f, 0f), new Vector2(0.5f, 0.5f), })); var cps = ClipPlaneSet.From(cv); Assert.Equal(0, cps.Count); // Degenerate sliver carries no area → safest is "nothing visible" (over-include would // need a real AABB; a zero-area line has none). Assert.True(cps.IsNothingVisible); } }