feat(render): Phase U.2c — ClipPlaneSet (NDC convex region → gl_ClipDistance planes)

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>
This commit is contained in:
Erik 2026-05-30 17:03:32 +02:00
parent 65781f5768
commit a83b4306f8
2 changed files with 515 additions and 0 deletions

View file

@ -0,0 +1,255 @@
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);
}
}