feat(render): Phase A8.F — ScreenPolygonClip 2D convex-polygon intersection
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
406307e8ee
commit
7f46c278e5
2 changed files with 144 additions and 0 deletions
77
src/AcDream.App/Rendering/ScreenPolygonClip.cs
Normal file
77
src/AcDream.App/Rendering/ScreenPolygonClip.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// ScreenPolygonClip.cs
|
||||
//
|
||||
// Phase A8.F: 2D convex-polygon intersection (Sutherland-Hodgman).
|
||||
// Ports the BEHAVIOR of retail ACRender::polyClipFinish (the screen-space
|
||||
// portal clipper invoked by PView::GetClip, decomp:432344). Both inputs are
|
||||
// convex (a cell's accumulated view polygon and a projected portal polygon),
|
||||
// so the result is convex. We match retail's clipping relationship, not its
|
||||
// exact float output.
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public static class ScreenPolygonClip
|
||||
{
|
||||
private const float Eps = 1e-7f;
|
||||
|
||||
/// <summary>Intersect two convex polygons given CCW. Returns the clipped
|
||||
/// vertices (CCW) or an array with <3 verts when the intersection is empty.</summary>
|
||||
public static Vector2[] Intersect(IReadOnlyList<Vector2> subject, IReadOnlyList<Vector2> clip)
|
||||
{
|
||||
if (subject == null || clip == null || subject.Count < 3 || clip.Count < 3)
|
||||
return System.Array.Empty<Vector2>();
|
||||
|
||||
var output = new List<Vector2>(subject);
|
||||
|
||||
for (int i = 0; i < clip.Count; i++)
|
||||
{
|
||||
if (output.Count < 3) return System.Array.Empty<Vector2>();
|
||||
|
||||
Vector2 a = clip[i];
|
||||
Vector2 b = clip[(i + 1) % clip.Count];
|
||||
output = ClipByEdge(output, a, b);
|
||||
}
|
||||
|
||||
return output.Count >= 3 ? output.ToArray() : System.Array.Empty<Vector2>();
|
||||
}
|
||||
|
||||
// Keep the part of `poly` on the left of directed edge a->b (CCW inside).
|
||||
private static List<Vector2> ClipByEdge(List<Vector2> poly, Vector2 a, Vector2 b)
|
||||
{
|
||||
var result = new List<Vector2>(poly.Count + 1);
|
||||
Vector2 edge = b - a;
|
||||
|
||||
for (int i = 0; i < poly.Count; i++)
|
||||
{
|
||||
Vector2 cur = poly[i];
|
||||
Vector2 prev = poly[(i + poly.Count - 1) % poly.Count];
|
||||
|
||||
float curSide = Cross(edge, cur - a); // > 0 = left (inside)
|
||||
float prevSide = Cross(edge, prev - a);
|
||||
|
||||
bool curIn = curSide >= -Eps;
|
||||
bool prevIn = prevSide >= -Eps;
|
||||
|
||||
if (curIn)
|
||||
{
|
||||
if (!prevIn)
|
||||
result.Add(Intersection(prev, cur, prevSide, curSide));
|
||||
result.Add(cur);
|
||||
}
|
||||
else if (prevIn)
|
||||
{
|
||||
result.Add(Intersection(prev, cur, prevSide, curSide));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float Cross(Vector2 u, Vector2 v) => u.X * v.Y - u.Y * v.X;
|
||||
|
||||
private static Vector2 Intersection(Vector2 p, Vector2 q, float dp, float dq)
|
||||
{
|
||||
float t = dp / (dp - dq); // dp, dq are signed distances (cross products); opposite signs here
|
||||
return p + t * (q - p);
|
||||
}
|
||||
}
|
||||
67
tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs
Normal file
67
tests/AcDream.App.Tests/Rendering/ScreenPolygonClipTests.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public class ScreenPolygonClipTests
|
||||
{
|
||||
// CCW square [min,max]^2.
|
||||
private static Vector2[] Square(float min, float max) => new[]
|
||||
{
|
||||
new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max),
|
||||
};
|
||||
|
||||
private static float Area(Vector2[] poly)
|
||||
{
|
||||
if (poly.Length < 3) return 0f;
|
||||
float a = 0f;
|
||||
for (int i = 0; i < poly.Length; i++)
|
||||
{
|
||||
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
|
||||
a += p.X * q.Y - q.X * p.Y;
|
||||
}
|
||||
return System.MathF.Abs(a) * 0.5f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Intersect_FullyContained_ReturnsSubject()
|
||||
{
|
||||
var outer = Square(-1f, 1f);
|
||||
var inner = Square(-0.5f, 0.5f);
|
||||
var r = ScreenPolygonClip.Intersect(inner, outer);
|
||||
Assert.Equal(1.0f, Area(r), 3); // inner area = 1x1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Intersect_PartialOverlap_ReturnsOverlapArea()
|
||||
{
|
||||
var a = Square(0f, 2f);
|
||||
var b = Square(1f, 3f);
|
||||
var r = ScreenPolygonClip.Intersect(a, b);
|
||||
Assert.Equal(1.0f, Area(r), 3); // overlap [1,2]^2 = 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Intersect_Disjoint_ReturnsEmpty()
|
||||
{
|
||||
var a = Square(0f, 1f);
|
||||
var b = Square(5f, 6f);
|
||||
var r = ScreenPolygonClip.Intersect(a, b);
|
||||
Assert.True(r.Length < 3); // empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Intersect_Sliver_PreservesNarrowOverlap()
|
||||
{
|
||||
// A tall thin clip (the "stairwell") intersected with a wide subject (the "window").
|
||||
var window = Square(-1f, 1f);
|
||||
var stairwell = new[]
|
||||
{
|
||||
new Vector2(-0.1f, -1f), new Vector2(0.1f, -1f), new Vector2(0.1f, 1f), new Vector2(-0.1f, 1f),
|
||||
};
|
||||
var r = ScreenPolygonClip.Intersect(window, stairwell);
|
||||
Assert.Equal(0.4f, Area(r), 3); // 0.2 wide x 2 tall
|
||||
Assert.True(System.MathF.Abs(r[0].X) <= 0.1001f); // clipped to stairwell width
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue