CellView convex polygon edges → clip-space planes (nx,ny,0,d) for gl_ClipDistance, ≤8 with collinear-edge merge. Multi-polygon or >8-edge regions degrade to the union AABB scissor (over-include, never hide); empty regions are distinct (draw nothing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
9.2 KiB
C#
255 lines
9.2 KiB
C#
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);
|
|
}
|
|
}
|