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:
parent
65781f5768
commit
a83b4306f8
2 changed files with 515 additions and 0 deletions
260
src/AcDream.App/Rendering/ClipPlaneSet.cs
Normal file
260
src/AcDream.App/Rendering/ClipPlaneSet.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
// ClipPlaneSet.cs
|
||||
//
|
||||
// Phase U.2c: turn a CellView (a cell's accumulated screen-space clip region,
|
||||
// in NDC) into a small set of clip-space half-space planes for the GPU's
|
||||
// gl_ClipDistance, OR a scissor AABB when the region can't be expressed as one
|
||||
// convex plane set.
|
||||
//
|
||||
// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and
|
||||
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e).
|
||||
// Pure System.Numerics math; NO GL. The shader consumes each plane as
|
||||
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep)
|
||||
// where (nx, ny, dw) = the plane's (normal.xy, offset). z is always 0 because a
|
||||
// screen-space (NDC) edge is a vertical slab in clip space — independent of depth.
|
||||
//
|
||||
// === The convexity rule (read before touching this file) =====================
|
||||
// gl_ClipDistance planes are a CONJUNCTION of half-spaces, i.e. exactly ONE
|
||||
// convex region (their intersection). A CellView with MORE THAN ONE polygon is a
|
||||
// UNION of convex regions, which is in general NOT convex and CANNOT be
|
||||
// represented by one plane set. Emitting just the first/largest polygon's planes
|
||||
// would clip away the others → a real visibility bug (under-inclusion).
|
||||
//
|
||||
// Therefore From() NEVER emits a single polygon's planes when the CellView holds
|
||||
// several. Multi-polygon (and >8-edge) regions degrade to the UNION AABB scissor:
|
||||
// the scissor is a superset of the true region, so it OVER-includes (draws a few
|
||||
// extra pixels) but never hides anything. Over-inclusion is safe; under-inclusion
|
||||
// is the bug class.
|
||||
//
|
||||
// === The three Count==0 states (how a consumer tells them apart) =============
|
||||
// Count == 0 can mean three different things; the consumer MUST distinguish:
|
||||
// (a) Empty — IsNothingVisible == true, UseScissorFallback == false.
|
||||
// The cell/region isn't visible at all → DRAW NOTHING. The
|
||||
// ScissorNdcAabb is a degenerate inverted box (min > max) so that a
|
||||
// consumer which naively scissors on it still draws nothing.
|
||||
// (b) Scissor — UseScissorFallback == true, IsNothingVisible == false.
|
||||
// The convex-plane budget was exceeded (multi-polygon or >8 edges)
|
||||
// → DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle).
|
||||
// There is no third Count==0 state produced by From(). (A separate "no-clip,
|
||||
// pass-all" slot 0 is constructed by the consumer directly, not via From().)
|
||||
// When Count > 0, Planes carries the convex gate and the scissor fields are unused.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a
|
||||
/// scissor AABB fallback. See the file header for the convexity rule and the three
|
||||
/// Count==0 states.
|
||||
/// </summary>
|
||||
public readonly struct ClipPlaneSet
|
||||
{
|
||||
// Max simultaneous hardware clip planes we target (GL guarantees >= 8).
|
||||
private const int MaxPlanes = 8;
|
||||
|
||||
// Collinear-edge merge threshold. Two consecutive edge directions are treated as
|
||||
// the same edge when the turn between them is below ~0.5° (retail copy_view does a
|
||||
// ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265.
|
||||
private const float CollinearSinEps = 0.0087265f;
|
||||
|
||||
// Drop a vertex whose two incident edges are shorter than this (NDC) — a duplicate
|
||||
// or near-duplicate point that would otherwise yield a garbage normalized normal.
|
||||
private const float DegenerateEdgeLen = 1e-6f;
|
||||
|
||||
// A polygon whose absolute signed area (full area, not 2x) falls below this is a line
|
||||
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
|
||||
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
|
||||
private const float MinPolygonArea = 1e-7f;
|
||||
|
||||
private readonly Vector4[] _planes;
|
||||
|
||||
private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb)
|
||||
{
|
||||
_planes = planes ?? Array.Empty<Vector4>();
|
||||
UseScissorFallback = useScissorFallback;
|
||||
IsNothingVisible = isNothingVisible;
|
||||
ScissorNdcAabb = scissorNdcAabb;
|
||||
}
|
||||
|
||||
/// <summary>Number of active clip planes, 0..8. 0 ⇒ inspect <see cref="UseScissorFallback"/>
|
||||
/// and <see cref="IsNothingVisible"/> to decide between "draw the AABB" and "draw nothing".</summary>
|
||||
public int Count => _planes?.Length ?? 0;
|
||||
|
||||
/// <summary>The active clip-space planes (nx, ny, 0, d). Empty when <see cref="Count"/> is 0.
|
||||
/// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) >= 0</c> for every plane.</summary>
|
||||
public IReadOnlyList<Vector4> Planes => _planes ?? (IReadOnlyList<Vector4>)Array.Empty<Vector4>();
|
||||
|
||||
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
|
||||
/// instead (draw the box). Always false when <see cref="Count"/> > 0 or when the region is empty.</summary>
|
||||
public bool UseScissorFallback { get; }
|
||||
|
||||
/// <summary>True ⇒ the region is not visible at all; the consumer draws NOTHING.
|
||||
/// Mutually exclusive with <see cref="UseScissorFallback"/>, and only meaningful when Count == 0.</summary>
|
||||
public bool IsNothingVisible { get; }
|
||||
|
||||
/// <summary>NDC axis-aligned scissor box (minX, minY, maxX, maxY). Valid (min <= max) only when
|
||||
/// <see cref="UseScissorFallback"/> is true. For the empty/nothing-visible case it is a degenerate
|
||||
/// inverted box so naive scissoring still draws nothing.</summary>
|
||||
public Vector4 ScissorNdcAabb { get; }
|
||||
|
||||
/// <summary>The "nothing is visible" sentinel: Count == 0, not a scissor fallback, draw nothing.</summary>
|
||||
public static ClipPlaneSet Empty { get; } =
|
||||
new(Array.Empty<Vector4>(), useScissorFallback: false, isNothingVisible: true, scissorNdcAabb: DegenerateAabb);
|
||||
|
||||
// Inverted box (min > max) — any sane AABB intersection against it is empty.
|
||||
private static Vector4 DegenerateAabb => new(1f, 1f, -1f, -1f);
|
||||
|
||||
/// <summary>
|
||||
/// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (≤8 edges
|
||||
/// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor;
|
||||
/// empty/degenerate → <see cref="Empty"/>. See the file header for the full rule.
|
||||
/// </summary>
|
||||
public static ClipPlaneSet From(CellView region)
|
||||
{
|
||||
if (region is null || region.IsEmpty || region.Polygons.Count == 0)
|
||||
return Empty;
|
||||
|
||||
// MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes.
|
||||
// Over-include via the union AABB (safe). region.Min/Max already track the union.
|
||||
if (region.Polygons.Count > 1)
|
||||
return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY);
|
||||
|
||||
// Exactly one polygon: normalize winding to CCW, merge collinear edges, then decide.
|
||||
Vector2[] verts = NormalizeAndMerge(region.Polygons[0].Vertices);
|
||||
|
||||
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
|
||||
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
|
||||
if (verts.Length < 3)
|
||||
return Empty;
|
||||
|
||||
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
|
||||
// on ITS own AABB (still a superset of the polygon → over-include, safe).
|
||||
if (verts.Length > MaxPlanes)
|
||||
return Scissor(verts);
|
||||
|
||||
// 3..8 edges: emit one inward half-space plane per edge (CCW formula).
|
||||
var planes = new Vector4[verts.Length];
|
||||
for (int i = 0; i < verts.Length; i++)
|
||||
{
|
||||
Vector2 p = verts[i];
|
||||
Vector2 q = verts[(i + 1) % verts.Length];
|
||||
Vector2 dir = q - p;
|
||||
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
|
||||
// interior (the "left" side of the directed edge p→q).
|
||||
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
|
||||
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
|
||||
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
|
||||
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
|
||||
}
|
||||
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
|
||||
}
|
||||
|
||||
private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) =>
|
||||
new(Array.Empty<Vector4>(), useScissorFallback: true, isNothingVisible: false,
|
||||
scissorNdcAabb: new Vector4(minX, minY, maxX, maxY));
|
||||
|
||||
private static ClipPlaneSet Scissor(ReadOnlySpan<Vector2> verts)
|
||||
{
|
||||
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
||||
foreach (var v in verts)
|
||||
{
|
||||
if (v.X < minX) minX = v.X;
|
||||
if (v.X > maxX) maxX = v.X;
|
||||
if (v.Y < minY) minY = v.Y;
|
||||
if (v.Y > maxY) maxY = v.Y;
|
||||
}
|
||||
return Scissor(minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the polygon wound CCW with collinear vertices removed. The PortalVisibilityBuilder
|
||||
/// already EnsureCcw's its output, but From() is a public entry point that must be robust to
|
||||
/// either winding (e.g. a hand-built CellView), so we normalize here too.
|
||||
/// </summary>
|
||||
private static Vector2[] NormalizeAndMerge(Vector2[] input)
|
||||
{
|
||||
if (input is null || input.Length < 3)
|
||||
return Array.Empty<Vector2>();
|
||||
|
||||
// 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined.
|
||||
var pts = new List<Vector2>(input.Length);
|
||||
foreach (var v in input)
|
||||
{
|
||||
if (pts.Count == 0 || (v - pts[^1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen)
|
||||
pts.Add(v);
|
||||
}
|
||||
// Wrap-around duplicate (last == first).
|
||||
if (pts.Count >= 2 && (pts[^1] - pts[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen)
|
||||
pts.RemoveAt(pts.Count - 1);
|
||||
if (pts.Count < 3)
|
||||
return Array.Empty<Vector2>();
|
||||
|
||||
// 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only
|
||||
// for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive.
|
||||
if (SignedArea2(pts) < 0f)
|
||||
pts.Reverse();
|
||||
|
||||
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
|
||||
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
|
||||
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
|
||||
bool changed = true;
|
||||
while (changed && pts.Count >= 3)
|
||||
{
|
||||
changed = false;
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
{
|
||||
Vector2 prev = pts[(i - 1 + pts.Count) % pts.Count];
|
||||
Vector2 cur = pts[i];
|
||||
Vector2 next = pts[(i + 1) % pts.Count];
|
||||
|
||||
Vector2 d0 = cur - prev;
|
||||
Vector2 d1 = next - cur;
|
||||
float l0 = d0.Length();
|
||||
float l1 = d1.Length();
|
||||
if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen)
|
||||
{
|
||||
pts.RemoveAt(i);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
d0 /= l0;
|
||||
d1 /= l1;
|
||||
float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ
|
||||
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
|
||||
if (dot > 0f && MathF.Abs(cross) < CollinearSinEps)
|
||||
{
|
||||
pts.RemoveAt(i); // cur lies on the straight line prev→next
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pts.Count < 3)
|
||||
return Array.Empty<Vector2>();
|
||||
|
||||
// Final degeneracy gate: a polygon with negligible area is a line/point even if it still
|
||||
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
|
||||
// merge didn't quite collapse). Emitting its planes would yield an empty half-space
|
||||
// intersection that silently gates out everything; report it honestly as nothing-visible.
|
||||
if (MathF.Abs(SignedArea2(pts)) * 0.5f < MinPolygonArea)
|
||||
return Array.Empty<Vector2>();
|
||||
|
||||
return pts.ToArray();
|
||||
}
|
||||
|
||||
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
|
||||
private static float SignedArea2(List<Vector2> poly)
|
||||
{
|
||||
float a = 0f;
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
{
|
||||
Vector2 p = poly[i];
|
||||
Vector2 q = poly[(i + 1) % poly.Count];
|
||||
a += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
255
tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs
Normal file
255
tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue